code quality fixes and cleanup

This commit is contained in:
Riccardo Balbo 2024-09-07 09:09:22 +00:00
commit 2bbbcd4d65
32 changed files with 4943 additions and 1368 deletions

View file

@ -1,33 +1,28 @@
{ {
"name": "lnbits_nwc_provider", "name": "lnbits_nwc_provider",
"image": "mcr.microsoft.com/devcontainers/python:1-3.9-bullseye", "image": "mcr.microsoft.com/devcontainers/python:1-3.9-bullseye",
"features": { "features": {
"ghcr.io/devcontainers-contrib/features/poetry:2": { "ghcr.io/devcontainers-contrib/features/poetry:2": {}
} },
"mounts": [
}, "source=${localWorkspaceFolder}/.devcontainer/start.sh,target=/start-lnbits.sh,type=bind",
"mounts": [ "source=${localWorkspaceFolder}/.devcontainer/setup.sh,target=/setup.sh,type=bind",
"source=${localWorkspaceFolder}/.devcontainer/start.sh,target=/start-lnbits.sh,type=bind", "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind"
"source=${localWorkspaceFolder}/.devcontainer/setup.sh,target=/setup.sh,type=bind", ],
"source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" "containerEnv": {
"IS_DEV_CONTAINER": "true"
], },
"containerEnv": { "postCreateCommand": "/bin/bash /setup.sh ${containerWorkspaceFolder}",
"IS_DEV_CONTAINER": "true", "postStartCommand": "/bin/bash /start-lnbits.sh",
}, "forwardPorts": [5000],
"postCreateCommand": "/bin/bash /setup.sh ${containerWorkspaceFolder}", "customizations": {
"postStartCommand": "/bin/bash /start-lnbits.sh", "vscode": {
"forwardPorts": [ "settings": {
5000 "python.analysis.extraPaths": ["/home/vscode/lnbits"],
], "python.pythonPath": "/home/vscode/python"
"customizations": { // "python.pythonPath": "/opt/python/bin/python3.9"
"vscode": { }
"settings": { }
// "python.pythonPath": "/opt/python/bin/python3.9" },
} "runArgs": ["--network", "host"]
}
},
"runArgs": ["--network", "host"]
} }

View file

@ -1,4 +1,5 @@
#!/bin/bash #!/bin/bash
echo $PYTHONPATH
CONTAINER_WORKSPACE_FOLDER=$1 CONTAINER_WORKSPACE_FOLDER=$1
cd $CONTAINER_WORKSPACE_FOLDER cd $CONTAINER_WORKSPACE_FOLDER
sudo apt update -y sudo apt update -y
@ -8,14 +9,18 @@ sudo apt-get install -y docker.io
curl -fsSL https://deb.nodesource.com/setup_20.x -o /tmp/nodesource_setup.sh curl -fsSL https://deb.nodesource.com/setup_20.x -o /tmp/nodesource_setup.sh
sudo bash /tmp/nodesource_setup.sh sudo bash /tmp/nodesource_setup.sh
sudo apt-get install -y nodejs sudo apt-get install -y nodejs
if [ ! -d lnbits ] ; then
git clone https://github.com/lnbits/lnbits.git; cd $HOME
echo $PWD
if [ ! -d ./lnbits ] ; then
git clone https://github.com/lnbits/lnbits.git lnbits
fi fi
cd lnbits cd lnbits
echo $PWD
git checkout 0.12.8 git checkout 0.12.8
poetry env use python3.9 poetry env use python3.9
# export VENV_PATH=$(poetry env info -p) POETRY_PYTHON_PATH=$(poetry env info -p)/bin/python
# sudo ln -s $VENV_PATH /opt/python ln -sf $POETRY_PYTHON_PATH /home/vscode/python
make bundle make bundle
poetry install --no-interaction poetry install --no-interaction
mkdir -p lnbits/extensions/ mkdir -p lnbits/extensions/
@ -23,4 +28,7 @@ if [ ! -d lnbits/extensions/nwcprovider ] ; then
ln -s $CONTAINER_WORKSPACE_FOLDER lnbits/extensions/nwcprovider ln -s $CONTAINER_WORKSPACE_FOLDER lnbits/extensions/nwcprovider
fi fi
cd $CONTAINER_WORKSPACE_FOLDER
poetry install --no-interaction
npm i prettier
npm i pyright

View file

@ -1,4 +1,4 @@
#!/bin/bash #!/bin/bash
ln -s $PWD/.devcontainer/.env ./lnbits/.env ln -s $PWD/.devcontainer/.env $HOME/lnbits/.env
cd ./lnbits cd $HOME/lnbits
poetry run lnbits poetry run lnbits

View file

@ -4,7 +4,6 @@ on:
- "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:
@ -23,7 +22,7 @@ jobs:
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: |

View file

@ -4,7 +4,6 @@ on:
- "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:
@ -23,7 +22,7 @@ jobs:
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: |

View file

@ -4,45 +4,43 @@ on:
push: push:
pull_request: pull_request:
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v2
- 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.9"
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install poetry pip install poetry
- name: Run setup script - name: Run setup script
run: | run: |
/bin/bash .devcontainer/setup.sh ${{ github.workspace }} /bin/bash .devcontainer/setup.sh ${{ github.workspace }}
- name: Run unit tests
run: |
cd lnbits
poetry_env_path=$(poetry env info --path)
source $poetry_env_path/bin/activate
cd ..
pytest tests/unit/*.py -s
- name: Run unit tests - name: Setup integration tests
run: | run: bash tests/integration/start.sh
cd lnbits
poetry_env_path=$(poetry env info --path)
source $poetry_env_path/bin/activate
cd ..
pytest tests/unit/*.py -s
- name: Setup integration tests - name: Run integration tests
run: bash tests/integration/start.sh run: |
cd lnbits
- name: Run integration tests poetry_env_path=$(poetry env info --path)
run: | source $poetry_env_path/bin/activate
cd lnbits cd ..
poetry_env_path=$(poetry env info --path) pytest tests/integration/*.py -s
source $poetry_env_path/bin/activate
cd ..
pytest tests/unit/*.py -s

6
.gitignore vendored
View file

@ -1,4 +1,8 @@
__pycache__ __pycache__
lnbits lnbits
tests/integration/strfry-data tests/integration/strfry-data
tests/integration/lnbits_itest_data tests/integration/lnbits_itest_data
node_modules
.pytest_cache
.ruff_cache
.mypy_cache

1
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1 @@
{}

47
Makefile Normal file
View file

@ -0,0 +1,47 @@
all: format check
format: prettier black ruff
check: mypy pyright checkblack checkruff checkprettier
prettier:
poetry run ./node_modules/.bin/prettier --write .
pyright:
poetry run ./node_modules/.bin/pyright
mypy:
poetry run mypy .
black:
poetry run black .
ruff:
poetry run ruff check . --fix
checkruff:
poetry run ruff check .
checkprettier:
poetry run ./node_modules/.bin/prettier --check .
checkblack:
poetry run black --check .
checkeditorconfig:
editorconfig-checker
test:
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/unit/*.py -s
install-pre-commit-hook:
@echo "Installing pre-commit hook to git"
@echo "Uninstall the hook with poetry run pre-commit uninstall"
poetry run pre-commit install
pre-commit:
poetry run pre-commit run --all-files
checkbundle:
@echo "skipping checkbundle"

View file

@ -12,11 +12,11 @@ Configure the extension from the "Settings" page in the top right menu when logg
### Configuration Options: ### Configuration Options:
| Key | Description | Default | | Key | Description | Default |
|---------------|------------------------------------------------------------------------------------------------------------------------------------|--------------------------------| | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
| relay | URL of the nostr relay for dispatching and receiving NWC events. Use public relays or a custom one. Specify `nostrclient` to connect to the [nostrclient extension](https://github.com/lnbits/nostrclient). | nostrclient | | relay | URL of the nostr relay for dispatching and receiving NWC events. Use public relays or a custom one. Specify `nostrclient` to connect to the [nostrclient extension](https://github.com/lnbits/nostrclient). | nostrclient |
| provider_key | Nostr secret key of the NWC Service Provider. | Random key generated on install | | provider_key | Nostr secret key of the NWC Service Provider. | Random key generated on install |
| relay_alias | Relay URL to display in pairing URLs. Set if different from `relay`. | Empty (uses the `relay` value) | | relay_alias | Relay URL to display in pairing URLs. Set if different from `relay`. | Empty (uses the `relay` value) |
### Using Nostrclient ### Using Nostrclient

View file

@ -1,18 +1,22 @@
import asyncio import asyncio
from fastapi import APIRouter from fastapi import APIRouter
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import create_permanent_unique_task
from loguru import logger from loguru import logger
from lnbits.tasks import create_permanent_unique_task
from .crud import db
from .tasks import handle_execution_queue, handle_nwc
from .views import nwcprovider_router
from .views_api import nwcprovider_api_router
db = Database("ext_nwcprovider")
execution_queue = asyncio.Queue()
scheduled_tasks: list[asyncio.Task] = [] scheduled_tasks: list[asyncio.Task] = []
nwcprovider_ext: APIRouter = APIRouter( nwcprovider_ext: APIRouter = APIRouter(
prefix="/nwcprovider", tags=["NWC Service Provider"] prefix="/nwcprovider", tags=["NWC Service Provider"]
) )
nwcprovider_ext.include_router(nwcprovider_router)
nwcprovider_ext.include_router(nwcprovider_api_router)
nwcprovider_static_files = [ nwcprovider_static_files = [
{ {
@ -22,25 +26,6 @@ nwcprovider_static_files = [
] ]
def nwcprovider_renderer():
return template_renderer(["nwcprovider/templates"])
async def enqueue(action):
future = asyncio.Future()
execution_queue.put_nowait({
"action": action,
"future": future
})
return await future
from .views import *
from .views_api import *
from .tasks import handle_nwc, handle_execution_queue
def nwcprovider_stop(): def nwcprovider_stop():
for task in scheduled_tasks: for task in scheduled_tasks:
try: try:
@ -53,6 +38,9 @@ def nwcprovider_start():
task = create_permanent_unique_task("ext_nwcprovider", handle_nwc) task = create_permanent_unique_task("ext_nwcprovider", handle_nwc)
scheduled_tasks.append(task) scheduled_tasks.append(task)
task = create_permanent_unique_task( task = create_permanent_unique_task(
"ext_nwcprovider_execution_queue", handle_execution_queue) "ext_nwcprovider_execution_queue", handle_execution_queue
)
scheduled_tasks.append(task) scheduled_tasks.append(task)
__all__ = ["db"]

181
crud.py
View file

@ -1,56 +1,78 @@
from typing import Optional, Any, List
from . import db
from .models import NWCKey, NWCBudget, NWCLog, NWCNewBudget
import time import time
from . import enqueue from typing import List, Optional
import json
from lnbits.db import Database
from .execution_queue import enqueue
from .models import NWCBudget, NWCKey, NWCNewBudget
db = Database("ext_nwcprovider")
async def create_nwc( async def create_nwc(
pubkey: str, pubkey: str,
wallet_id:str, wallet_id: str,
description: str, description: str,
expires_at: int , expires_at: int,
permissions:List[str] , permissions: List[str],
budgets: Optional[List[NWCNewBudget]] = None budgets: Optional[List[NWCNewBudget]] = None,
) -> NWCKey: ) -> NWCKey:
# Check if the key already exists # Check if the key already exists
if await get_nwc(pubkey, None, True): if await get_nwc(pubkey, None, True):
raise Exception("Public key already used") raise Exception("Public key already used")
# If not, create it # If not, create it
now=int(time.time()) now = int(time.time())
await db.execute( await db.execute(
""" """
INSERT INTO nwcprovider.keys (pubkey, wallet, description, permissions, created_at, expires_at, last_used) INSERT INTO nwcprovider.keys (
pubkey,
wallet,
description,
permissions,
created_at,
expires_at,
last_used
)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
""", """,
(pubkey, wallet_id, description, " ".join(permissions), now,int(expires_at) if expires_at else 0, now) (
pubkey,
wallet_id,
description,
" ".join(permissions),
now,
int(expires_at) if expires_at else 0,
now,
),
) )
# Add budgets # Add budgets
if budgets: if budgets:
for budget in budgets: for budget in budgets:
await db.execute( await db.execute(
""" """
INSERT INTO nwcprovider.budgets (pubkey, budget_msats, refresh_window, created_at) INSERT INTO nwcprovider.budgets (
pubkey,
budget_msats,
refresh_window,
created_at
)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
""", """,
(pubkey, budget.budget_msats, budget.refresh_window, budget.created_at) (pubkey, budget.budget_msats, budget.refresh_window, budget.created_at),
) )
# Return the created key # Return the created key
return NWCKey( return NWCKey(
pubkey=pubkey, pubkey=pubkey,
wallet=wallet_id, wallet=wallet_id,
description=description, description=description,
expires_at=expires_at, expires_at=expires_at,
permissions=" ".join(permissions), permissions=" ".join(permissions),
created_at=now, created_at=now,
last_used=now last_used=now,
) )
async def delete_nwc( async def delete_nwc(pubkey: str, wallet_id: str):
pubkey: str,
wallet_id:str
):
nwc = await get_nwc(pubkey, wallet_id) nwc = await get_nwc(pubkey, wallet_id)
if not nwc: if not nwc:
raise Exception("Public key does not exist") raise Exception("Public key does not exist")
@ -58,35 +80,45 @@ async def delete_nwc(
""" """
DELETE FROM nwcprovider.keys WHERE pubkey = ? AND wallet = ? DELETE FROM nwcprovider.keys WHERE pubkey = ? AND wallet = ?
""", """,
(pubkey, wallet_id) (pubkey, wallet_id),
) )
async def get_wallet_nwcs( async def get_wallet_nwcs(
wallet_id: str, wallet_id: str, include_expired: Optional[bool] = False
include_expired: Optional[bool] = False
) -> List[NWCKey]: ) -> List[NWCKey]:
rows = await db.fetchall( rows = await db.fetchall(
"SELECT * FROM nwcprovider.keys WHERE wallet = ? AND (expires_at = 0 OR expires_at > ?)", (wallet_id, int(time.time()) if not include_expired else -1 ) """
SELECT * FROM nwcprovider.keys
WHERE wallet = ? AND (expires_at = 0 OR expires_at > ?)
""",
(wallet_id, int(time.time()) if not include_expired else -1),
) )
return [NWCKey(**row) for row in rows] return [NWCKey(**row) for row in rows]
async def get_nwc( async def get_nwc(
pubkey: str, pubkey: str,
wallet_id: Optional[str] = None, wallet_id: Optional[str] = None,
include_expired: Optional[bool] = False, include_expired: Optional[bool] = False,
refresh_last_used: Optional[bool] = False refresh_last_used: Optional[bool] = False,
) -> Optional[NWCKey]: ) -> Optional[NWCKey]:
# expires_at = 0 means it never expires # expires_at = 0 means it never expires
if wallet_id: if wallet_id:
row = await db.fetchone( row = await db.fetchone(
"SELECT * FROM nwcprovider.keys WHERE pubkey = ? AND wallet = ? AND (expires_at = 0 OR expires_at > ?)", (pubkey, wallet_id, int(time.time()) if not include_expired else -1 ) """
SELECT * FROM nwcprovider.keys
WHERE pubkey = ? AND wallet = ? AND (expires_at = 0 OR expires_at > ?)
""",
(pubkey, wallet_id, int(time.time()) if not include_expired else -1),
) )
else: else:
row = await db.fetchone( row = await db.fetchone(
"SELECT * FROM nwcprovider.keys WHERE pubkey = ? AND (expires_at = 0 OR expires_at > ?)", (pubkey, int(time.time()) if not include_expired else -1 ) """
SELECT * FROM nwcprovider.keys
WHERE pubkey = ? AND (expires_at = 0 OR expires_at > ?)
""",
(pubkey, int(time.time()) if not include_expired else -1),
) )
if not row: if not row:
return None return None
@ -95,10 +127,10 @@ async def get_nwc(
""" """
UPDATE nwcprovider.keys SET last_used = ? WHERE pubkey = ? UPDATE nwcprovider.keys SET last_used = ? WHERE pubkey = ?
""", """,
(int(time.time()), pubkey) (int(time.time()), pubkey),
) )
return NWCKey(**row) return NWCKey(**row)
async def get_budgets_nwc(pubkey, calculate_spent=False): async def get_budgets_nwc(pubkey, calculate_spent=False):
rows = await db.fetchall( rows = await db.fetchall(
@ -110,47 +142,35 @@ async def get_budgets_nwc(pubkey, calculate_spent=False):
last_cycle, next_cycle = budget.get_timestamp_range() last_cycle, next_cycle = budget.get_timestamp_range()
tot_spent_in_range_msats = await db.fetchone( tot_spent_in_range_msats = await db.fetchone(
""" """
SELECT SUM(amount_msats) FROM nwcprovider.spent WHERE pubkey = ? AND created_at >= ? AND created_at < ? SELECT SUM(amount_msats) FROM nwcprovider.spent
WHERE pubkey = ? AND created_at >= ? AND created_at < ?
""", """,
(pubkey, last_cycle, next_cycle) (pubkey, last_cycle, next_cycle),
) )
tot_spent_in_range_msats = tot_spent_in_range_msats[0] or 0 tot_spent_in_range_msats = tot_spent_in_range_msats[0] or 0
budget.used_budget_msats = tot_spent_in_range_msats budget.used_budget_msats = tot_spent_in_range_msats
return budgets return budgets
# async def log_nwc( async def tracked_spend_nwc(pubkey: str, amount_msats: int, action):
# pubkey: str,
# payload:Optional[Any] = None
# ):
# if not payload: payload=""
# payload = json.dumps(payload)
# await db.execute(
# """
# INSERT INTO nwcprovider.logs (pubkey, payload, created_at)
# VALUES (?, ?, ?)
# """,
# (pubkey, payload, int(time.time()))
# )
async def tracked_spend_nwc(
pubkey: str,
amount_msats: int,
action
):
async def r(): async def r():
created_at = int(time.time()) created_at = int(time.time())
budgets = await get_budgets_nwc(pubkey) budgets = await get_budgets_nwc(pubkey)
in_budget = True in_budget = True
for budget in budgets: for budget in budgets:
last_cycle, next_cycle = budget.get_timestamp_range() last_cycle, next_cycle = budget.get_timestamp_range()
tot_spent_in_range_msats =(await db.fetchone( tot_spent_in_range_msats = (
""" (
SELECT SUM(amount_msats) FROM nwcprovider.spent WHERE pubkey = ? AND created_at >= ? AND created_at < ? await db.fetchone(
""", """
(pubkey, last_cycle, next_cycle) SELECT SUM(amount_msats) FROM nwcprovider.spent
))[0] or 0 WHERE pubkey = ? AND created_at >= ? AND created_at < ?
""",
(pubkey, last_cycle, next_cycle),
)
)[0]
or 0
)
if tot_spent_in_range_msats + amount_msats > budget.budget_msats: if tot_spent_in_range_msats + amount_msats > budget.budget_msats:
in_budget = False in_budget = False
break break
@ -162,40 +182,37 @@ async def tracked_spend_nwc(
INSERT INTO nwcprovider.spent (pubkey, amount_msats, created_at) INSERT INTO nwcprovider.spent (pubkey, amount_msats, created_at)
VALUES (?, ?, ?) VALUES (?, ?, ?)
""", """,
(pubkey, amount_msats, created_at) (pubkey, amount_msats, created_at),
) )
return True, out return True, out
return await enqueue(r) return await enqueue(r)
async def get_config_nwc(key:str): async def get_config_nwc(key: str):
row = await db.fetchone( row = await db.fetchone("SELECT * FROM nwcprovider.config WHERE key = ?", (key,))
"SELECT * FROM nwcprovider.config WHERE key = ?", (key)
)
if not row: if not row:
return None return None
return row["value"] return row["value"]
async def get_all_config_nwc():
rows = await db.fetchall(
"SELECT * FROM nwcprovider.config"
)
return {row["key"]:row["value"] for row in rows}
async def set_config_nwc(key:str, value:str): async def get_all_config_nwc():
rows = await db.fetchall("SELECT * FROM nwcprovider.config")
return {row["key"]: row["value"] for row in rows}
async def set_config_nwc(key: str, value: str):
await db.execute( await db.execute(
""" """
DELETE FROM nwcprovider.config DELETE FROM nwcprovider.config
WHERE key = ? WHERE key = ?
""", """,
(key,) (key,),
) )
await db.execute( await db.execute(
""" """
INSERT INTO nwcprovider.config (key, value) INSERT INTO nwcprovider.config (key, value)
VALUES (?, ?) VALUES (?, ?)
""", """,
(key, value) (key, value),
) )

0
data/logs/debug.log Normal file
View file

0
data/logs/lnbits.log Normal file
View file

10
execution_queue.py Normal file
View file

@ -0,0 +1,10 @@
import asyncio
from typing import Any, Dict
execution_queue: asyncio.Queue[Dict[str, Any]] = asyncio.Queue()
async def enqueue(action):
future = asyncio.Future()
execution_queue.put_nowait({"action": action, "future": future})
return await future

View file

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

View file

@ -1,5 +1,6 @@
import secp256k1 import secp256k1
async def m001_initial(db): async def m001_initial(db):
""" """
Initial tables Initial tables
@ -8,11 +9,11 @@ async def m001_initial(db):
""" """
CREATE TABLE nwcprovider.keys ( CREATE TABLE nwcprovider.keys (
pubkey TEXT PRIMARY KEY, pubkey TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
expires_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
permissions TEXT NOT NULL, permissions TEXT NOT NULL,
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
); );
""" """
) )
@ -20,23 +21,27 @@ async def m001_initial(db):
await db.execute( await db.execute(
f""" f"""
CREATE TABLE nwcprovider.spent ( CREATE TABLE nwcprovider.spent (
id {db.serial_primary_key}, id {db.serial_primary_key},
pubkey TEXT NOT NULL, pubkey TEXT NOT NULL,
amount_msats INTEGER NOT NULL, amount_msats INTEGER NOT NULL,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
FOREIGN KEY(pubkey) REFERENCES {db.references_schema}keys(pubkey) ON DELETE CASCADE FOREIGN KEY(pubkey)
REFERENCES {db.references_schema}keys(pubkey)
ON DELETE CASCADE
); );
""" """
) )
await db.execute( await db.execute(
f""" f"""
CREATE TABLE nwcprovider.logs ( CREATE TABLE nwcprovider.logs (
id {db.serial_primary_key}, id {db.serial_primary_key},
pubkey TEXT NOT NULL, pubkey TEXT NOT NULL,
payload TEXT NOT NULL, payload TEXT NOT NULL,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
FOREIGN KEY(pubkey) REFERENCES {db.references_schema}keys(pubkey) ON DELETE CASCADE FOREIGN KEY(pubkey)
REFERENCES {db.references_schema}keys(pubkey)
ON DELETE CASCADE
); );
""" """
) )
@ -48,14 +53,15 @@ async def m001_initial(db):
pubkey TEXT NOT NULL, pubkey TEXT NOT NULL,
budget_msats INTEGER NOT NULL, budget_msats INTEGER NOT NULL,
refresh_window INTEGER NOT NULL, refresh_window INTEGER NOT NULL,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
FOREIGN KEY(pubkey) REFERENCES {db.references_schema}keys(pubkey) ON DELETE CASCADE FOREIGN KEY(pubkey)
REFERENCES {db.references_schema}keys(pubkey)
ON DELETE CASCADE
); );
""" """
) )
async def m002_config(db): async def m002_config(db):
""" """
Config table Config table
@ -69,6 +75,7 @@ async def m002_config(db):
""" """
) )
async def m003_default_config(db): async def m003_default_config(db):
""" """
Default config Default config
@ -83,21 +90,20 @@ async def m003_default_config(db):
""" """
INSERT INTO nwcprovider.config (key, value) VALUES ('provider_key', ?); INSERT INTO nwcprovider.config (key, value) VALUES ('provider_key', ?);
""", """,
(new_private_key,) (new_private_key,),
) )
async def m004_default_config2(db): async def m004_default_config2(db):
""" """
Default config Default config
""" """
await db.execute( await db.execute(
""" """
INSERT INTO nwcprovider.config (key, value) VALUES ('relay_alias', ?); INSERT INTO nwcprovider.config (key, value) VALUES ('relay_alias', ?);
""", """,
('',) ("",),
) )

View file

@ -1,54 +1,52 @@
# Data models for your extension # Data models for your extension
from sqlite3 import Row
from pydantic import BaseModel, Field
import time import time
import json from sqlite3 import Row
from typing import List, Dict, Any from typing import Any, Dict, List
from pydantic import BaseModel from pydantic import BaseModel
class NWCKey(BaseModel): class NWCKey(BaseModel):
pubkey: str pubkey: str
wallet: str wallet: str
description: str description: str
expires_at: int expires_at: int
permissions: str permissions: str
created_at: int created_at: int
last_used: int last_used: int
def getPermissions(cls) -> List[str]:
try:
return cls.permissions.split(" ")
except:
return []
def get_permissions(self) -> List[str]:
try:
return self.permissions.split(" ")
except Exception:
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)
class NWCBudget(BaseModel): class NWCBudget(BaseModel):
id: int id: int
pubkey: str pubkey: str
budget_msats: int budget_msats: int
refresh_window: int refresh_window: int
created_at: int created_at: int
used_budget_msats: int = 0 used_budget_msats: int = 0
def get_timestamp_range(cls): def get_timestamp_range(self) -> tuple[int, int]:
c = int(time.time()) c = int(time.time())
if cls.refresh_window <= 0: # never refresh if self.refresh_window <= 0: # never refresh
# return a timestamp in the future # return a timestamp in the future
return c + 21000000 return c, c + 21000000
# calculate the next refresh timestamp # calculate the next refresh timestamp
elapsed = c - cls.created_at elapsed = c - self.created_at
passed_cycles = elapsed // cls.refresh_window passed_cycles = elapsed // self.refresh_window
last_cycle = cls.created_at + (passed_cycles * cls.refresh_window) last_cycle = self.created_at + (passed_cycles * self.refresh_window)
next_cycle = last_cycle + cls.refresh_window next_cycle = last_cycle + self.refresh_window
return last_cycle, next_cycle return last_cycle, next_cycle
@classmethod @classmethod
def from_row(cls, row: Row) -> "NWCBudget": def from_row(cls, row: Row) -> "NWCBudget":
return cls(**dict(row)) return cls(**dict(row))
@ -81,5 +79,3 @@ class NWCRegistrationRequest(BaseModel):
class NWCGetResponse(BaseModel): class NWCGetResponse(BaseModel):
data: NWCKey data: NWCKey
budgets: List[NWCBudget] budgets: List[NWCBudget]

View file

@ -1,81 +1,90 @@
import asyncio import asyncio
import base64
import hashlib import hashlib
import json import json
from typing import Dict import random
import secp256k1
from loguru import logger
from lnbits.settings import settings
import time import time
import websockets from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Union
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
import base64
import random
from typing import Union, List, Callable, Tuple
from lnbits.app import settings
from lnbits.helpers import encrypt_internal_message
from urllib.parse import quote
from Cryptodome.Util.Padding import pad, unpad from Cryptodome.Util.Padding import pad, unpad
from typing import List, Dict, Optional from lnbits.helpers import encrypt_internal_message
from lnbits.settings import settings
from loguru import logger
class MainSubscription: class MainSubscription:
def __init__(self): def __init__(self):
self.requests_sub_id = None self.requests_sub_id: Optional[str] = None
self.responses_sub_id = None self.responses_sub_id: Optional[str] = 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 getStale(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.
""" """
pending_events = [] pending_events = []
for [id, event] in self.events.items(): for [event_id, event] in self.events.items():
if not id in self.responses: if event_id not in self.responses:
pending_events.append(event) pending_events.append(event)
return pending_events return pending_events
def registerResponse(self, event_id:str): def register_response(self, event_id: str):
""" """
Register a response for a request event (not stale anymore) Register a response for a request event (not stale anymore)
""" """
if not event_id in self.responses: if event_id not in self.responses:
self.responses.append(event_id) self.responses.append(event_id)
class NWCServiceProvider: class NWCServiceProvider:
def __init__(self, private_key:str=None, relay:str=None): def __init__(self, private_key: Optional[str] = None, relay: Optional[str] = None):
if not relay: # Connect to nostrclient if not relay: # Connect to nostrclient
relay = "nostrclient" relay = "nostrclient"
if relay == "nostrclient": if relay == "nostrclient":
relay=f"ws://localhost:{settings.port}/nostrclient/api/v1/relay" relay = f"ws://localhost:{settings.port}/nostrclient/api/v1/relay"
elif relay == "nostrclient:private": elif relay == "nostrclient:private":
relay_endpoint = encrypt_internal_message("relay") relay_endpoint = encrypt_internal_message("relay")
relay=f"ws://localhost:{settings.port}/nostrclient/api/v1/{relay_endpoint}" relay = (
f"ws://localhost:{settings.port}/nostrclient/api/v1/{relay_endpoint}"
)
self.relay = relay self.relay = relay
if not private_key: # Create random key if not private_key: # Create random key
private_key = bytes.hex(secp256k1._gen_private_key()) private_key = bytes.hex(secp256k1._gen_private_key())
self.private_key = secp256k1.PrivateKey(bytes.fromhex(private_key)) self.private_key = secp256k1.PrivateKey(bytes.fromhex(private_key))
self.private_key_hex = private_key self.private_key_hex = private_key
self.public_key = self.private_key.pubkey self.public_key = self.private_key.pubkey
if not self.public_key:
raise Exception("Invalid public key")
self.public_key_hex = self.public_key.serialize().hex()[2:] self.public_key_hex = self.public_key.serialize().hex()[2:]
# List of supported methods # List of supported methods
self.supported_methods = [] 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 = 0 self.subscriptions_count: int = 0
# Request listeners, listen to specific methods # Request listeners, listen to specific methods
self.request_listeners = {} self.request_listeners: Dict[
str,
Callable[
["NWCServiceProvider", str, Dict],
Awaitable[List[Tuple[Optional[Dict], Optional[Dict], List]]],
],
] = {}
# Reconnect task (if the connection is lost) # Reconnect task (if the connection is lost)
self.reconnect_task = None self.reconnect_task = None
# Subscription # Subscription
self.sub = None self.sub = None
# websocket connection # websocket connection
@ -83,30 +92,43 @@ class NWCServiceProvider:
# if True the websocket is connected # if True the websocket is connected
self.connected = False self.connected = False
# if True the instance is shutting down # if True the instance is shutting down
self.shutdown = False self.shutdown = False
logger.info("NWC Service is ready. relay: "+str(self.relay)+" pubkey: " + logger.info(
self.public_key_hex) "NWC Service is ready. relay: "
+ str(self.relay)
def getSupportedMethods(self): + " pubkey: "
+ self.public_key_hex
)
def get_supported_methods(self):
""" """
Returns the list of supported methods by this service provider. Returns the list of supported methods by this service provider.
""" """
return self.supported_methods return self.supported_methods
def addRequestListener(self, method: str, l: Callable[["NWCServiceProvider", str, Dict], List[Tuple[Dict, Dict, List]]]): def add_request_listener(
self,
method: str,
listener: Callable[
["NWCServiceProvider", str, Dict],
Awaitable[List[Tuple[Optional[Dict], Optional[Dict], List]]],
],
):
""" """
Adds a request listener for a specific method. Adds a request listener for a specific method.
Args: Args:
method (str): The method name. method (str): The method name.
l (Callable[["NWCServiceProvider", str, Dict], List[Tuple[Dict, Dict]]]): The listener function listener (Callable[
["NWCServiceProvider", str, Dict], List[Tuple[Dict, Dict]]
]): The listener function
""" """
if not method in self.supported_methods: if method not in self.supported_methods:
self.supported_methods.append(method) self.supported_methods.append(method)
self.request_listeners[method] = l self.request_listeners[method] = listener
async def start(self): async def start(self):
""" """
@ -114,21 +136,19 @@ class NWCServiceProvider:
""" """
self.reconnect_task = asyncio.create_task(self._connect_to_relay()) self.reconnect_task = asyncio.create_task(self._connect_to_relay())
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.
Args: Args:
data (Dict): The dictionary to be converted. data (Dict): The dictionary to be converted.
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)
def _is_shutting_down(self) -> bool: def _is_shutting_down(self) -> bool:
""" """
@ -136,14 +156,15 @@ 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: Dict):
""" """
Sends data to the relay. Sends data to the relay.
Args: Args:
data (Dict): The data to be sent. data (Dict): The data to be sent.
""" """
if not self.ws:
raise Exception("Websocket connection is not established")
if self._is_shutting_down(): if self._is_shutting_down():
logger.warning("Trying to send data while shutting down") logger.warning("Trying to send data while shutting down")
return return
@ -151,7 +172,6 @@ class NWCServiceProvider:
tx = self._json_dumps(data) tx = self._json_dumps(data)
await self.ws.send(tx) await self.ws.send(tx)
def _get_new_subid(self) -> str: def _get_new_subid(self) -> str:
""" """
Generates a unique subscription id. Generates a unique subscription id.
@ -159,17 +179,16 @@ class NWCServiceProvider:
Returns: Returns:
str: The generated 64 characters long subscription id (eg. lnbits0abc...) str: The generated 64 characters long subscription id (eg. lnbits0abc...)
""" """
subid = "lnbitsnwcs"+str(self.subscriptions_count) subid = "lnbitsnwcs" + str(self.subscriptions_count)
self.subscriptions_count += 1 self.subscriptions_count += 1
maxLength = 64 max_length = 64
chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
n = maxLength - len(subid) n = max_length - len(subid)
if n > 0: if n > 0:
for i in range(n): for _ in range(n):
subid += chars[random.randint(0, len(chars) - 1)] subid += chars[random.randint(0, len(chars) - 1)]
return subid return subid
async def _wait_for_connection(self): async def _wait_for_connection(self):
""" """
Waits until the connection is established. Waits until the connection is established.
@ -180,56 +199,52 @@ class NWCServiceProvider:
logger.debug("Waiting for connection...") logger.debug("Waiting for connection...")
await asyncio.sleep(1) await asyncio.sleep(1)
async def _subscribe(self): async def _subscribe(self):
""" """
[Re]Subscribe to receive nip 47 requests and responses from the relay [Re]Subscribe to receive nip 47 requests and responses from the relay
""" """
self.sub = MainSubscription() self.sub = MainSubscription()
# Create requests subscription # Create requests subscription
req_filter = { req_filter = {
"kinds": [23194], "kinds": [23194],
"#p": [self.public_key_hex], "#p": [self.public_key_hex],
# Since the last 3 hours (handles reboots) # Since the last 3 hours (handles reboots)
"since": int(time.time()) - 3*60*60 "since": int(time.time()) - 3 * 60 * 60,
} }
self.sub.requests_sub_id = self._get_new_subid() self.sub.requests_sub_id = self._get_new_subid()
# Create responses subscription (needed to track previosly responded requests) # Create responses subscription (needed to track previosly responded requests)
res_filter = { res_filter = {
"kinds": [23195], "kinds": [23195],
"authors": [self.public_key_hex], "authors": [self.public_key_hex],
"since": int(time.time()) - 3*60*60 "since": int(time.time()) - 3 * 60 * 60,
} }
self.sub.responses_sub_id = self._get_new_subid() self.sub.responses_sub_id = self._get_new_subid()
# Subscribe # Subscribe
await self._send(["REQ", self.sub.requests_sub_id, req_filter]) await self._send(["REQ", 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,ws):
""" """
On connection callback, announce the service provider methods and subscribe to nip67 events. On connection callback, announce the service provider
methods and subscribe to nip67 events.
""" """
# Send info event # Send info event
event = { event = {
"kind": 13194, "kind": 13194,
"content": " ".join(self.supported_methods), "content": " ".join(self.supported_methods),
"created_at": int(time.time()), "created_at": int(time.time()),
"tags": [ "tags": [["p", self.public_key_hex]],
["p", self.public_key_hex]
]
} }
self._sign_event(event) self._sign_event(event)
await self._send(["EVENT", event]) await self._send(["EVENT", event])
# 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
""" """
nwc_pubkey = event["pubkey"] nwc_pubkey = event["pubkey"]
content = event["content"] content = event["content"]
# Decrypt the content # Decrypt the content
content = self._decrypt_content(content, nwc_pubkey) content = self._decrypt_content(content, nwc_pubkey)
@ -237,34 +252,30 @@ class NWCServiceProvider:
content = json.loads(content) content = json.loads(content)
# Handle request # Handle request
method = content["method"] method = content["method"]
l = self.request_listeners.get(method, None) listener = self.request_listeners.get(method, None)
outs = [] outs: List[Dict[str, Any]] = []
if not l: if not listener:
outs.append({ outs.append(
"error": { {
"code": "NOT_IMPLEMENTED", "error": {
"message": "Method "+method+" is not implemented by this service provider" "code": "NOT_IMPLEMENTED",
"message": "Method "
+ method
+ " is not implemented by this service provider",
}
} }
}) )
else: else:
try: try:
results = await l(self, nwc_pubkey, content) results = await listener(self, nwc_pubkey, content)
for result in results: for result in results:
r = result[0] r = result[0]
e = result[1] e = result[1]
t = result[2] if len(result) > 2 else None t = result[2] if len(result) > 2 else None
out = {} out = {"result": r, "error": e, "tags": t}
if r: out["result"] = r
if e: out["error"] = e
if t: out["tags"] = t
outs.append(out) outs.append(out)
except Exception as e: except Exception as e:
outs.append({ outs.append({"error": {"code": "INTERNAL", "message": str(e)}})
"error":{
"code": "INTERNAL",
"message": str(e)
}
})
sent_events = [] sent_events = []
for out in outs: for out in outs:
# Finalize output # Finalize output
@ -275,7 +286,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 = { res: Dict = {
"kind": 23195, "kind": 23195,
"created_at": int(time.time()), "created_at": int(time.time()),
"tags": out.get("tags", []), "tags": out.get("tags", []),
@ -286,16 +297,94 @@ class NWCServiceProvider:
# Reference user # Reference user
res["tags"].append(["p", nwc_pubkey]) res["tags"].append(["p", nwc_pubkey])
# Finalize response event # Finalize response event
print(res)
res["content"] = self._encrypt_content(res["content"], nwc_pubkey) res["content"] = self._encrypt_content(res["content"], nwc_pubkey)
self._sign_event(res) self._sign_event(res)
# Register response for this request, so we knows it is not stale # Register response for this request, so we knows it is not stale
if self.sub: self.sub.registerResponse(event["id"]) if self.sub:
self.sub.register_response(event["id"])
# Send response event # Send response event
await self._send(["EVENT", res]) await self._send(["EVENT", res])
# Track sent events # Track sent events
sent_events.append(res) sent_events.append(res)
return sent_events return sent_events
async def _on_event_message(self, msg):
if not self.sub:
return
sub_id = msg[1]
event = msg[2]
# Ensure the event is valid (do not trust relays)
if not self._verify_event(event):
raise Exception("Invalid event signature")
tags = event["tags"]
expiration = int(next((tag for tag in tags if tag[0] == "expiration"), -1))
# Handle event expiration if the relay doesn't support nip 40
if expiration > 0 and expiration < int(time.time()):
logger.debug("Event expired")
return
if event["kind"] == 23194 and sub_id == self.sub.requests_sub_id:
# Ensure the request is for this service provider
valid_p = any(
tag[0] == "p" and tag[1] == self.public_key_hex for tag in tags
)
if not valid_p:
raise Exception("Unexpected request from another service")
# Track request
self.sub.events[event["id"]] = event
# if eose was received for both subscriptions, we handle the request
# in realtime if not, we do nothing since the request may be
# already handled or stale, all stale requests will be handled
# later when eose is received
if self.sub.requests_eose and self.sub.responses_eose:
await self._handle_request(event)
elif event["kind"] == 23195 and sub_id == self.sub.responses_sub_id:
# Ensure the response is from this service provider
if event["pubkey"] != self.public_key_hex:
raise Exception("Unexpected response from another service")
# Register as response for each e tag (request event id)
# Note: usually we expect only one "e" tag, but we are handling
# multiple "e" tags just in case
etag = next((tag[1] for tag in tags if tag[0] == "e"), None)
if etag:
self.sub.register_response(etag)
async def _on_eose_message(self, msg):
if not self.sub:
return
sub_id = msg[1]
# Track EOSE
if sub_id == self.sub.requests_sub_id:
self.sub.requests_eose = True
elif sub_id == self.sub.responses_sub_id:
self.sub.responses_eose = True
# When both EOSE are receives, handle all the stale requests
# Note: All the requests that were received prior to the
# service connection and do not have a response yet,
# are considered stale, we will process them now
if self.sub.requests_eose and self.sub.responses_eose:
stales = self.sub.get_stale()
for stale in stales:
await self._handle_request(stale)
async def _on_closed_message(self, msg):
if not self.sub:
return
# Subscription was closed remotely.
sub_id = msg[1]
info = msg[2] or "" if len(msg) > 2 else ""
# Resubscribe if one of the main subscriptions was closed
if sub_id == self.sub.requests_sub_id or sub_id == self.sub.responses_sub_id:
logger.warning(
"Subscription "
+ sub_id
+ " was closed remotely: "
+ info
+ " ... resubscribing..."
)
await self._subscribe()
async def _on_message(self, ws, message: str): async def _on_message(self, ws, message: str):
""" """
Handle incoming messages from the relay. Handle incoming messages from the relay.
@ -303,113 +392,63 @@ class NWCServiceProvider:
try: try:
msg = json.loads(message) msg = json.loads(message)
if msg[0] == "EVENT": # Event message if msg[0] == "EVENT": # Event message
sub_id = msg[1] await self._on_event_message(msg)
event = msg[2] elif msg[0] == "EOSE":
# Ensure the event is valid (do not trust relays) await self._on_eose_message(msg)
if not self._verify_event(event):
raise Exception("Invalid event signature")
tags = event["tags"]
expiration = -1
for tag in tags:
if tag[0] == "expiration":
expiration = int(tag[1])
break
# Handle event expiration if the relay doesn't support nip 40
if expiration > 0 and expiration < int(time.time()):
logger.debug("Event expired")
return
if event["kind"] == 23194 and sub_id == self.sub.requests_sub_id:
# Ensure the request is for this service provider
valid_p = False
for tag in tags:
if tag[0] == "p" and tag[1] == self.public_key_hex:
valid_p = True
break
if not valid_p:
raise Exception("Unexpected request from another service")
# Track request
self.sub.events[event["id"]] = event
# if eose was received for both subscriptions, we handle the request in realtime
# if not, we do nothing since the request may be already handled or stale,
# all stale requests will be handled later when eose is received
if self.sub.requests_eose and self.sub.responses_eose:
await self._handle_request(event)
elif event["kind"] == 23195 and sub_id == self.sub.responses_sub_id:
# Ensure the response is from this service provider
if event["pubkey"] != self.public_key_hex:
raise Exception("Unexpected response from another service")
# Register as response for each e tag (request event id)
# Note: usually we expect only one "e" tag, but we are handling multiple "e" tags just in case
for tag in tags:
if tag[0] == "e":
self.sub.registerResponse(tag[1])
elif msg[0] == "EOSE":
sub_id = msg[1]
# Track EOSE
if sub_id == self.sub.requests_sub_id:
self.sub.requests_eose = True
elif sub_id == self.sub.responses_sub_id:
self.sub.responses_eose = True
# When both EOSE are receives, handle all the stale requests
# Note: All the requests that were received prior to the service connection
# and do not have a response yet, are considered stale, we will process them now
if self.sub.requests_eose and self.sub.responses_eose:
stales = self.sub.getStale()
for stale in stales:
await self._handle_request(stale)
elif msg[0] == "CLOSED": elif msg[0] == "CLOSED":
# Subscription was closed remotely. await self._on_closed_message(msg)
sub_id = msg[1]
info = msg[2] or "" if len(msg) > 2 else ""
# Resubscribe if one of the main subscriptions was closed
if sub_id == self.sub.requests_sub_id or sub_id == self.sub.responses_sub_id:
logger.warning("Subscription "+sub_id+" was closed remotely: "+info+" ... resubscribing...")
self._subscribe()
elif msg[0] == "NOTICE": elif msg[0] == "NOTICE":
# A message from the relay, mostly useless, but we log it anyway # A message from the relay, mostly useless, but we log it anyway
logger.info("Notice from relay "+self.relay+": "+str(msg[1])) logger.info("Notice from relay " + self.relay + ": " + str(msg[1]))
elif msg[0] == "OK": elif msg[0] == "OK":
pass pass
else: else:
raise Exception("Unknown message type") raise Exception("Unknown message type")
except Exception as e: except Exception as e:
logger.error("Error parsing event: "+str(e)) logger.error("Error parsing event: " + str(e))
async def _connect_to_relay(self): async def _connect_to_relay(self):
""" """
Initiate websocket connection to the relay. Initiate websocket connection to the relay.
""" """
await asyncio.sleep(1) await asyncio.sleep(1)
logger.debug("Connecting to NWC relay "+self.relay) logger.debug("Connecting to NWC relay " + self.relay)
while not self._is_shutting_down(): # Reconnect until the instance is shutting down while (
logger.debug('Creating new connection...') not self._is_shutting_down()
): # Reconnect until the instance is shutting down
logger.debug("Creating new connection...")
try: try:
async with websockets.connect(self.relay) as ws: async with websockets.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)
while not self._is_shutting_down(): # receive messages until the instance is shutting down while (
not self._is_shutting_down()
): # receive messages until the instance is shutting down
try: try:
reply = await ws.recv() reply = await ws.recv()
if isinstance(reply, bytes):
reply = reply.decode("utf-8")
await self._on_message(ws, reply) await self._on_message(ws, reply)
except Exception as e: except Exception as e:
logger.debug("Error receiving message: " + str(e)) logger.debug("Error receiving message: " + str(e))
break break
logger.debug("Connection to NWC relay closed") logger.debug("Connection to NWC relay closed")
except Exception as e: except Exception as e:
logger.error("Error connecting to NWC relay: "+str(e)) logger.error("Error connecting to NWC relay: " + str(e))
await asyncio.sleep(5) await asyncio.sleep(5)
# the connection was closed, so we set the connected flag to False # the connection was closed, so we set the connected flag to False
# this will make the methods calling _wait_for_connection() to wait until the connection is re-established # this will make the methods calling _wait_for_connection() to wait
# until the connection is re-established
self.connected = False self.connected = False
if not self._is_shutting_down(): if not self._is_shutting_down():
# Wait some time before reconnecting # Wait some time before reconnecting
logger.debug("Reconnecting to NWC relay in 5 seconds...") logger.debug("Reconnecting to NWC relay in 5 seconds...")
await asyncio.sleep(5) await asyncio.sleep(5)
def _encrypt_content(
def _encrypt_content(self, content: str, pubkey_hex:str, iv_seed: Optional[int]=None) -> str: self, content: str, pubkey_hex: str, iv_seed: Optional[int] = None
) -> str:
""" """
Encrypts the content for the given public key Encrypts the content for the given public key
@ -420,16 +459,14 @@ class NWCServiceProvider:
Returns: Returns:
str: The encrypted content. str: The encrypted content.
""" """
pubkey = secp256k1.PublicKey( pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
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:]
# random iv (16B) # random iv (16B)
if not iv_seed: if not iv_seed:
iv = Random.new().read(AES.block_size) iv = Random.new().read(AES.block_size)
else: else:
iv = hashlib.sha256(iv_seed.to_bytes(32, byteorder='big')).digest() iv = hashlib.sha256(iv_seed.to_bytes(32, byteorder="big")).digest()
iv = iv[:AES.block_size] iv = iv[: AES.block_size]
aes = AES.new(shared, AES.MODE_CBC, iv) aes = AES.new(shared, AES.MODE_CBC, iv)
@ -438,14 +475,12 @@ class NWCServiceProvider:
# padding # padding
content_bytes = pad(content_bytes, AES.block_size) content_bytes = pad(content_bytes, AES.block_size)
encrypted_b64 = base64.b64encode( encrypted_b64 = base64.b64encode(aes.encrypt(content_bytes)).decode("ascii")
aes.encrypt(content_bytes)).decode("ascii") iv_b64 = base64.b64encode(iv).decode("ascii")
ivB64 = base64.b64encode(iv).decode("ascii") encrypted_content = encrypted_b64 + "?iv=" + iv_b64
encrypted_content = encrypted_b64 + "?iv=" + ivB64
return encrypted_content return encrypted_content
def _decrypt_content(self, content: str, pubkey_hex: str) -> str:
def _decrypt_content(self, content: str , pubkey_hex:str) -> str:
""" """
Decrypts the content for the given public key Decrypts the content for the given public key
@ -456,15 +491,12 @@ class NWCServiceProvider:
Returns: Returns:
str: The decrypted content. str: The decrypted content.
""" """
pubkey = secp256k1.PublicKey( pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
bytes.fromhex("02" + pubkey_hex), True)
shared = pubkey.tweak_mul(bytes.fromhex( shared = pubkey.tweak_mul(bytes.fromhex(self.private_key_hex)).serialize()[1:]
self.private_key_hex)).serialize()[1:]
# extract iv and content # extract iv and content
(encrypted_content_b64, iv_b64) = content.split("?iv=") (encrypted_content_b64, iv_b64) = content.split("?iv=")
encrypted_content = base64.b64decode( encrypted_content = base64.b64decode(encrypted_content_b64.encode("ascii"))
encrypted_content_b64.encode("ascii"))
iv = base64.b64decode(iv_b64.encode("ascii")) iv = base64.b64decode(iv_b64.encode("ascii"))
# Decrypt # Decrypt
aes = AES.new(shared, AES.MODE_CBC, iv) aes = AES.new(shared, AES.MODE_CBC, iv)
@ -473,7 +505,6 @@ 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
@ -484,27 +515,30 @@ class NWCServiceProvider:
Returns: Returns:
bool: True if the event signature is valid, False otherwise. bool: True if the event signature is valid, False otherwise.
""" """
signature_data = self._json_dumps([ signature_data = self._json_dumps(
0, [
event["pubkey"], 0,
event["created_at"], event["pubkey"],
event["kind"], event["created_at"],
event["tags"], event["kind"],
event["content"] event["tags"],
]) event["content"],
]
)
event_id = hashlib.sha256(signature_data.encode()).hexdigest() event_id = hashlib.sha256(signature_data.encode()).hexdigest()
if event_id != event["id"]: # Invalid event id if event_id != event["id"]: # Invalid event id
return False return False
pubkeyHex = event["pubkey"] pubkey_hex = event["pubkey"]
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkeyHex), True) pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
if not pubkey.schnorr_verify(bytes.fromhex(event_id), bytes.fromhex(event["sig"]), None, raw=True): if not pubkey.schnorr_verify(
bytes.fromhex(event_id), bytes.fromhex(event["sig"]), None, raw=True
):
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)
Args: Args:
event (Dict): The event to be signed. event (Dict): The event to be signed.
@ -512,24 +546,26 @@ class NWCServiceProvider:
Returns: Returns:
Dict: The input event with the signature added. Dict: The input event with the signature added.
""" """
signature_data = self._json_dumps([ signature_data = self._json_dumps(
0, [
self.public_key_hex, 0,
event["created_at"], self.public_key_hex,
event["kind"], event["created_at"],
event["tags"], event["kind"],
event["content"] event["tags"],
]) event["content"],
]
)
event_id = hashlib.sha256(signature_data.encode()).hexdigest() event_id = hashlib.sha256(signature_data.encode()).hexdigest()
event["id"] = event_id event["id"] = event_id
event["pubkey"] = self.public_key_hex event["pubkey"] = self.public_key_hex
signature = (self.private_key.schnorr_sign( signature = (
bytes.fromhex(event_id), None, raw=True)).hex() self.private_key.schnorr_sign(bytes.fromhex(event_id), None, raw=True)
).hex()
event["sig"] = signature event["sig"] = signature
return event return event
async def cleanup(self): async def cleanup(self):
logger.debug("Closing NWC Service Provider connection") logger.debug("Closing NWC Service Provider connection")
@ -539,10 +575,10 @@ class NWCServiceProvider:
if self.reconnect_task: if self.reconnect_task:
self.reconnect_task.cancel() self.reconnect_task.cancel()
except Exception as e: except Exception as e:
logger.warning("Error closing reconnection task: "+str(e)) logger.warning("Error closing reconnection task: " + str(e))
# close the websocket # close the websocket
try: try:
if self.ws: if self.ws:
await self.ws.close() await self.ws.close()
except Exception as e: except Exception as e:
logger.warning("Error closing websocket connection: "+str(e)) logger.warning("Error closing websocket connection: " + str(e))

61
package-lock.json generated Normal file
View file

@ -0,0 +1,61 @@
{
"name": "nwcprovider",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nwcprovider",
"version": "1.0.0",
"dependencies": {
"prettier": "^3.3.3",
"pyright": "^1.1.379"
}
},
"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.3.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
"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.379",
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.379.tgz",
"integrity": "sha512-n0X+IMqot6zL5b54vfU9GattS8jM9IOh8TRFho1k/6VoyjrpzQ7TnU6PtZzwEZNJaZi5izoLIDeMnGmbin8n8Q==",
"license": "MIT",
"bin": {
"pyright": "index.js",
"pyright-langserver": "langserver.index.js"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
}
}
}

8
package.json Normal file
View file

@ -0,0 +1,8 @@
{
"name": "nwcprovider",
"version": "1.0.0",
"dependencies": {
"prettier": "^3.3.3",
"pyright": "^1.1.379"
}
}

View file

@ -1,49 +1,33 @@
nwc_permissions = { nwc_permissions = {
"pay":{ "pay": {
"name":"Send payments", "name": "Send payments",
"methods":[ "methods": [
"multi_pay_invoice", "multi_pay_invoice",
"pay_invoice", "pay_invoice",
"pay_keysend", "pay_keysend",
"multi_pay_keysend", "multi_pay_keysend",
], ],
"default": True "default": True,
}, },
"invoice":{ "invoice": {
"name":"Create invoices", "name": "Create invoices",
"methods":[ "methods": ["make_invoice"],
"make_invoice" "default": True,
],
"default": True
}, },
"lookup":{ "lookup": {
"name":"Lookup status of invoice", "name": "Lookup status of invoice",
"methods":[ "methods": ["lookup_invoice"],
"lookup_invoice" "default": True,
],
"default": True
}, },
"history": { "history": {
"name": "Read transaction history", "name": "Read transaction history",
"methods": [ "methods": ["list_transactions"],
"list_transactions" "default": True,
],
"default": True
}, },
"balance": { "balance": {
"name": "Read wallet balance", "name": "Read wallet balance",
"methods": [ "methods": ["get_balance"],
"get_balance" "default": True,
],
"default": True
}, },
"info": { "info": {"name": "Read account info", "methods": ["get_info"], "default": True},
"name": "Read account info", }
"methods": [
"get_info"
],
"default": True
}
}

2512
poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

99
pyproject.toml Normal file
View file

@ -0,0 +1,99 @@
[tool.poetry]
name = "nwcprovider"
version = "0.0.0"
description = "A NWC service provider for LNbits."
authors = ["Riccardo Balbo <oc@rblb.it>"]
[tool.poetry.dependencies]
python = "^3.10 | ^3.9"
lnbits = "*"
psycopg2-binary = "^2.9.9"
[tool.poetry.group.dev.dependencies]
black = "^24.3.0"
pytest-asyncio = "^0.21.0"
pytest = "^7.3.2"
mypy = "^1.5.1"
pre-commit = "^3.2.2"
ruff = "^0.3.2"
types-cffi = "^1.16.0.20240331"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.mypy]
exclude = "(tests/*)"
[[tool.mypy.overrides]]
module = [
"lnbits.*",
"lnurl.*",
"loguru.*",
"fastapi.*",
"pydantic.*",
"pyqrcode.*",
"shortuuid.*",
"httpx.*",
"websocket.*",
"secp256k1.*",
]
ignore_missing_imports = "True"
[tool.pytest.ini_options]
log_cli = false
testpaths = [
"tests"
]
[tool.black]
line-length = 88
[tool.ruff]
# Same as Black. + 10% rule of black
line-length = 88
# exclude = [
# "nostr",
# ]
[tool.ruff.lint]
# Enable:
# F - pyflakes
# E - pycodestyle errors
# W - pycodestyle warnings
# I - isort
# A - flake8-builtins
# C - mccabe
# N - naming
# UP - pyupgrade
# RUF - ruff
# B - bugbear
select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"]
ignore = ["C901"]
# Allow autofix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
unfixable = []
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
# needed for pydantic
[tool.ruff.lint.pep8-naming]
classmethod-decorators = [
"root_validator",
]
# Ignore unused imports in __init__.py files.
# [tool.ruff.lint.extend-per-file-ignores]
# "__init__.py" = ["F401", "F403"]
# [tool.ruff.lint.mccabe]
# max-complexity = 10
[tool.ruff.lint.flake8-bugbear]
# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.
extend-immutable-calls = [
"fastapi.Depends",
"fastapi.Query",
]

File diff suppressed because one or more lines are too long

365
tasks.py
View file

@ -1,112 +1,131 @@
import asyncio import asyncio
import time
from math import ceil
from typing import Any, Dict, List, Optional, Tuple
from bolt11 import decode as bolt11_decode
from lnbits.core.crud import get_payments, get_wallet, get_wallet_payment
from lnbits.core.models import Payment
from lnbits.core.services import (
check_transaction_status,
create_invoice,
pay_invoice,
)
from lnbits.db import Filters
from lnbits.exceptions import PaymentError
from lnbits.settings import settings
from lnbits.wallets.base import PaymentStatus
from loguru import logger from loguru import logger
from lnbits.core.models import Payment from .crud import get_config_nwc, get_nwc, tracked_spend_nwc
from lnbits.core.services import create_invoice, pay_invoice, check_transaction_status, PaymentError
from bolt11 import decode as bolt11_decode
from lnbits.core.crud import get_wallet_payment, get_payments, get_wallet
from .crud import get_nwc, tracked_spend_nwc, get_config_nwc
#from .crud import log_nwc
from . import execution_queue
from .NWCServiceProvider import NWCServiceProvider
from typing import Dict, List, Tuple
from math import ceil
import time
from lnbits.db import Filters
from .models import NWCKey
from typing import Optional
from .permission import nwc_permissions
from lnbits.settings import settings
async def _check(nwc: Optional[NWCKey], method: str, payload: Dict): # from .crud import log_nwc
from .execution_queue import execution_queue
from .models import NWCKey
from .nwcp import NWCServiceProvider
from .permission import nwc_permissions
async def _check(nwc: Optional[NWCKey], method: str, payload: Dict) -> Optional[Dict]:
# check # check
if not nwc: if not nwc:
return { return {
"code": "UNAUTHORIZED", "code": "UNAUTHORIZED",
"message": "This public key has no wallet connected." "message": "This public key has no wallet connected.",
} }
# check permissions # check permissions
allowed = False allowed = False
permissions = nwc.getPermissions() permissions = nwc.get_permissions()
for p in permissions: for p in permissions:
allowed_methods = nwc_permissions.get(p, {}).get("methods", []) permissions_data: Dict[str, Any] = nwc_permissions.get(p, {})
allowed_methods: List[str] = permissions_data.get("methods", [])
if method in allowed_methods: if method in allowed_methods:
allowed = True allowed = True
break break
if not allowed: if not allowed:
return { return {
"code": "RESTRICTED", "code": "RESTRICTED",
"message": "This public key is not allowed to do this operation." "message": "This public key is not allowed to do this operation.",
} }
return None return None
async def _process_invoice(wallet_id:str, pubkey:str, invoice:str, amount_msats:int, description:Optional[str]=None): async def _process_invoice(
wallet_id: str,
pubkey: str,
invoice: str,
amount_msats: int,
description: Optional[str] = None,
):
async def execute_payment(): async def execute_payment():
return await pay_invoice(wallet_id=wallet_id, payment_request=invoice, return await pay_invoice(
max_sat=int(ceil(amount_msats/1000)), wallet_id=wallet_id,
description=description or "" payment_request=invoice,
) max_sat=int(ceil(amount_msats / 1000)),
description=description or "",
)
payment_hash = None payment_hash = None
try: try:
in_budget, payment_hash = await tracked_spend_nwc(pubkey, amount_msats, execute_payment) in_budget, payment_hash = await tracked_spend_nwc(
pubkey, amount_msats, execute_payment
)
if not in_budget: if not in_budget:
error = { error = {
"code":"QUOTA_EXCEEDED", "code": "QUOTA_EXCEEDED",
"message": "The wallet has exceeded its spending quota." "message": "The wallet has exceeded its spending quota.",
}
return {
"error": error,
"in_budget": False
} }
return {"error": error, "in_budget": False}
except PaymentError as e: except PaymentError as e:
status = e.status status = e.status
message = e.message message = e.message
if status == "failed": if status == "failed":
error = { error = {"code": "PAYMENT_FAILED", "message": message}
"code": "PAYMENT_FAILED", return {"error": error, "in_budget": False}
"message": message
}
return {
"error": error,
"in_budget": False
}
else: else:
raise e raise e
if not payment_hash: if not payment_hash:
raise Exception("Payment hash not found") raise Exception("Payment hash not found")
wait_for_preimage = True # currently required by nip 47 specs, might change in future wait_for_preimage = (
payment_status = None True # currently required by nip 47 specs, might change in future
)
payment_status: Optional[PaymentStatus] = 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:
break break
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
if not payment_status:
raise Exception("Payment status not found")
return { return {
"preimage": payment_status.preimage or "0000000000000000000000000000000000000000000000000000000000000000", "preimage": payment_status.preimage
or "0000000000000000000000000000000000000000000000000000000000000000",
"fee_msats": payment_status.fee_msat, "fee_msats": payment_status.fee_msat,
"paid": payment_status.paid, "paid": payment_status.paid,
"payment_hash": payment_hash, "payment_hash": payment_hash,
"in_budget": in_budget "in_budget": in_budget,
} }
async def _on_pay_invoice(sp: NWCServiceProvider, pubkey: str, payload: Dict) -> List[Tuple[Dict, Dict, List]]: async def _on_pay_invoice(
sp: NWCServiceProvider, pubkey: str, payload: Dict
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
nwc = await get_nwc(pubkey, None, False, True) nwc = await get_nwc(pubkey, None, False, True)
error = await _check(nwc, "pay_invoice", payload) error = await _check(nwc, "pay_invoice", payload)
if error: if error:
return [(None,error, [])] return [(None, error, [])]
if not nwc:
raise Exception("Pubkey has no associated wallet")
params = payload.get("params", {}) params = payload.get("params", {})
invoice = params.get("invoice", None) invoice = params.get("invoice", None)
# Ensures invoice is provided # Ensures invoice is provided
if not invoice: if not invoice:
raise Exception("Missing invoice") raise Exception("Missing invoice")
invoice_data = bolt11_decode(invoice) invoice_data = bolt11_decode(invoice)
amount_msats = invoice_data.amount_msat amount_msats = int(invoice_data.amount_msat or 0)
res = await _process_invoice(nwc.wallet, pubkey, invoice, amount_msats, invoice_data.description) res = await _process_invoice(
nwc.wallet, pubkey, invoice, amount_msats, invoice_data.description
)
error = res.get("error") error = res.get("error")
if error: if error:
return [(None, error, [])] return [(None, error, [])]
@ -114,103 +133,117 @@ async def _on_pay_invoice(sp: NWCServiceProvider, pubkey: str, payload: Dict) ->
out = { out = {
"preimage": preimage, "preimage": preimage,
} }
#await log_nwc(pubkey, payload) # await log_nwc(pubkey, payload)
return [(out,None, [])] return [(out, None, [])]
async def _on_multi_pay_invoice(sp: NWCServiceProvider, pubkey: str, payload: Dict) -> List[Tuple[Dict, Dict, List]]: async def _on_multi_pay_invoice(
sp: NWCServiceProvider, pubkey: str, payload: Dict
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
nwc = await get_nwc(pubkey, None, False, True) nwc = await get_nwc(pubkey, None, False, True)
error = await _check(nwc, "multi_pay_invoice", payload) error = await _check(nwc, "multi_pay_invoice", payload)
if error: if error:
return [(None, error, [])] return [(None, error, [])]
if not nwc:
raise Exception("Pubkey has no associated wallet")
params = payload.get("params", {}) params = payload.get("params", {})
invoices = params.get("invoices", []) invoices = params.get("invoices", [])
results = [] results: List[Tuple[Optional[Dict], Optional[Dict], List]] = []
# Ensures all invoices are provided # Ensures all invoices are provided
for i in invoices: for i in invoices:
invoice = i.get("invoice",None) invoice = i.get("invoice", None)
if not invoice: if not invoice:
raise Exception("Missing invoice") raise Exception("Missing invoice")
for i in invoices: for i in invoices:
try: try:
id = i.get("id", None) invoice_id = i.get("id", None)
invoice = i.get("invoice", None) invoice = i.get("invoice", None)
invoice_data = bolt11_decode(invoice) invoice_data = bolt11_decode(invoice)
amount_msats = invoice_data.amount_msat amount_msats = int(invoice_data.amount_msat or 0)
res = await _process_invoice(nwc.wallet, pubkey, invoice, amount_msats, invoice_data.description) res = await _process_invoice(
nwc.wallet, pubkey, invoice, amount_msats, invoice_data.description
)
error = res.get("error") error = res.get("error")
if error: if error:
results.append((None, error, [])) results.append((None, error, []))
else: else:
r = ( r = (
{ {
"preimage": res.get("preimage"), "preimage": res.get("preimage"),
}, },
None, None,
[ [["d", invoice_id if invoice_id else res.get("payment_hash")]],
["d", id if id else res.get("payment_hash")]
]
) )
results.append(r) results.append(r)
except Exception as e: except Exception as e:
results.append((None, { results.append((None, {"code": "INTERNAL", "message": str(e)}, []))
"code": "INTERNAL", # await log_nwc(pubkey, payload)
"message": str(e)
}))
#await log_nwc(pubkey, payload)
return results return results
async def _on_make_invoice(sp: NWCServiceProvider, pubkey: str, payload: Dict) -> List[Tuple[Dict, Dict, List]]: async def _on_make_invoice(
sp: NWCServiceProvider, pubkey: str, payload: Dict
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
nwc = await get_nwc(pubkey, None, False, True) nwc = await get_nwc(pubkey, None, False, True)
error = await _check(nwc, "make_invoice", payload) error = await _check(nwc, "make_invoice", payload)
if error: if error:
return [(None, error, [])] return [(None, error, [])]
if not nwc:
raise Exception("Pubkey has no associated wallet")
params = payload.get("params", {}) params = payload.get("params", {})
amount_msats = params.get("amount", None) amount_msats = params.get("amount", None)
# Ensures amount is provided # Ensures amount is provided
if not amount_msats: if not amount_msats:
raise Exception("Missing amount") raise Exception("Missing amount")
description = params.get("description", "") description = params.get("description", "")
description_hash = params.get("description_hash", None) description_hash = params.get("description_hash", None)
expiry = params.get("expiry" , None) expiry = params.get("expiry", None)
payment_hash, payment_request = await create_invoice( payment_hash, payment_request = await create_invoice(
wallet_id=nwc.wallet, wallet_id=nwc.wallet,
amount=int(amount_msats/1000), amount=int(amount_msats / 1000),
currency="sat", currency="sat",
memo=description, memo=description,
description_hash=bytes.fromhex(description_hash) if description_hash else None, description_hash=bytes.fromhex(description_hash) if description_hash else None,
unhashed_description=description.encode("utf-8"), unhashed_description=description.encode("utf-8"),
expiry=expiry) expiry=expiry,
payment_status = await check_transaction_status(wallet_id=nwc.wallet, payment_hash=payment_hash) )
preimage = payment_status.preimage payment_status = await check_transaction_status(
if not preimage: # Some backend do not return a preimage (eg. FakeWallet), so we fake it wallet_id=nwc.wallet, payment_hash=payment_hash
)
preimage = payment_status.preimage
if (
not preimage
): # Some backend do not return a preimage (eg. FakeWallet), so we fake it
preimage = "0000000000000000000000000000000000000000000000000000000000000000" preimage = "0000000000000000000000000000000000000000000000000000000000000000"
res = { res = {
"type": "incoming", "type": "incoming",
"invoice": payment_request, "invoice": payment_request,
"description": description, "description": description,
"description_hash": description_hash, "description_hash": description_hash,
"preimage": preimage, "preimage": preimage,
"payment_hash": payment_hash, "payment_hash": payment_hash,
"amount": amount_msats, "amount": amount_msats,
#"fees_paid":None, # "fees_paid":None,
"created_at": int(time.time()), "created_at": int(time.time()),
"metadata": {} "metadata": {},
} }
if expiry: if expiry:
res["expires_at"] = int(time.time()) + int(expiry) res["expires_at"] = int(time.time()) + int(expiry)
#await log_nwc(pubkey, payload) # await log_nwc(pubkey, payload)
return [(res, None, [])] return [(res, None, [])]
async def _on_lookup_invoice(sp: NWCServiceProvider, pubkey: str, payload: Dict) -> List[Tuple[Dict, Dict, List]]: async def _on_lookup_invoice(
sp: NWCServiceProvider, pubkey: str, payload: Dict
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
nwc = await get_nwc(pubkey, None, False, True) nwc = await get_nwc(pubkey, None, False, True)
error = await _check(nwc, "lookup_invoice", payload) error = await _check(nwc, "lookup_invoice", payload)
if error: if error:
return [(None, error, [])] return [(None, error, [])]
if not nwc:
raise Exception("Pubkey has no associated wallet")
params = payload.get("params", {}) params = payload.get("params", {})
payment_hash = params.get("payment_hash", None) payment_hash = params.get("payment_hash", None)
invoice = params.get("invoice", None) invoice = params.get("invoice", None)
@ -228,131 +261,149 @@ async def _on_lookup_invoice(sp: NWCServiceProvider, pubkey: str, payload: Dict)
invoice_data = bolt11_decode(payment.bolt11) invoice_data = bolt11_decode(payment.bolt11)
is_settled = not payment.pending is_settled = not payment.pending
timestamp = payment.time or invoice_data.date timestamp = payment.time or invoice_data.date
res = { 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": invoice_data.description if invoice_data.description else payment.memo, "description": (
invoice_data.description if invoice_data.description else payment.memo
),
"preimage": payment.preimage if is_settled or payment.is_in else None, "preimage": payment.preimage if is_settled or payment.is_in else None,
"payment_hash": payment.payment_hash, "payment_hash": payment.payment_hash,
"amount": abs(payment.msat), "amount": abs(payment.msat),
"fees_paid": abs(payment.fee), "fees_paid": abs(payment.fee),
"created_at": timestamp, "created_at": timestamp,
"expires_at": payment.expiry if payment.expiry else timestamp+3600, "expires_at": payment.expiry if payment.expiry else timestamp + 3600,
"settled_at": timestamp if is_settled else None, "settled_at": timestamp if is_settled else None,
"metadata": {} "metadata": {},
} }
if invoice_data.description_hash: if invoice_data.description_hash:
res["description_hash"] = invoice_data.description_hash res["description_hash"] = invoice_data.description_hash
#await log_nwc(pubkey, payload) # await log_nwc(pubkey, payload)
return [(res, None, [])] return [(res, None, [])]
async def _on_list_transactions(sp: NWCServiceProvider, pubkey: str, payload: Dict) -> List[Tuple[Dict, Dict, List]]: async def _on_list_transactions(
sp: NWCServiceProvider, pubkey: str, payload: Dict
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
nwc = await get_nwc(pubkey, None, False, True) nwc = await get_nwc(pubkey, None, False, True)
error = await _check(nwc, "list_transactions", payload) error = await _check(nwc, "list_transactions", payload)
if error: if error:
return [(None, error, [])] return [(None, error, [])]
if not nwc:
raise Exception("Pubkey has no associated wallet")
tfrom = payload.get("from", 0) tfrom = payload.get("from", 0)
tto = payload.get("to", int(time.time())) tto = payload.get("to", int(time.time()))
limit = payload.get("limit", 10) limit = payload.get("limit", 10)
offset = payload.get("offset", 0) offset = payload.get("offset", 0)
unpaid = payload.get("unpaid", False) unpaid = payload.get("unpaid", False)
type = payload.get("type", None) tx_type = payload.get("type", None)
values = [] values = []
filters = Filters() filters: Filters = Filters()
filters.where("time <= ?") filters.where(["time <= ?"])
values.append(tto) values.append(tto)
filters.values(values) filters.values(values)
history = await get_payments( history = await get_payments(
wallet_id=nwc.wallet, wallet_id=nwc.wallet,
complete=True, complete=True,
pending = unpaid, pending=unpaid,
outgoing=not type or type == "outgoing", outgoing=not tx_type or tx_type == "outgoing",
incoming =not type or type=="incoming", incoming=not tx_type or tx_type == "incoming",
since=tfrom, since=tfrom,
exclude_uncheckable=False, exclude_uncheckable=False,
filters=filters, filters=filters,
limit=limit, limit=limit,
offset=offset offset=offset,
) )
transactions = [] transactions: List[Dict] = []
p: Payment
for p in history: for p in history:
p: Payment
invoice_data = bolt11_decode(p.bolt11) invoice_data = bolt11_decode(p.bolt11)
is_settled = not p.pending is_settled = not p.pending
transactions.append({ transactions.append(
"type": "outgoing" if p.is_out else "incoming", {
"invoice": p.bolt11, "type": "outgoing" if p.is_out else "incoming",
"description": invoice_data.description, "invoice": p.bolt11,
"description_hash": invoice_data.description_hash, "description": invoice_data.description,
"preimage": p.preimage if is_settled or p.is_in else None, "description_hash": invoice_data.description_hash,
"payment_hash": p.payment_hash, "preimage": p.preimage if is_settled or p.is_in else None,
"amount": abs(p.msat), "payment_hash": p.payment_hash,
"fees_paid": p.fee, "amount": abs(p.msat),
"created_at": p.time, "fees_paid": p.fee,
"settled_at": p.time if is_settled else None, "created_at": p.time,
"metadata": {} "settled_at": p.time if is_settled else None,
}) "metadata": {},
#await log_nwc(pubkey, payload) }
return [({ )
"transactions": transactions # await log_nwc(pubkey, payload)
}, None, [])] return [({"transactions": transactions}, None, [])]
async def _on_get_balance(sp: NWCServiceProvider, pubkey: str, payload: Dict) -> List[Tuple[Dict, Dict, List]]: async def _on_get_balance(
sp: NWCServiceProvider, pubkey: str, payload: Dict
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
nwc = await get_nwc(pubkey, None, False, True) nwc = await get_nwc(pubkey, None, False, True)
error = await _check(nwc, "get_balance", payload) error = await _check(nwc, "get_balance", payload)
if error: if error:
return [(None, error, [])] return [(None, error, [])]
if not nwc:
raise Exception("Pubkey has no associated wallet")
balance = 0 balance = 0
wallet = await get_wallet(nwc.wallet) wallet = await get_wallet(nwc.wallet)
if not wallet: if not wallet:
raise Exception("Wallet not found") raise Exception("Wallet not found")
balance = wallet.balance_msat balance = wallet.balance_msat
#await log_nwc(pubkey, payload) # await log_nwc(pubkey, payload)
return [({ return [({"balance": balance}, None, [])]
"balance": balance
}, None, [])]
async def _on_get_info(sp: NWCServiceProvider, pubkey: str, payload: Dict) -> List[Tuple[Dict, Dict, List]]: async def _on_get_info(
sp: NWCServiceProvider, pubkey: str, payload: Dict
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
nwc = await get_nwc(pubkey, None, False, True) nwc = await get_nwc(pubkey, None, False, True)
error = await _check(nwc, "get_info", payload) error = await _check(nwc, "get_info", payload)
if error: if error:
return [(None, error, [])] return [(None, error, [])]
sp_methods = sp.getSupportedMethods() if not nwc:
permissions = nwc.getPermissions() raise Exception("Pubkey has no associated wallet")
sp_methods = sp.get_supported_methods()
permissions = nwc.get_permissions()
# Filter only methods supported by the extension and allowed by the permissions # Filter only methods supported by the extension and allowed by the permissions
account_methods = [] account_methods = []
for spm in sp_methods: for spm in sp_methods:
for p in permissions: for p in permissions:
allowed_methods = nwc_permissions.get(p, {}).get("methods", []) permissions_data: Dict[str, Any] = nwc_permissions.get(p, {})
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
#await log_nwc(pubkey, payload) # await log_nwc(pubkey, payload)
return [({ return [
"alias": settings.lnbits_site_title, (
"color": "", {
"network": "mainnet", "alias": settings.lnbits_site_title,
"block_height": 0, "color": "",
"block_hash": "", "network": "mainnet",
"methods": account_methods "block_height": 0,
}, None, [])] "block_hash": "",
"methods": account_methods,
},
None,
[],
)
]
async def handle_nwc(): async def handle_nwc():
priv_key = await get_config_nwc("provider_key") priv_key = await get_config_nwc("provider_key")
relay = await get_config_nwc("relay") relay = await get_config_nwc("relay")
nwcsp = NWCServiceProvider(priv_key, relay) nwcsp = NWCServiceProvider(priv_key, relay)
nwcsp.addRequestListener("pay_invoice", _on_pay_invoice) nwcsp.add_request_listener("pay_invoice", _on_pay_invoice)
nwcsp.addRequestListener("multi_pay_invoice", _on_multi_pay_invoice) nwcsp.add_request_listener("multi_pay_invoice", _on_multi_pay_invoice)
nwcsp.addRequestListener("make_invoice", _on_make_invoice) nwcsp.add_request_listener("make_invoice", _on_make_invoice)
nwcsp.addRequestListener("lookup_invoice", _on_lookup_invoice) nwcsp.add_request_listener("lookup_invoice", _on_lookup_invoice)
nwcsp.addRequestListener("list_transactions", _on_list_transactions) nwcsp.add_request_listener("list_transactions", _on_list_transactions)
nwcsp.addRequestListener("get_balance", _on_get_balance) nwcsp.add_request_listener("get_balance", _on_get_balance)
nwcsp.addRequestListener("get_info", _on_get_info) nwcsp.add_request_listener("get_info", _on_get_info)
# currently not supported by lnbits # currently not supported by lnbits
# nwcsp.addRequestListener("pay_keysend", _on_pay_keysend) # nwcsp.addRequestListener("pay_keysend", _on_pay_keysend)
# nwcsp.addRequestListener("multi_pay_keysend", _on_multi_pay_keysend) # nwcsp.addRequestListener("multi_pay_keysend", _on_multi_pay_keysend)
@ -363,7 +414,7 @@ async def handle_nwc():
await asyncio.sleep(3600) await asyncio.sleep(3600)
except asyncio.CancelledError: except asyncio.CancelledError:
await nwcsp.cleanup() await nwcsp.cleanup()
raise raise
async def handle_execution_queue(): async def handle_execution_queue():
@ -373,9 +424,13 @@ async def handle_execution_queue():
action = task.get("action") action = task.get("action")
future = task.get("future") future = task.get("future")
try: try:
if not action:
raise Exception("Invalid action")
res = await action() res = await action()
future.set_result(res) if future:
future.set_result(res)
except Exception as e: except Exception as e:
future.set_exception(e) if future:
future.set_exception(e)
except Exception as e: except Exception as e:
logger.error(str(e)) logger.error(str(e))

View file

@ -1,101 +1,123 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context {% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %} %} {% block page %}
<div class="row q-col-gutter-md" id="configTable"> <div class="row q-col-gutter-md" id="configTable">
<div class="col-12 q-gutter-y-md"> <div class="col-12 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <q-card-section>
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
<div class="col"> <div class="col">
<h5 class="text-subtitle1 q-my-none">NWC Service Provider - Config</h5> <h5 class="text-subtitle1 q-my-none">
</div> NWC Service Provider - Config
</div> </h5>
<template> </div>
<q-markup-table flat> </div>
<tbody> <template>
<q-tr v-for="entry in entries" :key="entry.key"> <q-markup-table flat>
<q-td> <tbody>
<span> <q-tr v-for="entry in entries" :key="entry.key">
${entry.key} <q-td>
</span> <span> ${entry.key} </span>
</q-td> </q-td>
<q-td> <q-td>
<q-input v-model="entry.value" /> <q-input v-model="entry.value" />
</q-td> </q-td>
</q-tr> </q-tr>
</tbody> </tbody>
</q-markup-table> </q-markup-table>
</template> </template>
<q-btn @click="saveConfig" unelevated label="Save" color="primary" class="q-mt-md full-width" /> <q-btn
</q-card-section> @click="saveConfig"
</q-card> unelevated
</div> label="Save"
color="primary"
class="q-mt-md full-width"
/>
</q-card-section>
</q-card>
</div>
</div> </div>
{% endblock %} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block scripts %} {{ window_vars(user) }}
<script> <script>
new Vue({
el: "#vue",
mixins: [windowMixin],
delimiters: ["${", "}"],
data: function () {
return {
entries: [],
columns: [
{
name: "key",
required: true,
label: "Key",
align: "left",
field: (row) => row.key,
sortable: true,
},
{
name: "value",
required: true,
label: "Value",
align: "left",
field: (row) => row.value,
sortable: true,
},
],
};
},
methods: {
new Vue({ fetchConfig() {
el: '#vue', this.entries = [];
mixins: [windowMixin], LNbits.api
delimiters: ['${', '}'], .request(
data: function () { "GET",
return { "/nwcprovider/api/v1/config",
entries: [], this.g.user.wallets[0].adminkey,
columns: [ )
{ name: 'key', required: true, label: 'Key', align: 'left', field: row => row.key, sortable: true }, .then((response) => {
{ name: 'value', required: true, label: 'Value', align: 'left', field: row => row.value, sortable: true }, const newEntries = [];
] for (const [key, value] of Object.entries(response.data)) {
newEntries.push({
key: key,
value: value,
});
} }
},
this.entries = newEntries;
methods: { })
fetchConfig() { .catch(function (error) {
this.entries = []; console.error("Error fetching config:", error);
LNbits.api });
.request('GET', '/nwcprovider/api/v1/config', this.g.user.wallets[0].adminkey) },
.then((response) => { async saveConfig() {
const newEntries = []; const data = {};
for (const [key, value] of Object.entries(response.data)) { for (const entry of this.entries) {
newEntries.push({ data[entry.key] = entry.value;
key: key,
value: value,
});
}
this.entries = newEntries;
})
.catch(function (error) {
console.error("Error fetching config:", error);
});
},
async saveConfig() {
const data = {};
for (const entry of this.entries) {
data[entry.key] = entry.value;
}
try {
const response = await LNbits.api
.request('POST', '/nwcprovider/api/v1/config', this.g.user.wallets[0].adminkey, data)
this.$q.notify({
message: 'Config saved, please restart the server',
color: 'positive',
position: 'top',
timeout: 2000,
actions: [{ icon: 'close', color: 'white' }]
})
} catch (error) {
console.error("Error saving config:", error);
}
}
},
created: function () {
this.fetchConfig()
} }
}) try {
const response = await LNbits.api.request(
"POST",
"/nwcprovider/api/v1/config",
this.g.user.wallets[0].adminkey,
data,
);
this.$q.notify({
message: "Config saved, please restart the server",
color: "positive",
position: "top",
timeout: 2000,
actions: [{ icon: "close", color: "white" }],
});
} catch (error) {
console.error("Error saving config:", error);
}
},
},
created: function () {
this.fetchConfig();
},
});
</script> </script>
{% endblock %} {% endblock %}

View file

@ -8,8 +8,14 @@
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md"> <div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <q-card-section>
<q-select v-model="selectedWallet" filled dense emit-value :options="g.user.walletOptions" <q-select
label="Wallet *"></q-select> v-model="selectedWallet"
filled
dense
emit-value
:options="g.user.walletOptions"
label="Wallet *"
></q-select>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -19,16 +25,28 @@
<div class="col items-center"> <div class="col items-center">
<h5 class="text-subtitle1">Connected Apps</h5> <h5 class="text-subtitle1">Connected Apps</h5>
</div> </div>
<q-btn @click="showConnectDialog()" aria-label="Connect" icon="add" color="primary" size="sm" round /> <q-btn
@click="showConnectDialog()"
aria-label="Connect"
icon="add"
color="primary"
size="sm"
round
/>
</div> </div>
<q-table dense flat :data="nwcEntries" row-key="id" :columns="nwcsTable.columns" <q-table
:pagination.sync="nwcsTable.pagination"> dense
flat
:data="nwcEntries"
row-key="id"
:columns="nwcsTable.columns"
:pagination.sync="nwcsTable.pagination"
>
<nwcprovider v-slot:header="props"> <nwcprovider v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props"> <q-th v-for="col in props.cols" :key="col.name" :props="props">
${ col.label } ${ col.label }
</q-th> </q-th>
</q-tr> </q-tr>
</nwcprovider> </nwcprovider>
@ -38,20 +56,30 @@
<div>${ col.value }</div> <div>${ col.value }</div>
</q-td> </q-td>
<q-td> <q-td>
<q-btn flat dense size="xs" @click="openConnectionInfoDialog(props.row)" icon="info" color="primary"> <q-btn
flat
dense
size="xs"
@click="openConnectionInfoDialog(props.row)"
icon="info"
color="primary"
>
<q-tooltip> Info </q-tooltip> <q-tooltip> Info </q-tooltip>
</q-btn> </q-btn>
<q-btn flat dense size="xs" @click="deleteNWC(props.row.pubkey)" icon="cancel" color="pink"> <q-btn
flat
dense
size="xs"
@click="deleteNWC(props.row.pubkey)"
icon="cancel"
color="pink"
>
<q-tooltip> Delete </q-tooltip> <q-tooltip> Delete </q-tooltip>
</q-btn> </q-btn>
</q-td> </q-td>
</q-tr> </q-tr>
</template> </template>
</q-table> </q-table>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -62,80 +90,101 @@
<q-card-section> <q-card-section>
<h6 class="text-subtitle1 q-my-none">NWC Service provider</h6> <h6 class="text-subtitle1 q-my-none">NWC Service provider</h6>
<p> <p>
Nostr Wallet Connect (NWC) are a way for applications to connect to a remote lightning wallet. Nostr Wallet Connect (NWC) are a way for applications to connect to a
remote lightning wallet.
</p> </p>
</q-card-section> </q-card-section>
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<q-separator></q-separator> <q-separator></q-separator>
<q-list> <q-list>
<q-item
<q-item clickable @click="go('../docs#/NWC%20Service%20Provider')" clickable
class="q-flex items-center justify-between"> @click="go('../docs#/NWC%20Service%20Provider')"
class="q-flex items-center justify-between"
>
<q-icon name="swap_vertical_circle" class="q-mr-md"></q-icon> <q-icon name="swap_vertical_circle" class="q-mr-md"></q-icon>
<q-item-section>Swagger API</q-item-section> <q-item-section>Swagger API</q-item-section>
</q-item> </q-item>
<q-separator></q-separator> <q-separator></q-separator>
<q-item v-if="this.g.user.admin" clickable @click="go('/nwcprovider/admin')" <q-item
class="q-flex items-center justify-between"> v-if="this.g.user.admin"
clickable
@click="go('/nwcprovider/admin')"
class="q-flex items-center justify-between"
>
<q-icon name="settings" class="q-mr-md"></q-icon> <q-icon name="settings" class="q-mr-md"></q-icon>
<q-item-section>Settings</q-item-section> <q-item-section>Settings</q-item-section>
</q-item> </q-item>
</q-list> </q-list>
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
<q-dialog
<q-dialog v-model="connectionInfoDialog.show" @hide="closeConnectionInfoDialog"> v-model="connectionInfoDialog.show"
@hide="closeConnectionInfoDialog"
>
<q-card class="q-pa-lg q-pt-xl" style="width: 500px"> <q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-card-section class="q-ma-none"> <q-card-section class="q-ma-none">
<h3 class="q-ma-none q-mb-md text-center">Info</h3> <h3 class="q-ma-none q-mb-md text-center">Info</h3>
<q-list> <q-list>
<q-item> <q-item>
<q-item-section>Description</q-item-section> <q-item-section>Description</q-item-section>
<q-item-section side>${connectionInfoDialog.data.description}</q-item-section> <q-item-section side
>${connectionInfoDialog.data.description}</q-item-section
>
</q-item> </q-item>
<q-item> <q-item>
<q-item-section>Last used</q-item-section> <q-item-section>Last used</q-item-section>
<q-item-section side>${connectionInfoDialog.data.last_used}</q-item-section> <q-item-section side
>${connectionInfoDialog.data.last_used}</q-item-section
>
</q-item> </q-item>
<q-item> <q-item>
<q-item-section>Expires</q-item-section> <q-item-section>Expires</q-item-section>
<q-item-section side>${connectionInfoDialog.data.expires_at}</q-item-section> <q-item-section side
>${connectionInfoDialog.data.expires_at}</q-item-section
>
</q-item> </q-item>
<q-item> <q-item>
<q-item-section>Created</q-item-section> <q-item-section>Created</q-item-section>
<q-item-section side>${connectionInfoDialog.data.created_at}</q-item-section> <q-item-section side
>${connectionInfoDialog.data.created_at}</q-item-section
>
</q-item> </q-item>
<q-item> <q-item>
<q-item-section>Permissions</q-item-section> <q-item-section>Permissions</q-item-section>
<q-item-section side> <q-item-section side>
${connectionInfoDialog.data.permissions} ${connectionInfoDialog.data.permissions}
</q-item-section> </q-item-section>
</q-item> </q-item>
<q-item> <q-item>
<q-item-section>Limits <q-item-section
>Limits
<q-markup-table class="q-mt-md"> <q-markup-table class="q-mt-md">
<thead> <thead>
<q-tr> <q-tr>
<q-th> <q-th>
Budget (sats) Budget (sats)
<div style="font-size: smaller;">used/max</div> <div style="font-size: smaller">used/max</div>
</q-th> </q-th>
<q-th>Refresh</q-th> <q-th>Refresh</q-th>
</q-tr> </q-tr>
</thead> </thead>
<tbody> <tbody>
<q-tr v-if="!connectionInfoDialog.data.budgets || connectionInfoDialog.data.budgets.length === 0"> <q-tr
v-if="!connectionInfoDialog.data.budgets || connectionInfoDialog.data.budgets.length === 0"
>
<q-td colspan="2" class="text-center">No limits</q-td> <q-td colspan="2" class="text-center">No limits</q-td>
</q-tr> </q-tr>
<q-tr v-else v-for="budget in connectionInfoDialog.data.budgets"> <q-tr
<q-td class="text-center">${budget.used_budget_sats} / ${budget.budget_sats}</q-td> v-else
v-for="budget in connectionInfoDialog.data.budgets"
>
<q-td class="text-center"
>${budget.used_budget_sats} / ${budget.budget_sats}</q-td
>
<q-td class="text-center">${budget.refresh_window}</q-td> <q-td class="text-center">${budget.refresh_window}</q-td>
</q-tr> </q-tr>
</tbody> </tbody>
@ -144,27 +193,44 @@
</q-item> </q-item>
</q-list> </q-list>
<q-card-actions class="q-mt-md"> <q-card-actions class="q-mt-md">
<q-btn v-close-popup flat color="grey" class="q-ml-auto full-width " @click="closePairingDialog">Close</q-btn> <q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto full-width"
@click="closePairingDialog"
>Close</q-btn
>
</q-card-actions> </q-card-actions>
</q-card-section>
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="pairingQrDialog.show" @hide="closePairingQrDialog"> <q-dialog v-model="pairingQrDialog.show" @hide="closePairingQrDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px"> <q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-card-section class="q-ma-none"> <q-card-section class="q-ma-none">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md"> <q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<lnbits-qrcode :value="pairingQrDialog.data.pairingUrl"></lnbits-qrcode> <lnbits-qrcode
:value="pairingQrDialog.data.pairingUrl"
></lnbits-qrcode>
</q-responsive> </q-responsive>
</q-card-section> </q-card-section>
<q-card-section class="q-ma-none"> <q-card-section class="q-ma-none">
<p class=" q-ma-none text-center">Please scan this QR code with a supported app</p> <p class="q-ma-none text-center">
<p class=" q-ma-none text-center text-caption ">Connect only with app you trust!</p> Please scan this QR code with a supported app
</p>
<p class="q-ma-none text-center text-caption">
Connect only with app you trust!
</p>
</q-card-section> </q-card-section>
<q-card-actions> <q-card-actions>
<q-btn label="Close" flat color="grey" class="full-width" @click="closePairingQrDialog"></q-btn> <q-btn
label="Close"
flat
color="grey"
class="full-width"
@click="closePairingQrDialog"
></q-btn>
</q-card-actions> </q-card-actions>
</q-card> </q-card>
</q-dialog> </q-dialog>
@ -173,33 +239,58 @@
<q-card class="q-pa-lg q-pt-xl" style="width: 500px"> <q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-card-section class="q-ma-none"> <q-card-section class="q-ma-none">
<h3 class="q-ma-none q-mb-md text-center">Pairing</h3> <h3 class="q-ma-none q-mb-md text-center">Pairing</h3>
<p class=" q-ma-none text-center">Complete the last step of the setup by pasting or scanning your connection's <p class="q-ma-none text-center">
pairing secret in the desired Complete the last step of the setup by pasting or scanning your
app to connection's pairing secret in the desired app to finalise the
finalise the connection.</p> connection.
<p class=" q-ma-none text-center text-caption ">Connect only with app you trust!</p> </p>
<p class="q-ma-none text-center text-caption">
Connect only with app you trust!
</p>
</q-card-section> </q-card-section>
<q-card-actions> <q-card-actions>
<q-btn label="Open in Supported App" color="primary" class="full-width" icon="launch" <q-btn
@click="openPairingUrl()"></q-btn> label="Open in Supported App"
color="primary"
class="full-width"
icon="launch"
@click="openPairingUrl()"
></q-btn>
</q-card-actions> </q-card-actions>
<q-card-section class="q-ma-none"> <q-card-section class="q-ma-none">
<h5 class="q-ma-none text-center">Advanced</h5> <h5 class="q-ma-none text-center">Advanced</h5>
</q-card-section> </q-card-section>
<q-card-actions> <q-card-actions>
<q-btn label="Copy Pairing URL" flat color="grey" class="full-width" icon="content_copy" <q-btn
@click="copyPairingUrl()"></q-btn> label="Copy Pairing URL"
flat
color="grey"
class="full-width"
icon="content_copy"
@click="copyPairingUrl()"
></q-btn>
</q-card-actions> </q-card-actions>
<q-card-actions> <q-card-actions>
<q-btn
<q-btn label="Show QR Code" flat color="grey" class="full-width" icon="qr_code_scanner" label="Show QR Code"
@click="showPairingQR()"></q-btn> flat
color="grey"
class="full-width"
icon="qr_code_scanner"
@click="showPairingQR()"
></q-btn>
</q-card-actions> </q-card-actions>
<q-card-actions> <q-card-actions>
<q-btn v-close-popup flat color="grey" class="q-ml-auto full-width" @click="closePairingDialog">Close</q-btn> <q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto full-width"
@click="closePairingDialog"
>Close</q-btn
>
</q-card-actions> </q-card-actions>
</q-card> </q-card>
</q-dialog> </q-dialog>
@ -209,42 +300,78 @@
<h3 class="q-ma-none q-mb-xl text-center">Add connection</h3> <h3 class="q-ma-none q-mb-xl text-center">Add connection</h3>
<q-form @submit="confirmConnectDialog" class="q-gutter-md"> <q-form @submit="confirmConnectDialog" class="q-gutter-md">
<q-input v-model="connectDialog.data.description" filled dense label="Description"></q-input> <q-input
v-model="connectDialog.data.description"
filled
dense
label="Description"
></q-input>
<div class="row"> <div class="row">
<q-input :disable="connectDialog.data.neverExpires" v-model="connectDialog.data.expires_at" class="col" <q-input
v-model="connectDialog.data.expires_at" filled dense label="Expires at" type="datetime-local"></q-input> :disable="connectDialog.data.neverExpires"
<q-checkbox v-model="connectDialog.data.neverExpires" class="col-auto" v-model="connectDialog.data.expires_at"
v-model="connectDialog.data.neverExpires" label="Never"></q-checkbox> class="col"
v-model="connectDialog.data.expires_at"
filled
dense
label="Expires at"
type="datetime-local"
></q-input>
<q-checkbox
v-model="connectDialog.data.neverExpires"
class="col-auto"
v-model="connectDialog.data.neverExpires"
label="Never"
></q-checkbox>
</div> </div>
<q-list dense bordered padding> <q-list dense bordered padding>
<q-item> <q-item>
<q-item-label header>Authorize the app to</q-item-label> <q-item-label header>Authorize the app to</q-item-label>
</q-item> </q-item>
<q-item v-for="permission in connectDialog.data.permissions"> <q-item v-for="permission in connectDialog.data.permissions">
<q-checkbox v-model="permission.value" :label="permission.name"></q-checkbox> <q-checkbox
v-model="permission.value"
:label="permission.name"
></q-checkbox>
</q-item> </q-item>
</q-list> </q-list>
<q-list dense bordered padding> <q-list dense bordered padding>
<q-item> <q-item>
<q-item-label header>Limit the spendable amount</q-item-label> <q-item-label header>Limit the spendable amount</q-item-label>
</q-item> </q-item>
<q-item v-if="!connectDialog.data.budgets || connectDialog.data.budgets.length === 0"> <q-item
<q-item-section class="full-width text-center">No limit</q-item-section> v-if="!connectDialog.data.budgets || connectDialog.data.budgets.length === 0"
>
<q-item-section class="full-width text-center"
>No limit</q-item-section
>
</q-item> </q-item>
<template v-else> <template v-else>
<q-item v-for="(budget,index) in connectDialog.data.budgets"> <q-item v-for="(budget,index) in connectDialog.data.budgets">
<q-item-section> <q-item-section>
<q-input filled dense type="number" v-model="budget.budget_sats" label="Limit (sats)"></q-input> <q-input
filled
dense
type="number"
v-model="budget.budget_sats"
label="Limit (sats)"
></q-input>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-select filled dense emit-value :options="[ <q-select
filled
dense
emit-value
:options="[
{label: 'Daily', value: 'Daily'}, {label: 'Daily', value: 'Daily'},
{label: 'Weekly', value: 'Weekly'}, {label: 'Weekly', value: 'Weekly'},
{label: 'Monthly', value: 'Monthly'}, {label: 'Monthly', value: 'Monthly'},
{label: 'Yearly', value: 'Yearly'}, {label: 'Yearly', value: 'Yearly'},
{label: 'Never', value: 'Never'} {label: 'Never', value: 'Never'}
]" label="Expires" v-model="budget.expiry"></q-select> ]"
label="Expires"
v-model="budget.expiry"
></q-select>
</q-item-section> </q-item-section>
<q-item-section side> <q-item-section side>
<q-btn flat icon="delete" @click="deleteBudget(index)"></q-btn> <q-btn flat icon="delete" @click="deleteBudget(index)"></q-btn>
@ -252,16 +379,25 @@
</q-item> </q-item>
</template> </template>
<q-item> <q-item>
<q-btn flat icon="add" class="full-width" label="Add limit" @click="addBudget"></q-btn> <q-btn
flat
icon="add"
class="full-width"
label="Add limit"
@click="addBudget"
></q-btn>
</q-item> </q-item>
</q-list> </q-list>
<q-card-actions class="q-gutter-sm"> <q-card-actions class="q-gutter-sm">
<div class="col q-mr-md"> <div class="col q-mr-md">
<q-btn unelevated color="primary" type="submit" class="full-width">Connect</q-btn> <q-btn unelevated color="primary" type="submit" class="full-width"
>Connect</q-btn
>
</div> </div>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn> <q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</q-card-actions> </q-card-actions>
</q-form> </q-form>
</q-card> </q-card>
@ -269,16 +405,14 @@
</div> </div>
{% endblock %} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% 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> <script>
new Vue({ new Vue({
el: '#vue', el: "#vue",
mixins: [windowMixin], mixins: [windowMixin],
delimiters: ['${', '}'], delimiters: ["${", "}"],
data: function () { data: function () {
return { return {
selectedWallet: null, selectedWallet: null,
@ -286,15 +420,35 @@
nwcEntries: [], nwcEntries: [],
nwcsTable: { nwcsTable: {
columns: [ columns: [
{ name: 'description', align: 'left', label: 'Description', field: 'description' }, {
{ name: 'status', align: 'left', label: 'Status', field: 'status' }, name: "description",
{ name: 'last_used', align: 'left', label: 'Last used', field: 'last_used' }, align: "left",
{ name: 'created_at', align: 'left', label: 'Created', field: 'created_at' }, label: "Description",
{ name: 'expires_at', align: 'left', label: 'Expires', field: 'expires_at' } field: "description",
},
{ name: "status", align: "left", label: "Status", field: "status" },
{
name: "last_used",
align: "left",
label: "Last used",
field: "last_used",
},
{
name: "created_at",
align: "left",
label: "Created",
field: "created_at",
},
{
name: "expires_at",
align: "left",
label: "Expires",
field: "expires_at",
},
], ],
pagination: { pagination: {
rowsPerPage: 10 rowsPerPage: 10,
} },
}, },
connectDialog: { connectDialog: {
show: false, show: false,
@ -303,28 +457,30 @@
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) {
this.$q.notify({ color: 'red', message: 'Please select a wallet first' }); this.$q.notify({
color: "red",
message: "Please select a wallet first",
});
return; return;
} else { } else {
this.connectDialog.show = true; this.connectDialog.show = true;
@ -339,24 +495,28 @@
}, },
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);
this.$q.notify({ color: 'primary', message: 'URL copied to clipboard!' }); this.$q.notify({
color: "primary",
message: "URL copied to clipboard!",
});
} catch (err) { } catch (err) {
this.$q.notify({ color: 'red', message: 'Failed to copy URL.' }); this.$q.notify({ color: "red", 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() {
@ -364,33 +524,34 @@
}, },
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:
expiration: 'never' new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
}) expiration: "never",
});
}, },
closeConnectDialog() { closeConnectDialog() {
this.connectDialog.show = false this.connectDialog.show = false;
this.loadConnectDialogData(); this.loadConnectDialogData();
}, },
getWallet: function () { getWallet: function () {
@ -405,7 +566,7 @@
}, },
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);
@ -413,62 +574,67 @@
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) {
this.$q.dialog({ this.$q
title: 'Confirm Deletion', .dialog({
message: 'Are you sure you want to delete this connection?', title: "Confirm Deletion",
cancel: true, message: "Are you sure you want to delete this connection?",
persistent: true cancel: true,
}).onOk(async () => { persistent: true,
try { })
const wallet = this.getWallet(); .onOk(async () => {
const response = await LNbits.api.request('DELETE', `/nwcprovider/api/v1/nwc/${pubkey}`, wallet.adminkey); try {
this.loadNwcs(); const wallet = this.getWallet();
this.$q.notify({ type: 'positive', message: 'Deleted successfully' }); const response = await LNbits.api.request(
"DELETE",
} catch (error) { `/nwcprovider/api/v1/nwc/${pubkey}`,
LNbits.utils.notifyApiError(error); wallet.adminkey,
} );
}).onCancel(() => { this.loadNwcs();
// User canceled the operation this.$q.notify({
}); type: "positive",
message: "Deleted successfully",
});
} catch (error) {
LNbits.utils.notifyApiError(error);
}
})
.onCancel(() => {
// 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 const response = await LNbits.api.request(
.request( "GET",
'GET', "/nwcprovider/api/v1/nwc?includeExpired=true&calculateSpendBudget=true",
'/nwcprovider/api/v1/nwc?includeExpired=true&calculateSpendBudget=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;
@ -480,15 +646,18 @@
for (const nwc of this.nwcs) { for (const nwc of this.nwcs) {
const t = Quasar.utils.date.formatDate( const t = Quasar.utils.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 = nwc.data.expires_at > 0 ? Quasar.utils.date.formatDate( const e =
new Date(nwc.data.expires_at * 1000), nwc.data.expires_at > 0
'YYYY-MM-DD HH:mm' ? Quasar.utils.date.formatDate(
) : "Never"; new Date(nwc.data.expires_at * 1000),
"YYYY-MM-DD HH:mm",
)
: "Never";
const l = Quasar.utils.date.formatDate( const l = Quasar.utils.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,
@ -498,47 +667,49 @@
pubkey: nwc.data.pubkey, pubkey: nwc.data.pubkey,
permissions: nwc.data.permissions, permissions: nwc.data.permissions,
budgets: [], budgets: [],
status: "Active" status: "Active",
} };
if(nwc.data.expires_at > 0 && nwc.data.expires_at < new Date().getTime() / 1000){ if (
nwc.data.expires_at > 0 &&
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.utils.date.formatDate( const createdAt = Quasar.utils.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);
@ -550,70 +721,79 @@
// timestamp // timestamp
let expires_at = 0; let expires_at = 0;
if (!this.connectDialog.data.neverExpires) { if (!this.connectDialog.data.neverExpires) {
expires_at = new Date(this.connectDialog.data.expires_at).getTime() / 1000; expires_at =
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: new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000, created_at:
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 const response = await LNbits.api.request(
.request('PUT', '/nwcprovider/api/v1/nwc/' + keyPair.pubKey, wallet.adminkey, data); "PUT",
this.closeConnectDialog() "/nwcprovider/api/v1/nwc/" + keyPair.pubKey,
if (!response.data || !response.data.data || !response.data.data.pubkey) { wallet.adminkey,
data,
);
this.closeConnectDialog();
if (
!response.data ||
!response.data.data ||
!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();
}, },
}, },
}) });
</script> </script>
{% endblock %} {% endblock %}

File diff suppressed because it is too large Load diff

View file

@ -1,35 +1,41 @@
import json import json
from loguru import logger
import sys
import os import os
sys.path.insert(0, os.path.abspath( import sys
os.path.join(os.path.dirname(__file__), '..', '..')))
from loguru import logger
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
#### ####
import string
import random import random
from NWCServiceProvider import NWCServiceProvider import string
from unittest.mock import AsyncMock, patch
import asyncio
import pytest import pytest
from nwcp import NWCServiceProvider
@pytest.fixture @pytest.fixture
def nwc_service_provider(): def nwc_service_provider():
return NWCServiceProvider("d7b5232fba0e02e32cfe26f20cdf2c803b27ecd81052c2dd5d17e5e1a333fe58", "") return NWCServiceProvider(
"d7b5232fba0e02e32cfe26f20cdf2c803b27ecd81052c2dd5d17e5e1a333fe58", ""
)
@pytest.fixture @pytest.fixture
def nwc_service_provider2(): def nwc_service_provider2():
return NWCServiceProvider("ce40821040275f72f3074a89770db3e2744b189f204807c867840eb58565de51", "") return NWCServiceProvider(
"ce40821040275f72f3074a89770db3e2744b189f204807c867840eb58565de51", ""
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_supported_methods(nwc_service_provider): async def test_supported_methods(nwc_service_provider):
def make_invoice(provider, pubkey, content): def make_invoice(provider, pubkey, content):
return "invoice" return "invoice"
nwc_service_provider.addRequestListener("make_invoice", make_invoice)
s = nwc_service_provider.getSupportedMethods() nwc_service_provider.add_request_listener("make_invoice", make_invoice)
s = nwc_service_provider.get_supported_methods()
assert s == ["make_invoice"] assert s == ["make_invoice"]
@ -38,14 +44,18 @@ async def test_encrytdecrypt(nwc_service_provider, nwc_service_provider2):
content = "Hello World" content = "Hello World"
expected_enc = "qVurNVISSl/9CfREIhk5Lg==?iv=QpCo5dI9gUcoLsSMLA7o7Q==" expected_enc = "qVurNVISSl/9CfREIhk5Lg==?iv=QpCo5dI9gUcoLsSMLA7o7Q=="
enc_a = nwc_service_provider._encrypt_content( enc_a = nwc_service_provider._encrypt_content(
content, nwc_service_provider2.public_key_hex, 21) content, nwc_service_provider2.public_key_hex, 21
)
enc_b = nwc_service_provider2._encrypt_content( enc_b = nwc_service_provider2._encrypt_content(
content, nwc_service_provider.public_key_hex, 21) content, nwc_service_provider.public_key_hex, 21
)
dec_a = nwc_service_provider2._decrypt_content( dec_a = nwc_service_provider2._decrypt_content(
enc_a, nwc_service_provider.public_key_hex) enc_a, nwc_service_provider.public_key_hex
)
dec_b = nwc_service_provider._decrypt_content( dec_b = nwc_service_provider._decrypt_content(
enc_b, nwc_service_provider2.public_key_hex) enc_b, nwc_service_provider2.public_key_hex
)
assert dec_a == content assert dec_a == content
assert dec_b == content assert dec_b == content
@ -62,15 +72,16 @@ async def test_signverify(nwc_service_provider, nwc_service_provider2):
tags = [] tags = []
for _ in range(random.choice([1, 2, 3, 4])): for _ in range(random.choice([1, 2, 3, 4])):
tags.append([random.choice(string.ascii_letters)+"_" + tags.append(
random.choice(string.ascii_letters), random.choice(string.ascii_letters)]) [
random.choice(string.ascii_letters)
+ "_"
+ random.choice(string.ascii_letters),
random.choice(string.ascii_letters),
]
)
event = { event = {"kind": 1, "content": content, "tags": tags, "created_at": 1234567890}
"kind": 1,
"content": content,
"tags": tags,
"created_at": 1234567890
}
signed = nwc_service_provider._sign_event(event) signed = nwc_service_provider._sign_event(event)
assert nwc_service_provider2._verify_event(signed) assert nwc_service_provider2._verify_event(signed)
@ -78,22 +89,17 @@ async def test_signverify(nwc_service_provider, nwc_service_provider2):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_handle(nwc_service_provider, nwc_service_provider2): async def test_handle(nwc_service_provider, nwc_service_provider2):
content = nwc_service_provider._json_dumps({ content = nwc_service_provider._json_dumps(
"method": "pay_invoice", {"method": "pay_invoice", "params": {"invoice": "abc"}}
"params": { )
"invoice": "abc"
}
})
content = nwc_service_provider._encrypt_content( content = nwc_service_provider._encrypt_content(
content, nwc_service_provider2.public_key_hex, 21) content, nwc_service_provider2.public_key_hex, 21
)
event = { event = {
"kind": 23194, "kind": 23194,
"content": content, "content": content,
"tags": [ "tags": [["p", nwc_service_provider2.public_key_hex]],
["p", nwc_service_provider2.public_key_hex] "created_at": 1234567890,
],
"created_at": 1234567890
} }
signed = nwc_service_provider._sign_event(event) signed = nwc_service_provider._sign_event(event)
@ -101,30 +107,20 @@ async def test_handle(nwc_service_provider, nwc_service_provider2):
assert pubkey == nwc_service_provider.public_key_hex assert pubkey == nwc_service_provider.public_key_hex
assert content["method"] == "pay_invoice" assert content["method"] == "pay_invoice"
assert content["params"]["invoice"] == "abc" assert content["params"]["invoice"] == "abc"
return [ return [({"preimage": "00000"}, None, [["r1", "v1"]])]
(
{
"preimage": "00000"
},
None,
[
["r1", "v1"]
]
)
]
async def _send_pass(obj): async def _send_pass(obj):
pass pass
nwc_service_provider2._send=_send_pass
nwc_service_provider2.addRequestListener( nwc_service_provider2._send = _send_pass
"pay_invoice", _handle_pay_invoice) nwc_service_provider2.add_request_listener("pay_invoice", _handle_pay_invoice)
sent_events = await nwc_service_provider2._handle_request(signed) sent_events = await nwc_service_provider2._handle_request(signed)
assert len(sent_events) == 1 assert len(sent_events) == 1
for revent in sent_events: for revent in sent_events:
assert nwc_service_provider2._verify_event(revent) assert nwc_service_provider2._verify_event(revent)
content = nwc_service_provider2._decrypt_content( content = nwc_service_provider2._decrypt_content(
revent["content"], nwc_service_provider.public_key_hex) revent["content"], nwc_service_provider.public_key_hex
)
logger.debug(event) logger.debug(event)
logger.debug(revent) logger.debug(revent)
content = json.loads(content) content = json.loads(content)
@ -138,7 +134,7 @@ async def test_handle(nwc_service_provider, nwc_service_provider2):
e_tag = [tag for tag in tags if tag[0] == "e"] e_tag = [tag for tag in tags if tag[0] == "e"]
assert len(e_tag) == 1 assert len(e_tag) == 1
assert e_tag[0][1] == event["id"] assert e_tag[0][1] == event["id"]
p_tag = [tag for tag in tags if tag[0] == "p"] p_tag = [tag for tag in tags if tag[0] == "p"]
assert len(p_tag) == 1 assert len(p_tag) == 1
assert p_tag[0][1] == nwc_service_provider.public_key_hex assert p_tag[0][1] == nwc_service_provider.public_key_hex

View file

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

View file

@ -1,162 +1,178 @@
from http import HTTPStatus from http import HTTPStatus
import json from typing import Dict, List, Optional
from typing import List, Optional, Dict
from .models import NWCRegistrationRequest, NWCGetResponse
from fastapi import Depends, Request
from lnbits.decorators import (
WalletTypeInfo,
require_admin_key
)
from fastapi.responses import JSONResponse
from . import nwcprovider_ext
from .crud import get_nwc, get_wallet_nwcs, get_all_config_nwc, create_nwc, delete_nwc, get_budgets_nwc, get_config_nwc,set_config_nwc
from lnbits.decorators import check_admin
from fastapi import Depends
import secp256k1 import secp256k1
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from lnbits.core.models import WalletTypeInfo
from lnbits.decorators import check_admin, require_admin_key
from .crud import (
create_nwc,
delete_nwc,
get_all_config_nwc,
get_budgets_nwc,
get_config_nwc,
get_nwc,
get_wallet_nwcs,
set_config_nwc,
)
from .models import NWCGetResponse, NWCRegistrationRequest
from .permission import nwc_permissions from .permission import nwc_permissions
nwcprovider_api_router = APIRouter()
# Get supported permissions # Get supported permissions
@nwcprovider_ext.get("/api/v1/permissions", status_code=HTTPStatus.OK) @nwcprovider_api_router.get("/api/v1/permissions", status_code=HTTPStatus.OK)
async def api_get_permissions( async def api_get_permissions(
req: Request, req: Request,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Dict: ) -> Dict:
return nwc_permissions return nwc_permissions
## Get nwc keys associated with the wallet ## Get nwc keys associated with the wallet
@nwcprovider_ext.get("/api/v1/nwc", status_code=HTTPStatus.OK, response_model=List[NWCGetResponse]) @nwcprovider_api_router.get(
"/api/v1/nwc", status_code=HTTPStatus.OK, response_model=List[NWCGetResponse]
)
async def api_get_nwcs( async def api_get_nwcs(
req: Request, req: Request,
includeExpired: Optional[bool] = False, include_expired: bool = False,
calculateSpendBudget: Optional[bool] = False, calculate_spent_budget: bool = False,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
): ):
wallet_id = wallet.wallet.id wallet_id = wallet.wallet.id
nwcs = await get_wallet_nwcs(wallet_id, includeExpired) nwcs = await get_wallet_nwcs(wallet_id, include_expired)
out = [] out = []
for nwc in nwcs: for nwc in nwcs:
budgets = await get_budgets_nwc(nwc.pubkey, calculateSpendBudget) budgets = await get_budgets_nwc(nwc.pubkey, calculate_spent_budget)
res = NWCGetResponse( res = NWCGetResponse(data=nwc, budgets=budgets)
data=nwc,
budgets=budgets
)
out.append(res) out.append(res)
return out return out
# Get a nwc key # Get a nwc key
@nwcprovider_ext.get("/api/v1/nwc/{pubkey}", status_code=HTTPStatus.OK, response_model=NWCGetResponse) @nwcprovider_api_router.get(
"/api/v1/nwc/{pubkey}", status_code=HTTPStatus.OK, response_model=NWCGetResponse
)
async def api_get_nwc( async def api_get_nwc(
req: Request, req: Request,
pubkey: str, pubkey: str,
includeExpired: Optional[bool] = False, include_expired: Optional[bool] = False,
wallet: WalletTypeInfo = Depends(require_admin_key) wallet: WalletTypeInfo = Depends(require_admin_key),
) -> NWCGetResponse: ) -> NWCGetResponse:
wallet_id = wallet.wallet.id wallet_id = wallet.wallet.id
nwc = await get_nwc(pubkey, wallet_id, includeExpired) nwc = await get_nwc(pubkey, wallet_id, include_expired)
res = NWCGetResponse( if not nwc:
data=nwc, raise Exception("Pubkey has no associated wallet")
budgets=await get_budgets_nwc(pubkey) res = NWCGetResponse(data=nwc, budgets=await get_budgets_nwc(pubkey))
)
return res return res
# Get pairing url for given secret # Get pairing url for given secret
@nwcprovider_ext.get("/api/v1/pairing/{secret}", status_code=HTTPStatus.OK, response_model=str) @nwcprovider_api_router.get(
async def api_get_pairing_url( "/api/v1/pairing/{secret}", status_code=HTTPStatus.OK, response_model=str
req: Request, )
secret: str async def api_get_pairing_url(req: Request, secret: str) -> str:
) -> str: pprivkey: Optional[str] = await get_config_nwc("provider_key")
pprivkey = await get_config_nwc("provider_key") if not pprivkey:
raise Exception("Extension is not configured")
relay = await get_config_nwc("relay") relay = await get_config_nwc("relay")
relay_alias = await get_config_nwc("relay_alias") if not relay:
raise Exception("Extension is not configured")
relay_alias: Optional[str] = await get_config_nwc("relay_alias")
if relay_alias: if relay_alias:
relay = relay_alias relay = relay_alias
else: else:
if relay == "nostrclient": if relay == "nostrclient":
scheme = req.url.scheme # http or https scheme = req.url.scheme # http or https
netloc = req.url.netloc # hostname and port netloc = req.url.netloc # hostname and port
if scheme=="http": if scheme == "http":
scheme = "ws" scheme = "ws"
else: else:
scheme = "wss" scheme = "wss"
netloc += "/nostrclient/api/v1/relay" netloc += "/nostrclient/api/v1/relay"
relay = f"{scheme}://{netloc}" relay = f"{scheme}://{netloc}"
psk = secp256k1.PrivateKey(bytes.fromhex(pprivkey)) psk = secp256k1.PrivateKey(bytes.fromhex(pprivkey))
ppk = psk.pubkey ppk = psk.pubkey
if not ppk:
raise Exception("Error generating pubkey")
ppubkey = ppk.serialize().hex()[2:] ppubkey = ppk.serialize().hex()[2:]
url = "nostr+walletconnect://" url = "nostr+walletconnect://"
url += ppubkey url += ppubkey
url += "?relay="+relay url += "?relay=" + relay
url += "&secret="+secret url += "&secret=" + secret
#lud16=? # lud16=?
return url return url
## Register a new nwc key ## Register a new nwc key
@nwcprovider_ext.put("/api/v1/nwc/{pubkey}", status_code=HTTPStatus.CREATED, response_model=NWCGetResponse) @nwcprovider_api_router.put(
"/api/v1/nwc/{pubkey}",
status_code=HTTPStatus.CREATED,
response_model=NWCGetResponse,
)
async def api_register_nwc( async def api_register_nwc(
req: Request, req: Request,
pubkey: str, pubkey: str,
registration_data: NWCRegistrationRequest, # Use the Pydantic model here registration_data: NWCRegistrationRequest, # Use the Pydantic model here
wallet: WalletTypeInfo = Depends(require_admin_key) wallet: WalletTypeInfo = Depends(require_admin_key),
): ):
wallet_id = wallet.wallet.id wallet_id = wallet.wallet.id
nwc = await create_nwc(pubkey, wallet_id, registration_data.description, registration_data.expires_at, registration_data.permissions, registration_data.budgets) nwc = await create_nwc(
budgets = await get_budgets_nwc(pubkey) pubkey,
res = NWCGetResponse( wallet_id,
data=nwc, registration_data.description,
budgets=budgets registration_data.expires_at,
registration_data.permissions,
registration_data.budgets,
) )
budgets = await get_budgets_nwc(pubkey)
res = NWCGetResponse(data=nwc, budgets=budgets)
return res return res
# Delete a nwc key # Delete a nwc key
@nwcprovider_ext.delete("/api/v1/nwc/{pubkey}", status_code=HTTPStatus.OK) @nwcprovider_api_router.delete("/api/v1/nwc/{pubkey}", status_code=HTTPStatus.OK)
async def api_delete_nwc( async def api_delete_nwc(
req: Request, req: Request, pubkey: str, wallet: WalletTypeInfo = Depends(require_admin_key)
pubkey: str,
wallet: WalletTypeInfo=Depends(require_admin_key)
): ):
wallet_id = wallet.wallet.id wallet_id = wallet.wallet.id
await delete_nwc(pubkey, wallet_id) await delete_nwc(pubkey, wallet_id)
return JSONResponse(content={"message": f"NWC key {pubkey} deleted successfully."}) return JSONResponse(content={"message": f"NWC key {pubkey} deleted successfully."})
# Get config # Get config
@nwcprovider_ext.get("/api/v1/config", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)]) @nwcprovider_api_router.get(
"/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(
req: Request, req: Request,
): ):
config = await get_all_config_nwc() config = await get_all_config_nwc()
return config return config
# Get config # Get config
@nwcprovider_ext.get("/api/v1/config/{key}", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)]) @nwcprovider_api_router.get(
async def api_get_config_nwc( "/api/v1/config/{key}",
req: Request, status_code=HTTPStatus.OK,
key:str dependencies=[Depends(check_admin)],
): )
async def api_get_config_nwc(req: Request, key: str):
config = await get_config_nwc(key) config = await get_config_nwc(key)
out = {} out = {}
out[key] = config out[key] = config
return out return out
# Set config # Set config
@nwcprovider_ext.post("/api/v1/config", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)]) @nwcprovider_api_router.post(
async def api_set_config_nwc( "/api/v1/config", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)]
req: Request )
): async def api_set_config_nwc(req: Request):
data = await req.json() data = await req.json()
for key, value in data.items(): for key, value in data.items():
await set_config_nwc(key, value) await set_config_nwc(key, value)