Compare commits

..

93 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
dni ⚡
603b345073
fix: use lnbits linting, change to uv (#9)
Some checks failed
/ release (push) Has been cancelled
Tests / test (push) Has been cancelled
/ pullrequest (push) Has been cancelled
* fix: use lnbits linting, change to uv

- formatting
- prettier
- pyright

* dont ignore packagejson

* readd poetry lock for integration tests

* fix pyright

* fix mypy

* fix test?

* fix lnbits version

* fixup tests

* fixup!
2025-09-12 09:30:16 +02:00
Arc
c1f9d6a754
Merge pull request #10 from lnbits/revert-8-feat/config-improvements
Revert "Improve configuration form for readability and UX"
2025-09-11 05:15:36 +01:00
Arc
93ba3ba628
Revert "Improve configuration form for readability and UX" 2025-09-11 05:15:23 +01:00
Arc
771427a0b2
Merge pull request #8 from blackcoffeexbt/feat/config-improvements
Improve configuration form for readability and UX
2025-09-10 17:07:41 +01:00
blackcoffeexbt
abdd7a781b Improve configuration form for readability and UX 2025-09-10 12:08:54 +01:00
Vlad Stan
55d53208ff
Merge pull request #6 from riccardobl/fixhardn
Some checks failed
/ release (push) Has been cancelled
Tests / test (push) Has been cancelled
/ pullrequest (push) Has been cancelled
Relax hardening for lookup
2025-06-25 18:54:38 +03:00
Riccardo Balbo
18f04bcef0 Merge remote-tracking branch 'upstream/main' into fixhardn 2025-06-25 16:28:34 +02:00
Vlad Stan
d9adb80b77
Merge pull request #2 from riccardobl/main
Fix list_transactions filtering and tests
2025-06-25 17:18:26 +03:00
Riccardo Balbo
ce4b4a5530 relax hardening: don't block lookup calls without payment_hash or invoice 2025-06-23 12:22:23 +02:00
blackcoffeexbt
3b4f6e4c12
Updated extension preview image paths. 2025-06-18 13:26:08 +01:00
blackcoffeexbt
25852e98d9
Update image paths in config.json and added BC as contributor 2025-06-18 13:25:33 +01:00
blackcoffeexbt
c2504dc6d2
Merge pull request #4 from blackcoffeexbt/chore-add-extension-images 2025-06-18 13:19:37 +01:00
blackcoffeexbt
af130015e1 Move images 2025-06-18 13:18:56 +01:00
blackcoffeexbt
d1936f0d07
Merge pull request #3 from blackcoffeexbt/chore-add-extension-images 2025-06-18 13:16:21 +01:00
blackcoffeexbt
59bdc8cc53
Add extension images for extension preview modal 2025-06-18 13:15:38 +01:00
Riccardo Balbo
dd1d35b99d make list_transactions simpler to avoid timeouts 2025-06-09 18:18:44 +02:00
Riccardo Balbo
0baeaadb24 update dev container 2025-06-09 17:24:41 +02:00
Riccardo Balbo
fb1c3b87eb add list_transactions test 2025-06-09 17:16:16 +02:00
21M4TW
b851920eb3 -Two issues identified with the list_transactions command: (#11)
-Was accessing the arguments from payload instead of from params
 -Was using "to" instead of "until" defined in NIP-47
2025-06-09 15:32:31 +02:00
Riccardo Balbo
eb0ac9d13d fix test_multi_pay_invoices 2025-06-09 14:22:48 +02:00
Riccardo Balbo
a6e0a18a06 postgres fix
Some checks failed
Tests / test (push) Has been cancelled
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-03-03 20:20:08 +00:00
Riccardo Balbo
9dcaf125b0 lint 2025-02-23 16:53:06 +00:00
Riccardo Balbo
44d3e28182 format 2025-02-23 16:52:03 +00:00
Riccardo Balbo
ac69ae9846 more hardening 2025-02-23 16:51:26 +00:00
Riccardo Balbo
2fbc100da5 Add more tests 2025-02-23 16:19:34 +00:00
Riccardo Balbo
abb6f888f1 increase timeout 2025-02-22 14:47:31 +00:00
Riccardo Balbo
d6d679520c increase timeout 2025-02-22 14:39:59 +00:00
Riccardo Balbo
a26cef75d7 more hardening 2025-02-22 14:11:22 +00:00
Riccardo Balbo
49b3a0d4e9 default handle_missed_events to 0 2025-02-22 13:44:07 +00:00
Riccardo Balbo
79c01a0f34 readme format 2025-02-22 13:43:07 +00:00
Riccardo Balbo
1f26e11e8b configurable handle_missed_events 2025-02-22 13:41:44 +00:00
Riccardo Balbo
06fdb06d9a garbage collect old tracked events 2025-02-22 14:09:45 +01:00
Riccardo Balbo
fac130c49c more hardening 2025-02-22 12:54:17 +00:00
Riccardo Balbo
26a7dd821c fix integration test run? 2025-02-22 13:48:59 +01:00
Riccardo Balbo
404da582fe more ghaction fixes 2025-02-22 13:47:02 +01:00
Riccardo Balbo
c33fd1c2df fix ghactions? 2025-02-22 12:37:32 +00:00
Riccardo Balbo
171d2b128c fix for ghactions ? 2025-02-22 12:29:25 +00:00
Riccardo Balbo
686eda82b7 more hardening 2025-02-22 12:27:22 +00:00
Riccardo Balbo
0b0efa0518 fix integration test run? 2025-02-22 13:21:40 +01:00
Riccardo Balbo
e3957b5135 edges hardening 2025-02-22 12:16:21 +00:00
Riccardo Balbo
c4faac35c0 fix integration test run? 2025-02-22 12:35:30 +01:00
Riccardo Balbo
d48f5a5989 fix integration test run? 2025-02-22 11:29:52 +00:00
Riccardo Balbo
d9462282e1 fix integration test run? 2025-02-22 10:55:21 +00:00
Riccardo Balbo
064e34c2ba fix integration test run? 2025-02-22 10:08:23 +00:00
Riccardo Balbo
24c8ccd7de fix integration test run 2025-02-22 09:59:45 +00:00
Riccardo Balbo
49d8f75e06 fix for new api 2025-02-22 09:54:55 +00:00
Riccardo Balbo
eaf723919c fix broken refactoring 2025-02-19 19:31:52 +00:00
Riccardo Balbo
0f42344c6b revert listener refactoring to avoid cyclic dependency hell 2025-02-19 19:13:49 +00:00
Riccardo Balbo
35a4d3ed12 update integration tests 2025-02-19 18:55:17 +00:00
Riccardo Balbo
7ee5552467 install pytest-asyncio in poetry env 2025-02-18 14:00:16 +00:00
Riccardo Balbo
4210995c9e mount docker socket for integration test 2025-02-18 13:58:43 +00:00
Riccardo Balbo
f72bfca206 fix async pytests 2025-02-18 13:56:24 +00:00
Riccardo Balbo
c1dab6a632 fix tracked budget 2025-02-18 13:49:34 +00:00
Riccardo Balbo
a366dbce33 fix budget 2025-02-18 13:31:13 +00:00
Riccardo Balbo
0518e6f7d0 fix models 2025-02-17 20:00:26 +00:00
Riccardo Balbo
4a8303b049 upgrade quasar api calls 2025-02-17 20:00:19 +00:00
Riccardo Balbo
b1beab7aa0 fix template regression 2025-02-17 20:00:00 +00:00
Riccardo Balbo
0559a1b8dc update dev environment 2025-02-17 15:42:38 +00:00
arcbtc
c9a7c41e1f move admin js to own file 2025-02-15 17:53:44 +00:00
arcbtc
be3d4b9e5c fix json bug, move js to own file 2025-02-15 17:49:25 +00:00
Arc
dd0f4cad7f
Update manifest.json 2025-02-15 17:09:54 +00:00
48 changed files with 7968 additions and 2854 deletions

View file

@ -1,20 +1,18 @@
{
"name": "lnbits_nwc_provider",
"image": "mcr.microsoft.com/devcontainers/python:1-3.9-bullseye",
"features": {
"ghcr.io/devcontainers-contrib/features/poetry:2": {}
},
"image": "mcr.microsoft.com/devcontainers/python:1-3.12",
"mounts": [
"source=${localWorkspaceFolder}/.devcontainer/start.sh,target=/start-lnbits.sh,type=bind",
"source=${localWorkspaceFolder}/.devcontainer/setup.sh,target=/setup.sh,type=bind",
"source=${localWorkspaceFolder}/.devcontainer/pre-setup.sh,target=/pre-setup.sh,type=bind",
"source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind"
],
"containerEnv": {
"IS_DEV_CONTAINER": "true"
},
"postCreateCommand": "/bin/bash /setup.sh ${containerWorkspaceFolder}",
"postCreateCommand": "/bin/bash /pre-setup.sh && /bin/bash /setup.sh ${containerWorkspaceFolder}",
"postStartCommand": "/bin/bash /start-lnbits.sh",
"forwardPorts": [5000],
"customizations": {
"vscode": {
"settings": {

View file

@ -0,0 +1,16 @@
#!/bin/bash
set -e
# workaround for devimage
sudo find /etc/apt/sources.list.d -maxdepth 1 -type f -exec \
sh -c 'grep -q "dl.yarnpkg.com/debian" "$1" && rm -f "$1" || true' _ {} \;
sudo sed -i '/dl.yarnpkg.com\/debian/d' /etc/apt/sources.list || true
sudo apt update -y
sudo apt install -y curl
sudo apt-get install -y docker.io
curl -sSL https://install.python-poetry.org | python3 -
curl -fsSL https://deb.nodesource.com/setup_20.x -o /tmp/nodesource_setup.sh
sudo bash /tmp/nodesource_setup.sh
sudo apt-get install -y nodejs

View file

@ -2,13 +2,11 @@
echo $PYTHONPATH
CONTAINER_WORKSPACE_FOLDER=$1
cd $CONTAINER_WORKSPACE_FOLDER
sudo apt update -y
sudo apt install -y python3.9-distutils curl
sudo apt-get install -y docker.io
curl -fsSL https://deb.nodesource.com/setup_20.x -o /tmp/nodesource_setup.sh
sudo bash /tmp/nodesource_setup.sh
sudo apt-get install -y nodejs
# workaround for devimage
sudo find /etc/apt/sources.list.d -maxdepth 1 -type f -exec \
sh -c 'grep -q "dl.yarnpkg.com/debian" "$1" && rm -f "$1" || true' _ {} \;
sudo sed -i '/dl.yarnpkg.com\/debian/d' /etc/apt/sources.list || true
cd $HOME
echo $PWD
@ -17,8 +15,8 @@ if [ ! -d ./lnbits ] ; then
fi
cd lnbits
echo $PWD
git checkout 0.12.8
poetry env use python3.9
git checkout dev
poetry env use 3.12
POETRY_PYTHON_PATH=$(poetry env info -p)/bin/python
ln -sf $POETRY_PYTHON_PATH /home/vscode/python
make bundle
@ -32,3 +30,4 @@ cd $CONTAINER_WORKSPACE_FOLDER
poetry install --no-interaction
npm i prettier
npm i pyright
pip install uv

10
.github/workflows/lint.yml vendored Normal file
View file

@ -0,0 +1,10 @@
name: lint
on:
push:
branches:
- main
pull_request:
jobs:
lint:
uses: lnbits/lnbits/.github/workflows/lint.yml@dev

View file

@ -1,52 +0,0 @@
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Create github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --generate-notes
pullrequest:
needs: [release]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
token: ${{ secrets.EXT_GITHUB }}
repository: lnbits/lnbits-extensions
path: "./lnbits-extensions"
- name: setup git user
run: |
git config --global user.name "alan"
git config --global user.email "alan@lnbits.com"
- name: Create pull request in extensions repo
env:
GH_TOKEN: ${{ secrets.EXT_GITHUB }}
repo_name: "${{ github.event.repository.name }}"
tag: "${{ github.ref_name }}"
branch: "update-${{ github.event.repository.name }}-${{ github.ref_name }}"
title: "[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}"
body: "https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}"
archive: "https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip"
run: |
cd lnbits-extensions
git checkout -b $branch
# if there is another open PR
git pull origin $branch || echo "branch does not exist"
sh util.sh update_extension $repo_name $tag
git add -A
git commit -am "$title"
git push origin $branch
# check if pr exists before creating it
gh config set pager cat
check=$(gh pr list -H $branch | wc -l)
test $check -ne 0 || gh pr create --title "$title" --body "$body" --repo lnbits/lnbits-extensions

View file

@ -1,51 +1,57 @@
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
- 'v[0-9]+.[0-9]+.[0-9]+'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Create github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --generate-notes
pullrequest:
needs: [release]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
token: ${{ secrets.EXT_GITHUB }}
repository: lnbits/lnbits-extensions
path: "./lnbits-extensions"
path: './lnbits-extensions'
- name: setup git user
run: |
git config --global user.name "alan"
git config --global user.email "alan@lnbits.com"
- name: Create pull request in extensions repo
env:
GH_TOKEN: ${{ secrets.EXT_GITHUB }}
repo_name: "${{ github.event.repository.name }}"
tag: "${{ github.ref_name }}"
branch: "update-${{ github.event.repository.name }}-${{ github.ref_name }}"
title: "[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}"
body: "https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}"
archive: "https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip"
repo_name: '${{ github.event.repository.name }}'
tag: '${{ github.ref_name }}'
branch: 'update-${{ github.event.repository.name }}-${{ github.ref_name }}'
title: '[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}'
body: 'https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}'
archive: 'https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip'
run: |
cd lnbits-extensions
git checkout -b $branch
# if there is another open PR
git pull origin $branch || echo "branch does not exist"
sh util.sh update_extension $repo_name $tag
git add -A
git commit -am "$title"
git push origin $branch
# check if pr exists before creating it
gh config set pager cat
check=$(gh pr list -H $branch | wc -l)

View file

@ -7,15 +7,16 @@ on:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: "3.9"
python-version: '3.10'
- name: Install dependencies
run: |
@ -32,11 +33,14 @@ jobs:
cd $HOME/lnbits
poetry_env_path=$(poetry env info --path)
source $poetry_env_path/bin/activate
pip install pytest-asyncio
cd $cdir
pytest tests/unit/*.py -s
- name: Setup integration tests
run: bash tests/integration/start.sh
run: |
export HEADLESS=1
bash tests/integration/start.sh
- name: Run integration tests
run: |
@ -44,5 +48,19 @@ jobs:
cd $HOME/lnbits
poetry_env_path=$(poetry env info --path)
source $poetry_env_path/bin/activate
pip install pytest-asyncio
cd $cdir
pytest tests/integration/*.py -s
- name: Show integration service logs
if: failure()
run: |
docker ps -a
docker logs --tail 200 lnbits_nwcprovider_ext_nostr_test || true
docker exec lnbits_nwcprovider_ext_lnbits_test tail -n 200 /tmp/lnbits.log || true
- name: Stop integration services
if: always()
run: |
docker rm -f lnbits_nwcprovider_ext_lnbits_test lnbits_nwcprovider_ext_nostr_test || true
docker network rm lnbits_nwcprovider_ext_test_network || true

3
.gitignore vendored
View file

@ -8,6 +8,5 @@ node_modules
.mypy_cache
data
.vscode
package.json
package-lock.json
dump
.venv

12
.prettierrc Normal file
View file

@ -0,0 +1,12 @@
{
"semi": false,
"arrowParens": "avoid",
"insertPragma": false,
"printWidth": 80,
"proseWrap": "preserve",
"singleQuote": true,
"trailingComma": "none",
"useTabs": false,
"bracketSameLine": false,
"bracketSpacing": false
}

View file

@ -5,27 +5,27 @@ format: prettier black ruff
check: mypy pyright checkblack checkruff checkprettier
prettier:
poetry run ./node_modules/.bin/prettier --write .
uv run ./node_modules/.bin/prettier --write .
pyright:
poetry run ./node_modules/.bin/pyright
uv run ./node_modules/.bin/pyright
mypy:
poetry run mypy .
uv run mypy .
black:
poetry run black .
uv run black .
ruff:
poetry run ruff check . --fix
uv run ruff check . --fix
checkruff:
poetry run ruff check .
uv run ruff check .
checkprettier:
poetry run ./node_modules/.bin/prettier --check .
uv run ./node_modules/.bin/prettier --check .
checkblack:
poetry run black --check .
uv run black --check .
checkeditorconfig:
editorconfig-checker
@ -33,14 +33,14 @@ checkeditorconfig:
test:
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest
uv run pytest
install-pre-commit-hook:
@echo "Installing pre-commit hook to git"
@echo "Uninstall the hook with poetry run pre-commit uninstall"
poetry run pre-commit install
@echo "Uninstall the hook with uv run pre-commit uninstall"
uv run pre-commit install
pre-commit:
poetry run pre-commit run --all-files
uv run pre-commit run --all-files
checkbundle:

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)
Easily connect your LNbits wallets via [NWC](https://nwc.dev/).
@ -6,29 +16,72 @@ 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).
## 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:
| Key | Description | Default |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
| relay | URL of the nostr relay for dispatching and receiving NWC events. Use public relays or a custom one. Specify `nostrclient` to connect to the [nostrclient extension](https://github.com/lnbits/nostrclient). | nostrclient |
| provider_key | Nostr secret key of the NWC Service Provider. | Random key generated on install |
| relay_alias | Relay URL to display in pairing URLs. Set if different from `relay`. | Empty (uses the `relay` value) |
| handle_missed_events | Number of seconds to look back for processing events missed while offline. Setting it to 0 disables this functionality. | 0 |
### Using Nostrclient
> [!WARNING]
>
> Do not change `handle_missed_events` from its default value of `0` unless you fully understand its implications.
> While a non-zero value may improve service quality under unstable conditions (e.g., poor connectivity or unreliable power), it can also lead to unexpected behavior.
> 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`.
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.
## Powered by LNbits
### Using a Custom Relay
[LNbits](https://lnbits.com) is a free and open-source lightning accounts system.
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.
[![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

@ -32,7 +32,7 @@ def nwcprovider_stop():
except Exception as ex:
logger.warning(ex)
logger.debug("NWC Service Provider extension loaded")
def nwcprovider_start():
task = create_permanent_unique_task("ext_nwcprovider", handle_nwc)
scheduled_tasks.append(task)
@ -45,7 +45,7 @@ def nwcprovider_start():
__all__ = [
"db",
"nwcprovider_ext",
"nwcprovider_static_files",
"nwcprovider_start",
"nwcprovider_static_files",
"nwcprovider_stop",
]

View file

@ -1,8 +1,12 @@
{
"id": "nwcprovider",
"name": "NWC Service Provider",
"repo": "https://github.com/lnbits/nwcprovider",
"short_description": "A NWC service provider for LNbits",
"description": "",
"tile": "/nwcprovider/static/image/nwcprovider.png",
"min_lnbits_version": "1.0.0",
"version": "1.1.3-aio.1",
"min_lnbits_version": "1.4.0",
"contributors": [
{
"name": "Riccardo Balbo",
@ -13,20 +17,33 @@
"name": "Ben Arc",
"uri": "https://github.com/arcbtc",
"role": "Dev"
},
{
"name": "BlackCoffee",
"uri": "https://github.com/blackcoffeexbt",
"role": "Dev"
}
],
"images": [
{
"uri": "https://raw.githubusercontent.com/riccardobl/nwcprovider/main/static/image/1.png"
"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/riccardobl/nwcprovider/main/static/image/2.png"
"uri": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/static/image/1.png"
},
{
"uri": "https://raw.githubusercontent.com/riccardobl/nwcprovider/main/static/image/3.png"
"uri": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/static/image/2.png"
},
{
"uri": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/static/image/3.png"
}
],
"description_md": "https://raw.githubusercontent.com/riccardobl/nwcprovider/main/description.md",
"terms_and_conditions_md": "https://raw.githubusercontent.com/riccardobl/nwcprovider/main/toc.md",
"license": "MIT"
"description_md": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/description.md",
"terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/toc.md",
"license": "MIT",
"paid_features": "",
"tags": ["Nostr", "Wallet"],
"donate": "",
"hidden": false
}

170
crud.py
View file

@ -1,5 +1,4 @@
import time
from typing import List, Optional
from lnbits.db import Database
@ -8,20 +7,47 @@ from .models import (
CreateNWCKey,
DeleteNWC,
GetBudgetsNWC,
GetNWCKey,
GetNWC,
GetWalletNWC,
NWCBudget,
NWCConfig,
NWCKey,
NWCNewBudget,
TrackedSpendNWC,
)
from .paranoia import (
assert_sane_string,
assert_valid_expiration_seconds,
assert_valid_msats,
assert_valid_positive_int,
assert_valid_pubkey,
assert_valid_timestamp_seconds,
assert_valid_wallet_id,
)
db = Database("ext_nwcprovider")
async def create_nwc(data: CreateNWCKey) -> NWCKey:
# hardening #
assert_valid_pubkey(data.pubkey)
assert_valid_wallet_id(data.wallet)
assert_sane_string(data.description)
assert_valid_expiration_seconds(data.expires_at)
for permission in data.permissions:
assert_sane_string(permission)
if data.budgets:
for budget in data.budgets:
assert_valid_msats(budget.budget_msats)
assert_valid_positive_int(budget.refresh_window)
assert_valid_timestamp_seconds(budget.created_at)
# ## #
nwckey_entry = NWCKey(
pubkey=data.pubkey,
wallet=data.wallet_id,
wallet=data.wallet,
description=data.description,
expires_at=int(data.expires_at) if data.expires_at else 0,
permissions=" ".join(data.permissions),
@ -31,40 +57,65 @@ async def create_nwc(data: CreateNWCKey) -> NWCKey:
await db.insert("nwcprovider.keys", nwckey_entry)
if data.budgets:
for budget in data.budgets:
budget_entry = NWCKey(
budget_entry = NWCNewBudget( # fixme
pubkey=data.pubkey,
budget_msats=budget.budget_msats,
refresh_window=budget.refresh_window,
created_at=budget.created_at,
)
await db.insert("nwcprovider.budgets", budget_entry)
return NWCKey(**data.dict())
return NWCKey(**nwckey_entry.dict())
async def delete_nwc(data: DeleteNWC) -> None:
# hardening #
assert_valid_pubkey(data.pubkey)
if data.wallet:
assert_valid_wallet_id(data.wallet)
# ## #
await db.execute(
"DELETE FROM nwcprovider.keys WHERE pubkey = :pubkey AND wallet = :wallet",
{"pubkey": data.pubkey, "wallet": data.wallet_id},
{"pubkey": data.pubkey, "wallet": data.wallet},
)
async def get_wallet_nwcs(data: GetWalletNWC) -> List[NWCKey]:
async def get_wallet_nwcs(data: GetWalletNWC) -> list[NWCKey]:
expires = int(time.time()) if not data.include_expired else -1
if not data.wallet:
return []
# hardening #
assert_valid_wallet_id(data.wallet)
assert_valid_expiration_seconds(expires)
# ## #
return await db.fetchall(
"""
SELECT * FROM nwcprovider.keys
WHERE wallet = :wallet AND (expires_at = 0 OR expires_at > :expires)
""",
{
"wallet": data.wallet_id,
"expires": int(time.time()) if not data.include_expired else -1,
"wallet": data.wallet,
"expires": expires,
},
model=NWCKey,
)
async def get_nwc(data: GetNWCKey) -> Optional[NWCKey]:
async def get_nwc(data: GetNWC) -> NWCKey | None:
expires = int(time.time()) if not data.include_expired else -1
# hardening #
assert_valid_pubkey(data.pubkey)
assert_valid_expiration_seconds(expires)
# ## #
# expires_at = 0 means it never expires
if data.wallet_id:
if data.wallet:
assert_valid_wallet_id(data.wallet)
row = await db.fetchone(
"""
SELECT * FROM nwcprovider.keys
@ -73,19 +124,12 @@ async def get_nwc(data: GetNWCKey) -> Optional[NWCKey]:
""",
{
"pubkey": data.pubkey,
"wallet": data.wallet_id,
"expires": int(time.time()) if not data.include_expired else -1,
"wallet": data.wallet,
"expires": expires,
},
NWCKey,
)
else:
row = await db.fetchone(
"""
SELECT * FROM nwcprovider.keys
WHERE pubkey = ? AND (expires_at = 0 OR expires_at > ?)
""",
(data.pubkey, int(time.time()) if not data.include_expired else -1),
)
row = await db.fetchone(
"""
SELECT * FROM nwcprovider.keys
@ -93,7 +137,7 @@ async def get_nwc(data: GetNWCKey) -> Optional[NWCKey]:
""",
{
"pubkey": data.pubkey,
"expires": int(time.time()) if not data.include_expired else -1,
"expires": expires,
},
NWCKey,
)
@ -107,23 +151,34 @@ async def get_nwc(data: GetNWCKey) -> Optional[NWCKey]:
""",
{"last_used": int(time.time()), "pubkey": data.pubkey},
)
return NWCKey(**row)
return row
async def get_budgets_nwc(data: GetBudgetsNWC) -> Optional[NWCBudget]:
rows = await db.fetchall(
async def get_budgets_nwc(data: GetBudgetsNWC) -> list[NWCBudget]:
# hardening #
assert_valid_pubkey(data.pubkey)
# ## #
budgets = await db.fetchall(
"SELECT * FROM nwcprovider.budgets WHERE pubkey = :pubkey",
{"pubkey": data.pubkey},
model=NWCBudget,
)
budgets = [NWCBudget(**row) for row in rows]
if data.calculate_spent:
for budget in budgets:
last_cycle, next_cycle = budget.get_timestamp_range()
tot_spent_in_range_msats = await db.fetchone(
# hardening #
assert_valid_timestamp_seconds(last_cycle)
assert_valid_timestamp_seconds(next_cycle)
# ## #
result: dict = await db.fetchone(
"""
SELECT SUM(amount_msats) FROM nwcprovider.spent
WHERE pubkey = :pubkey AND created_at >=
:last_cycle AND created_at < next_cycle
:last_cycle AND created_at < :next_cycle
""",
{
"pubkey": data.pubkey,
@ -131,19 +186,38 @@ async def get_budgets_nwc(data: GetBudgetsNWC) -> Optional[NWCBudget]:
"next_cycle": next_cycle,
},
)
tot_spent_in_range_msats = tot_spent_in_range_msats[0] or 0
tot_spent_in_range_msats = next(iter(result.values())) or 0
# hardening #
assert_valid_msats(tot_spent_in_range_msats)
# ## #
budget.used_budget_msats = tot_spent_in_range_msats
return budgets
async def tracked_spend_nwc(data: TrackedSpendNWC, action):
async def r():
# hardening #
assert_valid_pubkey(data.pubkey)
assert_valid_msats(data.amount_msats)
# ## #
created_at = int(time.time())
budgets = await get_budgets_nwc(data.pubkey)
budgets = await get_budgets_nwc(GetBudgetsNWC(pubkey=data.pubkey))
in_budget = True
for budget in budgets:
last_cycle, next_cycle = budget.get_timestamp_range()
# hardening #
assert_valid_timestamp_seconds(last_cycle)
assert_valid_timestamp_seconds(next_cycle)
# ## #
tot_spent_in_range_msats = (
next(
iter(
(
await db.fetchone(
"""
@ -157,9 +231,17 @@ async def tracked_spend_nwc(data: TrackedSpendNWC, action):
"next_cycle": next_cycle,
},
)
)[0]
).values()
)
)
or 0
)
# hardening #
assert_valid_msats(tot_spent_in_range_msats)
assert_valid_msats(budget.budget_msats)
# ## #
if tot_spent_in_range_msats + data.amount_msats > budget.budget_msats:
in_budget = False
break
@ -183,31 +265,33 @@ async def tracked_spend_nwc(data: TrackedSpendNWC, action):
async def get_config_nwc(key: str):
row = await db.fetchone(
"SELECT * FROM nwcprovider.config WHERE key = :key", {"key": key}
config = await db.fetchone(
"SELECT * FROM nwcprovider.config WHERE key = :key",
{"key": key},
model=NWCConfig,
)
if not row:
if not config:
return None
return row["value"]
return config.value
async def set_config_nwc(key: str, value: str):
await db.execute(
"""
DELETE FROM nwcprovider.config
WHERE key = :key
""",
{"key": key},
)
# hardening #
assert_sane_string(key)
assert_sane_string(value)
# ## #
await db.execute(
"""
INSERT INTO nwcprovider.config (key, value)
VALUES (:key, :value)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
""",
{"key": key, "value": value},
)
async def get_all_config_nwc():
rows = await db.fetchall("SELECT * FROM nwcprovider.config")
return {row["key"]: row["value"] for row in rows}
rows = await db.fetchall("SELECT * FROM nwcprovider.config", model=NWCConfig)
return {row.key: row.value for row in rows}

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,7 +1,7 @@
import asyncio
from typing import Any, Dict
from typing import Any
execution_queue: asyncio.Queue[Dict[str, Any]] = asyncio.Queue()
execution_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
async def enqueue(action):

View file

@ -2,7 +2,7 @@
"repos": [
{
"id": "nwcprovider",
"organisation": "riccardobl",
"organisation": "lnbits",
"repository": "nwcprovider"
}
]

View file

@ -1,4 +1,4 @@
import secp256k1
from coincurve import PrivateKey
async def m001_initial(db):
@ -68,17 +68,18 @@ async def m003_default_config(db):
"""
await db.execute(
"""
INSERT OR REPLACE INTO nwcprovider.config
(key, value) VALUES ('relay', 'nostrclient');
INSERT INTO nwcprovider.config (key, value) VALUES ('relay', 'nostrclient')
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
"""
)
new_private_key = bytes.hex(secp256k1._gen_private_key())
private_key = PrivateKey()
await db.execute(
"""
INSERT OR REPLACE INTO nwcprovider.config
(key, value) VALUES ('provider_key', :provider_key);
INSERT INTO nwcprovider.config (key, value)
VALUES ('provider_key', :provider_key)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
""",
{"provider_key": new_private_key},
{"provider_key": private_key.to_hex()},
)
@ -86,11 +87,10 @@ async def m004_default_config2(db):
"""
Default config
"""
await db.execute(
"""
INSERT OR REPLACE INTO nwcprovider.config
(key, value) VALUES ('relay_alias', :value);
INSERT INTO nwcprovider.config (key, value) VALUES ('relay_alias', :value)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
""",
{"value": ""},
)
@ -105,3 +105,17 @@ async def m005_key_last_used(db):
ALTER TABLE nwcprovider.keys ADD COLUMN last_used INTEGER;
"""
)
async def m006_default_config3(db):
"""
Default config
"""
await db.execute(
"""
INSERT INTO nwcprovider.config (key, value)
VALUES ('handle_missed_events', :value)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
""",
{"value": "0"},
)

118
models.py
View file

@ -2,12 +2,10 @@
import time
from sqlite3 import Row
from typing import Any, Dict, List, Optional
from typing import Any
from pydantic import BaseModel
from .nwcp import NWCServiceProvider
class NWCKey(BaseModel):
pubkey: str
@ -18,58 +16,17 @@ class NWCKey(BaseModel):
created_at: int
last_used: int
def get_permissions(self) -> List[str]:
def get_permissions(self) -> list[str]:
try:
return self.permissions.split(" ")
except Exception:
return []
@classmethod
def from_row(cls, row: Dict[str, Any]) -> "NWCKey":
def from_row(cls, row: dict[str, Any]) -> "NWCKey":
return cls(**row)
class GetNWCKey(BaseModel):
pubkey: str
wallet_id: Optional[str] = None
include_expired: Optional[bool] = False
refresh_last_used: Optional[bool] = False
class GetNWCs(BaseModel):
include_expired: bool = False
calculate_spent_budget: bool = False
class GetWalletNWC(BaseModel):
wallet_id: Optional[str] = None
include_expired: Optional[bool] = False
class GetBudgetsNWC(BaseModel):
pubkey: str
calculate_spent: Optional[bool] = False
class TrackedSpendNWC(BaseModel):
pubkey: str
amount_msats: int
class DeleteNWC(BaseModel):
pubkey: str
wallet_id: Optional[str] = None
class OnInvoicePaid(BaseModel):
class Config:
arbitrary_types_allowed = True
sp: NWCServiceProvider
pubkey: str
payload: Dict
class NWCBudget(BaseModel):
id: int
pubkey: str
@ -82,7 +39,7 @@ class NWCBudget(BaseModel):
c = int(time.time())
if self.refresh_window <= 0: # never refresh
# return a timestamp in the future
return c, c + 21000000
return self.created_at, c + 21000000
# calculate the next refresh timestamp
elapsed = c - self.created_at
passed_cycles = elapsed // self.refresh_window
@ -95,44 +52,63 @@ class NWCBudget(BaseModel):
return cls(**dict(row))
class NWCLog(BaseModel):
id: int
pubkey: str
payload: str
created_at: int
@classmethod
def from_row(cls, row: Row) -> "NWCLog":
return cls(**dict(row))
class NWCNewBudget(BaseModel):
pubkey: str | None
budget_msats: int
refresh_window: int
created_at: int
class NWCRegistrationRequest(BaseModel):
permissions: List[str]
# CRUD models
class CreateNWCKey(BaseModel):
pubkey: str
wallet: str
description: str
expires_at: int
budgets: List[NWCNewBudget]
permissions: list[str]
budgets: list[NWCNewBudget] | None = None
class RegisterNWC(BaseModel):
class DeleteNWC(BaseModel):
pubkey: str
registration_data: NWCRegistrationRequest
wallet: str | None = None
class GetWalletNWC(BaseModel):
wallet: str | None = None
include_expired: bool | None = False
class GetNWC(BaseModel):
pubkey: str
wallet: str | None = None
include_expired: bool | None = False
refresh_last_used: bool | None = False
class GetBudgetsNWC(BaseModel):
pubkey: str
calculate_spent: bool | None = False
class TrackedSpendNWC(BaseModel):
pubkey: str
amount_msats: int
# API models
class NWCRegistrationRequest(BaseModel):
permissions: list[str]
description: str
expires_at: int
budgets: list[NWCNewBudget]
class NWCGetResponse(BaseModel):
data: NWCKey
budgets: List[NWCBudget]
budgets: list[NWCBudget]
class CreateNWCKey(BaseModel):
pubkey: str
wallet_id: str
description: str
expires_at: int
permissions: List[str]
budgets: Optional[List[NWCNewBudget]] = None
class NWCConfig(BaseModel):
key: str
value: str

324
nwcp.py
View file

@ -1,20 +1,17 @@
import asyncio
import base64
import hashlib
import json
import random
import time
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Union
from collections.abc import Awaitable, Callable
from typing import Any, Union
import secp256k1
import websockets.client as websockets
from Cryptodome import Random
from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import pad, unpad
from coincurve import PublicKeyXOnly
from lnbits.helpers import encrypt_internal_message
from lnbits.settings import settings
from loguru import logger
from pydantic import BaseModel
from pynostr.key import PrivateKey
from websockets.legacy.client import connect
class RateLimit:
@ -24,14 +21,15 @@ class RateLimit:
class MainSubscription:
def __init__(self):
self.requests_sub_id: Optional[str] = None
self.responses_sub_id: Optional[str] = None
self.requests_sub_id: str | None = None
self.responses_sub_id: str | None = None
self.requests_eose = False
self.responses_eose = False
self.events: Dict[str, Dict] = {}
self.responses: List[str] = []
self.events: dict[str, dict] = {}
self.responses: list[str] = []
self.seen_requests: dict[str, int] = {}
def get_stale(self) -> List[Dict]:
def get_stale(self) -> list[dict]:
"""
Get all the pending events that do not have a response yet.
"""
@ -48,12 +46,43 @@ class MainSubscription:
if event_id not in self.responses:
self.responses.append(event_id)
def gc(self, expire: int | None = None):
"""
Garbage collection, remove all the events that have a response older
than expire seconds (defaults to 1 hour if 0 or None)
and all seen requests that are expired
"""
expire = expire or 1 * 60 * 60
now = int(time.time())
deleted_ids = []
for event_id, event in list(self.events.items()):
if event_id in self.responses:
if now - event["created_at"] > expire:
del self.events[event_id]
deleted_ids.append(event_id)
self.responses = [
event_id for event_id in self.responses if event_id not in deleted_ids
]
if len(deleted_ids) > 0:
logger.debug("Garbage collected " + str(len(deleted_ids)) + " events")
# Clean seen requests
for event_id, expiry in list(self.seen_requests.items()):
if expiry < now:
del self.seen_requests[event_id]
class Config:
arbitrary_types_allowed = True
class NWCServiceProvider:
def __init__(self, private_key: Optional[str] = None, relay: Optional[str] = None):
def __init__(
self,
private_key_hex: str | None = None,
relay: str | None = None,
handle_missed_events: int = 0,
):
if not relay: # Connect to nostrclient
relay = "nostrclient"
if relay == "nostrclient":
@ -65,37 +94,49 @@ class NWCServiceProvider:
)
self.relay = relay
if not private_key: # Create random key
private_key = bytes.hex(secp256k1._gen_private_key())
if not private_key_hex: # Create random 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.private_key_hex = private_key
self.public_key = self.private_key.pubkey
self.public_key = self.private_key.public_key
if not self.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
self.supported_methods: List[str] = []
self.supported_methods: list[str] = []
# Keep track of the number of subscriptions (used for unique subid)
self.subscriptions_count: int = 0
# Request listeners, listen to specific methods
self.request_listeners: Dict[
self.request_listeners: dict[
str,
Callable[
[NWCServiceProvider, str, Dict],
Awaitable[List[Tuple[Optional[Dict], Optional[Dict], List]]],
[NWCServiceProvider, str, dict],
Awaitable[list[tuple[dict | None, dict | None, list]]],
],
] = {}
# Reconnect task (if the connection is lost)
self.reconnect_task = None
# Garbage collection loop
self.gc_task = None
# Periodic info event resend loop
self.info_event_task = None
# Requests are handled independently from the relay receive loop.
self.request_tasks: set[asyncio.Task[list[dict]]] = set()
# Subscription
self.sub = None
self.rate_limit: Dict[str, RateLimit] = {}
self.sub: MainSubscription | None = None
self.rate_limit: dict[str, RateLimit] = {}
# websocket connection
self.ws = None
@ -106,6 +147,13 @@ class NWCServiceProvider:
# if True the instance is shutting down
self.shutdown = False
# process missed events that are not older than
# handle_missed_events seconds (0 to disable)
# (handles reboots)
self.handle_missed_events = handle_missed_events
self.event_max_age = self.handle_missed_events or 5 * 60
logger.info(
"NWC Service is ready. relay: "
+ str(self.relay)
@ -113,6 +161,12 @@ class NWCServiceProvider:
+ self.public_key_hex
)
async def _gc_loop(self):
while not self._is_shutting_down():
if self.sub:
self.sub.gc(self.handle_missed_events)
await asyncio.sleep(60)
def get_supported_methods(self):
"""
Returns the list of supported methods by this service provider.
@ -123,8 +177,8 @@ class NWCServiceProvider:
self,
method: str,
listener: Callable[
["NWCServiceProvider", str, Dict],
Awaitable[List[Tuple[Optional[Dict], Optional[Dict], List]]],
["NWCServiceProvider", str, dict],
Awaitable[list[tuple[dict | None, dict | None, list]]],
],
):
"""
@ -145,8 +199,10 @@ class NWCServiceProvider:
Starts the NWC service provider.
"""
self.reconnect_task = asyncio.create_task(self._connect_to_relay())
self.gc_task = asyncio.create_task(self._gc_loop())
self.info_event_task = asyncio.create_task(self._info_event_loop())
def _json_dumps(self, data: Union[Dict, list]) -> str:
def _json_dumps(self, data: Union[dict, list]) -> str:
"""
Converts a Python dictionary to a JSON string with compact encoding.
@ -156,7 +212,7 @@ class NWCServiceProvider:
Returns:
str: The compact JSON string.
"""
if isinstance(data, Dict):
if isinstance(data, dict):
data = {k: v for k, v in data.items() if v is not None}
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
@ -166,7 +222,7 @@ class NWCServiceProvider:
"""
return self.shutdown or not settings.lnbits_running
async def _send(self, data: List[Any]):
async def _send(self, data: list[Any]):
"""
Sends data to the relay.
@ -210,7 +266,7 @@ class NWCServiceProvider:
await asyncio.sleep(1)
async def _ratelimit(self, unit: str, max_sleep_time: int = 120) -> None:
limit: Optional[RateLimit] = self.rate_limit.get(unit)
limit: RateLimit | None = self.rate_limit.get(unit)
if not limit:
self.rate_limit[unit] = limit = RateLimit()
@ -226,36 +282,49 @@ class NWCServiceProvider:
await asyncio.sleep(limit.backoff)
limit.last_attempt_time = int(time.time())
def _create_subscription(self) -> MainSubscription:
sub = MainSubscription()
self.sub = sub
return sub
async def _subscribe(self):
"""
[Re]Subscribe to receive nip 47 requests and responses from the relay
"""
self.sub = MainSubscription()
sub = self._create_subscription()
# Create requests subscription
req_filter = {
"kinds": [23194],
"#p": [self.public_key_hex],
# Since the last 3 hours (handles reboots)
"since": int(time.time()) - 3 * 60 * 60,
# Since the last handle_missed_events seconds (handles reboots)
"since": int(time.time()) - self.handle_missed_events,
}
self.sub.requests_sub_id = self._get_new_subid()
sub.requests_sub_id = self._get_new_subid()
# Create responses subscription (needed to track previosly responded requests)
res_filter = {
"kinds": [23195],
"authors": [self.public_key_hex],
"since": int(time.time()) - 3 * 60 * 60,
"since": int(time.time()) - self.handle_missed_events,
}
self.sub.responses_sub_id = self._get_new_subid()
sub.responses_sub_id = self._get_new_subid()
# Subscribe
await self._send(["REQ", self.sub.requests_sub_id, req_filter])
await self._send(["REQ", self.sub.responses_sub_id, res_filter])
await self._send(["REQ", sub.requests_sub_id, req_filter])
await self._send(["REQ", sub.responses_sub_id, res_filter])
async def _on_connection(self, ws):
async def _on_connection(self, _):
"""
On connection callback, announce the service provider
methods and subscribe to nip67 events.
"""
# Send info event
await self._send_info_event()
# Resubscribe to nwc events
await self._subscribe()
async def _send_info_event(self):
"""
Build and publish the NWC service info event (kind 13194).
"""
event = {
"kind": 13194,
"content": " ".join(self.supported_methods),
@ -264,23 +333,48 @@ class NWCServiceProvider:
}
self._sign_event(event)
await self._send(["EVENT", event])
# Resubscribe to nwc events
await self._subscribe()
async def _handle_request(self, event: Dict) -> List[Dict]:
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]:
"""
Handle a nwc request
"""
if not self.sub:
raise Exception("Subscription is not established")
sub = self.sub
expire = sub.seen_requests.get(event["id"])
if expire or event["created_at"] < int(time.time() - self.event_max_age):
raise Exception("Event is too old or already handled")
expiration = self._extract_expiration_from_tags(event["tags"])
if expiration <= 0:
expiration = int(time.time() + self.event_max_age)
sub.seen_requests[event["id"]] = expiration
nwc_pubkey = event["pubkey"]
content = event["content"]
# Decrypt the content
content = self._decrypt_content(content, nwc_pubkey)
content = self.private_key.decrypt_message(content, nwc_pubkey)
# Deserialize content
content = json.loads(content)
# Handle request
method = content["method"]
listener = self.request_listeners.get(method, None)
outs: List[Dict[str, Any]] = []
outs: list[dict[str, Any]] = []
if not listener:
outs.append(
{
@ -298,7 +392,7 @@ class NWCServiceProvider:
for result in results:
r = result[0]
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}
outs.append(out)
except Exception as e:
@ -312,11 +406,13 @@ class NWCServiceProvider:
content["result"] = out["result"]
if "error" in out:
content["error"] = out["error"]
raw_tags = out.get("tags")
tags = list(raw_tags) if isinstance(raw_tags, list) else []
# Prepare response event
res: Dict = {
res: dict = {
"kind": 23195,
"created_at": int(time.time()),
"tags": out.get("tags", []),
"tags": tags,
"content": self._json_dumps(content),
}
# Reference request
@ -324,8 +420,9 @@ class NWCServiceProvider:
# Reference user
res["tags"].append(["p", nwc_pubkey])
# Finalize response event
print(res)
res["content"] = self._encrypt_content(res["content"], nwc_pubkey)
res["content"] = self.private_key.encrypt_message(
res["content"], nwc_pubkey
)
self._sign_event(res)
# Register response for this request, so we knows it is not stale
@ -337,6 +434,30 @@ class NWCServiceProvider:
sent_events.append(res)
return sent_events
def _log_request_task_exception(self, task: asyncio.Future[list[dict]]) -> None:
if task.cancelled():
return
exception = task.exception()
if exception:
logger.error("Error handling request: " + str(exception))
def _dispatch_request(self, event: dict) -> None:
task = asyncio.create_task(self._handle_request(event))
self.request_tasks.add(task)
task.add_done_callback(self.request_tasks.discard)
task.add_done_callback(self._log_request_task_exception)
def _extract_expiration_from_tags(self, tags: list) -> int:
expiration = -1
for tag in tags:
try:
if tag[0] == "expiration" and len(tag) > 1:
expiration = int(tag[1])
break
except Exception:
pass
return expiration
async def _on_event_message(self, msg):
if not self.sub:
return
@ -346,7 +467,7 @@ class NWCServiceProvider:
if not self._verify_event(event):
raise Exception("Invalid event signature")
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
if expiration > 0 and expiration < int(time.time()):
logger.debug("Event expired")
@ -365,7 +486,7 @@ class NWCServiceProvider:
# already handled or stale, all stale requests will be handled
# later when eose is received
if self.sub.requests_eose and self.sub.responses_eose:
await self._handle_request(event)
self._dispatch_request(event)
elif event["kind"] == 23195 and sub_id == self.sub.responses_sub_id:
# Ensure the response is from this service provider
if event["pubkey"] != self.public_key_hex:
@ -393,7 +514,7 @@ class NWCServiceProvider:
if self.sub.requests_eose and self.sub.responses_eose:
stales = self.sub.get_stale()
for stale in stales:
await self._handle_request(stale)
self._dispatch_request(stale)
async def _on_closed_message(self, msg):
if not self.sub:
@ -413,7 +534,7 @@ class NWCServiceProvider:
await self._ratelimit("subscribing")
await self._subscribe()
async def _on_message(self, ws, message: str):
async def _on_message(self, _, message: str):
"""
Handle incoming messages from the relay.
"""
@ -446,7 +567,7 @@ class NWCServiceProvider:
): # Reconnect until the instance is shutting down
logger.debug("Creating new connection...")
try:
async with websockets.connect(self.relay) as ws:
async with connect(self.relay) as ws:
self.ws = ws
self.connected = True
await self._on_connection(ws)
@ -474,66 +595,7 @@ class NWCServiceProvider:
logger.debug("Reconnecting to NWC relay...")
await self._ratelimit("connecting")
def _encrypt_content(
self, content: str, pubkey_hex: str, iv_seed: Optional[int] = 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
@ -557,14 +619,12 @@ class NWCServiceProvider:
if event_id != event["id"]: # Invalid event id
return False
pubkey_hex = event["pubkey"]
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
if not pubkey.schnorr_verify(
bytes.fromhex(event_id), bytes.fromhex(event["sig"]), None, raw=True
):
pubkey = PublicKeyXOnly(bytes.fromhex(pubkey_hex))
if not pubkey.verify(bytes.fromhex(event["sig"]), bytes.fromhex(event_id)):
return False
return True
def _sign_event(self, event: Dict) -> Dict:
def _sign_event(self, event: dict) -> dict:
"""
Signs the event (in place)
@ -589,10 +649,8 @@ class NWCServiceProvider:
event["id"] = event_id
event["pubkey"] = self.public_key_hex
signature = (
self.private_key.schnorr_sign(bytes.fromhex(event_id), None, raw=True)
).hex()
event["sig"] = signature
signature = self.private_key.sign(bytes.fromhex(event_id))
event["sig"] = signature.hex() # type: ignore
return event
async def cleanup(self):
@ -604,6 +662,22 @@ class NWCServiceProvider:
self.reconnect_task.cancel()
except Exception as e:
logger.warning("Error closing reconnection task: " + str(e))
try:
if self.gc_task:
self.gc_task.cancel()
except Exception as e:
logger.warning("Error closing gc loop: " + str(e))
try:
if self.info_event_task:
self.info_event_task.cancel()
except Exception as e:
logger.warning("Error closing info event loop: " + str(e))
request_tasks = list(self.request_tasks)
for task in request_tasks:
task.cancel()
if request_tasks:
await asyncio.gather(*request_tasks, return_exceptions=True)
self.request_tasks.clear()
# close the websocket
try:
if self.ws:

62
package-lock.json generated Normal file
View file

@ -0,0 +1,62 @@
{
"name": "nwcprovider",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nwcprovider",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"prettier": "^3.8.1",
"pyright": "^1.1.408"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pyright": {
"version": "1.1.408",
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.408.tgz",
"integrity": "sha512-N61pxaLLCsPcUuPPHMNIrGoZgGBgrbjBX5UqkaT5UV8NVZdL7ExsO6N3ectv1DzAUsLOzdlyqoYtX76u8eF4YA==",
"license": "MIT",
"bin": {
"pyright": "index.js",
"pyright-langserver": "langserver.index.js"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
}
}
}

15
package.json Normal file
View file

@ -0,0 +1,15 @@
{
"name": "nwcprovider",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"prettier": "^3.8.1",
"pyright": "^1.1.408"
}
}

185
paranoia.py Normal file
View file

@ -0,0 +1,185 @@
# Run-time hardening to detect unexpected inputs at the edges
from loguru import logger
ENABLE_HARDENING = True
WHITELISTED_NON_PRINTABLE_CHARS = {
"\n", # newline
"\r", # carriage return
"\t", # tab
"\xa0", # non-breaking space (&nbsp;)
}
def panic(reason: str):
if not ENABLE_HARDENING:
return
logger.error(f"hardening: {reason}")
raise ValueError(f"hardening: {reason}")
# Throw if string contains any non-printable characters
def assert_printable(v: str):
if not ENABLE_HARDENING:
return
if not isinstance(v, str):
panic("not a string " + str(v))
for ch in v:
# check if printable
if ch.isprintable():
continue
# check if whitelisted non-printable
if ch in WHITELISTED_NON_PRINTABLE_CHARS:
continue
# Anything else is rejected
panic(f"string contains non-printable character: (0x{ord(ch):04X})")
# Check if number is valid int and not NaN
def assert_valid_int(v: int):
if not ENABLE_HARDENING:
return
if not isinstance(v, int):
panic("number is not a valid int")
# Check if number is valid positive int
def assert_valid_positive_int(v: int):
if not ENABLE_HARDENING:
return
assert_valid_int(v)
if v < 0:
panic("number is not positive")
# Check if number is a valid sats amount
def assert_valid_sats(v: int):
if not ENABLE_HARDENING:
return
assert_valid_positive_int(v)
max_sats_value = 10_000_000
if v >= max_sats_value:
panic("sats amount looks too high")
# Check if number is a valid msats amount
def assert_valid_msats(v: int):
if not ENABLE_HARDENING:
return
assert_valid_positive_int(v)
max_msats_value = 10_000_000 * 1000
if v >= max_msats_value:
panic("msats amount looks too high")
# Check if string is a valid sha256 hash
def assert_valid_sha256(v: str):
if not ENABLE_HARDENING:
return
assert_printable(v)
if len(v) != 64 or not all(c in "0123456789abcdef" for c in v):
panic("string is not a valid sha256 hash")
# Check if value is an hash of an unexpected input (eg. empty strings, booleans etc)
def assert_no_badhash(v: str):
bad_hashes = [
# empty string
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
# 1 space string
"36a9e7f1c95b82ffb99743e0c5c4ce95d83c9a430aac59f84ef3cbfab6145068",
# None
"c1c4b7fbd3e146bb14ec6258e5231c1ec703590721ff1e321b179a62b5857c9c",
# True
"cdca0b9bb2325fc8ed7eba7734a3a1f876d919221399b6587ae7d26305adee9d",
# False
"f9e08f8b038b1b401497f17da3adc120667ac742bf035657869a6ca1cd180e69",
]
if v in bad_hashes:
panic("bad hash detected")
# Check if valid nostr pubkey
def assert_valid_pubkey(v: str):
if not ENABLE_HARDENING:
return
assert_valid_sha256(v)
assert_no_badhash(v)
# Check if valid wallet id
def assert_valid_wallet_id(v: str):
if not ENABLE_HARDENING:
return
assert_printable(v)
if not v.isalnum():
panic("string is not a valid wallet id")
# Check if valid timestamp in seconds
def assert_valid_timestamp_seconds(v: int):
if not ENABLE_HARDENING:
return
assert_valid_positive_int(v)
if v > 2**31:
panic("timestamp is too high")
# Check if valid expiration in seconds
def assert_valid_expiration_seconds(v: int):
if not ENABLE_HARDENING:
return
assert_valid_int(v)
if v == -1:
return
if v < 0:
panic("expiration is invalid")
if v > 2**31:
panic("expiration is too high")
# Check if string is within sane parameters
def assert_sane_string(v: str):
if not ENABLE_HARDENING:
return
assert_printable(v)
if len(v) > 1024:
panic("string is too long")
# Check if string is a non-empty string
def assert_non_empty_string(v: str):
if not ENABLE_HARDENING:
return
assert_printable(v)
if len(v.strip()) == 0:
panic("string is empty")
# Assert valid json
def assert_valid_json(v: str):
if not ENABLE_HARDENING:
return
assert_non_empty_string(v)
try:
import json
json.loads(v)
except Exception as e:
panic("string is not valid json " + str(e))
# Check if string is a valid bolt11 invoice
def assert_valid_bolt11(invoice: str):
if not ENABLE_HARDENING:
return
assert_printable(invoice)
# Check if boolean
def assert_boolean(v: bool):
if not ENABLE_HARDENING:
return
if not isinstance(v, bool):
panic("not a boolean")

4221
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,37 +1,41 @@
[tool.poetry]
[project]
name = "nwcprovider"
version = "0.0.0"
description = "A NWC service provider for LNbits."
authors = ["Riccardo Balbo <oc@rblb.it>"]
requires-python = ">=3.10,<3.13"
authors = [{ name = "Riccardo Balbo", email = "oc@rblb.it" }]
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/nwcprovider" }
dependencies = [ "lnbits>1" ]
[tool.poetry.dependencies]
python = "^3.10 | ^3.9"
lnbits = {version = "*", allow-prereleases = true}
mypy = "^1.13.0"
[dependency-groups]
dev = [
"black",
"pytest-asyncio",
"pytest",
"mypy",
"pre-commit",
"ruff",
"pytest-md",
]
[tool.poetry.group.dev.dependencies]
black = "^24.3.0"
pytest-asyncio = "^0.21.0"
pytest = "^7.3.2"
mypy = "^1.5.1"
pre-commit = "^3.2.2"
ruff = "^0.3.2"
pytest-md = "^0.2.0"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry]
package-mode = false
[tool.mypy]
plugins = ["pydantic.mypy"]
[[tool.mypy.overrides]]
module = [
"lnbits.*",
"loguru.*",
"fastapi.*",
"pydantic.*",
"pynostr.*",
]
ignore_missing_imports = "True"
[tool.pydantic-mypy]
init_forbid_extra = true
init_typed = true
warn_required_dynamic_aliases = true
warn_untyped_fields = true
[tool.pytest.ini_options]
log_cli = false
testpaths = [
@ -79,8 +83,8 @@ classmethod-decorators = [
# [tool.ruff.lint.extend-per-file-ignores]
# "views_api.py" = ["F401"]
# [tool.ruff.lint.mccabe]
# max-complexity = 10
[tool.ruff.lint.mccabe]
max-complexity = 11
[tool.ruff.lint.flake8-bugbear]
# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.

BIN
static/image/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
static/image/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
static/image/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

77
static/js/admin.js Normal file
View file

@ -0,0 +1,77 @@
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
delimiters: ['${', '}'],
data: function () {
return {
config: {},
columns: [
{
name: 'key',
required: true,
label: 'Key',
align: 'left',
field: row => row.key,
sortable: true
},
{
name: 'value',
required: true,
label: 'Value',
align: 'left',
field: row => row.value,
sortable: true
}
]
}
},
methods: {
fetchConfig() {
this.entries = []
LNbits.api
.request('GET', '/nwcprovider/api/v1/config')
.then(response => {
this.config = response.data
console.log('Config fetched:', this.config)
})
.catch(function (error) {
console.error('Error fetching config:', error)
})
},
async saveConfig() {
const data = {}
for (const [key, value] of Object.entries(this.config)) {
data[key] = value
}
console.log('Saving config:', data)
try {
const response = await LNbits.api.request(
'POST',
'/nwcprovider/api/v1/config',
null,
data
)
Quasar.Notify.create({
type: 'positive',
message: 'Config saved!'
})
Quasar.Notify.create({
type: 'warning',
message:
'You need to restart the server for the changes to take effect!'
})
} catch (error) {
Quasar.Notify.create({
type: 'negative',
message: 'Error saving config: ' + String(error)
})
console.error('Error saving config:', error)
}
}
},
created: function () {
this.fetchConfig()
}
})

385
static/js/index.js Normal file
View file

@ -0,0 +1,385 @@
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
delimiters: ['${', '}'],
data: function () {
return {
selectedWallet: null,
nodePermissions: [],
nwcEntries: [],
nwcsTable: {
columns: [
{
name: 'description',
align: 'left',
label: 'Description',
field: 'description'
},
{name: 'status', align: 'left', label: 'Status', field: 'status'},
{
name: 'last_used',
align: 'left',
label: 'Last used',
field: 'last_used'
},
{
name: 'created_at',
align: 'left',
label: 'Created',
field: 'created_at'
},
{
name: 'expires_at',
align: 'left',
label: 'Expires',
field: 'expires_at'
}
],
pagination: {
rowsPerPage: 10
}
},
connectDialog: {
show: false,
data: {}
},
pairingDialog: {
show: false,
data: {
pairingUrl: ''
}
},
pairingQrDialog: {
show: false,
data: {
pairingUrl: ''
}
},
connectionInfoDialog: {
show: false,
data: {}
}
}
},
methods: {
showConnectDialog() {
const wallet = this.getWallet()
if (!wallet) {
Quasar.Notify.create({
type: 'negative',
message: 'Please select a wallet first'
})
return
} else {
this.connectDialog.show = true
}
},
openConnectionInfoDialog(data) {
this.connectionInfoDialog.data = data
this.connectionInfoDialog.show = true
},
closeConnectionInfoDialog() {
this.connectionInfoDialog.show = false
},
openPairingUrl() {
const url = this.pairingDialog.data.pairingUrl
if (url) window.open(url, '_blank')
},
go(url) {
window.open(url, '_blank')
},
async copyPairingUrl() {
const url = this.pairingDialog.data.pairingUrl
if (url) {
try {
await navigator.clipboard.writeText(url)
Quasar.Notify.create({
type: 'positive',
message: 'URL copied to clipboard'
})
} catch (err) {
Quasar.Notify.create({
type: 'negative',
message: 'Failed to copy URL.'
})
}
}
},
showPairingQR() {
this.pairingQrDialog.data.pairingUrl = this.pairingDialog.data.pairingUrl
this.pairingQrDialog.show = true
},
closePairingQrDialog() {
this.pairingQrDialog.show = false
},
loadConnectDialogData() {
this.connectDialog.data = {
description: '',
expires_at: Date.now() + 1000 * 60 * 60 * 24 * 7,
neverExpires: true,
permissions: [],
budgets: []
}
for (const permission of this.nodePermissions) {
this.connectDialog.data.permissions.push({
key: permission.key,
name: permission.name,
value: permission.value
})
}
},
deleteBudget(index) {
this.connectDialog.data.budgets.splice(index, 1)
},
addBudget() {
this.connectDialog.data.budgets.push({
budget_sats: 1000,
used_budget_sats: 0,
created_at: new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
expiration: 'never'
})
},
closeConnectDialog() {
this.connectDialog.show = false
this.loadConnectDialogData()
},
getWallet: function () {
let wallet = undefined
for (let i = 0; i < this.g.user.wallets.length; i++) {
if (this.g.user.wallets[i].id == this.selectedWallet) {
wallet = this.g.user.wallets[i]
break
}
}
return wallet
},
async generateKeyPair() {
while (!window.NobleSecp256k1) {
await new Promise(resolve => setTimeout(resolve, 1))
}
const privKeyBytes = window.NobleSecp256k1.utils.randomPrivateKey()
const pubKeyBytes = window.NobleSecp256k1.getPublicKey(privKeyBytes)
const out = {
privKeyBytes: privKeyBytes,
pubKeyBytes: pubKeyBytes,
privKey: window.NobleSecp256k1.etc.bytesToHex(privKeyBytes),
pubKey: window.NobleSecp256k1.etc.bytesToHex(pubKeyBytes.slice(1))
}
return out
},
deleteNWC: async function (pubkey) {
Quasar.Dialog.create({
title: 'Confirm Deletion',
message: 'Are you sure you want to delete this connection?',
cancel: true,
persistent: true
})
.onOk(async () => {
try {
const wallet = this.getWallet()
const response = await LNbits.api.request(
'DELETE',
`/nwcprovider/api/v1/nwc/${pubkey}`,
wallet.adminkey
)
this.loadNwcs()
Quasar.Notify.create({
type: 'positive',
message: 'Deleted successfully'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
})
.onCancel(() => {
// User canceled the operation
})
},
loadNwcs: async function () {
const wallet = this.getWallet()
if (!wallet) {
this.nwcs = []
return
}
try {
const response = await LNbits.api.request(
'GET',
'/nwcprovider/api/v1/nwc?include_expired=true&calculate_spent_budget=true',
wallet.adminkey
)
this.nwcs = response.data
} catch (error) {
this.nwcs = []
}
try {
const response = await LNbits.api.request(
'GET',
'/nwcprovider/api/v1/permissions',
wallet.adminkey
)
const permissions = []
for (const [key, value] of Object.entries(response.data)) {
permissions.push({
key: key,
name: value.name,
value: value.default
})
}
this.nodePermissions = permissions
} catch (error) {
Lnbits.utils.notifyApiError(error)
}
this.loadConnectDialogData()
const newTableEntries = []
for (const nwc of this.nwcs) {
const t = Quasar.date.formatDate(
new Date(nwc.data.created_at * 1000),
'YYYY-MM-DD HH:mm'
)
const e =
nwc.data.expires_at > 0
? Quasar.date.formatDate(
new Date(nwc.data.expires_at * 1000),
'YYYY-MM-DD HH:mm'
)
: 'Never'
const l = Quasar.date.formatDate(
new Date(nwc.data.last_used * 1000),
'YYYY-MM-DD HH:mm'
)
const nwcTableEntry = {
description: nwc.data.description,
created_at: t,
expires_at: e,
last_used: l,
pubkey: nwc.data.pubkey,
permissions: nwc.data.permissions,
budgets: [],
status: 'Active'
}
if (
nwc.data.expires_at > 0 &&
nwc.data.expires_at < new Date().getTime() / 1000
) {
nwcTableEntry.status = 'Expired'
}
for (const budget of nwc.budgets) {
const createdAt = Quasar.date.formatDate(
new Date(budget.created_at * 1000),
'YYYY-MM-DD HH:mm'
)
let refreshWindow = budget.refresh_window
if (refreshWindow <= 0) {
refreshWindow = 'Never'
} else if (refreshWindow == 60 * 60 * 24) {
refreshWindow = 'Daily'
} else if (refreshWindow == 60 * 60 * 24 * 7) {
refreshWindow = 'Weekly'
} else if (refreshWindow == 60 * 60 * 24 * 30) {
refreshWindow = 'Monthly'
} else if (refreshWindow == 60 * 60 * 24 * 365) {
refreshWindow = 'Yearly'
}
nwcTableEntry.budgets.push({
budget_sats: budget.budget_msats / 1000,
used_budget_sats: budget.used_budget_msats / 1000,
created_at: createdAt,
refresh_window: refreshWindow
})
}
newTableEntries.push(nwcTableEntry)
}
this.nwcEntries = newTableEntries
},
closePairingDialog() {
this.pairingDialog.show = false
},
async showPairingDialog(secret) {
let response = await LNbits.api.request(
'GET',
'/nwcprovider/api/v1/pairing/{SECRET}'
)
response = response.data
response = response.replace('{SECRET}', secret)
this.pairingDialog.data.pairingUrl = response
this.pairingDialog.show = true
},
async confirmConnectDialog() {
const keyPair = await this.generateKeyPair()
// timestamp
let expires_at = 0
if (!this.connectDialog.data.neverExpires) {
expires_at =
new Date(this.connectDialog.data.expires_at).getTime() / 1000
}
const data = {
permissions: [],
description: this.connectDialog.data.description,
expires_at: expires_at,
budgets: []
}
for (const permission of this.connectDialog.data.permissions) {
if (permission.value) data.permissions.push(permission.key)
}
for (const budget of this.connectDialog.data.budgets) {
const budget_msats = budget.budget_sats * 1000
let refresh_window = 0
switch (budget.expiry) {
case 'Daily':
refresh_window = 60 * 60 * 24
break
case 'Weekly':
refresh_window = 60 * 60 * 24 * 7
break
case 'Monthly':
refresh_window = 60 * 60 * 24 * 30
break
case 'Yearly':
refresh_window = 60 * 60 * 24 * 365
break
case 'Never':
refresh_window = 0
break
}
data.budgets.push({
budget_msats: budget_msats,
refresh_window: refresh_window,
created_at: new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000
})
}
const wallet = this.getWallet()
try {
const response = await LNbits.api.request(
'PUT',
'/nwcprovider/api/v1/nwc/' + keyPair.pubKey,
wallet.adminkey,
data
)
this.closeConnectDialog()
if (
!response.data ||
!response.data.data ||
!response.data.data.pubkey
) {
LNbits.utils.notifyApiError('Error creating nwc pairing')
return
}
this.showPairingDialog(keyPair.privKey)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
this.loadNwcs()
}
},
created: function () {
this.loadNwcs()
},
watch: {
selectedWallet(newValue, oldValue) {
this.loadNwcs()
}
}
})

View file

@ -6,60 +6,60 @@ const B256 = 2n ** 256n,
Gy = 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n,
CURVE = {p: P, n: N, a: 0n, b: 7n, Gx, Gy},
fLen = 32,
crv = (t) => mod(mod(t * t) * t + CURVE.b),
err = (t = "") => {
throw new Error(t);
crv = t => mod(mod(t * t) * t + CURVE.b),
err = (t = '') => {
throw new Error(t)
},
big = (t) => "bigint" == typeof t,
str = (t) => "string" == typeof t,
fe = (t) => big(t) && 0n < t && t < P,
ge = (t) => big(t) && 0n < t && t < N,
isu8 = (t) =>
big = t => 'bigint' == typeof t,
str = t => 'string' == typeof t,
fe = t => big(t) && 0n < t && t < P,
ge = t => big(t) && 0n < t && t < N,
isu8 = t =>
t instanceof Uint8Array ||
(null != t && "object" == typeof t && "Uint8Array" === t.constructor.name),
(null != t && 'object' == typeof t && 'Uint8Array' === t.constructor.name),
au8 = (t, n) =>
!isu8(t) || ("number" == typeof n && n > 0 && t.length !== n)
? err("Uint8Array expected")
!isu8(t) || ('number' == typeof n && n > 0 && t.length !== n)
? err('Uint8Array expected')
: t,
u8n = (t) => new Uint8Array(t),
u8n = t => new Uint8Array(t),
toU8 = (t, n) => au8(str(t) ? h2b(t) : u8n(au8(t)), n),
mod = (t, n = P) => {
let e = t % n;
return e >= 0n ? e : n + e;
let e = t % n
return e >= 0n ? e : n + e
},
isPoint = (t) => (t instanceof Point ? t : err("Point expected"));
isPoint = t => (t instanceof Point ? t : err('Point expected'))
class Point {
constructor(t, n, e) {
(this.px = t), (this.py = n), (this.pz = e);
;((this.px = t), (this.py = n), (this.pz = e))
}
static fromAffine(t) {
return 0n === t.x && 0n === t.y ? Point.ZERO : new Point(t.x, t.y, 1n);
return 0n === t.x && 0n === t.y ? Point.ZERO : new Point(t.x, t.y, 1n)
}
static fromHex(t) {
let n;
let n
const e = (t = toU8(t))[0],
r = t.subarray(1),
o = slcNum(r, 0, 32),
i = t.length;
i = t.length
if (33 === i && [2, 3].includes(e)) {
fe(o) || err("Point hex invalid: x not FE");
let t = sqrt(crv(o));
!(1 & ~e) !== (1n === (1n & t)) && (t = mod(-t)),
(n = new Point(o, t, 1n));
fe(o) || err('Point hex invalid: x not FE')
let t = sqrt(crv(o))
;(!(1 & ~e) !== (1n === (1n & t)) && (t = mod(-t)),
(n = new Point(o, t, 1n)))
}
return (
65 === i && 4 === e && (n = new Point(o, slcNum(r, 32, 64), 1n)),
n ? n.ok() : err("Point is not on curve")
);
n ? n.ok() : err('Point is not on curve')
)
}
static fromPrivateKey(t) {
return G.mul(toPriv(t));
return G.mul(toPriv(t))
}
get x() {
return this.aff().x;
return this.aff().x
}
get y() {
return this.aff().y;
return this.aff().y
}
equals(t) {
const {px: n, py: e, pz: r} = this,
@ -67,30 +67,30 @@ class Point {
a = mod(n * s),
c = mod(o * r),
d = mod(e * s),
u = mod(i * r);
return a === c && d === u;
u = mod(i * r)
return a === c && d === u
}
negate() {
return new Point(this.px, mod(-this.py), this.pz);
return new Point(this.px, mod(-this.py), this.pz)
}
double() {
return this.add(this);
return this.add(this)
}
add(t) {
const {px: n, py: e, pz: r} = this,
{px: o, py: i, pz: s} = isPoint(t),
{ a, b: c } = CURVE;
{a, b: c} = CURVE
let d = 0n,
u = 0n,
m = 0n;
const h = mod(3n * c);
m = 0n
const h = mod(3n * c)
let l = mod(n * o),
y = mod(e * i),
f = mod(r * s),
p = mod(n + e),
b = mod(o + i);
(p = mod(p * b)), (b = mod(l + y)), (p = mod(p - b)), (b = mod(n + r));
let g = mod(o + s);
b = mod(o + i)
;((p = mod(p * b)), (b = mod(l + y)), (p = mod(p - b)), (b = mod(n + r)))
let g = mod(o + s)
return (
(b = mod(b * g)),
(g = mod(l + f)),
@ -123,300 +123,301 @@ class Point {
(m = mod(g * m)),
(m = mod(m + l)),
new Point(d, u, m)
);
)
}
mul(t, n = !0) {
if (!n && 0n === t) return I;
if ((ge(t) || err("invalid scalar"), this.equals(G))) return wNAF(t).p;
if (!n && 0n === t) return I
if ((ge(t) || err('invalid scalar'), this.equals(G))) return wNAF(t).p
let e = I,
r = G;
r = G
for (let o = this; t > 0n; o = o.double(), t >>= 1n)
1n & t ? (e = e.add(o)) : n && (r = r.add(o));
return e;
1n & t ? (e = e.add(o)) : n && (r = r.add(o))
return e
}
mulAddQUns(t, n, e) {
return this.mul(n, !1).add(t.mul(e, !1)).ok();
return this.mul(n, !1).add(t.mul(e, !1)).ok()
}
toAffine() {
const { px: t, py: n, pz: e } = this;
if (this.equals(I)) return { x: 0n, y: 0n };
if (1n === e) return { x: t, y: n };
const r = inv(e);
const {px: t, py: n, pz: e} = this
if (this.equals(I)) return {x: 0n, y: 0n}
if (1n === e) return {x: t, y: n}
const r = inv(e)
return (
1n !== mod(e * r) && err("invalid inverse"),
1n !== mod(e * r) && err('invalid inverse'),
{x: mod(t * r), y: mod(n * r)}
);
)
}
assertValidity() {
const { x: t, y: n } = this.aff();
const {x: t, y: n} = this.aff()
return (
(fe(t) && fe(n)) || err("Point invalid: x or y"),
mod(n * n) === crv(t) ? this : err("Point invalid: not on curve")
);
(fe(t) && fe(n)) || err('Point invalid: x or y'),
mod(n * n) === crv(t) ? this : err('Point invalid: not on curve')
)
}
multiply(t) {
return this.mul(t);
return this.mul(t)
}
aff() {
return this.toAffine();
return this.toAffine()
}
ok() {
return this.assertValidity();
return this.assertValidity()
}
toHex(t = !0) {
const { x: n, y: e } = this.aff();
const {x: n, y: e} = this.aff()
return (
(t ? (0n === (1n & e) ? "02" : "03") : "04") + n2h(n) + (t ? "" : n2h(e))
);
(t ? (0n === (1n & e) ? '02' : '03') : '04') + n2h(n) + (t ? '' : n2h(e))
)
}
toRawBytes(t = !0) {
return h2b(this.toHex(t));
return h2b(this.toHex(t))
}
}
(Point.BASE = new Point(Gx, Gy, 1n)), (Point.ZERO = new Point(0n, 1n, 0n));
;((Point.BASE = new Point(Gx, Gy, 1n)), (Point.ZERO = new Point(0n, 1n, 0n)))
const {BASE: G, ZERO: I} = Point,
padh = (t, n) => t.toString(16).padStart(n, "0"),
b2h = (t) =>
padh = (t, n) => t.toString(16).padStart(n, '0'),
b2h = t =>
Array.from(t)
.map((t) => padh(t, 2))
.join(""),
h2b = (t) => {
const n = t.length;
(!str(t) || n % 2) && err("hex invalid 1");
const e = u8n(n / 2);
.map(t => padh(t, 2))
.join(''),
h2b = t => {
const n = t.length
;(!str(t) || n % 2) && err('hex invalid 1')
const e = u8n(n / 2)
for (let n = 0; n < e.length; n++) {
const r = 2 * n,
o = t.slice(r, r + 2),
i = Number.parseInt(o, 16);
(Number.isNaN(i) || i < 0) && err("hex invalid 2"), (e[n] = i);
i = Number.parseInt(o, 16)
;((Number.isNaN(i) || i < 0) && err('hex invalid 2'), (e[n] = i))
}
return e;
return e
},
b2n = (t) => BigInt("0x" + (b2h(t) || "0")),
b2n = t => BigInt('0x' + (b2h(t) || '0')),
slcNum = (t, n, e) => b2n(t.slice(n, e)),
n2b = (t) =>
big(t) && t >= 0n && t < B256 ? h2b(padh(t, 64)) : err("bigint expected"),
n2h = (t) => b2h(n2b(t)),
n2b = t =>
big(t) && t >= 0n && t < B256 ? h2b(padh(t, 64)) : err('bigint expected'),
n2h = t => b2h(n2b(t)),
concatB = (...t) => {
const n = u8n(t.reduce((t, n) => t + au8(n).length, 0));
let e = 0;
const n = u8n(t.reduce((t, n) => t + au8(n).length, 0))
let e = 0
return (
t.forEach((t) => {
n.set(t, e), (e += t.length);
t.forEach(t => {
;(n.set(t, e), (e += t.length))
}),
n
);
)
},
inv = (t, n = P) => {
(0n === t || n <= 0n) && err("no inverse n=" + t + " mod=" + n);
;(0n === t || n <= 0n) && err('no inverse n=' + t + ' mod=' + n)
let e = mod(t, n),
r = n,
o = 0n,
i = 1n,
s = 1n,
a = 0n;
a = 0n
for (; 0n !== e; ) {
const t = r / e,
n = r % e,
c = o - s * t,
d = i - a * t;
(r = e), (e = n), (o = s), (i = a), (s = c), (a = d);
d = i - a * t
;((r = e), (e = n), (o = s), (i = a), (s = c), (a = d))
}
return 1n === r ? mod(o, n) : err("no inverse");
return 1n === r ? mod(o, n) : err('no inverse')
},
sqrt = (t) => {
let n = 1n;
sqrt = t => {
let n = 1n
for (let e = t, r = (P + 1n) / 4n; r > 0n; r >>= 1n)
1n & r && (n = (n * e) % P), (e = (e * e) % P);
return mod(n * n) === t ? n : err("sqrt invalid");
(1n & r && (n = (n * e) % P), (e = (e * e) % P))
return mod(n * n) === t ? n : err('sqrt invalid')
},
toPriv = (t) => (
toPriv = t => (
big(t) || (t = b2n(toU8(t, 32))),
ge(t) ? t : err("private key out of range")
ge(t) ? t : err('private key out of range')
),
moreThanHalfN = (t) => t > N >> 1n,
getPublicKey = (t, n = !0) => Point.fromPrivateKey(t).toRawBytes(n);
moreThanHalfN = t => t > N >> 1n,
getPublicKey = (t, n = !0) => Point.fromPrivateKey(t).toRawBytes(n)
class Signature {
constructor(t, n, e) {
(this.r = t), (this.s = n), (this.recovery = e), this.assertValidity();
;((this.r = t), (this.s = n), (this.recovery = e), this.assertValidity())
}
static fromCompact(t) {
return (
(t = toU8(t, 64)), new Signature(slcNum(t, 0, 32), slcNum(t, 32, 64))
);
(t = toU8(t, 64)),
new Signature(slcNum(t, 0, 32), slcNum(t, 32, 64))
)
}
assertValidity() {
return ge(this.r) && ge(this.s) ? this : err();
return ge(this.r) && ge(this.s) ? this : err()
}
addRecoveryBit(t) {
return new Signature(this.r, this.s, t);
return new Signature(this.r, this.s, t)
}
hasHighS() {
return moreThanHalfN(this.s);
return moreThanHalfN(this.s)
}
normalizeS() {
return this.hasHighS()
? new Signature(this.r, mod(this.s, N), this.recovery)
: this;
: this
}
recoverPublicKey(t) {
const { r: n, s: e, recovery: r } = this;
[0, 1, 2, 3].includes(r) || err("recovery id invalid");
const {r: n, s: e, recovery: r} = this
;[0, 1, 2, 3].includes(r) || err('recovery id invalid')
const o = bits2int_modN(toU8(t, 32)),
i = 2 === r || 3 === r ? n + N : n;
i >= P && err("q.x invalid");
const s = 1 & r ? "03" : "02",
i = 2 === r || 3 === r ? n + N : n
i >= P && err('q.x invalid')
const s = 1 & r ? '03' : '02',
a = Point.fromHex(s + n2h(i)),
c = inv(i, N),
d = mod(-o * c, N),
u = mod(e * c, N);
return G.mulAddQUns(a, d, u);
u = mod(e * c, N)
return G.mulAddQUns(a, d, u)
}
toCompactRawBytes() {
return h2b(this.toCompactHex());
return h2b(this.toCompactHex())
}
toCompactHex() {
return n2h(this.r) + n2h(this.s);
return n2h(this.r) + n2h(this.s)
}
}
const bits2int = (t) => {
const bits2int = t => {
const n = 8 * t.length - 256,
e = b2n(t);
return n > 0 ? e >> BigInt(n) : e;
e = b2n(t)
return n > 0 ? e >> BigInt(n) : e
},
bits2int_modN = (t) => mod(bits2int(t), N),
i2o = (t) => n2b(t),
bits2int_modN = t => mod(bits2int(t), N),
i2o = t => n2b(t),
cr = () =>
"object" == typeof globalThis && "crypto" in globalThis
'object' == typeof globalThis && 'crypto' in globalThis
? globalThis.crypto
: void 0;
let _hmacSync;
: void 0
let _hmacSync
const optS = {lowS: !0},
optV = {lowS: !0},
prepSig = (t, n, e = optS) => {
["der", "recovered", "canonical"].some((t) => t in e) &&
err("sign() legacy options not supported");
let { lowS: r } = e;
null == r && (r = !0);
;['der', 'recovered', 'canonical'].some(t => t in e) &&
err('sign() legacy options not supported')
let {lowS: r} = e
null == r && (r = !0)
const o = bits2int_modN(toU8(t)),
i = i2o(o),
s = toPriv(n),
a = [i2o(s), i];
let c = e.extraEntropy;
a = [i2o(s), i]
let c = e.extraEntropy
if (c) {
!0 === c && (c = etc.randomBytes(32));
const t = toU8(c);
32 !== t.length && err(), a.push(t);
!0 === c && (c = etc.randomBytes(32))
const t = toU8(c)
;(32 !== t.length && err(), a.push(t))
}
const d = o;
const d = o
return {
seed: concatB(...a),
k2sig: (t) => {
const n = bits2int(t);
if (!ge(n)) return;
k2sig: t => {
const n = bits2int(t)
if (!ge(n)) return
const e = inv(n, N),
o = G.mul(n).aff(),
i = mod(o.x, N);
if (0n === i) return;
const a = mod(e * mod(d + mod(s * i, N), N), N);
if (0n === a) return;
i = mod(o.x, N)
if (0n === i) return
const a = mod(e * mod(d + mod(s * i, N), N), N)
if (0n === a) return
let c = a,
u = (o.x === i ? 0 : 2) | Number(1n & o.y);
u = (o.x === i ? 0 : 2) | Number(1n & o.y)
return (
r && moreThanHalfN(a) && ((c = mod(-a, N)), (u ^= 1)),
new Signature(i, c, u)
);
},
};
};
)
}
}
}
function hmacDrbg(t) {
let n = u8n(32),
e = u8n(32),
r = 0;
r = 0
const o = () => {
n.fill(1), e.fill(0), (r = 0);
;(n.fill(1), e.fill(0), (r = 0))
},
i = "drbg: tried 1000 values";
i = 'drbg: tried 1000 values'
if (t) {
const t = (...t) => etc.hmacSha256Async(e, n, ...t),
s = async (r = u8n()) => {
(e = await t(u8n([0]), r)),
;((e = await t(u8n([0]), r)),
(n = await t()),
0 !== r.length && ((e = await t(u8n([1]), r)), (n = await t()));
0 !== r.length && ((e = await t(u8n([1]), r)), (n = await t())))
},
a = async () => (r++ >= 1e3 && err(i), (n = await t()), n);
a = async () => (r++ >= 1e3 && err(i), (n = await t()), n)
return async (t, n) => {
let e;
for (o(), await s(t); !(e = n(await a())); ) await s();
return o(), e;
};
let e
for (o(), await s(t); !(e = n(await a())); ) await s()
return (o(), e)
}
}
{
const t = (...t) => {
const r = _hmacSync;
return r || err("etc.hmacSha256Sync not set"), r(e, n, ...t);
const r = _hmacSync
return (r || err('etc.hmacSha256Sync not set'), r(e, n, ...t))
},
s = (r = u8n()) => {
(e = t(u8n([0]), r)),
;((e = t(u8n([0]), r)),
(n = t()),
0 !== r.length && ((e = t(u8n([1]), r)), (n = t()));
0 !== r.length && ((e = t(u8n([1]), r)), (n = t())))
},
a = () => (r++ >= 1e3 && err(i), (n = t()), n);
a = () => (r++ >= 1e3 && err(i), (n = t()), n)
return (t, n) => {
let e;
for (o(), s(t); !(e = n(a())); ) s();
return o(), e;
};
let e
for (o(), s(t); !(e = n(a())); ) s()
return (o(), e)
}
}
}
const signAsync = async (t, n, e = optS) => {
const { seed: r, k2sig: o } = prepSig(t, n, e);
return hmacDrbg(!0)(r, o);
const {seed: r, k2sig: o} = prepSig(t, n, e)
return hmacDrbg(!0)(r, o)
},
sign = (t, n, e = optS) => {
const { seed: r, k2sig: o } = prepSig(t, n, e);
return hmacDrbg(!1)(r, o);
const {seed: r, k2sig: o} = prepSig(t, n, e)
return hmacDrbg(!1)(r, o)
},
verify = (t, n, e, r = optV) => {
let o,
i,
s,
{ lowS: a } = r;
null == a && (a = !0),
"strict" in r && err("verify() legacy options not supported");
const c = t && "object" == typeof t && "r" in t;
c || 64 === toU8(t).length || err("signature must be 64 bytes");
{lowS: a} = r
;(null == a && (a = !0),
'strict' in r && err('verify() legacy options not supported'))
const c = t && 'object' == typeof t && 'r' in t
c || 64 === toU8(t).length || err('signature must be 64 bytes')
try {
(o = c
;((o = c
? new Signature(t.r, t.s).assertValidity()
: Signature.fromCompact(t)),
(i = bits2int_modN(toU8(n))),
(s = e instanceof Point ? e.ok() : Point.fromHex(e));
(s = e instanceof Point ? e.ok() : Point.fromHex(e)))
} catch (t) {
return !1;
return !1
}
if (!o) return !1;
const { r: d, s: u } = o;
if (a && moreThanHalfN(u)) return !1;
let m;
if (!o) return !1
const {r: d, s: u} = o
if (a && moreThanHalfN(u)) return !1
let m
try {
const t = inv(u, N),
n = mod(i * t, N),
e = mod(d * t, N);
m = G.mulAddQUns(s, n, e).aff();
e = mod(d * t, N)
m = G.mulAddQUns(s, n, e).aff()
} catch (t) {
return !1;
return !1
}
if (!m) return !1;
return mod(m.x, N) === d;
if (!m) return !1
return mod(m.x, N) === d
},
getSharedSecret = (t, n, e = !0) =>
Point.fromHex(n).mul(toPriv(t)).toRawBytes(e),
hashToPrivateKey = (t) => {
((t = toU8(t)).length < 40 || t.length > 1024) &&
err("expected proper params");
const n = mod(b2n(t), N - 1n) + 1n;
return n2b(n);
hashToPrivateKey = t => {
;((t = toU8(t)).length < 40 || t.length > 1024) &&
err('expected proper params')
const n = mod(b2n(t), N - 1n) + 1n
return n2b(n)
},
etc = {
hexToBytes: h2b,
@ -428,84 +429,84 @@ const signAsync = async (t, n, e = optS) => {
invert: inv,
hmacSha256Async: async (t, ...n) => {
const e = cr(),
r = e && e.subtle;
if (!r) return err("etc.hmacSha256Async not set");
r = e && e.subtle
if (!r) return err('etc.hmacSha256Async not set')
const o = await r.importKey(
"raw",
'raw',
t,
{ name: "HMAC", hash: { name: "SHA-256" } },
{name: 'HMAC', hash: {name: 'SHA-256'}},
!1,
["sign"],
);
return u8n(await r.sign("HMAC", o, concatB(...n)));
['sign']
)
return u8n(await r.sign('HMAC', o, concatB(...n)))
},
hmacSha256Sync: _hmacSync,
hashToPrivateKey,
randomBytes: (t = 32) => {
const n = cr();
const n = cr()
return (
(n && n.getRandomValues) ||
err("crypto.getRandomValues must be defined"),
err('crypto.getRandomValues must be defined'),
n.getRandomValues(u8n(t))
);
},
)
}
},
utils = {
normPrivateKeyToScalar: toPriv,
isValidPrivateKey: (t) => {
isValidPrivateKey: t => {
try {
return !!toPriv(t);
return !!toPriv(t)
} catch (t) {
return !1;
return !1
}
},
randomPrivateKey: () => hashToPrivateKey(etc.randomBytes(48)),
precompute: (t = 8, n = G) => (n.multiply(3n), n),
};
precompute: (t = 8, n = G) => (n.multiply(3n), n)
}
Object.defineProperties(etc, {
hmacSha256Sync: {
configurable: !1,
get: () => _hmacSync,
set(t) {
_hmacSync || (_hmacSync = t);
},
},
});
_hmacSync || (_hmacSync = t)
}
}
})
const W = 8,
precompute = () => {
const t = [];
const t = []
let n = G,
e = n;
e = n
for (let r = 0; r < 33; r++) {
(e = n), t.push(e);
for (let r = 1; r < 128; r++) (e = e.add(n)), t.push(e);
n = e.double();
;((e = n), t.push(e))
for (let r = 1; r < 128; r++) ((e = e.add(n)), t.push(e))
n = e.double()
}
return t;
};
let Gpows;
const wNAF = (t) => {
return t
}
let Gpows
const wNAF = t => {
const n = Gpows || (Gpows = precompute()),
e = (t, n) => {
let e = n.negate();
return t ? e : n;
};
let e = n.negate()
return t ? e : n
}
let r = I,
o = G;
o = G
const i = BigInt(255),
s = BigInt(8);
s = BigInt(8)
for (let a = 0; a < 33; a++) {
const c = 128 * a;
let d = Number(t & i);
(t >>= s), d > 128 && ((d -= 256), (t += 1n));
const c = 128 * a
let d = Number(t & i)
;((t >>= s), d > 128 && ((d -= 256), (t += 1n)))
const u = c,
m = c + Math.abs(d) - 1,
h = a % 2 != 0,
l = d < 0;
0 === d ? (o = o.add(e(h, n[u]))) : (r = r.add(e(l, n[m])));
l = d < 0
0 === d ? (o = o.add(e(h, n[u]))) : (r = r.add(e(l, n[m])))
}
return {p: r, f: o}
}
return { p: r, f: o };
};
export {
getPublicKey,
sign,
@ -516,5 +517,5 @@ export {
etc,
utils,
Point as ProjectivePoint,
Signature,
};
Signature
}

0
static/routes.json Normal file
View file

280
tasks.py
View file

@ -1,7 +1,7 @@
import asyncio
import time
from math import ceil
from typing import Any, Dict, List, Optional, Tuple
from typing import Any
from bolt11 import decode as bolt11_decode
from lnbits.core.crud import get_payments, get_wallet, get_wallet_payment
@ -19,12 +19,27 @@ from loguru import logger
from .crud import get_config_nwc, get_nwc, tracked_spend_nwc
from .execution_queue import execution_queue
from .models import NWCKey, OnInvoicePaid
from .models import GetNWC, NWCKey, TrackedSpendNWC
from .nwcp import NWCServiceProvider
from .paranoia import (
assert_boolean,
assert_sane_string,
assert_valid_bolt11,
assert_valid_expiration_seconds,
assert_valid_msats,
assert_valid_positive_int,
assert_valid_pubkey,
assert_valid_sha256,
assert_valid_wallet_id,
)
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: Optional[NWCKey], method: str) -> Optional[Dict]:
async def _check(nwc: NWCKey | None, method: str) -> dict | None:
# check
if not nwc:
return {
@ -35,8 +50,8 @@ async def _check(nwc: Optional[NWCKey], method: str) -> Optional[Dict]:
allowed = False
permissions = nwc.get_permissions()
for p in permissions:
permissions_data: Dict[str, Any] = nwc_permissions.get(p, {})
allowed_methods: List[str] = permissions_data.get("methods", [])
permissions_data: dict[str, Any] = nwc_permissions.get(p, {})
allowed_methods: list[str] = permissions_data.get("methods", [])
if method in allowed_methods:
allowed = True
break
@ -53,20 +68,31 @@ async def _process_invoice(
pubkey: str,
invoice: str,
amount_msats: int,
description: Optional[str] = None,
description: str | None = None,
):
async def execute_payment():
return await pay_invoice(
# hardening #
assert_valid_wallet_id(wallet_id)
assert_valid_pubkey(pubkey)
assert_valid_bolt11(invoice)
assert_valid_msats(amount_msats)
if description:
assert_sane_string(description)
# ## #
async def execute_payment() -> str:
payment = await pay_invoice(
wallet_id=wallet_id,
payment_request=invoice,
max_sat=int(ceil(amount_msats / 1000)),
max_sat=ceil(amount_msats / 1000),
description=description or "",
)
return payment.payment_hash
payment_hash = None
try:
in_budget, payment_hash = await tracked_spend_nwc(
pubkey, amount_msats, execute_payment
TrackedSpendNWC(pubkey=pubkey, amount_msats=amount_msats), execute_payment
)
if not in_budget:
error = {
@ -87,12 +113,25 @@ async def _process_invoice(
wait_for_preimage = (
True # currently required by nip 47 specs, might change in future
)
payment_status: Optional[PaymentStatus] = None
payment_status: PaymentStatus | None = None
poll_interval = PAYMENT_STATUS_POLL_INITIAL_INTERVAL_SECONDS
while wait_for_preimage:
payment_status = await check_transaction_status(wallet_id, payment_hash)
if payment_status.success:
break
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:
raise Exception("Payment status not found")
return {
@ -106,23 +145,34 @@ async def _process_invoice(
async def _on_pay_invoice(
data: OnInvoicePaid,
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
nwc = await get_nwc(data.pubkey, None, False, True)
error = await _check(nwc, "pay_invoice", data.payload)
sp: NWCServiceProvider, pubkey: str, payload: dict
) -> list[tuple[dict | None, dict | None, list]]:
# hardening #
assert_valid_pubkey(pubkey)
# ## #
nwc = await get_nwc(GetNWC(pubkey=pubkey, refresh_last_used=True))
error = await _check(nwc, "pay_invoice")
if error:
return [(None, error, [])]
if not nwc:
raise Exception("Pubkey has no associated wallet")
params = data.payload.get("params", {})
params = payload.get("params", {})
invoice = params.get("invoice", None)
# Ensures invoice is provided
if not invoice:
raise Exception("Missing invoice")
invoice_data = bolt11_decode(invoice)
amount_msats = int(invoice_data.amount_msat or 0)
# hardening #
assert_valid_bolt11(invoice)
assert_valid_msats(amount_msats)
# ## #
res = await _process_invoice(
nwc.wallet, data.pubkey, invoice, amount_msats, invoice_data.description
nwc.wallet, pubkey, invoice, amount_msats, invoice_data.description
)
error = res.get("error")
if error:
@ -136,17 +186,22 @@ async def _on_pay_invoice(
async def _on_multi_pay_invoice(
data: OnInvoicePaid,
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
nwc = await get_nwc(data.pubkey, None, False, True)
error = await _check(nwc, "multi_pay_invoice", data.payload)
sp: NWCServiceProvider, pubkey: str, payload: dict
) -> list[tuple[dict | None, dict | None, list]]:
# hardening #
assert_valid_pubkey(pubkey)
# ## #
nwc = await get_nwc(GetNWC(pubkey=pubkey, refresh_last_used=True))
error = await _check(nwc, "multi_pay_invoice")
if error:
return [(None, error, [])]
if not nwc:
raise Exception("Pubkey has no associated wallet")
params = data.payload.get("params", {})
params = payload.get("params", {})
invoices = params.get("invoices", [])
results: List[Tuple[Optional[Dict], Optional[Dict], List]] = []
results: list[tuple[dict | None, dict | None, list]] = []
# Ensures all invoices are provided
for i in invoices:
@ -160,8 +215,16 @@ async def _on_multi_pay_invoice(
invoice = i.get("invoice", None)
invoice_data = bolt11_decode(invoice)
amount_msats = int(invoice_data.amount_msat or 0)
# hardening #
assert_valid_bolt11(invoice)
assert_valid_msats(amount_msats)
if invoice_id:
assert_sane_string(invoice_id)
# ## #
res = await _process_invoice(
nwc.wallet, data.pubkey, invoice, amount_msats, invoice_data.description
nwc.wallet, pubkey, invoice, amount_msats, invoice_data.description
)
error = res.get("error")
if error:
@ -177,36 +240,57 @@ async def _on_multi_pay_invoice(
results.append(r)
except Exception as e:
results.append((None, {"code": "INTERNAL", "message": str(e)}, []))
await asyncio.sleep(0)
# await log_nwc(pubkey, payload)
return results
async def _on_make_invoice(
data: OnInvoicePaid,
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
nwc = await get_nwc(data.pubkey, None, False, True)
error = await _check(nwc, "make_invoice", data.payload)
sp: NWCServiceProvider, pubkey: str, payload: dict
) -> list[tuple[dict | None, dict | None, list]]:
# hardening #
assert_valid_pubkey(pubkey)
# ## #
nwc = await get_nwc(GetNWC(pubkey=pubkey, refresh_last_used=True))
error = await _check(nwc, "make_invoice")
if error:
return [(None, error, [])]
if not nwc:
raise Exception("Pubkey has no associated wallet")
params = data.payload.get("params", {})
params = payload.get("params", {})
amount_msats = params.get("amount", None)
# Ensures amount is provided
if not amount_msats:
raise Exception("Missing amount")
description = params.get("description", "")
description_hash = params.get("description_hash", None)
expiry = params.get("expiry", None)
payment_hash, payment_request = await create_invoice(
# Optional params may arrive as explicit JSON null, which dict.get does
# not default, so coerce here rather than trusting the fallback.
description = params.get("description") or ""
description_hash = params.get("description_hash") or None
expiry = params.get("expiry") or None
# hardening #
assert_valid_msats(amount_msats)
if description:
assert_sane_string(description)
if description_hash:
assert_valid_sha256(description_hash)
if expiry:
assert_valid_expiration_seconds(expiry)
# ## #
payment = await create_invoice(
wallet_id=nwc.wallet,
amount=int(amount_msats / 1000),
currency="sat",
memo=description,
description_hash=bytes.fromhex(description_hash) if description_hash else None,
unhashed_description=description.encode("utf-8"),
unhashed_description=description.encode("utf-8") if description else None,
expiry=expiry,
)
payment_hash = payment.payment_hash
payment_request = payment.bolt11
payment_status = await check_transaction_status(
wallet_id=nwc.wallet, payment_hash=payment_hash
)
@ -234,15 +318,20 @@ async def _on_make_invoice(
async def _on_lookup_invoice(
data: OnInvoicePaid,
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
nwc = await get_nwc(data.pubkey, None, False, True)
error = await _check(nwc, "lookup_invoice", data.payload)
sp: NWCServiceProvider, pubkey: str, payload: dict
) -> list[tuple[dict | None, dict | None, list]]:
# hardening #
assert_valid_pubkey(pubkey)
# ## #
nwc = await get_nwc(GetNWC(pubkey=pubkey, refresh_last_used=True))
error = await _check(nwc, "lookup_invoice")
if error:
return [(None, error, [])]
if not nwc:
raise Exception("Pubkey has no associated wallet")
params = data.payload.get("params", {})
params = payload.get("params", {})
payment_hash = params.get("payment_hash", None)
invoice = params.get("invoice", None)
# Ensure payment_hash or invoice are provided
@ -252,25 +341,38 @@ async def _on_lookup_invoice(
if not payment_hash:
invoice_data = bolt11_decode(invoice)
payment_hash = invoice_data.payment_hash
# hardening #
if payment_hash:
assert_valid_sha256(payment_hash)
if invoice:
assert_valid_bolt11(invoice)
# ## #
# Get payment data
payment = await get_wallet_payment(nwc.wallet, payment_hash)
if not payment:
raise Exception("Payment not found")
invoice_data = bolt11_decode(payment.bolt11)
is_settled = not payment.pending
timestamp = payment.time or invoice_data.date
res: Dict = {
timestamp = int(payment.time.timestamp()) or int(invoice_data.date)
expiry = int(payment.expiry.timestamp()) if payment.expiry else timestamp + 3600
preimage = (
payment.preimage
or "0000000000000000000000000000000000000000000000000000000000000000"
)
res: dict = {
"type": "outgoing" if payment.is_out else "incoming",
"invoice": payment.bolt11,
"description": (
invoice_data.description if invoice_data.description else payment.memo
),
"preimage": payment.preimage if is_settled or payment.is_in else None,
"preimage": preimage if is_settled or payment.is_in else None,
"payment_hash": payment.payment_hash,
"amount": abs(payment.msat),
"fees_paid": abs(payment.fee),
"created_at": timestamp,
"expires_at": payment.expiry if payment.expiry else timestamp + 3600,
"expires_at": expiry,
"settled_at": timestamp if is_settled else None,
"metadata": {},
}
@ -281,25 +383,38 @@ async def _on_lookup_invoice(
async def _on_list_transactions(
data: OnInvoicePaid,
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
nwc = await get_nwc(data.pubkey, None, False, True)
error = await _check(nwc, "list_transactions", data.payload)
sp: NWCServiceProvider, pubkey: str, payload: dict
) -> list[tuple[dict | None, dict | None, list]]:
# hardening #
assert_valid_pubkey(pubkey)
# ## #
nwc = await get_nwc(GetNWC(pubkey=pubkey, refresh_last_used=True))
error = await _check(nwc, "list_transactions")
if error:
return [(None, error, [])]
if not nwc:
raise Exception("Pubkey has no associated wallet")
tfrom = data.payload.get("from", 0)
tto = data.payload.get("to", int(time.time()))
limit = data.payload.get("limit", 10)
offset = data.payload.get("offset", 0)
unpaid = data.payload.get("unpaid", False)
tx_type = data.payload.get("type", None)
values = []
params = payload.get("params", 0)
tfrom = params.get("from") or 0
tuntil = params.get("until") or int(time.time())
limit = params.get("limit") or 10
offset = params.get("offset") or 0
unpaid = params.get("unpaid") or False
tx_type = params.get("type") or ""
# hardening #
assert_valid_positive_int(tfrom)
assert_valid_positive_int(tuntil)
assert_valid_positive_int(limit)
assert_valid_positive_int(offset)
assert_boolean(unpaid)
assert_sane_string(tx_type)
# ## #
filters: Filters = Filters()
filters.where(["time <= ?"])
values.append(tto)
filters.values(values)
filters.where(["time <= :tuntil"])
filters.values({"tuntil": tuntil})
history = await get_payments(
wallet_id=nwc.wallet,
complete=True,
@ -312,35 +427,46 @@ async def _on_list_transactions(
limit=limit,
offset=offset,
)
transactions: List[Dict] = []
transactions: list[dict] = []
p: Payment
for p in history:
invoice_data = bolt11_decode(p.bolt11)
is_settled = not p.pending
timestamp = int(p.time.timestamp()) or invoice_data.date
transactions.append(
{
"type": "outgoing" if p.is_out else "incoming",
"invoice": p.bolt11,
"description": invoice_data.description,
# Fallback chain so a human-readable description reaches
# the NWC client. Mirror of `_on_lookup_invoice`
"description": (
(p.extra or {}).get("comment") or invoice_data.description or p.memo
),
"description_hash": invoice_data.description_hash,
"preimage": p.preimage if is_settled or p.is_in else None,
"payment_hash": p.payment_hash,
"amount": abs(p.msat),
"fees_paid": p.fee,
"created_at": p.time,
"settled_at": p.time if is_settled else None,
"created_at": timestamp,
"settled_at": timestamp if is_settled else None,
"metadata": {},
}
)
await asyncio.sleep(0)
# await log_nwc(pubkey, payload)
return [({"transactions": transactions}, None, [])]
async def _on_get_balance(
data: OnInvoicePaid,
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
nwc = await get_nwc(data.pubkey, None, False, True)
error = await _check(nwc, "get_balance", data.payload)
sp: NWCServiceProvider, pubkey: str, payload: dict
) -> list[tuple[dict | None, dict | None, list]]:
# hardening #
assert_valid_pubkey(pubkey)
# ## #
nwc = await get_nwc(GetNWC(pubkey=pubkey, refresh_last_used=True))
error = await _check(nwc, "get_balance")
if error:
return [(None, error, [])]
if not nwc:
@ -355,22 +481,27 @@ async def _on_get_balance(
async def _on_get_info(
data: OnInvoicePaid,
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
nwc = await get_nwc(data.pubkey, None, False, True)
error = await _check(nwc, "get_info", data.payload)
sp: NWCServiceProvider, pubkey: str, payload: dict
) -> list[tuple[dict | None, dict | None, list]]:
# hardening #
assert_valid_pubkey(pubkey)
# ## #
nwc = await get_nwc(GetNWC(pubkey=pubkey, refresh_last_used=True))
error = await _check(nwc, "get_info")
if error:
return [(None, error, [])]
if not nwc:
raise Exception("Pubkey has no associated wallet")
sp_methods = data.sp.get_supported_methods()
sp_methods = sp.get_supported_methods()
permissions = nwc.get_permissions()
# Filter only methods supported by the extension and allowed by the permissions
account_methods = []
for spm in sp_methods:
for p in permissions:
permissions_data: Dict[str, Any] = nwc_permissions.get(p, {})
allowed_methods: List[str] = permissions_data.get("methods", [])
permissions_data: dict[str, Any] = nwc_permissions.get(p, {})
allowed_methods: list[str] = permissions_data.get("methods", [])
if spm in allowed_methods:
account_methods.append(spm)
break
@ -394,7 +525,8 @@ async def _on_get_info(
async def handle_nwc():
priv_key = await get_config_nwc("provider_key")
relay = await get_config_nwc("relay")
nwcsp = NWCServiceProvider(priv_key, relay)
handle_missed_events = int(await get_config_nwc("handle_missed_events") or 0)
nwcsp = NWCServiceProvider(priv_key, relay, handle_missed_events)
nwcsp.add_request_listener("pay_invoice", _on_pay_invoice)
nwcsp.add_request_listener("multi_pay_invoice", _on_multi_pay_invoice)
nwcsp.add_request_listener("make_invoice", _on_make_invoice)

View file

@ -1,30 +1,65 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
%} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('nwcprovider/static', path='js/admin.js') }}"></script>
{% endblock %} {% block page %}
<div class="row q-col-gutter-md" id="configTable">
<div class="col-12 q-gutter-y-md">
<q-card>
<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">
<h5 class="text-subtitle1 q-my-none">
NWC Service Provider - Config
NWC Service Provider Configuration
</h5>
</div>
</div>
<template>
<q-markup-table flat>
<q-markup-table flat wrap-cells="true">
<tbody>
<q-tr v-for="entry in entries" :key="entry.key">
<q-tr>
<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-tr>
<q-tr>
<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-tr>
</tbody>
</q-markup-table>
</template>
<q-btn
@click="saveConfig"
unelevated
@ -36,88 +71,4 @@
</q-card>
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
window.app = Vue.createApp({
el: "#vue",
mixins: [windowMixin],
delimiters: ["${", "}"],
data: function () {
return {
entries: [],
columns: [
{
name: "key",
required: true,
label: "Key",
align: "left",
field: (row) => row.key,
sortable: true,
},
{
name: "value",
required: true,
label: "Value",
align: "left",
field: (row) => row.value,
sortable: true,
},
],
};
},
methods: {
fetchConfig() {
this.entries = [];
LNbits.api
.request(
"GET",
"/nwcprovider/api/v1/config",
this.g.user.wallets[0].adminkey,
)
.then((response) => {
const newEntries = [];
for (const [key, value] of Object.entries(response.data)) {
newEntries.push({
key: key,
value: value,
});
}
this.entries = newEntries;
})
.catch(function (error) {
console.error("Error fetching config:", error);
});
},
async saveConfig() {
const data = {};
for (const entry of this.entries) {
data[entry.key] = entry.value;
}
try {
const response = await LNbits.api.request(
"POST",
"/nwcprovider/api/v1/config",
this.g.user.wallets[0].adminkey,
data,
);
this.$q.notify({
message: "Config saved, please restart the server",
color: "positive",
position: "top",
timeout: 2000,
actions: [{ icon: "close", color: "white" }],
});
} catch (error) {
console.error("Error saving config:", error);
}
},
},
created: function () {
this.fetchConfig();
},
});
</script>
{% endblock %}

View file

@ -1,9 +1,11 @@
<!--/////////////////////////////////////////////////-->
<!--//PAGE FOR THE EXTENSIONS BACKEND IN LNBITS//////-->
<!--/////////////////////////////////////////////////-->
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
%} {% block scripts %} {{ window_vars(user) }}
<script type="module">
import * as NobleSecp256k1 from '/nwcprovider/static/js/noble-secp256k1.min.js'
window.NobleSecp256k1 = NobleSecp256k1
</script>
<script src="{{ static_url_for('nwcprovider/static', path='js/index.js') }}"></script>
{% endblock %} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
@ -97,12 +99,51 @@
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>
<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>
Nostr Wallet Connect (NWC) is an open protocol to connect lightning
wallets to apps
Nostr Wallet Connect (NWC) is an open protocol to connect Lightning
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>
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 class="q-pa-none">
<q-separator></q-separator>
<q-list>
@ -402,397 +443,4 @@
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script type="module">
import * as NobleSecp256k1 from "/nwcprovider/static/js/noble-secp256k1.min.js";
window.NobleSecp256k1 = NobleSecp256k1;
</script>
<script>
window.app = Vue.createApp({
el: "#vue",
mixins: [windowMixin],
delimiters: ["${", "}"],
data: function () {
return {
selectedWallet: null,
nodePermissions: [],
nwcEntries: [],
nwcsTable: {
columns: [
{
name: "description",
align: "left",
label: "Description",
field: "description",
},
{ name: "status", align: "left", label: "Status", field: "status" },
{
name: "last_used",
align: "left",
label: "Last used",
field: "last_used",
},
{
name: "created_at",
align: "left",
label: "Created",
field: "created_at",
},
{
name: "expires_at",
align: "left",
label: "Expires",
field: "expires_at",
},
],
pagination: {
rowsPerPage: 10,
},
},
connectDialog: {
show: false,
data: {},
},
pairingDialog: {
show: false,
data: {
pairingUrl: "",
},
},
pairingQrDialog: {
show: false,
data: {
pairingUrl: "",
},
},
connectionInfoDialog: {
show: false,
data: {},
},
};
},
methods: {
showConnectDialog() {
const wallet = this.getWallet();
if (!wallet) {
this.$q.notify({
color: "red",
message: "Please select a wallet first",
});
return;
} else {
this.connectDialog.show = true;
}
},
openConnectionInfoDialog(data) {
this.connectionInfoDialog.data = data;
this.connectionInfoDialog.show = true;
},
closeConnectionInfoDialog() {
this.connectionInfoDialog.show = false;
},
openPairingUrl() {
const url = this.pairingDialog.data.pairingUrl;
if (url) window.open(url, "_blank");
},
go(url) {
window.open(url, "_blank");
},
async copyPairingUrl() {
const url = this.pairingDialog.data.pairingUrl;
if (url) {
try {
await navigator.clipboard.writeText(url);
this.$q.notify({
color: "primary",
message: "URL copied to clipboard!",
});
} catch (err) {
this.$q.notify({ color: "red", message: "Failed to copy URL." });
}
}
},
showPairingQR() {
this.pairingQrDialog.data.pairingUrl =
this.pairingDialog.data.pairingUrl;
this.pairingQrDialog.show = true;
},
closePairingQrDialog() {
this.pairingQrDialog.show = false;
},
loadConnectDialogData() {
this.connectDialog.data = {
description: "",
expires_at: Date.now() + 1000 * 60 * 60 * 24 * 7,
neverExpires: true,
permissions: [],
budgets: [],
};
for (const permission of this.nodePermissions) {
this.connectDialog.data.permissions.push({
key: permission.key,
name: permission.name,
value: permission.value,
});
}
},
deleteBudget(index) {
this.connectDialog.data.budgets.splice(index, 1);
},
addBudget() {
this.connectDialog.data.budgets.push({
budget_sats: 1000,
used_budget_sats: 0,
created_at:
new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
expiration: "never",
});
},
closeConnectDialog() {
this.connectDialog.show = false;
this.loadConnectDialogData();
},
getWallet: function () {
let wallet = undefined;
for (let i = 0; i < this.g.user.wallets.length; i++) {
if (this.g.user.wallets[i].id == this.selectedWallet) {
wallet = this.g.user.wallets[i];
break;
}
}
return wallet;
},
async generateKeyPair() {
while (!window.NobleSecp256k1) {
await new Promise((resolve) => setTimeout(resolve, 1));
}
const privKeyBytes = window.NobleSecp256k1.utils.randomPrivateKey();
const pubKeyBytes = window.NobleSecp256k1.getPublicKey(privKeyBytes);
const out = {
privKeyBytes: privKeyBytes,
pubKeyBytes: pubKeyBytes,
privKey: window.NobleSecp256k1.etc.bytesToHex(privKeyBytes),
pubKey: window.NobleSecp256k1.etc.bytesToHex(pubKeyBytes.slice(1)),
};
return out;
},
deleteNWC: async function (pubkey) {
this.$q
.dialog({
title: "Confirm Deletion",
message: "Are you sure you want to delete this connection?",
cancel: true,
persistent: true,
})
.onOk(async () => {
try {
const wallet = this.getWallet();
const response = await LNbits.api.request(
"DELETE",
`/nwcprovider/api/v1/nwc/${pubkey}`,
wallet.adminkey,
);
this.loadNwcs();
this.$q.notify({
type: "positive",
message: "Deleted successfully",
});
} catch (error) {
LNbits.utils.notifyApiError(error);
}
})
.onCancel(() => {
// User canceled the operation
});
},
loadNwcs: async function () {
const wallet = this.getWallet();
if (!wallet) {
this.nwcs = [];
return;
}
try {
const response = await LNbits.api.request(
"GET",
"/nwcprovider/api/v1/nwc?include_expired=true&calculate_spent_budget=true",
wallet.adminkey,
);
this.nwcs = response.data;
} catch (error) {
this.nwcs = [];
}
try {
const response = await LNbits.api.request(
"GET",
"/nwcprovider/api/v1/permissions",
wallet.adminkey,
);
const permissions = [];
for (const [key, value] of Object.entries(response.data)) {
permissions.push({
key: key,
name: value.name,
value: value.default,
});
}
this.nodePermissions = permissions;
} catch (error) {
Lnbits.utils.notifyApiError(error);
}
this.loadConnectDialogData();
const newTableEntries = [];
for (const nwc of this.nwcs) {
const t = Quasar.utils.date.formatDate(
new Date(nwc.data.created_at * 1000),
"YYYY-MM-DD HH:mm",
);
const e =
nwc.data.expires_at > 0
? Quasar.utils.date.formatDate(
new Date(nwc.data.expires_at * 1000),
"YYYY-MM-DD HH:mm",
)
: "Never";
const l = Quasar.utils.date.formatDate(
new Date(nwc.data.last_used * 1000),
"YYYY-MM-DD HH:mm",
);
const nwcTableEntry = {
description: nwc.data.description,
created_at: t,
expires_at: e,
last_used: l,
pubkey: nwc.data.pubkey,
permissions: nwc.data.permissions,
budgets: [],
status: "Active",
};
if (
nwc.data.expires_at > 0 &&
nwc.data.expires_at < new Date().getTime() / 1000
) {
nwcTableEntry.status = "Expired";
}
for (const budget of nwc.budgets) {
const createdAt = Quasar.utils.date.formatDate(
new Date(budget.created_at * 1000),
"YYYY-MM-DD HH:mm",
);
let refreshWindow = budget.refresh_window;
if (refreshWindow <= 0) {
refreshWindow = "Never";
} else if (refreshWindow == 60 * 60 * 24) {
refreshWindow = "Daily";
} else if (refreshWindow == 60 * 60 * 24 * 7) {
refreshWindow = "Weekly";
} else if (refreshWindow == 60 * 60 * 24 * 30) {
refreshWindow = "Monthly";
} else if (refreshWindow == 60 * 60 * 24 * 365) {
refreshWindow = "Yearly";
}
nwcTableEntry.budgets.push({
budget_sats: budget.budget_msats / 1000,
used_budget_sats: budget.used_budget_msats / 1000,
created_at: createdAt,
refresh_window: refreshWindow,
});
}
newTableEntries.push(nwcTableEntry);
}
this.nwcEntries = newTableEntries;
},
closePairingDialog() {
this.pairingDialog.show = false;
},
async showPairingDialog(secret) {
let response = await LNbits.api.request(
"GET",
"/nwcprovider/api/v1/pairing/{SECRET}",
);
response = response.data;
response = response.replace("{SECRET}", secret);
this.pairingDialog.data.pairingUrl = response;
this.pairingDialog.show = true;
},
async confirmConnectDialog() {
const keyPair = await this.generateKeyPair();
// timestamp
let expires_at = 0;
if (!this.connectDialog.data.neverExpires) {
expires_at =
new Date(this.connectDialog.data.expires_at).getTime() / 1000;
}
const data = {
permissions: [],
description: this.connectDialog.data.description,
expires_at: expires_at,
budgets: [],
};
for (const permission of this.connectDialog.data.permissions) {
if (permission.value) data.permissions.push(permission.key);
}
for (const budget of this.connectDialog.data.budgets) {
const budget_msats = budget.budget_sats * 1000;
let refresh_window = 0;
switch (budget.expiry) {
case "Daily":
refresh_window = 60 * 60 * 24;
break;
case "Weekly":
refresh_window = 60 * 60 * 24 * 7;
break;
case "Monthly":
refresh_window = 60 * 60 * 24 * 30;
break;
case "Yearly":
refresh_window = 60 * 60 * 24 * 365;
break;
case "Never":
refresh_window = 0;
break;
}
data.budgets.push({
budget_msats: budget_msats,
refresh_window: refresh_window,
created_at:
new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
});
}
const wallet = this.getWallet();
try {
const response = await LNbits.api.request(
"PUT",
"/nwcprovider/api/v1/nwc/" + keyPair.pubKey,
wallet.adminkey,
data,
);
this.closeConnectDialog();
if (
!response.data ||
!response.data.data ||
!response.data.data.pubkey
) {
LNbits.utils.notifyApiError("Error creating nwc pairing");
return;
}
this.showPairingDialog(keyPair.privKey);
} catch (error) {
LNbits.utils.notifyApiError(error);
}
this.loadNwcs();
},
},
created: function () {
this.loadNwcs();
},
watch: {
selectedWallet(newValue, oldValue) {
this.loadNwcs();
},
},
});
</script>
{% endblock %}

0
tests/__init__.py Normal file
View file

View file

@ -199,14 +199,16 @@ LNBITS_HIDE_API=false
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
# The extension must be removed from this list in order to not be re-installed.
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
# The tpos extension is no longer shipped with the LNbits dev tree. Keep the
# integration fixture focused on the extension under test.
LNBITS_EXTENSIONS_DEFAULT_INSTALL=""
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://...
# for both PostgreSQL and CockroachDB, you'll need to install
# psycopg2 as an additional dependency
LNBITS_DATA_FOLDER="./data"
LNBITS_DATA_FOLDER="/data"
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
# the service fee (in percent)

Binary file not shown.

View file

@ -1,5 +1,5 @@
#!/bin/bash
set -e
# cd in the script folder
cd "$(dirname "$0")"
@ -15,30 +15,95 @@ if [ "`cat .v039fk_lnbits_integration_test_folder`" != "yes v039fk_lnbits_integr
exit 1
fi
# Start nostr Relay
# Start nostr Relay. The image defaults to the `strfry` user (UID 1000),
# which is not necessarily the user running the CI job. Create the bind mount
# first and run the relay as the current user so LMDB can initialize its files.
id=$(id -u)
gid=$(id -g)
mkdir -p strfry-data
docker run --name=lnbits_nwcprovider_ext_nostr_test \
-d \
--rm \
-v $PWD/strfry.conf:/etc/strfry.conf \
-v $PWD/strfry-data:/app/strfry-db \
--user $id:$gid \
-v $PWD/strfry.conf:/etc/strfry.conf:Z \
-v $PWD/strfry-data:/app/strfry-db:Z \
-p 7777:7777 \
ghcr.io/hoytech/strfry:latest
# Start lnbits with the nwcprovider extension
rm -Rf lnbits_itest_data
unzip data.zip
# The fixture was created with the standalone tpos extension installed. tpos
# is no longer part of the LNbits dev tree, so leaving its database metadata in
# the fixture makes current LNbits attempt to import a module that is absent.
# The integration suite only exercises nwcprovider.
python3 - <<'PY'
import sqlite3
with sqlite3.connect("lnbits_itest_data/database.sqlite3") as conn:
conn.execute("DELETE FROM installed_extensions WHERE id = 'tpos'")
conn.execute("DELETE FROM dbversions WHERE db = 'tpos'")
PY
rm -f lnbits_itest_data/ext_tpos.sqlite3 lnbits_itest_data/zips/tpos.zip
docker run --name=lnbits_nwcprovider_ext_lnbits_test \
-d \
--rm \
--user $id:$gid \
-p 5002:5000 \
-v ${PWD}/.env:/app/.env \
-v ${PWD}/lnbits_itest_data/:/app/data \
-v ${PWD}/../../:/app/lnbits/extensions/nwcprovider:ro \
lnbits/lnbits
-v ${PWD}/lnbits_itest_data/:/data \
-v ${PWD}/../../:/nwcprovider \
-v ${PWD}/../../.devcontainer/start.sh:/start-lnbits.sh:ro \
-v ${PWD}/../../.devcontainer/setup.sh:/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"
if ! docker network inspect lnbits_nwcprovider_ext_test_network >/dev/null 2>&1; then
docker network create lnbits_nwcprovider_ext_test_network
fi
docker network connect lnbits_nwcprovider_ext_test_network lnbits_nwcprovider_ext_nostr_test --alias nostr
docker network connect lnbits_nwcprovider_ext_test_network lnbits_nwcprovider_ext_lnbits_test --alias lnbits
docker exec -u root lnbits_nwcprovider_ext_lnbits_test bash -c "id -u $id &>/dev/null || useradd -m -u $id tester"
docker exec -u root lnbits_nwcprovider_ext_lnbits_test bash -c "bash /pre-setup.sh"
docker exec --user $id:$gid lnbits_nwcprovider_ext_lnbits_test bash -c "curl -sSL https://install.python-poetry.org | python3 -"
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 network create lnbits_nwcprovider_ext_test_network || true
docker network connect lnbits_nwcprovider_ext_test_network lnbits_nwcprovider_ext_nostr_test --alias nostr|| true
docker network connect lnbits_nwcprovider_ext_test_network lnbits_nwcprovider_ext_lnbits_test --alias lnbits|| true
if [ "$HEADLESS" != "" ];
then
# Keep the server log inside the container so a failed health check can
# show the actual startup error instead of silently swallowing it.
docker exec --user $id:$gid -d lnbits_nwcprovider_ext_lnbits_test bash -c "export PATH=\"\$HOME/.local/bin:\$PATH\" && cd \$HOME/lnbits && poetry run lnbits > /tmp/lnbits.log 2>&1"
wait_for_http() {
local service="$1"
local url="$2"
local timeout_seconds="$3"
local deadline=$((SECONDS + timeout_seconds))
until curl --fail --silent --show-error --max-time 2 "$url" >/dev/null 2>&1; do
if [ "$SECONDS" -ge "$deadline" ]; then
echo "Timed out waiting for $service at $url" >&2
docker ps -a >&2
if [ "$service" = "LNbits" ]; then
docker exec lnbits_nwcprovider_ext_lnbits_test tail -n 100 /tmp/lnbits.log >&2 || true
else
docker logs --tail 100 lnbits_nwcprovider_ext_nostr_test >&2 || true
fi
return 1
fi
sleep 1
done
}
# LNbits may need a few minutes for a fresh database migration.
wait_for_http "nostr relay" "http://localhost:7777" 180
wait_for_http "LNbits" "http://localhost:5002" 180
else
docker exec --user $id:$gid lnbits_nwcprovider_ext_lnbits_test bash -c "export PATH=\"\$HOME/.local/bin:\$PATH\" && cd \$HOME/lnbits && poetry run lnbits"
fi

View file

@ -47,7 +47,7 @@ relay {
port = 7777
# 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)
realIpHeader = ""

View file

@ -1,20 +1,20 @@
import asyncio
import base64
import hashlib
import json
import random
import time
from typing import Dict, List, Optional, Union
from typing import Union
import bolt11
import httpx
import pytest
import secp256k1
import websockets.client as websockets
from Cryptodome import Random
from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import pad, unpad
from loguru import logger
from pynostr.key import PrivateKey
from websockets.legacy.client import connect
SERVICE_STARTUP_TIMEOUT_SECONDS = 180
NWC_CONNECTION_TIMEOUT_SECONDS = 60
NWC_RESPONSE_TIMEOUT_SECONDS = 60
wallets = {
"wallet1": {
@ -45,35 +45,29 @@ wallets = {
async def check_services():
# wait for http server in localhost:7777
while True:
async def wait_for_service(name: str, url: str):
deadline = time.monotonic() + SERVICE_STARTUP_TIMEOUT_SECONDS
try:
async with httpx.AsyncClient() as client:
resp = await client.get("http://localhost:7777")
assert resp.status_code == 200
break
except Exception:
logger.info("Waiting for nostr relay @ http://localhost:7777")
logger.info(
"""Please start the required services by running\
`bash start.sh` if you haven't already"""
while True:
try:
resp = await client.get(url)
if resp.status_code == 200:
return
except httpx.HTTPError:
pass
if time.monotonic() >= deadline:
raise RuntimeError(
f"Timed out waiting for {name} at {url}. "
"Start the integration services with `bash start.sh`."
)
logger.info(f"Waiting for {name} @ {url}")
await asyncio.sleep(1)
except httpx.HTTPError as exc:
raise RuntimeError(f"Unable to check {name} at {url}: {exc}") from exc
# wait lnbits @ localhost:5000
while True:
try:
async with httpx.AsyncClient() as client:
resp = await client.get("http://localhost:5002")
assert resp.status_code == 200
break
except Exception:
logger.info("Waiting for lnbits @ http://localhost:5002")
logger.info(
"""Please start the required services by running\
`bash start.sh` if you haven't already"""
)
await asyncio.sleep(1)
await wait_for_service("nostr relay", "http://localhost:7777")
await wait_for_service("LNbits", "http://localhost:5002")
async def get_wallet_balance(w: str):
@ -95,20 +89,20 @@ async def refresh_wallet_balances():
def gen_keypair():
private_key_hex = bytes.hex(secp256k1._gen_private_key())
private_key = secp256k1.PrivateKey(bytes.fromhex(private_key_hex))
public_key = private_key.pubkey
private_key = PrivateKey()
private_key_hex = private_key.hex()
public_key = private_key.public_key
if not public_key:
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}
async def create_nwc(
w: str,
desc: str,
permissions: List[str],
budgets: List[Dict[str, int]],
permissions: list[str],
budgets: list[dict[str, int]],
expiration: int = 0,
):
keypair = gen_keypair()
@ -164,12 +158,12 @@ class NWCWallet:
self.event_queue = []
self.subscriptions_count = 0
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.public_key = self.private_key.pubkey
self.public_key = self.private_key.public_key
if not self.public_key:
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
async def close(self):
@ -187,7 +181,17 @@ class NWCWallet:
async def start(self):
self.task = asyncio.create_task(self._run())
await self._wait_for_connection()
try:
await asyncio.wait_for(
self._wait_for_connection(), timeout=NWC_CONNECTION_TIMEOUT_SECONDS
)
except asyncio.TimeoutError as exc:
self.task.cancel()
await asyncio.gather(self.task, return_exceptions=True)
self.task = None
raise RuntimeError(
f"Timed out connecting to NWC relay {self.relay}"
) from exc
def _is_shutting_down(self):
return self.shutdown
@ -206,7 +210,7 @@ class NWCWallet:
async def _run(self):
while True:
try:
async with websockets.connect(self.relay) as ws:
async with connect(self.relay) as ws:
self.ws = ws
self.connected = True
self.sub_id = self._get_new_subid()
@ -239,43 +243,13 @@ class NWCWallet:
else:
break
def _encrypt_content(
self, content: str, pubkey_hex: str, iv_seed: Optional[int] = 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, ws, message: str):
async def _on_message(self, _, message: str):
logger.debug("Received message: " + message)
msg = json.loads(message)
if msg[0] == "EVENT": # Event message
event = msg[2]
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)
self.event_queue.append(
{
@ -288,12 +262,12 @@ class NWCWallet:
}
)
def _json_dumps(self, data: Union[Dict, list]) -> str:
if isinstance(data, Dict):
def _json_dumps(self, data: Union[dict, list]) -> str:
if isinstance(data, dict):
data = {k: v for k, v in data.items() if v is not None}
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
def _sign_event(self, event: Dict) -> Dict:
def _sign_event(self, event: dict) -> dict:
signature_data = self._json_dumps(
[
0,
@ -308,10 +282,9 @@ class NWCWallet:
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
event["id"] = event_id
event["pubkey"] = self.public_key_hex
signature = (
self.private_key.schnorr_sign(bytes.fromhex(event_id), None, raw=True)
).hex()
event["sig"] = signature
signature = self.private_key.sign(bytes.fromhex(event_id))
# type error? returns str but is bytes
event["sig"] = signature.hex() # type: ignore
return event
async def send_event(self, method, params):
@ -327,7 +300,7 @@ class NWCWallet:
"content": json.dumps({"method": method, "params": params}),
}
logger.debug("Sending event: " + str(event))
event["content"] = self._encrypt_content(
event["content"] = self.private_key.encrypt_message(
event["content"], self.provider_pub_hex
)
self._sign_event(event)
@ -335,7 +308,11 @@ class NWCWallet:
await self.ws.send(self._json_dumps(["EVENT", event]))
async def wait_for(
self, result_type, callback=None, on_error_callback=None, timeout=10
self,
result_type,
callback=None,
on_error_callback=None,
timeout=NWC_RESPONSE_TIMEOUT_SECONDS,
):
now = time.time()
while True:
@ -382,14 +359,14 @@ async def test_make_invoice():
await wallet1.send_event(
"make_invoice", {"amount": 1, "description": "test 123", "expiry": 1000}
)
result, tags, error = await wallet1.wait_for("make_invoice")
result, _, error = await wallet1.wait_for("make_invoice")
logger.info(error)
assert error, "Expected internal error, because amount is too low"
await wallet1.send_event(
"make_invoice", {"amount": 123000, "description": "test 123", "expiry": 1000}
)
result, tags, error = await wallet1.wait_for("make_invoice")
result, _, error = await wallet1.wait_for("make_invoice")
assert not error
assert result["type"] == "incoming"
assert result["description"] == "test 123"
@ -420,7 +397,7 @@ async def test_lookup_invoice():
await wallet1.send_event(
"make_invoice", {"amount": 123000, "description": "test 123", "expiry": 1000}
)
result, tags, error = await wallet1.wait_for("make_invoice")
result, _, error = await wallet1.wait_for("make_invoice")
assert not error
assert result["type"] == "incoming"
assert result["description"] == "test 123"
@ -436,7 +413,7 @@ async def test_lookup_invoice():
await wallet2.start()
await wallet2.send_event("lookup_invoice", {"invoice": result["invoice"]})
result, tags, error = await wallet2.wait_for("lookup_invoice")
result, _, error = await wallet2.wait_for("lookup_invoice")
assert not error
assert result["type"] == "incoming"
assert result["description"] == "test 123"
@ -461,7 +438,7 @@ async def test_get_info():
await wallet1.start()
await wallet1.send_event("get_info", {})
result, tags, error = await wallet1.wait_for("get_info")
result, _, error = await wallet1.wait_for("get_info")
assert not error
assert result["alias"] == "LNBits_NWC_SP"
assert result["color"] == ""
@ -488,33 +465,33 @@ async def test_permisions():
await wallet1.start()
await wallet1.send_event("get_info", {})
result, tags, error = await wallet1.wait_for("get_info")
result, _, error = await wallet1.wait_for("get_info")
assert not error
await wallet1.send_event(
"make_invoice", {"amount": 123000, "description": "test 123", "expiry": 1000}
)
result, tags, error = await wallet1.wait_for("make_invoice")
result, _, error = await wallet1.wait_for("make_invoice")
assert error
await wallet1.close()
await wallet2.start()
await wallet2.send_event("get_info", {})
result, tags, error = await wallet2.wait_for("get_info")
result, _, error = await wallet2.wait_for("get_info")
assert error
await wallet2.send_event(
"make_invoice", {"amount": 123000, "description": "test 123", "expiry": 1000}
)
result, tags, error = await wallet2.wait_for("make_invoice")
result, _, error = await wallet2.wait_for("make_invoice")
assert not error
await wallet2.close()
await wallet3.start()
await wallet3.send_event("get_info", {})
result, tags, error = await wallet3.wait_for("get_info")
result, _, error = await wallet3.wait_for("get_info")
assert not error
assert "make_invoice" in result["methods"]
assert "pay_invoice" in result["methods"]
@ -544,7 +521,7 @@ async def test_pay_invoice_and_balance():
"make_invoice", {"amount": 123000, "description": "test 123"}
)
result, tags, error = await wallet1.wait_for("make_invoice")
result, _, error = await wallet1.wait_for("make_invoice")
assert not error
assert result["invoice"]
@ -553,7 +530,7 @@ async def test_pay_invoice_and_balance():
await wallet2.start()
await wallet2.send_event("pay_invoice", {"invoice": invoice})
result, tags, error = await wallet2.wait_for("pay_invoice")
result, _, error = await wallet2.wait_for("pay_invoice")
assert not error
assert result["preimage"]
@ -565,12 +542,12 @@ async def test_pay_invoice_and_balance():
assert wallet2_balance_new == wallet2_balance - 123000
await wallet1.send_event("get_balance", {})
result, tags, error = await wallet1.wait_for("get_balance")
result, _, error = await wallet1.wait_for("get_balance")
assert not error
assert result["balance"] == wallet1_balance_new
await wallet2.send_event("get_balance", {})
result, tags, error = await wallet2.wait_for("get_balance")
result, _, error = await wallet2.wait_for("get_balance")
assert not error
assert result["balance"] == wallet2_balance_new
@ -627,6 +604,7 @@ async def test_multi_pay_invoices():
assert not error
assert result["invoice"]
invoice3 = result["invoice"]
invoice3_payhash = result["payment_hash"]
await wallet3.send_event(
"multi_pay_invoice",
@ -645,7 +623,7 @@ async def test_multi_pay_invoices():
assert result["preimage"]
elif d_tag == "invoice2":
assert result["preimage"]
elif d_tag == invoice3:
elif d_tag == invoice3_payhash:
assert result["preimage"]
else:
raise AssertionError("Unexpected d tag")
@ -698,13 +676,13 @@ async def test_insufficient_balance():
await wallet2.send_event(
"make_invoice", {"amount": amount_to_spend, "description": "test 123"}
)
result, tags, error = await wallet2.wait_for("make_invoice")
result, _, error = await wallet2.wait_for("make_invoice")
assert not error
assert result["invoice"]
invoice = result["invoice"]
await wallet1.send_event("pay_invoice", {"invoice": invoice})
result, tags, error = await wallet1.wait_for("pay_invoice")
result, _, error = await wallet1.wait_for("pay_invoice")
logger.info(error)
logger.info(result)
logger.info(amount_to_spend)
@ -730,7 +708,7 @@ async def test_expiry():
await wallet3.send_event(
"make_invoice", {"amount": 123000, "description": "test 123"}
)
result, tags, error = await wallet3.wait_for("make_invoice")
_, _, error = await wallet3.wait_for("make_invoice")
assert error
assert (
error["code"] == "UNAUTHORIZED"
@ -763,22 +741,22 @@ async def test_budget():
await wallet1.send_event(
"make_invoice", {"amount": 101000, "description": "Invalid"}
)
result, tags, error = await wallet1.wait_for("make_invoice")
result, _, error = await wallet1.wait_for("make_invoice")
assert not error
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
result, tags, error = await wallet3.wait_for("pay_invoice")
result, _, error = await wallet3.wait_for("pay_invoice")
assert error
assert (
error["code"] == "QUOTA_EXCEEDED"
), "Expected QUOTA_EXCEEDED error, because the budget was exceeded"
await wallet1.send_event("make_invoice", {"amount": 99000, "description": "Valid"})
result, tags, error = await wallet1.wait_for("make_invoice")
result, _, error = await wallet1.wait_for("make_invoice")
assert not error
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
result, tags, error = await wallet3.wait_for("pay_invoice")
result, _, error = await wallet3.wait_for("pay_invoice")
assert not error, "Expected successful payment, because the budget was not exceeded"
assert result["preimage"]
@ -786,11 +764,11 @@ async def test_budget():
"make_invoice", {"amount": 100000 - 99000 + 1000, "description": "Invalid"}
)
result, tags, error = await wallet1.wait_for("make_invoice")
result, _, error = await wallet1.wait_for("make_invoice")
assert not error
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
result, tags, error = await wallet3.wait_for("pay_invoice")
result, _, error = await wallet3.wait_for("pay_invoice")
assert error
assert (
error["code"] == "QUOTA_EXCEEDED"
@ -819,21 +797,21 @@ async def test_budget_refresh():
await wallet1.send_event(
"make_invoice", {"amount": 100000, "description": "Invalid"}
)
result, tags, error = await wallet1.wait_for("make_invoice")
result, _, error = await wallet1.wait_for("make_invoice")
assert not error
await wallet1.send_event(
"make_invoice", {"amount": 100000, "description": "Invalid"}
)
result2, tags, error = await wallet1.wait_for("make_invoice")
result2, _, error = await wallet1.wait_for("make_invoice")
assert not error
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
result, tags, error = await wallet3.wait_for("pay_invoice")
result, _, error = await wallet3.wait_for("pay_invoice")
assert not error, "Expected successful payment, because the budget was not exceeded"
await wallet3.send_event("pay_invoice", {"invoice": result2["invoice"]})
result, tags, error = await wallet3.wait_for("pay_invoice")
result, _, error = await wallet3.wait_for("pay_invoice")
assert error
assert (
error["code"] == "QUOTA_EXCEEDED"
@ -841,12 +819,298 @@ async def test_budget_refresh():
await asyncio.sleep(5)
await wallet1.send_event("make_invoice", {"amount": 100000, "description": "Valid"})
result, tags, error = await wallet1.wait_for("make_invoice")
result, _, error = await wallet1.wait_for("make_invoice")
assert not error
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
result, tags, error = await wallet3.wait_for("pay_invoice")
result, _, error = await wallet3.wait_for("pay_invoice")
assert not error, "Expected successful payment, because the budget was refreshed"
await wallet3.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
@pytest.mark.asyncio
async def test_unauthorized_access():
"""Test accessing protected endpoints without valid API keys"""
async with httpx.AsyncClient() as client:
# privkey b758d3c535f8d089ce20473bafb33ee2f2f8deb94c97a0c5272cbf5bdc29f573
# Try to create NWC without API key
resp = await client.put(
"http://localhost:5002/nwcprovider/api/v1/nwc/033c415d948f92aa7aa788ecfe49e49c3acae882d3dd2294574141bd786e18b6"
)
assert resp.status_code == 401
# Try to access config endpoint without admin privileges
resp = await client.get("http://localhost:5002/nwcprovider/api/v1/config")
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_idor_vulnerability():
"""Test Insecure Direct Object Reference through pubkey manipulation"""
# Create NWC for wallet1
nwc_wallet1 = await create_nwc("wallet1", "test_idor", ["pay"], [], 0)
# Attempt to access wallet1's NWC using wallet2's credentials
async with httpx.AsyncClient() as client:
resp = await client.get(
f"http://localhost:5002/nwcprovider/api/v1/nwc/{nwc_wallet1['pubkey']}",
headers={"X-Api-Key": wallets["wallet2"]["admin_key"]},
)
assert resp.status_code == 400
assert "Pubkey has no associated wallet" in resp.text
@pytest.mark.asyncio
async def test_sql_injection():
"""Test for SQL injection vulnerabilities in parameters"""
malicious_pubkey = "'; DROP TABLE nwc;--"
async with httpx.AsyncClient() as client:
resp = await client.put(
f"http://localhost:5002/nwcprovider/api/v1/nwc/{malicious_pubkey}",
headers={"X-Api-Key": wallets["wallet1"]["admin_key"]},
json={"permissions": ["pay"], "description": "test"},
)
# Should be rejected by input validation
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_invalid_invoice_handling():
"""Test handling of malformed invoices"""
nwc = await create_nwc("wallet1", "test_invalid", ["pay"], [], 0)
wallet = NWCWallet(nwc["pairing"])
await wallet.start()
# Send invalid invoice
await wallet.send_event("pay_invoice", {"invoice": "invalid_lninvoice"})
_, _, error = await wallet.wait_for("pay_invoice")
assert error
assert error["code"] == "INTERNAL"
@pytest.mark.asyncio
async def test_replay_attack():
"""Test message replay protection"""
nwc = await create_nwc("wallet1", "test_replay", ["pay", "invoice"], [], 0)
wallet = NWCWallet(nwc["pairing"])
await wallet.start()
# Capture valid payment request
valid_invoice = await create_valid_invoice(wallet)
await wallet.send_event("pay_invoice", {"invoice": valid_invoice})
_, _, error = await wallet.wait_for("pay_invoice")
assert not error
# Replay same message
await wallet.send_event("pay_invoice", {"invoice": valid_invoice})
_, _, error = await wallet.wait_for("pay_invoice")
assert error
assert error["code"] == "PAYMENT_FAILED"
@pytest.mark.asyncio
async def test_budget_bypass():
"""Test budget limit enforcement"""
nwc = await create_nwc(
"wallet1",
"test_budget_bypass",
["pay", "invoice"],
[
{
"budget_msats": 100000,
"refresh_window": 3600,
"created_at": int(time.time()),
}
],
0,
)
wallet = NWCWallet(nwc["pairing"])
await wallet.start()
# First payment within budget
invoice1 = await create_valid_invoice(wallet, 50000)
await wallet.send_event("pay_invoice", {"invoice": invoice1})
_, _, error = await wallet.wait_for("pay_invoice")
assert not error
# Attempt to exceed budget
invoice2 = await create_valid_invoice(wallet, 60000)
await wallet.send_event("pay_invoice", {"invoice": invoice2})
_, _, error = await wallet.wait_for("pay_invoice")
assert error
assert error["code"] == "QUOTA_EXCEEDED"
@pytest.mark.asyncio
async def test_unauthorized_config():
"""Test unauthorized access to config endpoint"""
malicious_relay = "ws://attacker-relay.example"
async def set_config_nwc(key: str, value: str):
async with httpx.AsyncClient() as client:
resp = await client.post(
"http://localhost:5002/nwcprovider/api/v1/config",
json={key: value},
headers={"X-Api-Key": "lnbitsadmin"}, # Assuming admin key
)
assert resp.status_code == 401
await set_config_nwc("relay", malicious_relay)
async def create_valid_invoice(wallet, amount=1000):
"""Helper function to create valid test invoice"""
await wallet.send_event(
"make_invoice", {"amount": amount, "description": "test invoice"}
)
result, _, error = await wallet.wait_for("make_invoice")
if error:
raise Exception(f"Failed to create invoice: {error}")
return result["invoice"]
@pytest.mark.asyncio
async def test_list_transactions():
# Create wallets with required permissions
nwc1 = await create_nwc(
"wallet1",
"test_list_transactions",
["invoice", "pay", "balance", "history"],
[],
0,
)
nwc2 = await create_nwc(
"wallet2",
"test_list_transactions",
["invoice", "pay", "balance", "history"],
[],
0,
)
wallet1 = NWCWallet(nwc1["pairing"])
wallet2 = NWCWallet(nwc2["pairing"])
try:
await wallet1.start()
await wallet2.start()
# First invoice
await wallet1.send_event(
"make_invoice", {"amount": 1000, "description": "test invoice 1"}
)
result1, _, error = await wallet1.wait_for("make_invoice")
assert not error
invoice1 = result1["invoice"]
# Pay first invoice
await wallet2.send_event("pay_invoice", {"invoice": invoice1})
_, _, error = await wallet2.wait_for("pay_invoice")
assert not error
# Second invoice
await wallet1.send_event(
"make_invoice", {"amount": 2000, "description": "test invoice 2"}
)
result2, _, error = await wallet1.wait_for("make_invoice")
assert not error
invoice2 = result2["invoice"]
# Pay second invoice
await wallet2.send_event("pay_invoice", {"invoice": invoice2})
_, _, error = await wallet2.wait_for("pay_invoice")
assert not error
# Test basic transaction listing
await wallet1.send_event("list_transactions", {})
result, _, error = await wallet1.wait_for("list_transactions")
assert not error
assert "transactions" in result
transactions = result["transactions"]
assert len(transactions) >= 2
# Test limit
await wallet1.send_event("list_transactions", {"limit": 1})
result, _, error = await wallet1.wait_for("list_transactions")
assert not error
limited_txs = result["transactions"]
assert len(limited_txs) == 1
finally:
await wallet1.close()
await wallet2.close()

0
tests/unit/__init__.py Normal file
View file

View file

@ -1,18 +1,13 @@
import asyncio
import json
import os
import sys
from loguru import logger
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
####
import random
import string
import time
import pytest
from loguru import logger
from nwcp import NWCServiceProvider
from ...nwcp import NWCServiceProvider
@pytest.fixture
@ -29,8 +24,7 @@ def nwc_service_provider2():
)
@pytest.mark.asyncio
async def test_supported_methods(nwc_service_provider):
def test_supported_methods(nwc_service_provider):
def make_invoice(provider, pubkey, content):
return "invoice"
@ -39,32 +33,27 @@ async def test_supported_methods(nwc_service_provider):
assert s == ["make_invoice"]
@pytest.mark.asyncio
async def test_encrytdecrypt(nwc_service_provider, nwc_service_provider2):
def test_encrytdecrypt(nwc_service_provider, nwc_service_provider2):
content = "Hello World"
expected_enc = "qVurNVISSl/9CfREIhk5Lg==?iv=QpCo5dI9gUcoLsSMLA7o7Q=="
enc_a = nwc_service_provider._encrypt_content(
content, nwc_service_provider2.public_key_hex, 21
enc_a = nwc_service_provider.private_key.encrypt_message(
content, nwc_service_provider2.public_key_hex
)
enc_b = nwc_service_provider2._encrypt_content(
content, nwc_service_provider.public_key_hex, 21
enc_b = nwc_service_provider2.private_key.encrypt_message(
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
)
dec_b = nwc_service_provider._decrypt_content(
dec_b = nwc_service_provider.private_key.decrypt_message(
enc_b, nwc_service_provider2.public_key_hex
)
assert dec_a == content
assert dec_b == content
assert enc_a == expected_enc
assert enc_b == expected_enc
@pytest.mark.asyncio
async def test_signverify(nwc_service_provider, nwc_service_provider2):
def test_signverify(nwc_service_provider, nwc_service_provider2):
# Random content
content = ""
for _ in range(100):
@ -87,19 +76,31 @@ async def test_signverify(nwc_service_provider, nwc_service_provider2):
assert nwc_service_provider2._verify_event(signed)
def test_default_event_max_age(nwc_service_provider):
assert nwc_service_provider.event_max_age == 5 * 60
assert (
NWCServiceProvider(
"d7b5232fba0e02e32cfe26f20cdf2c803b27ecd81052c2dd5d17e5e1a333fe58",
"",
handle_missed_events=123,
).event_max_age
== 123
)
@pytest.mark.asyncio
async def test_handle(nwc_service_provider, nwc_service_provider2):
content = nwc_service_provider._json_dumps(
{"method": "pay_invoice", "params": {"invoice": "abc"}}
)
content = nwc_service_provider._encrypt_content(
content, nwc_service_provider2.public_key_hex, 21
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": 1234567890,
"created_at": int(time.time()),
}
signed = nwc_service_provider._sign_event(event)
@ -113,12 +114,13 @@ async def test_handle(nwc_service_provider, nwc_service_provider2):
pass
nwc_service_provider2._send = _send_pass
nwc_service_provider2._create_subscription()
nwc_service_provider2.add_request_listener("pay_invoice", _handle_pay_invoice)
sent_events = await nwc_service_provider2._handle_request(signed)
assert len(sent_events) == 1
for revent in sent_events:
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
)
logger.debug(event)
@ -138,3 +140,187 @@ async def test_handle(nwc_service_provider, nwc_service_provider2):
p_tag = [tag for tag in tags if tag[0] == "p"]
assert len(p_tag) == 1
assert p_tag[0][1] == nwc_service_provider.public_key_hex
@pytest.mark.asyncio
async def test_handle_rejects_same_event_replay(
nwc_service_provider, nwc_service_provider2
):
content = nwc_service_provider._json_dumps(
{"method": "pay_invoice", "params": {"invoice": "abc"}}
)
content = nwc_service_provider.private_key.encrypt_message(
content, nwc_service_provider2.public_key_hex
)
event = {
"kind": 23194,
"content": content,
"tags": [["p", nwc_service_provider2.public_key_hex]],
"created_at": int(time.time()),
}
signed = nwc_service_provider._sign_event(event)
calls = 0
async def _handle_pay_invoice(provider, pubkey, content):
nonlocal calls
calls += 1
return [({"preimage": "00000"}, None, [])]
async def _send_pass(obj):
pass
nwc_service_provider2._send = _send_pass
nwc_service_provider2._create_subscription()
nwc_service_provider2.add_request_listener("pay_invoice", _handle_pay_invoice)
await nwc_service_provider2._handle_request(signed)
with pytest.raises(Exception, match="already handled"):
await nwc_service_provider2._handle_request(signed)
assert calls == 1
@pytest.mark.asyncio
async def test_relay_dispatches_requests_without_waiting_for_previous_request(
nwc_service_provider, monkeypatch
):
sub = nwc_service_provider._create_subscription()
sub.requests_sub_id = "requests"
sub.requests_eose = True
sub.responses_eose = True
monkeypatch.setattr(nwc_service_provider, "_verify_event", lambda event: True)
first_request_finished = asyncio.Event()
second_request_finished = asyncio.Event()
async def _handle_request(event):
if event["id"] == "first":
await first_request_finished.wait()
else:
second_request_finished.set()
return []
monkeypatch.setattr(nwc_service_provider, "_handle_request", _handle_request)
def request(event_id):
return json.dumps(
[
"EVENT",
sub.requests_sub_id,
{
"id": event_id,
"kind": 23194,
"pubkey": "a" * 64,
"content": "",
"tags": [["p", nwc_service_provider.public_key_hex]],
"created_at": int(time.time()),
},
]
)
await nwc_service_provider._on_message(None, request("first"))
await nwc_service_provider._on_message(None, request("second"))
await asyncio.wait_for(second_request_finished.wait(), timeout=1)
assert not first_request_finished.is_set()
first_request_finished.set()
await asyncio.gather(*list(nwc_service_provider.request_tasks))
@pytest.mark.asyncio
async def test_cleanup_cancels_pending_request_tasks(nwc_service_provider, monkeypatch):
request_started = asyncio.Event()
async def _handle_request(event):
request_started.set()
await asyncio.Event().wait()
return []
monkeypatch.setattr(nwc_service_provider, "_handle_request", _handle_request)
nwc_service_provider._dispatch_request({"id": "pending"})
await request_started.wait()
await nwc_service_provider.cleanup()
assert not nwc_service_provider.request_tasks
@pytest.mark.asyncio
async def test_send_info_event(nwc_service_provider):
"""_send_info_event should publish a signed kind-13194 event."""
nwc_service_provider.add_request_listener(
"pay_invoice", lambda *args, **kwargs: None # type: ignore[arg-type]
)
sent: list[list] = []
async def _send_capture(obj):
sent.append(obj)
nwc_service_provider._send = _send_capture
nwc_service_provider.connected = True
await nwc_service_provider._send_info_event()
assert len(sent) == 1
msg = sent[0]
assert msg[0] == "EVENT"
event = msg[1]
assert event["kind"] == 13194
assert "pay_invoice" in event["content"]
assert nwc_service_provider._verify_event(event)
@pytest.mark.asyncio
async def test_info_event_loop_resends(nwc_service_provider):
"""_info_event_loop should resend the info event while connected."""
sent: list[list] = []
async def _send_capture(obj):
sent.append(obj)
nwc_service_provider._send = _send_capture
nwc_service_provider.connected = True
loop_task = asyncio.create_task(nwc_service_provider._info_event_loop())
# Allow the loop to run through one sleep cycle (patched to near-zero).
# We drive it by cancelling right after the first send opportunity.
await asyncio.sleep(0) # yield to let the task start
# Manually trigger a resend call to verify the helper works correctly.
await nwc_service_provider._send_info_event()
loop_task.cancel()
try:
await loop_task
except asyncio.CancelledError:
pass
# At least the manual call went through.
assert len(sent) >= 1
for msg in sent:
assert msg[0] == "EVENT"
assert msg[1]["kind"] == 13194
@pytest.mark.asyncio
async def test_info_event_loop_skips_when_disconnected(nwc_service_provider):
"""_info_event_loop should not send the info event while disconnected."""
sent: list[list] = []
async def _send_capture(obj):
sent.append(obj)
nwc_service_provider._send = _send_capture
nwc_service_provider.connected = False # not connected
loop_task = asyncio.create_task(nwc_service_provider._info_event_loop())
await asyncio.sleep(0)
loop_task.cancel()
try:
await loop_task
except asyncio.CancelledError:
pass
# Nothing should have been sent because connected=False.
assert len(sent) == 0

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

2276
uv.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -4,23 +4,22 @@ from lnbits.decorators import check_admin, check_user_exists
from lnbits.helpers import template_renderer
from starlette.responses import HTMLResponse
nwcprovider_router = APIRouter()
def nwcprovider_renderer():
return template_renderer(["nwcprovider/templates"])
nwcprovider_router = APIRouter()
@nwcprovider_router.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return nwcprovider_renderer().TemplateResponse(
"nwcprovider/index.html", {"request": request, "user": user.dict()}
"nwcprovider/index.html", {"request": request, "user": user.json()}
)
@nwcprovider_router.get("/admin", response_class=HTMLResponse)
async def admin(request: Request, user: User = Depends(check_admin)):
return nwcprovider_renderer().TemplateResponse(
"nwcprovider/admin.html", {"request": request, "user": user.dict()}
"nwcprovider/admin.html", {"request": request, "user": user.json()}
)

View file

@ -1,11 +1,10 @@
from http import HTTPStatus
from typing import Dict, List, Optional
import secp256k1
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from lnbits.core.models import WalletTypeInfo
from lnbits.decorators import check_admin, require_admin_key
from pynostr.key import PrivateKey
from .crud import (
create_nwc,
@ -18,13 +17,19 @@ from .crud import (
set_config_nwc,
)
from .models import (
CreateNWCKey,
DeleteNWC,
GetBudgetsNWC,
GetNWCKey,
GetNWCs,
GetNWC,
GetWalletNWC,
NWCGetResponse,
RegisterNWC,
NWCRegistrationRequest,
)
from .paranoia import (
assert_boolean,
assert_sane_string,
assert_valid_pubkey,
assert_valid_wallet_id,
)
from .permission import nwc_permissions
@ -32,31 +37,32 @@ nwcprovider_api_router = APIRouter()
# Get supported permissions
@nwcprovider_api_router.get("/api/v1/permissions", status_code=HTTPStatus.OK)
async def api_get_permissions(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Dict:
@nwcprovider_api_router.get("/api/v1/permissions")
async def api_get_permissions() -> dict:
return nwc_permissions
## Get nwc keys associated with the wallet
@nwcprovider_api_router.get(
"/api/v1/nwc", status_code=HTTPStatus.OK, response_model=List[NWCGetResponse]
)
@nwcprovider_api_router.get("/api/v1/nwc")
async def api_get_nwcs(
data: GetNWCs,
include_expired: bool = False,
calculate_spent_budget: bool = False,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
) -> list[NWCGetResponse]:
wallet_id = wallet.wallet.id
wallet_nwcs = GetWalletNWC(
wallet_id=wallet_id, include_expired=data.include_expired
)
# hardening #
assert_valid_wallet_id(wallet_id)
assert_boolean(include_expired)
assert_boolean(calculate_spent_budget)
# ## #
wallet_nwcs = GetWalletNWC(wallet=wallet_id, include_expired=include_expired)
nwcs = await get_wallet_nwcs(wallet_nwcs)
out = []
for nwc in nwcs:
budgets_nwc = GetBudgetsNWC(
pubkey=nwc.pubkey, calculate_spent=data.calculate_spent_budget
pubkey=nwc.pubkey, calculate_spent=calculate_spent_budget
)
budgets = await get_budgets_nwc(budgets_nwc)
res = NWCGetResponse(data=nwc, budgets=budgets)
@ -65,32 +71,48 @@ async def api_get_nwcs(
# Get a nwc key
@nwcprovider_api_router.get(
"/api/v1/nwc/{pubkey}", status_code=HTTPStatus.OK, response_model=NWCGetResponse
)
@nwcprovider_api_router.get("/api/v1/nwc/{pubkey}")
async def api_get_nwc(
data: GetNWCKey, wallet: WalletTypeInfo = Depends(require_admin_key)
pubkey: str,
include_expired: bool = False,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> NWCGetResponse:
wallet_id = wallet.wallet.id
nwc = await get_nwc(data.pubkey, wallet_id, data.include_expired)
# hardening #
assert_valid_pubkey(pubkey)
assert_boolean(include_expired)
assert_valid_wallet_id(wallet_id)
# ## #
nwc = await get_nwc(
GetNWC(pubkey=pubkey, wallet=wallet_id, include_expired=include_expired)
)
if not nwc:
raise Exception("Pubkey has no associated wallet")
res = NWCGetResponse(data=nwc, budgets=await get_budgets_nwc(data.pubkey))
raise ValueError("Pubkey has no associated wallet")
res = NWCGetResponse(
data=nwc, budgets=await get_budgets_nwc(GetBudgetsNWC(pubkey=pubkey))
)
return res
# Get pairing url for given secret
@nwcprovider_api_router.get(
"/api/v1/pairing/{secret}", status_code=HTTPStatus.OK, response_model=str
)
@nwcprovider_api_router.get("/api/v1/pairing/{secret}")
async def api_get_pairing_url(req: Request, secret: str) -> str:
pprivkey: Optional[str] = await get_config_nwc("provider_key")
# hardening #
assert_sane_string(secret)
# ## #
pprivkey: str | None = await get_config_nwc("provider_key")
if not pprivkey:
raise Exception("Extension is not configured")
relay = await get_config_nwc("relay")
if not relay:
raise Exception("Extension is not configured")
relay_alias: Optional[str] = await get_config_nwc("relay_alias")
relay_alias: str | None = await get_config_nwc("relay_alias")
if relay_alias:
relay = relay_alias
else:
@ -103,11 +125,11 @@ async def api_get_pairing_url(req: Request, secret: str) -> str:
scheme = "wss"
netloc += "/nostrclient/api/v1/relay"
relay = f"{scheme}://{netloc}"
psk = secp256k1.PrivateKey(bytes.fromhex(pprivkey))
ppk = psk.pubkey
psk = PrivateKey.from_hex(pprivkey)
ppk = psk.public_key
if not ppk:
raise Exception("Error generating pubkey")
ppubkey = ppk.serialize().hex()[2:]
ppubkey = ppk.hex()
url = "nostr+walletconnect://"
url += ppubkey
url += "?relay=" + relay
@ -120,42 +142,52 @@ async def api_get_pairing_url(req: Request, secret: str) -> str:
@nwcprovider_api_router.put(
"/api/v1/nwc/{pubkey}",
status_code=HTTPStatus.CREATED,
response_model=NWCGetResponse,
)
async def api_register_nwc(
data: RegisterNWC, # Use the Pydantic model here
pubkey: str,
data: NWCRegistrationRequest,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
) -> NWCGetResponse:
wallet_id = wallet.wallet.id
# hardening #
assert_valid_pubkey(pubkey)
assert_valid_wallet_id(wallet_id)
# ## #
nwc = await create_nwc(
data.pubkey,
wallet_id,
data.registration_data.description,
data.registration_data.expires_at,
data.registration_data.permissions,
data.registration_data.budgets,
CreateNWCKey(
pubkey=pubkey,
wallet=wallet_id,
description=data.description,
expires_at=data.expires_at,
permissions=data.permissions,
budgets=data.budgets,
)
budgets = await get_budgets_nwc(data.pubkey)
)
budgets = await get_budgets_nwc(GetBudgetsNWC(pubkey=pubkey))
res = NWCGetResponse(data=nwc, budgets=budgets)
return res
# Delete a nwc key
@nwcprovider_api_router.delete("/api/v1/nwc/{pubkey}", status_code=HTTPStatus.OK)
@nwcprovider_api_router.delete("/api/v1/nwc/{pubkey}")
async def api_delete_nwc(
data: DeleteNWC, wallet: WalletTypeInfo = Depends(require_admin_key)
pubkey: str, wallet: WalletTypeInfo = Depends(require_admin_key)
):
wallet_id = wallet.wallet.id
await delete_nwc(data.pubkey, wallet_id)
return JSONResponse(
content={"message": f"NWC key {data.pubkey} deleted successfully."}
)
# hardening #
assert_valid_pubkey(pubkey)
assert_valid_wallet_id(wallet_id)
# ## #
await delete_nwc(DeleteNWC(pubkey=pubkey, wallet=wallet_id))
return JSONResponse(content={"message": f"NWC key {pubkey} deleted successfully."})
# Get config
@nwcprovider_api_router.get(
"/api/v1/config", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)]
)
@nwcprovider_api_router.get("/api/v1/config", dependencies=[Depends(check_admin)])
async def api_get_all_config_nwc():
config = await get_all_config_nwc()
return config
@ -164,7 +196,6 @@ async def api_get_all_config_nwc():
# Get config
@nwcprovider_api_router.get(
"/api/v1/config/{key}",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def api_get_config_nwc(key: str):
@ -175,11 +206,16 @@ async def api_get_config_nwc(key: str):
# Set config
@nwcprovider_api_router.post(
"/api/v1/config", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)]
)
@nwcprovider_api_router.post("/api/v1/config", dependencies=[Depends(check_admin)])
async def api_set_config_nwc(req: Request):
data = await req.json()
# hardening #
for key, value in data.items():
assert_sane_string(key)
assert_sane_string(value)
# ## #
for key, value in data.items():
await set_config_nwc(key, value)
return await api_get_all_config_nwc(req)
return await api_get_all_config_nwc()