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!
This commit is contained in:
dni ⚡ 2025-09-12 09:30:16 +02:00 • committed by GitHub
commit 603b345073
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 5059 additions and 2199 deletions

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

View file

@ -10,12 +10,12 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with: with:
python-version: "3.9" python-version: '3.10'
- name: Install dependencies - name: Install dependencies
run: | run: |

3
.gitignore vendored
View file

@ -8,6 +8,5 @@ node_modules
.mypy_cache .mypy_cache
data data
.vscode .vscode
package.json
package-lock.json
dump 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 check: mypy pyright checkblack checkruff checkprettier
prettier: prettier:
poetry run ./node_modules/.bin/prettier --write . uv run ./node_modules/.bin/prettier --write .
pyright: pyright:
poetry run ./node_modules/.bin/pyright uv run ./node_modules/.bin/pyright
mypy: mypy:
poetry run mypy . uv run mypy .
black: black:
poetry run black . uv run black .
ruff: ruff:
poetry run ruff check . --fix uv run ruff check . --fix
checkruff: checkruff:
poetry run ruff check . uv run ruff check .
checkprettier: checkprettier:
poetry run ./node_modules/.bin/prettier --check . uv run ./node_modules/.bin/prettier --check .
checkblack: checkblack:
poetry run black --check . uv run black --check .
checkeditorconfig: checkeditorconfig:
editorconfig-checker editorconfig-checker
@ -33,14 +33,14 @@ checkeditorconfig:
test: test:
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
DEBUG=true \ DEBUG=true \
poetry run pytest uv run pytest
install-pre-commit-hook: install-pre-commit-hook:
@echo "Installing pre-commit hook to git" @echo "Installing pre-commit hook to git"
@echo "Uninstall the hook with poetry run pre-commit uninstall" @echo "Uninstall the hook with uv run pre-commit uninstall"
poetry run pre-commit install uv run pre-commit install
pre-commit: pre-commit:
poetry run pre-commit run --all-files uv run pre-commit run --all-files
checkbundle: checkbundle:

View file

@ -45,7 +45,7 @@ def nwcprovider_start():
__all__ = [ __all__ = [
"db", "db",
"nwcprovider_ext", "nwcprovider_ext",
"nwcprovider_static_files",
"nwcprovider_start", "nwcprovider_start",
"nwcprovider_static_files",
"nwcprovider_stop", "nwcprovider_stop",
] ]

36
crud.py
View file

@ -1,5 +1,4 @@
import time import time
from typing import List, Optional
from lnbits.db import Database from lnbits.db import Database
@ -11,6 +10,7 @@ from .models import (
GetNWC, GetNWC,
GetWalletNWC, GetWalletNWC,
NWCBudget, NWCBudget,
NWCConfig,
NWCKey, NWCKey,
NWCNewBudget, NWCNewBudget,
TrackedSpendNWC, TrackedSpendNWC,
@ -71,6 +71,7 @@ async def delete_nwc(data: DeleteNWC) -> None:
# hardening # # hardening #
assert_valid_pubkey(data.pubkey) assert_valid_pubkey(data.pubkey)
if data.wallet:
assert_valid_wallet_id(data.wallet) assert_valid_wallet_id(data.wallet)
# ## # # ## #
@ -80,9 +81,12 @@ async def delete_nwc(data: DeleteNWC) -> None:
) )
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 expires = int(time.time()) if not data.include_expired else -1
if not data.wallet:
return []
# hardening # # hardening #
assert_valid_wallet_id(data.wallet) assert_valid_wallet_id(data.wallet)
assert_valid_expiration_seconds(expires) assert_valid_expiration_seconds(expires)
@ -101,7 +105,7 @@ async def get_wallet_nwcs(data: GetWalletNWC) -> List[NWCKey]:
) )
async def get_nwc(data: GetNWC) -> Optional[NWCKey]: async def get_nwc(data: GetNWC) -> NWCKey | None:
expires = int(time.time()) if not data.include_expired else -1 expires = int(time.time()) if not data.include_expired else -1
# hardening # # hardening #
@ -150,17 +154,17 @@ async def get_nwc(data: GetNWC) -> Optional[NWCKey]:
return row return row
async def get_budgets_nwc(data: GetBudgetsNWC) -> Optional[NWCBudget]: async def get_budgets_nwc(data: GetBudgetsNWC) -> list[NWCBudget]:
# hardening # # hardening #
assert_valid_pubkey(data.pubkey) assert_valid_pubkey(data.pubkey)
# ## # # ## #
rows = await db.fetchall( budgets = await db.fetchall(
"SELECT * FROM nwcprovider.budgets WHERE pubkey = :pubkey", "SELECT * FROM nwcprovider.budgets WHERE pubkey = :pubkey",
{"pubkey": data.pubkey}, {"pubkey": data.pubkey},
model=NWCBudget,
) )
budgets = [NWCBudget(**row) for row in rows]
if data.calculate_spent: if data.calculate_spent:
for budget in budgets: for budget in budgets:
last_cycle, next_cycle = budget.get_timestamp_range() last_cycle, next_cycle = budget.get_timestamp_range()
@ -170,7 +174,7 @@ async def get_budgets_nwc(data: GetBudgetsNWC) -> Optional[NWCBudget]:
assert_valid_timestamp_seconds(next_cycle) assert_valid_timestamp_seconds(next_cycle)
# ## # # ## #
tot_spent_in_range_msats = await db.fetchone( result: dict = await db.fetchone(
""" """
SELECT SUM(amount_msats) FROM nwcprovider.spent SELECT SUM(amount_msats) FROM nwcprovider.spent
WHERE pubkey = :pubkey AND created_at >= WHERE pubkey = :pubkey AND created_at >=
@ -182,9 +186,7 @@ async def get_budgets_nwc(data: GetBudgetsNWC) -> Optional[NWCBudget]:
"next_cycle": next_cycle, "next_cycle": next_cycle,
}, },
) )
tot_spent_in_range_msats = ( tot_spent_in_range_msats = next(iter(result.values())) or 0
next(iter(tot_spent_in_range_msats.values())) or 0
)
# hardening # # hardening #
assert_valid_msats(tot_spent_in_range_msats) assert_valid_msats(tot_spent_in_range_msats)
@ -263,12 +265,14 @@ async def tracked_spend_nwc(data: TrackedSpendNWC, action):
async def get_config_nwc(key: str): async def get_config_nwc(key: str):
row = await db.fetchone( config = await db.fetchone(
"SELECT * FROM nwcprovider.config WHERE key = :key", {"key": key} "SELECT * FROM nwcprovider.config WHERE key = :key",
{"key": key},
model=NWCConfig,
) )
if not row: if not config:
return None return None
return row["value"] return config.value
async def set_config_nwc(key: str, value: str): async def set_config_nwc(key: str, value: str):
@ -289,5 +293,5 @@ async def set_config_nwc(key: str, value: str):
async def get_all_config_nwc(): async def get_all_config_nwc():
rows = await db.fetchall("SELECT * FROM nwcprovider.config") rows = await db.fetchall("SELECT * FROM nwcprovider.config", model=NWCConfig)
return {row["key"]: row["value"] for row in rows} return {row.key: row.value for row in rows}

View file

@ -1,7 +1,7 @@
import asyncio 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): async def enqueue(action):

View file

@ -75,7 +75,8 @@ async def m003_default_config(db):
new_private_key = bytes.hex(secp256k1._gen_private_key()) new_private_key = bytes.hex(secp256k1._gen_private_key())
await db.execute( await db.execute(
""" """
INSERT 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; ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
""", """,
{"provider_key": new_private_key}, {"provider_key": new_private_key},
@ -94,6 +95,7 @@ async def m004_default_config2(db):
{"value": ""}, {"value": ""},
) )
async def m005_key_last_used(db): async def m005_key_last_used(db):
""" """
Add last_used to keys Add last_used to keys
@ -111,7 +113,8 @@ async def m006_default_config3(db):
""" """
await db.execute( await db.execute(
""" """
INSERT INTO nwcprovider.config (key, value) VALUES ('handle_missed_events', :value) INSERT INTO nwcprovider.config (key, value)
VALUES ('handle_missed_events', :value)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
""", """,
{"value": "0"}, {"value": "0"},

View file

@ -2,7 +2,7 @@
import time import time
from sqlite3 import Row from sqlite3 import Row
from typing import Any, Dict, List, Optional from typing import Any
from pydantic import BaseModel from pydantic import BaseModel
@ -16,14 +16,14 @@ class NWCKey(BaseModel):
created_at: int created_at: int
last_used: int last_used: int
def get_permissions(self) -> List[str]: def get_permissions(self) -> list[str]:
try: try:
return self.permissions.split(" ") return self.permissions.split(" ")
except Exception: except Exception:
return [] return []
@classmethod @classmethod
def from_row(cls, row: Dict[str, Any]) -> "NWCKey": def from_row(cls, row: dict[str, Any]) -> "NWCKey":
return cls(**row) return cls(**row)
@ -53,7 +53,7 @@ class NWCBudget(BaseModel):
class NWCNewBudget(BaseModel): class NWCNewBudget(BaseModel):
pubkey: Optional[str] pubkey: str | None
budget_msats: int budget_msats: int
refresh_window: int refresh_window: int
created_at: int created_at: int
@ -65,30 +65,30 @@ class CreateNWCKey(BaseModel):
wallet: str wallet: str
description: str description: str
expires_at: int expires_at: int
permissions: List[str] permissions: list[str]
budgets: Optional[List[NWCNewBudget]] = None budgets: list[NWCNewBudget] | None = None
class DeleteNWC(BaseModel): class DeleteNWC(BaseModel):
pubkey: str pubkey: str
wallet: Optional[str] = None wallet: str | None = None
class GetWalletNWC(BaseModel): class GetWalletNWC(BaseModel):
wallet: Optional[str] = None wallet: str | None = None
include_expired: Optional[bool] = False include_expired: bool | None = False
class GetNWC(BaseModel): class GetNWC(BaseModel):
pubkey: str pubkey: str
wallet: Optional[str] = None wallet: str | None = None
include_expired: Optional[bool] = False include_expired: bool | None = False
refresh_last_used: Optional[bool] = False refresh_last_used: bool | None = False
class GetBudgetsNWC(BaseModel): class GetBudgetsNWC(BaseModel):
pubkey: str pubkey: str
calculate_spent: Optional[bool] = False calculate_spent: bool | None = False
class TrackedSpendNWC(BaseModel): class TrackedSpendNWC(BaseModel):
@ -98,12 +98,17 @@ class TrackedSpendNWC(BaseModel):
# API models # API models
class NWCRegistrationRequest(BaseModel): class NWCRegistrationRequest(BaseModel):
permissions: List[str] permissions: list[str]
description: str description: str
expires_at: int expires_at: int
budgets: List[NWCNewBudget] budgets: list[NWCNewBudget]
class NWCGetResponse(BaseModel): class NWCGetResponse(BaseModel):
data: NWCKey data: NWCKey
budgets: List[NWCBudget] budgets: list[NWCBudget]
class NWCConfig(BaseModel):
key: str
value: str

61
nwcp.py
View file

@ -4,16 +4,17 @@ import hashlib
import json import json
import random import random
import time 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 secp256k1
import websockets.client as websockets
from Cryptodome import Random from Cryptodome import Random
from Cryptodome.Cipher import AES from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import pad, unpad from Cryptodome.Util.Padding import pad, unpad
from lnbits.helpers import encrypt_internal_message from lnbits.helpers import encrypt_internal_message
from lnbits.settings import settings from lnbits.settings import settings
from loguru import logger from loguru import logger
from websockets.legacy.client import connect
class RateLimit: class RateLimit:
@ -23,14 +24,14 @@ class RateLimit:
class MainSubscription: class MainSubscription:
def __init__(self): def __init__(self):
self.requests_sub_id: Optional[str] = None self.requests_sub_id: str | None = None
self.responses_sub_id: Optional[str] = None self.responses_sub_id: str | None = None
self.requests_eose = False self.requests_eose = False
self.responses_eose = False self.responses_eose = False
self.events: Dict[str, Dict] = {} self.events: dict[str, dict] = {}
self.responses: List[str] = [] self.responses: list[str] = []
def get_stale(self) -> List[Dict]: def get_stale(self) -> list[dict]:
""" """
Get all the pending events that do not have a response yet. Get all the pending events that do not have a response yet.
""" """
@ -47,7 +48,7 @@ class MainSubscription:
if event_id not in self.responses: if event_id not in self.responses:
self.responses.append(event_id) self.responses.append(event_id)
def gc(self, expire: Optional[int] = None): def gc(self, expire: int | None = None):
""" """
Garbage collection, remove all the events that have a response older Garbage collection, remove all the events that have a response older
than expire seconds (defaults to 1 hour if 0 or None) than expire seconds (defaults to 1 hour if 0 or None)
@ -74,8 +75,8 @@ class MainSubscription:
class NWCServiceProvider: class NWCServiceProvider:
def __init__( def __init__(
self, self,
private_key: Optional[str] = None, private_key: str | None = None,
relay: Optional[str] = None, relay: str | None = None,
handle_missed_events: int = 0, handle_missed_events: int = 0,
): ):
if not relay: # Connect to nostrclient if not relay: # Connect to nostrclient
@ -100,17 +101,17 @@ class NWCServiceProvider:
self.public_key_hex = self.public_key.serialize().hex()[2:] self.public_key_hex = self.public_key.serialize().hex()[2:]
# List of supported methods # 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) # Keep track of the number of subscriptions (used for unique subid)
self.subscriptions_count: int = 0 self.subscriptions_count: int = 0
# Request listeners, listen to specific methods # Request listeners, listen to specific methods
self.request_listeners: Dict[ self.request_listeners: dict[
str, str,
Callable[ Callable[
[NWCServiceProvider, str, Dict], [NWCServiceProvider, str, dict],
Awaitable[List[Tuple[Optional[Dict], Optional[Dict], List]]], Awaitable[list[tuple[dict | None, dict | None, list]]],
], ],
] = {} ] = {}
@ -122,7 +123,7 @@ class NWCServiceProvider:
# Subscription # Subscription
self.sub = None self.sub = None
self.rate_limit: Dict[str, RateLimit] = {} self.rate_limit: dict[str, RateLimit] = {}
# websocket connection # websocket connection
self.ws = None self.ws = None
@ -161,8 +162,8 @@ class NWCServiceProvider:
self, self,
method: str, method: str,
listener: Callable[ listener: Callable[
["NWCServiceProvider", str, Dict], ["NWCServiceProvider", str, dict],
Awaitable[List[Tuple[Optional[Dict], Optional[Dict], List]]], Awaitable[list[tuple[dict | None, dict | None, list]]],
], ],
): ):
""" """
@ -185,7 +186,7 @@ class NWCServiceProvider:
self.reconnect_task = asyncio.create_task(self._connect_to_relay()) self.reconnect_task = asyncio.create_task(self._connect_to_relay())
self.gc_task = asyncio.create_task(self._gc_loop()) self.gc_task = asyncio.create_task(self._gc_loop())
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. Converts a Python dictionary to a JSON string with compact encoding.
@ -195,7 +196,7 @@ class NWCServiceProvider:
Returns: Returns:
str: The compact JSON string. 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} data = {k: v for k, v in data.items() if v is not None}
return json.dumps(data, separators=(",", ":"), ensure_ascii=False) return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
@ -205,7 +206,7 @@ class NWCServiceProvider:
""" """
return self.shutdown or not settings.lnbits_running 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. Sends data to the relay.
@ -249,7 +250,7 @@ class NWCServiceProvider:
await asyncio.sleep(1) await asyncio.sleep(1)
async def _ratelimit(self, unit: str, max_sleep_time: int = 120) -> None: 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: if not limit:
self.rate_limit[unit] = limit = RateLimit() self.rate_limit[unit] = limit = RateLimit()
@ -289,7 +290,7 @@ class NWCServiceProvider:
await self._send(["REQ", self.sub.requests_sub_id, req_filter]) 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", self.sub.responses_sub_id, res_filter])
async def _on_connection(self, ws): async def _on_connection(self, _):
""" """
On connection callback, announce the service provider On connection callback, announce the service provider
methods and subscribe to nip67 events. methods and subscribe to nip67 events.
@ -306,7 +307,7 @@ class NWCServiceProvider:
# Resubscribe to nwc events # Resubscribe to nwc events
await self._subscribe() await self._subscribe()
async def _handle_request(self, event: Dict) -> List[Dict]: async def _handle_request(self, event: dict) -> list[dict]:
""" """
Handle a nwc request Handle a nwc request
""" """
@ -319,7 +320,7 @@ class NWCServiceProvider:
# Handle request # Handle request
method = content["method"] method = content["method"]
listener = self.request_listeners.get(method, None) listener = self.request_listeners.get(method, None)
outs: List[Dict[str, Any]] = [] outs: list[dict[str, Any]] = []
if not listener: if not listener:
outs.append( outs.append(
{ {
@ -352,7 +353,7 @@ class NWCServiceProvider:
if "error" in out: if "error" in out:
content["error"] = out["error"] content["error"] = out["error"]
# Prepare response event # Prepare response event
res: Dict = { res: dict = {
"kind": 23195, "kind": 23195,
"created_at": int(time.time()), "created_at": int(time.time()),
"tags": out.get("tags", []), "tags": out.get("tags", []),
@ -451,7 +452,7 @@ class NWCServiceProvider:
await self._ratelimit("subscribing") await self._ratelimit("subscribing")
await self._subscribe() await self._subscribe()
async def _on_message(self, ws, message: str): async def _on_message(self, _, message: str):
""" """
Handle incoming messages from the relay. Handle incoming messages from the relay.
""" """
@ -484,7 +485,7 @@ class NWCServiceProvider:
): # Reconnect until the instance is shutting down ): # Reconnect until the instance is shutting down
logger.debug("Creating new connection...") logger.debug("Creating new connection...")
try: try:
async with websockets.connect(self.relay) as ws: async with connect(self.relay) as ws:
self.ws = ws self.ws = ws
self.connected = True self.connected = True
await self._on_connection(ws) await self._on_connection(ws)
@ -513,7 +514,7 @@ class NWCServiceProvider:
await self._ratelimit("connecting") await self._ratelimit("connecting")
def _encrypt_content( def _encrypt_content(
self, content: str, pubkey_hex: str, iv_seed: Optional[int] = None self, content: str, pubkey_hex: str, iv_seed: int | None = None
) -> str: ) -> str:
""" """
Encrypts the content for the given public key Encrypts the content for the given public key
@ -571,7 +572,7 @@ class NWCServiceProvider:
decrypted = decrypted_bytes.decode("utf-8") decrypted = decrypted_bytes.decode("utf-8")
return decrypted return decrypted
def _verify_event(self, event: Dict) -> bool: def _verify_event(self, event: dict) -> bool:
""" """
Verify the event signature Verify the event signature
@ -602,7 +603,7 @@ class NWCServiceProvider:
return False return False
return True return True
def _sign_event(self, event: Dict) -> Dict: def _sign_event(self, event: dict) -> dict:
""" """
Signs the event (in place) Signs the event (in place)

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.2.5",
"pyright": "^1.1.358"
}
},
"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.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pyright": {
"version": "1.1.405",
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.405.tgz",
"integrity": "sha512-hgy12kLZ1oAMtl9LTsByHftg3AD6Pouwu5rBsQlqYQqCCdGBgaQm9XDAPDap7ayWe9W+NWrUwO7Zy1K7uXoE2A==",
"license": "MIT",
"bin": {
"pyright": "index.js",
"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.2.5",
"pyright": "^1.1.358"
}
}

3385
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,37 +1,41 @@
[tool.poetry] [project]
name = "nwcprovider" name = "nwcprovider"
version = "0.0.0" version = "0.0.0"
description = "A NWC service provider for LNbits." 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] [tool.poetry]
python = "^3.10 | ^3.9" package-mode = false
lnbits = {version = "*", allow-prereleases = true}
mypy = "^1.13.0"
[tool.poetry.group.dev.dependencies] [tool.uv]
black = "^24.3.0" dev-dependencies = [
pytest-asyncio = "^0.21.0" "black",
pytest = "^7.3.2" "pytest-asyncio",
mypy = "^1.5.1" "pytest",
pre-commit = "^3.2.2" "mypy",
ruff = "^0.3.2" "pre-commit",
pytest-md = "^0.2.0" "ruff",
"pytest-md",
[build-system] ]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.mypy] [tool.mypy]
plugins = ["pydantic.mypy"]
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = [ module = [
"lnbits.*", "secp256k1.*",
"loguru.*",
"fastapi.*",
"pydantic.*",
] ]
ignore_missing_imports = "True" 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] [tool.pytest.ini_options]
log_cli = false log_cli = false
testpaths = [ testpaths = [

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import asyncio import asyncio
import time import time
from math import ceil from math import ceil
from typing import Any, Dict, List, Optional, Tuple from typing import Any
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from lnbits.core.crud import get_payments, get_wallet, get_wallet_payment from lnbits.core.crud import get_payments, get_wallet, get_wallet_payment
@ -35,7 +35,7 @@ from .paranoia import (
from .permission import nwc_permissions from .permission import nwc_permissions
async def _check(nwc: Optional[NWCKey], method: str) -> Optional[Dict]: async def _check(nwc: NWCKey | None, method: str) -> dict | None:
# check # check
if not nwc: if not nwc:
return { return {
@ -46,8 +46,8 @@ async def _check(nwc: Optional[NWCKey], method: str) -> Optional[Dict]:
allowed = False allowed = False
permissions = nwc.get_permissions() permissions = nwc.get_permissions()
for p in permissions: for p in permissions:
permissions_data: Dict[str, Any] = nwc_permissions.get(p, {}) permissions_data: dict[str, Any] = nwc_permissions.get(p, {})
allowed_methods: List[str] = permissions_data.get("methods", []) allowed_methods: list[str] = permissions_data.get("methods", [])
if method in allowed_methods: if method in allowed_methods:
allowed = True allowed = True
break break
@ -64,7 +64,7 @@ async def _process_invoice(
pubkey: str, pubkey: str,
invoice: str, invoice: str,
amount_msats: int, amount_msats: int,
description: Optional[str] = None, description: str | None = None,
): ):
# hardening # # hardening #
@ -80,7 +80,7 @@ async def _process_invoice(
payment = await pay_invoice( payment = await pay_invoice(
wallet_id=wallet_id, wallet_id=wallet_id,
payment_request=invoice, payment_request=invoice,
max_sat=int(ceil(amount_msats / 1000)), max_sat=ceil(amount_msats / 1000),
description=description or "", description=description or "",
) )
return payment.payment_hash return payment.payment_hash
@ -109,7 +109,7 @@ async def _process_invoice(
wait_for_preimage = ( wait_for_preimage = (
True # currently required by nip 47 specs, might change in future True # currently required by nip 47 specs, might change in future
) )
payment_status: Optional[PaymentStatus] = None payment_status: PaymentStatus | None = None
while wait_for_preimage: while wait_for_preimage:
payment_status = await check_transaction_status(wallet_id, payment_hash) payment_status = await check_transaction_status(wallet_id, payment_hash)
if payment_status.success: if payment_status.success:
@ -128,8 +128,8 @@ async def _process_invoice(
async def _on_pay_invoice( async def _on_pay_invoice(
sp: NWCServiceProvider, pubkey: str, payload: Dict sp: NWCServiceProvider, pubkey: str, payload: dict
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]: ) -> list[tuple[dict | None, dict | None, list]]:
# hardening # # hardening #
assert_valid_pubkey(pubkey) assert_valid_pubkey(pubkey)
@ -169,8 +169,8 @@ async def _on_pay_invoice(
async def _on_multi_pay_invoice( async def _on_multi_pay_invoice(
sp: NWCServiceProvider, pubkey: str, payload: Dict sp: NWCServiceProvider, pubkey: str, payload: dict
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]: ) -> list[tuple[dict | None, dict | None, list]]:
# hardening # # hardening #
assert_valid_pubkey(pubkey) assert_valid_pubkey(pubkey)
@ -184,7 +184,7 @@ async def _on_multi_pay_invoice(
raise Exception("Pubkey has no associated wallet") raise Exception("Pubkey has no associated wallet")
params = payload.get("params", {}) params = payload.get("params", {})
invoices = params.get("invoices", []) 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 # Ensures all invoices are provided
for i in invoices: for i in invoices:
@ -228,8 +228,8 @@ async def _on_multi_pay_invoice(
async def _on_make_invoice( async def _on_make_invoice(
sp: NWCServiceProvider, pubkey: str, payload: Dict sp: NWCServiceProvider, pubkey: str, payload: dict
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]: ) -> list[tuple[dict | None, dict | None, list]]:
# hardening # # hardening #
assert_valid_pubkey(pubkey) assert_valid_pubkey(pubkey)
@ -298,8 +298,8 @@ async def _on_make_invoice(
async def _on_lookup_invoice( async def _on_lookup_invoice(
sp: NWCServiceProvider, pubkey: str, payload: Dict sp: NWCServiceProvider, pubkey: str, payload: dict
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]: ) -> list[tuple[dict | None, dict | None, list]]:
# hardening # # hardening #
assert_valid_pubkey(pubkey) assert_valid_pubkey(pubkey)
@ -336,12 +336,12 @@ async def _on_lookup_invoice(
invoice_data = bolt11_decode(payment.bolt11) invoice_data = bolt11_decode(payment.bolt11)
is_settled = not payment.pending is_settled = not payment.pending
timestamp = int(payment.time.timestamp()) or int(invoice_data.date) timestamp = int(payment.time.timestamp()) or int(invoice_data.date)
expiry = int(payment.expiry.timestamp()) or timestamp + 3600 expiry = int(payment.expiry.timestamp()) if payment.expiry else timestamp + 3600
preimage = ( preimage = (
payment.preimage payment.preimage
or "0000000000000000000000000000000000000000000000000000000000000000" or "0000000000000000000000000000000000000000000000000000000000000000"
) )
res: Dict = { res: dict = {
"type": "outgoing" if payment.is_out else "incoming", "type": "outgoing" if payment.is_out else "incoming",
"invoice": payment.bolt11, "invoice": payment.bolt11,
"description": ( "description": (
@ -363,8 +363,8 @@ async def _on_lookup_invoice(
async def _on_list_transactions( async def _on_list_transactions(
sp: NWCServiceProvider, pubkey: str, payload: Dict sp: NWCServiceProvider, pubkey: str, payload: dict
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]: ) -> list[tuple[dict | None, dict | None, list]]:
# hardening # # hardening #
assert_valid_pubkey(pubkey) assert_valid_pubkey(pubkey)
# ## # # ## #
@ -392,11 +392,9 @@ async def _on_list_transactions(
assert_sane_string(tx_type) assert_sane_string(tx_type)
# ## # # ## #
values = []
filters: Filters = Filters() filters: Filters = Filters()
filters.where(["time <= ?"]) filters.where(["time <= :tuntil"])
values.append(tuntil) filters.values({"tuntil": tuntil})
filters.values(values)
history = await get_payments( history = await get_payments(
wallet_id=nwc.wallet, wallet_id=nwc.wallet,
complete=True, complete=True,
@ -409,7 +407,7 @@ async def _on_list_transactions(
limit=limit, limit=limit,
offset=offset, offset=offset,
) )
transactions: List[Dict] = [] transactions: list[dict] = []
p: Payment p: Payment
for p in history: for p in history:
invoice_data = bolt11_decode(p.bolt11) invoice_data = bolt11_decode(p.bolt11)
@ -435,8 +433,8 @@ async def _on_list_transactions(
async def _on_get_balance( async def _on_get_balance(
sp: NWCServiceProvider, pubkey: str, payload: Dict sp: NWCServiceProvider, pubkey: str, payload: dict
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]: ) -> list[tuple[dict | None, dict | None, list]]:
# hardening # # hardening #
assert_valid_pubkey(pubkey) assert_valid_pubkey(pubkey)
@ -458,8 +456,8 @@ async def _on_get_balance(
async def _on_get_info( async def _on_get_info(
sp: NWCServiceProvider, pubkey: str, payload: Dict sp: NWCServiceProvider, pubkey: str, payload: dict
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]: ) -> list[tuple[dict | None, dict | None, list]]:
# hardening # # hardening #
assert_valid_pubkey(pubkey) assert_valid_pubkey(pubkey)
@ -477,8 +475,8 @@ async def _on_get_info(
account_methods = [] account_methods = []
for spm in sp_methods: for spm in sp_methods:
for p in permissions: for p in permissions:
permissions_data: Dict[str, Any] = nwc_permissions.get(p, {}) permissions_data: dict[str, Any] = nwc_permissions.get(p, {})
allowed_methods: List[str] = permissions_data.get("methods", []) allowed_methods: list[str] = permissions_data.get("methods", [])
if spm in allowed_methods: if spm in allowed_methods:
account_methods.append(spm) account_methods.append(spm)
break break

View file

@ -1,8 +1,8 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context {% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user) }} %} {% block scripts %} {{ window_vars(user) }}
<script type="module"> <script type="module">
import * as NobleSecp256k1 from "/nwcprovider/static/js/noble-secp256k1.min.js"; import * as NobleSecp256k1 from '/nwcprovider/static/js/noble-secp256k1.min.js'
window.NobleSecp256k1 = NobleSecp256k1; window.NobleSecp256k1 = NobleSecp256k1
</script> </script>
<script src="{{ static_url_for('nwcprovider/static', path='js/index.js') }}"></script> <script src="{{ static_url_for('nwcprovider/static', path='js/index.js') }}"></script>
{% endblock %} {% block page %} {% endblock %} {% block page %}

0
tests/__init__.py Normal file
View file

View file

@ -4,17 +4,17 @@ import hashlib
import json import json
import random import random
import time import time
from typing import Dict, List, Optional, Union from typing import Union
import bolt11 import bolt11
import httpx import httpx
import pytest import pytest
import secp256k1 import secp256k1
import websockets.client as websockets
from Cryptodome import Random from Cryptodome import Random
from Cryptodome.Cipher import AES from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import pad, unpad from Cryptodome.Util.Padding import pad, unpad
from loguru import logger from loguru import logger
from websockets.legacy.client import connect
wallets = { wallets = {
"wallet1": { "wallet1": {
@ -107,8 +107,8 @@ def gen_keypair():
async def create_nwc( async def create_nwc(
w: str, w: str,
desc: str, desc: str,
permissions: List[str], permissions: list[str],
budgets: List[Dict[str, int]], budgets: list[dict[str, int]],
expiration: int = 0, expiration: int = 0,
): ):
keypair = gen_keypair() keypair = gen_keypair()
@ -210,7 +210,7 @@ class NWCWallet:
async def _run(self): async def _run(self):
while True: while True:
try: try:
async with websockets.connect(self.relay) as ws: async with connect(self.relay) as ws:
self.ws = ws self.ws = ws
self.connected = True self.connected = True
self.sub_id = self._get_new_subid() self.sub_id = self._get_new_subid()
@ -244,7 +244,7 @@ class NWCWallet:
break break
def _encrypt_content( def _encrypt_content(
self, content: str, pubkey_hex: str, iv_seed: Optional[int] = None self, content: str, pubkey_hex: str, iv_seed: int | None = None
) -> str: ) -> str:
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True) pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
shared = pubkey.tweak_mul(bytes.fromhex(self.private_key_hex)).serialize()[1:] shared = pubkey.tweak_mul(bytes.fromhex(self.private_key_hex)).serialize()[1:]
@ -273,7 +273,7 @@ class NWCWallet:
decrypted = decrypted_bytes.decode("utf-8") decrypted = decrypted_bytes.decode("utf-8")
return decrypted return decrypted
async def _on_message(self, ws, message: str): async def _on_message(self, _, message: str):
logger.debug("Received message: " + message) logger.debug("Received message: " + message)
msg = json.loads(message) msg = json.loads(message)
if msg[0] == "EVENT": # Event message if msg[0] == "EVENT": # Event message
@ -292,12 +292,12 @@ class NWCWallet:
} }
) )
def _json_dumps(self, data: Union[Dict, list]) -> str: def _json_dumps(self, data: Union[dict, list]) -> str:
if isinstance(data, Dict): if isinstance(data, dict):
data = {k: v for k, v in data.items() if v is not None} data = {k: v for k, v in data.items() if v is not None}
return json.dumps(data, separators=(",", ":"), ensure_ascii=False) 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( signature_data = self._json_dumps(
[ [
0, 0,
@ -386,14 +386,14 @@ async def test_make_invoice():
await wallet1.send_event( await wallet1.send_event(
"make_invoice", {"amount": 1, "description": "test 123", "expiry": 1000} "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) logger.info(error)
assert error, "Expected internal error, because amount is too low" assert error, "Expected internal error, because amount is too low"
await wallet1.send_event( await wallet1.send_event(
"make_invoice", {"amount": 123000, "description": "test 123", "expiry": 1000} "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 not error
assert result["type"] == "incoming" assert result["type"] == "incoming"
assert result["description"] == "test 123" assert result["description"] == "test 123"
@ -424,7 +424,7 @@ async def test_lookup_invoice():
await wallet1.send_event( await wallet1.send_event(
"make_invoice", {"amount": 123000, "description": "test 123", "expiry": 1000} "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 not error
assert result["type"] == "incoming" assert result["type"] == "incoming"
assert result["description"] == "test 123" assert result["description"] == "test 123"
@ -440,7 +440,7 @@ async def test_lookup_invoice():
await wallet2.start() await wallet2.start()
await wallet2.send_event("lookup_invoice", {"invoice": result["invoice"]}) 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 not error
assert result["type"] == "incoming" assert result["type"] == "incoming"
assert result["description"] == "test 123" assert result["description"] == "test 123"
@ -465,7 +465,7 @@ async def test_get_info():
await wallet1.start() await wallet1.start()
await wallet1.send_event("get_info", {}) 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 not error
assert result["alias"] == "LNBits_NWC_SP" assert result["alias"] == "LNBits_NWC_SP"
assert result["color"] == "" assert result["color"] == ""
@ -492,33 +492,33 @@ async def test_permisions():
await wallet1.start() await wallet1.start()
await wallet1.send_event("get_info", {}) 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 not error
await wallet1.send_event( await wallet1.send_event(
"make_invoice", {"amount": 123000, "description": "test 123", "expiry": 1000} "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 assert error
await wallet1.close() await wallet1.close()
await wallet2.start() await wallet2.start()
await wallet2.send_event("get_info", {}) 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 assert error
await wallet2.send_event( await wallet2.send_event(
"make_invoice", {"amount": 123000, "description": "test 123", "expiry": 1000} "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 assert not error
await wallet2.close() await wallet2.close()
await wallet3.start() await wallet3.start()
await wallet3.send_event("get_info", {}) 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 not error
assert "make_invoice" in result["methods"] assert "make_invoice" in result["methods"]
assert "pay_invoice" in result["methods"] assert "pay_invoice" in result["methods"]
@ -548,7 +548,7 @@ async def test_pay_invoice_and_balance():
"make_invoice", {"amount": 123000, "description": "test 123"} "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 not error
assert result["invoice"] assert result["invoice"]
@ -557,7 +557,7 @@ async def test_pay_invoice_and_balance():
await wallet2.start() await wallet2.start()
await wallet2.send_event("pay_invoice", {"invoice": invoice}) 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 not error
assert result["preimage"] assert result["preimage"]
@ -569,12 +569,12 @@ async def test_pay_invoice_and_balance():
assert wallet2_balance_new == wallet2_balance - 123000 assert wallet2_balance_new == wallet2_balance - 123000
await wallet1.send_event("get_balance", {}) 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 not error
assert result["balance"] == wallet1_balance_new assert result["balance"] == wallet1_balance_new
await wallet2.send_event("get_balance", {}) 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 not error
assert result["balance"] == wallet2_balance_new assert result["balance"] == wallet2_balance_new
@ -703,13 +703,13 @@ async def test_insufficient_balance():
await wallet2.send_event( await wallet2.send_event(
"make_invoice", {"amount": amount_to_spend, "description": "test 123"} "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 not error
assert result["invoice"] assert result["invoice"]
invoice = result["invoice"] invoice = result["invoice"]
await wallet1.send_event("pay_invoice", {"invoice": 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(error)
logger.info(result) logger.info(result)
logger.info(amount_to_spend) logger.info(amount_to_spend)
@ -735,7 +735,7 @@ async def test_expiry():
await wallet3.send_event( await wallet3.send_event(
"make_invoice", {"amount": 123000, "description": "test 123"} "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
assert ( assert (
error["code"] == "UNAUTHORIZED" error["code"] == "UNAUTHORIZED"
@ -768,22 +768,22 @@ async def test_budget():
await wallet1.send_event( await wallet1.send_event(
"make_invoice", {"amount": 101000, "description": "Invalid"} "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 assert not error
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]}) 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
assert ( assert (
error["code"] == "QUOTA_EXCEEDED" error["code"] == "QUOTA_EXCEEDED"
), "Expected QUOTA_EXCEEDED error, because the budget was exceeded" ), "Expected QUOTA_EXCEEDED error, because the budget was exceeded"
await wallet1.send_event("make_invoice", {"amount": 99000, "description": "Valid"}) 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 assert not error
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]}) 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 not error, "Expected successful payment, because the budget was not exceeded"
assert result["preimage"] assert result["preimage"]
@ -791,11 +791,11 @@ async def test_budget():
"make_invoice", {"amount": 100000 - 99000 + 1000, "description": "Invalid"} "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 assert not error
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]}) 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
assert ( assert (
error["code"] == "QUOTA_EXCEEDED" error["code"] == "QUOTA_EXCEEDED"
@ -824,21 +824,21 @@ async def test_budget_refresh():
await wallet1.send_event( await wallet1.send_event(
"make_invoice", {"amount": 100000, "description": "Invalid"} "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 assert not error
await wallet1.send_event( await wallet1.send_event(
"make_invoice", {"amount": 100000, "description": "Invalid"} "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 assert not error
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]}) 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 not error, "Expected successful payment, because the budget was not exceeded"
await wallet3.send_event("pay_invoice", {"invoice": result2["invoice"]}) 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
assert ( assert (
error["code"] == "QUOTA_EXCEEDED" error["code"] == "QUOTA_EXCEEDED"
@ -846,11 +846,11 @@ async def test_budget_refresh():
await asyncio.sleep(5) await asyncio.sleep(5)
await wallet1.send_event("make_invoice", {"amount": 100000, "description": "Valid"}) 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 assert not error
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]}) 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" assert not error, "Expected successful payment, because the budget was refreshed"
await wallet3.close() await wallet3.close()
@ -916,6 +916,7 @@ async def test_invalid_invoice_handling():
# Send invalid invoice # Send invalid invoice
await wallet.send_event("pay_invoice", {"invoice": "invalid_lninvoice"}) await wallet.send_event("pay_invoice", {"invoice": "invalid_lninvoice"})
_, _, error = await wallet.wait_for("pay_invoice") _, _, error = await wallet.wait_for("pay_invoice")
assert error
assert error["code"] == "INTERNAL" assert error["code"] == "INTERNAL"
@ -935,6 +936,7 @@ async def test_replay_attack():
# Replay same message # Replay same message
await wallet.send_event("pay_invoice", {"invoice": valid_invoice}) await wallet.send_event("pay_invoice", {"invoice": valid_invoice})
_, _, error = await wallet.wait_for("pay_invoice") _, _, error = await wallet.wait_for("pay_invoice")
assert error
assert error["code"] == "PAYMENT_FAILED" assert error["code"] == "PAYMENT_FAILED"
@ -967,6 +969,7 @@ async def test_budget_bypass():
invoice2 = await create_valid_invoice(wallet, 60000) invoice2 = await create_valid_invoice(wallet, 60000)
await wallet.send_event("pay_invoice", {"invoice": invoice2}) await wallet.send_event("pay_invoice", {"invoice": invoice2})
_, _, error = await wallet.wait_for("pay_invoice") _, _, error = await wallet.wait_for("pay_invoice")
assert error
assert error["code"] == "QUOTA_EXCEEDED" assert error["code"] == "QUOTA_EXCEEDED"
@ -992,19 +995,28 @@ async def create_valid_invoice(wallet, amount=1000):
await wallet.send_event( await wallet.send_event(
"make_invoice", {"amount": amount, "description": "test invoice"} "make_invoice", {"amount": amount, "description": "test invoice"}
) )
result, tags, error = await wallet.wait_for("make_invoice") result, _, error = await wallet.wait_for("make_invoice")
if error: if error:
raise Exception(f"Failed to create invoice: {error}") raise Exception(f"Failed to create invoice: {error}")
return result["invoice"] return result["invoice"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_transactions(): async def test_list_transactions():
# Create wallets with required permissions # Create wallets with required permissions
nwc1 = await create_nwc( nwc1 = await create_nwc(
"wallet1", "test_list_transactions", ["invoice", "pay", "balance", "history"], [], 0 "wallet1",
"test_list_transactions",
["invoice", "pay", "balance", "history"],
[],
0,
) )
nwc2 = await create_nwc( nwc2 = await create_nwc(
"wallet2", "test_list_transactions", ["invoice", "pay", "balance", "history"], [], 0 "wallet2",
"test_list_transactions",
["invoice", "pay", "balance", "history"],
[],
0,
) )
wallet1 = NWCWallet(nwc1["pairing"]) wallet1 = NWCWallet(nwc1["pairing"])

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

View file

@ -1,18 +1,11 @@
import json 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 random
import string import string
import pytest import pytest
from loguru import logger
from nwcp import NWCServiceProvider from ...nwcp import NWCServiceProvider
@pytest.fixture @pytest.fixture

2276
uv.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,4 @@
from http import HTTPStatus from http import HTTPStatus
from typing import Dict, List, Optional
import secp256k1 import secp256k1
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
@ -38,20 +37,18 @@ nwcprovider_api_router = APIRouter()
# Get supported permissions # Get supported permissions
@nwcprovider_api_router.get("/api/v1/permissions", status_code=HTTPStatus.OK) @nwcprovider_api_router.get("/api/v1/permissions")
async def api_get_permissions() -> Dict: async def api_get_permissions() -> dict:
return nwc_permissions return nwc_permissions
## Get nwc keys associated with the wallet ## Get nwc keys associated with the wallet
@nwcprovider_api_router.get( @nwcprovider_api_router.get("/api/v1/nwc")
"/api/v1/nwc", status_code=HTTPStatus.OK, response_model=List[NWCGetResponse]
)
async def api_get_nwcs( async def api_get_nwcs(
include_expired: bool = False, include_expired: bool = False,
calculate_spent_budget: bool = False, calculate_spent_budget: bool = False,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
): ) -> list[NWCGetResponse]:
wallet_id = wallet.wallet.id wallet_id = wallet.wallet.id
# hardening # # hardening #
@ -74,9 +71,7 @@ async def api_get_nwcs(
# Get a nwc key # Get a nwc key
@nwcprovider_api_router.get( @nwcprovider_api_router.get("/api/v1/nwc/{pubkey}")
"/api/v1/nwc/{pubkey}", status_code=HTTPStatus.OK, response_model=NWCGetResponse
)
async def api_get_nwc( async def api_get_nwc(
pubkey: str, pubkey: str,
include_expired: bool = False, include_expired: bool = False,
@ -102,22 +97,20 @@ async def api_get_nwc(
# Get pairing url for given secret # Get pairing url for given secret
@nwcprovider_api_router.get( @nwcprovider_api_router.get("/api/v1/pairing/{secret}")
"/api/v1/pairing/{secret}", status_code=HTTPStatus.OK, response_model=str
)
async def api_get_pairing_url(req: Request, secret: str) -> str: async def api_get_pairing_url(req: Request, secret: str) -> str:
# hardening # # hardening #
assert_sane_string(secret) assert_sane_string(secret)
# ## # # ## #
pprivkey: Optional[str] = await get_config_nwc("provider_key") pprivkey: str | None = await get_config_nwc("provider_key")
if not pprivkey: if not pprivkey:
raise Exception("Extension is not configured") raise Exception("Extension is not configured")
relay = await get_config_nwc("relay") relay = await get_config_nwc("relay")
if not relay: if not relay:
raise Exception("Extension is not configured") 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: if relay_alias:
relay = relay_alias relay = relay_alias
else: else:
@ -147,13 +140,12 @@ async def api_get_pairing_url(req: Request, secret: str) -> str:
@nwcprovider_api_router.put( @nwcprovider_api_router.put(
"/api/v1/nwc/{pubkey}", "/api/v1/nwc/{pubkey}",
status_code=HTTPStatus.CREATED, status_code=HTTPStatus.CREATED,
response_model=NWCGetResponse,
) )
async def api_register_nwc( async def api_register_nwc(
pubkey: str, pubkey: str,
data: NWCRegistrationRequest, data: NWCRegistrationRequest,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
): ) -> NWCGetResponse:
wallet_id = wallet.wallet.id wallet_id = wallet.wallet.id
# hardening # # hardening #
@ -177,7 +169,7 @@ async def api_register_nwc(
# Delete a nwc key # 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( async def api_delete_nwc(
pubkey: str, wallet: WalletTypeInfo = Depends(require_admin_key) pubkey: str, wallet: WalletTypeInfo = Depends(require_admin_key)
): ):
@ -193,9 +185,7 @@ async def api_delete_nwc(
# Get config # Get config
@nwcprovider_api_router.get( @nwcprovider_api_router.get("/api/v1/config", dependencies=[Depends(check_admin)])
"/api/v1/config", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)]
)
async def api_get_all_config_nwc(): async def api_get_all_config_nwc():
config = await get_all_config_nwc() config = await get_all_config_nwc()
return config return config
@ -204,7 +194,6 @@ async def api_get_all_config_nwc():
# Get config # Get config
@nwcprovider_api_router.get( @nwcprovider_api_router.get(
"/api/v1/config/{key}", "/api/v1/config/{key}",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)], dependencies=[Depends(check_admin)],
) )
async def api_get_config_nwc(key: str): async def api_get_config_nwc(key: str):
@ -215,9 +204,7 @@ async def api_get_config_nwc(key: str):
# Set config # Set config
@nwcprovider_api_router.post( @nwcprovider_api_router.post("/api/v1/config", dependencies=[Depends(check_admin)])
"/api/v1/config", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)]
)
async def api_set_config_nwc(req: Request): async def api_set_config_nwc(req: Request):
data = await req.json() data = await req.json()