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": [ "mounts": [
"source=${localWorkspaceFolder}/.devcontainer/start.sh,target=/start-lnbits.sh,type=bind", "source=${localWorkspaceFolder}/.devcontainer/start.sh,target=/start-lnbits.sh,type=bind",
"source=${localWorkspaceFolder}/.devcontainer/setup.sh,target=/setup.sh,type=bind", "source=${localWorkspaceFolder}/.devcontainer/setup.sh,target=/setup.sh,type=bind",
"source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind"
], ],
"containerEnv": { "containerEnv": {
"IS_DEV_CONTAINER": "true", "IS_DEV_CONTAINER": "true"
}, },
"postCreateCommand": "/bin/bash /setup.sh ${containerWorkspaceFolder}", "postCreateCommand": "/bin/bash /setup.sh ${containerWorkspaceFolder}",
"postStartCommand": "/bin/bash /start-lnbits.sh", "postStartCommand": "/bin/bash /start-lnbits.sh",
"forwardPorts": [ "forwardPorts": [5000],
5000
],
"customizations": { "customizations": {
"vscode": { "vscode": {
"settings": { "settings": {
"python.analysis.extraPaths": ["/home/vscode/lnbits"],
"python.pythonPath": "/home/vscode/python"
// "python.pythonPath": "/opt/python/bin/python3.9" // "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,7 +4,6 @@ on:
push: push:
pull_request: pull_request:
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -16,7 +15,7 @@ jobs:
- 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: |
@ -27,7 +26,6 @@ jobs:
run: | run: |
/bin/bash .devcontainer/setup.sh ${{ github.workspace }} /bin/bash .devcontainer/setup.sh ${{ github.workspace }}
- name: Run unit tests - name: Run unit tests
run: | run: |
cd lnbits cd lnbits
@ -45,4 +43,4 @@ jobs:
poetry_env_path=$(poetry env info --path) poetry_env_path=$(poetry env info --path)
source $poetry_env_path/bin/activate source $poetry_env_path/bin/activate
cd .. cd ..
pytest tests/unit/*.py -s pytest tests/integration/*.py -s

4
.gitignore vendored
View file

@ -2,3 +2,7 @@ __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

@ -13,7 +13,7 @@ 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) |

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"]

139
crud.py
View file

@ -1,9 +1,13 @@
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,
@ -11,7 +15,7 @@ async def create_nwc(
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):
@ -20,20 +24,41 @@ async def create_nwc(
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(
@ -43,14 +68,11 @@ async def create_nwc(
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,17 +80,19 @@ 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]
@ -77,16 +101,24 @@ 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,7 +127,7 @@ 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)
@ -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 = (
(
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),
))[0] or 0 )
)[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(): async def get_all_config_nwc():
rows = await db.fetchall( rows = await db.fetchall("SELECT * FROM nwcprovider.config")
"SELECT * FROM nwcprovider.config"
)
return {row["key"]: row["value"] for row in rows} return {row["key"]: row["value"] for row in rows}
async def set_config_nwc(key: str, value: str): 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
@ -24,7 +25,9 @@ async def m001_initial(db):
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
); );
""" """
) )
@ -36,7 +39,9 @@ async def m001_initial(db):
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
); );
""" """
) )
@ -49,13 +54,14 @@ async def m001_initial(db):
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,11 +90,10 @@ 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
@ -97,7 +103,7 @@ async def m004_default_config2(db):
""" """
INSERT INTO nwcprovider.config (key, value) VALUES ('relay_alias', ?); INSERT INTO nwcprovider.config (key, value) VALUES ('relay_alias', ?);
""", """,
('',) ("",),
) )

View file

@ -1,12 +1,10 @@
# 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):
@ -18,17 +16,17 @@ class NWCKey(BaseModel):
created_at: int created_at: int
last_used: int last_used: int
def getPermissions(cls) -> List[str]: def get_permissions(self) -> List[str]:
try: try:
return cls.permissions.split(" ") return self.permissions.split(" ")
except: except Exception:
return [] return []
@classmethod @classmethod
def from_row(cls, row: Dict[str, Any]) -> "NWCKey": def from_row(cls, row: Dict[str, Any]) -> "NWCKey":
return cls(**row) return cls(**row)
class NWCBudget(BaseModel): class NWCBudget(BaseModel):
id: int id: int
pubkey: str pubkey: str
@ -37,16 +35,16 @@ class NWCBudget(BaseModel):
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
@ -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,58 +1,59 @@
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
@ -61,16 +62,24 @@ class NWCServiceProvider:
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
@ -87,26 +96,39 @@ class NWCServiceProvider:
# 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)
+ " pubkey: "
+ self.public_key_hex
)
def getSupportedMethods(self): 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,7 +136,6 @@ 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.
@ -127,8 +148,7 @@ class NWCServiceProvider:
""" """
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.
@ -161,15 +181,14 @@ class NWCServiceProvider:
""" """
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,7 +199,6 @@ 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
@ -191,40 +209,37 @@ class NWCServiceProvider:
"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
@ -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": { "error": {
"code": "NOT_IMPLEMENTED", "code": "NOT_IMPLEMENTED",
"message": "Method "+method+" is not implemented by this service provider" "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,68 +392,11 @@ 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]
# Ensure the event is valid (do not trust relays)
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": elif msg[0] == "EOSE":
sub_id = msg[1] await self._on_eose_message(msg)
# 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]))
@ -375,23 +407,28 @@ class NWCServiceProvider:
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))
@ -401,15 +438,17 @@ class NWCServiceProvider:
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,15 +459,13 @@ 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,13 +475,11 @@ 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,24 +515,27 @@ 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, 0,
event["pubkey"], event["pubkey"],
event["created_at"], event["created_at"],
event["kind"], event["kind"],
event["tags"], event["tags"],
event["content"] 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)
@ -512,25 +546,27 @@ 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, 0,
self.public_key_hex, self.public_key_hex,
event["created_at"], event["created_at"],
event["kind"], event["kind"],
event["tags"], event["tags"],
event["content"] 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")
self.shutdown = True # Mark for shutdown self.shutdown = True # Mark for shutdown

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,5 +1,3 @@
nwc_permissions = { nwc_permissions = {
"pay": { "pay": {
"name": "Send payments", "name": "Send payments",
@ -9,41 +7,27 @@ nwc_permissions = {
"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

259
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(
async def _process_invoice(wallet_id:str, pubkey:str, invoice:str, amount_msats:int, description:Optional[str]=None): 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(
wallet_id=wallet_id,
payment_request=invoice,
max_sat=int(ceil(amount_msats / 1000)), max_sat=int(ceil(amount_msats / 1000)),
description=description or "" 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, [])]
@ -118,14 +137,18 @@ async def _on_pay_invoice(sp: NWCServiceProvider, pubkey: str, payload: Dict) ->
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:
@ -135,11 +158,13 @@ async def _on_multi_pay_invoice(sp: NWCServiceProvider, pubkey: str, payload: Di
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, []))
@ -149,25 +174,24 @@ async def _on_multi_pay_invoice(sp: NWCServiceProvider, pubkey: str, payload: Di
"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",
"message": str(e)
}))
# await log_nwc(pubkey, payload) # 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
@ -183,10 +207,15 @@ async def _on_make_invoice(sp: NWCServiceProvider, pubkey: str, payload: Dict) -
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) )
payment_status = await check_transaction_status(
wallet_id=nwc.wallet, payment_hash=payment_hash
)
preimage = payment_status.preimage preimage = payment_status.preimage
if not preimage: # Some backend do not return a preimage (eg. FakeWallet), so we fake it 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",
@ -198,7 +227,7 @@ async def _on_make_invoice(sp: NWCServiceProvider, pubkey: str, payload: Dict) -
"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)
@ -206,11 +235,15 @@ async def _on_make_invoice(sp: NWCServiceProvider, pubkey: str, payload: Dict) -
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,10 +261,12 @@ 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),
@ -239,7 +274,7 @@ async def _on_lookup_invoice(sp: NWCServiceProvider, pubkey: str, payload: Dict)
"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
@ -247,40 +282,45 @@ async def _on_lookup_invoice(sp: NWCServiceProvider, pubkey: str, payload: Dict)
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] = []
for p in history:
p: Payment p: Payment
for p in history:
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", "type": "outgoing" if p.is_out else "incoming",
"invoice": p.bolt11, "invoice": p.bolt11,
"description": invoice_data.description, "description": invoice_data.description,
@ -291,68 +331,79 @@ async def _on_list_transactions(sp: NWCServiceProvider, pubkey: str, payload: Di
"fees_paid": p.fee, "fees_paid": p.fee,
"created_at": p.time, "created_at": p.time,
"settled_at": p.time if is_settled else None, "settled_at": p.time if is_settled else None,
"metadata": {} "metadata": {},
}) }
)
# await log_nwc(pubkey, payload) # await log_nwc(pubkey, payload)
return [({ return [({"transactions": transactions}, None, [])]
"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, "alias": settings.lnbits_site_title,
"color": "", "color": "",
"network": "mainnet", "network": "mainnet",
"block_height": 0, "block_height": 0,
"block_hash": "", "block_hash": "",
"methods": account_methods "methods": account_methods,
}, None, [])] },
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)
@ -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()
if future:
future.set_result(res) future.set_result(res)
except Exception as e: except Exception as e:
if future:
future.set_exception(e) future.set_exception(e)
except Exception as e: except Exception as e:
logger.error(str(e)) logger.error(str(e))

View file

@ -6,7 +6,9 @@
<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">
NWC Service Provider - Config
</h5>
</div> </div>
</div> </div>
<template> <template>
@ -14,9 +16,7 @@
<tbody> <tbody>
<q-tr v-for="entry in entries" :key="entry.key"> <q-tr v-for="entry in entries" :key="entry.key">
<q-td> <q-td>
<span> <span> ${entry.key} </span>
${entry.key}
</span>
</q-td> </q-td>
<q-td> <q-td>
<q-input v-model="entry.value" /> <q-input v-model="entry.value" />
@ -25,36 +25,56 @@
</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
@click="saveConfig"
unelevated
label="Save"
color="primary"
class="q-mt-md full-width"
/>
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
</div> </div>
{% endblock %} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block scripts %} {{ window_vars(user) }}
<script> <script>
new Vue({ new Vue({
el: '#vue', el: "#vue",
mixins: [windowMixin], mixins: [windowMixin],
delimiters: ['${', '}'], delimiters: ["${", "}"],
data: function () { data: function () {
return { return {
entries: [], entries: [],
columns: [ 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 }, 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: { methods: {
fetchConfig() { fetchConfig() {
this.entries = []; this.entries = [];
LNbits.api LNbits.api
.request('GET', '/nwcprovider/api/v1/config', this.g.user.wallets[0].adminkey) .request(
"GET",
"/nwcprovider/api/v1/config",
this.g.user.wallets[0].adminkey,
)
.then((response) => { .then((response) => {
const newEntries = []; const newEntries = [];
for (const [key, value] of Object.entries(response.data)) { for (const [key, value] of Object.entries(response.data)) {
@ -65,7 +85,6 @@
} }
this.entries = newEntries; this.entries = newEntries;
}) })
.catch(function (error) { .catch(function (error) {
console.error("Error fetching config:", error); console.error("Error fetching config:", error);
@ -77,25 +96,28 @@
data[entry.key] = entry.value; data[entry.key] = entry.value;
} }
try { try {
const response = await LNbits.api const response = await LNbits.api.request(
.request('POST', '/nwcprovider/api/v1/config', this.g.user.wallets[0].adminkey, data) "POST",
"/nwcprovider/api/v1/config",
this.g.user.wallets[0].adminkey,
data,
);
this.$q.notify({ this.$q.notify({
message: 'Config saved, please restart the server', message: "Config saved, please restart the server",
color: 'positive', color: "positive",
position: 'top', position: "top",
timeout: 2000, timeout: 2000,
actions: [{ icon: 'close', color: 'white' }] actions: [{ icon: "close", color: "white" }],
}) });
} catch (error) { } catch (error) {
console.error("Error saving config:", error); console.error("Error saving config:", error);
} }
},
}
}, },
created: function () { created: function () {
this.fetchConfig() 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",
message: "Are you sure you want to delete this connection?",
cancel: true, cancel: true,
persistent: true persistent: true,
}).onOk(async () => { })
.onOk(async () => {
try { try {
const wallet = this.getWallet(); const wallet = this.getWallet();
const response = await LNbits.api.request('DELETE', `/nwcprovider/api/v1/nwc/${pubkey}`, wallet.adminkey); const response = await LNbits.api.request(
"DELETE",
`/nwcprovider/api/v1/nwc/${pubkey}`,
wallet.adminkey,
);
this.loadNwcs(); this.loadNwcs();
this.$q.notify({ type: 'positive', message: 'Deleted successfully' }); this.$q.notify({
type: "positive",
message: "Deleted successfully",
});
} catch (error) { } catch (error) {
LNbits.utils.notifyApiError(error); LNbits.utils.notifyApiError(error);
} }
}).onCancel(() => { })
.onCancel(() => {
// User canceled the operation // User canceled the operation
}); });
}, },
loadNwcs: async function () { loadNwcs: async function () {
const wallet = this.getWallet(); const wallet = this.getWallet();
if (!wallet) { if (!wallet) {
this.nwcs = []; this.nwcs = [];
return; return;
} }
try { try {
const response = await LNbits.api 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 =
nwc.data.expires_at > 0
? Quasar.utils.date.formatDate(
new Date(nwc.data.expires_at * 1000), new Date(nwc.data.expires_at * 1000),
'YYYY-MM-DD HH:mm' "YYYY-MM-DD HH:mm",
) : "Never"; )
: "Never";
const l = Quasar.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 %}

View file

@ -1,22 +1,21 @@
import pytest
import asyncio import asyncio
from loguru import logger import base64
import httpx
import asyncio
import bolt11
import secp256k1
import time
from typing import List, Dict
import websockets
import random
import json
from typing import List, Dict, Optional
from Cryptodome.Util.Padding import pad, unpad
import hashlib import hashlib
import json
import random
import time
from typing import Dict, List, Optional, Union
import bolt11
import httpx
import pytest
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 from Cryptodome.Util.Padding import pad, unpad
from typing import Union, Dict from loguru import logger
wallets = { wallets = {
"wallet1": { "wallet1": {
"name": "wallet1", "name": "wallet1",
@ -41,44 +40,54 @@ wallets = {
"id": "2faa91184177414ab14712cadafbc78f", "id": "2faa91184177414ab14712cadafbc78f",
"admin_key": "0ffd65580a664e0aae85687f99dac7ad", "admin_key": "0ffd65580a664e0aae85687f99dac7ad",
"balance_msats": 1000000, "balance_msats": 1000000,
},
} }
}
async def check_services(): async def check_services():
# wait for http server in localhost:7777 # wait for http server in localhost:7777
while True: while True:
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get('http://localhost:7777') resp = await client.get("http://localhost:7777")
assert resp.status_code == 200 assert resp.status_code == 200
break break
except: except Exception:
logger.info("Waiting for nostr relay @ http://localhost:7777") logger.info("Waiting for nostr relay @ http://localhost:7777")
logger.info("Please start the required services by running `bash start.sh` if you haven't already") logger.info(
"""Please start the required services by running\
`bash start.sh` if you haven't already"""
)
await asyncio.sleep(1) await asyncio.sleep(1)
# wait lnbits @ localhost:5000 # wait lnbits @ localhost:5000
while True: while True:
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get('http://localhost:5002') resp = await client.get("http://localhost:5002")
assert resp.status_code == 200 assert resp.status_code == 200
break break
except: except Exception:
logger.info("Waiting for lnbits @ http://localhost:5002") logger.info("Waiting for lnbits @ http://localhost:5002")
logger.info("Please start the required services by running `bash start.sh` if you haven't already") logger.info(
"""Please start the required services by running\
`bash start.sh` if you haven't already"""
)
await asyncio.sleep(1) await asyncio.sleep(1)
async def get_wallet_balance(w: str): async def get_wallet_balance(w: str):
api_key = wallets[w]["admin_key"] api_key = wallets[w]["admin_key"]
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get(f'http://localhost:5002/api/v1/wallet?api-key={api_key}') resp = await client.get(
f"http://localhost:5002/api/v1/wallet?api-key={api_key}"
)
assert resp.status_code == 200 assert resp.status_code == 200
v = resp.json() v = resp.json()
balance = v["balance"] balance = v["balance"]
return balance return balance
async def refresh_wallet_balances(): async def refresh_wallet_balances():
for w in wallets: for w in wallets:
wallets[w]["balance_msats"] = await get_wallet_balance(w) wallets[w]["balance_msats"] = await get_wallet_balance(w)
@ -89,43 +98,55 @@ def gen_keypair():
private_key_hex = bytes.hex(secp256k1._gen_private_key()) private_key_hex = bytes.hex(secp256k1._gen_private_key())
private_key = secp256k1.PrivateKey(bytes.fromhex(private_key_hex)) private_key = secp256k1.PrivateKey(bytes.fromhex(private_key_hex))
public_key = private_key.pubkey public_key = private_key.pubkey
if not public_key:
raise Exception("Error generating pubkey")
public_key_hex = public_key.serialize().hex()[2:] public_key_hex = public_key.serialize().hex()[2:]
return { return {"priv": private_key_hex, "pub": public_key_hex}
"priv": private_key_hex,
"pub": public_key_hex
}
async def create_nwc(w:str, desc:str, permissions:List[str], budgets:List[Dict[str, int]], expiration: 0):
async def create_nwc(
w: str,
desc: str,
permissions: List[str],
budgets: List[Dict[str, int]],
expiration: int = 0,
):
keypair = gen_keypair() keypair = gen_keypair()
api_key = wallets[w]["admin_key"] api_key = wallets[w]["admin_key"]
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.put(f'http://localhost:5002/nwcprovider/api/v1/nwc/{keypair["pub"]}?api-key={api_key}', json={ resp = await client.put(
f'http://localhost:5002/nwcprovider/api/v1/nwc/{keypair["pub"]}?api-key={api_key}',
json={
"permissions": permissions, "permissions": permissions,
"description": desc, "description": desc,
"expires_at": time.time() + expiration if expiration > 0 else 0, "expires_at": time.time() + expiration if expiration > 0 else 0,
"budgets": budgets "budgets": budgets,
}) },
)
assert resp.status_code == 201 assert resp.status_code == 201
nwc = resp.json() nwc = resp.json()
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get(f'http://localhost:5002/nwcprovider/api/v1/pairing/{keypair["priv"]}') resp = await client.get(
f'http://localhost:5002/nwcprovider/api/v1/pairing/{keypair["priv"]}'
)
assert resp.status_code == 200 assert resp.status_code == 200
pairing = resp.json() pairing = resp.json()
return { return {
"pubkey": keypair["pub"], "pubkey": keypair["pub"],
"privkey": keypair["priv"], "privkey": keypair["priv"],
"pairing": pairing, "pairing": pairing,
"nwc": nwc "nwc": nwc,
} }
async def delete_nwc(w: str, pubkey: str): async def delete_nwc(w: str, pubkey: str):
api_key = wallets[w]["admin_key"] api_key = wallets[w]["admin_key"]
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.delete(f'http://localhost:5002/nwcprovider/api/v1/nwc/{pubkey}?api-key={api_key}') resp = await client.delete(
f"http://localhost:5002/nwcprovider/api/v1/nwc/{pubkey}?api-key={api_key}"
)
assert resp.status_code == 200 assert resp.status_code == 200
return resp.json() return resp.json()
@ -146,12 +167,18 @@ class NWCWallet :
self.private_key = secp256k1.PrivateKey(bytes.fromhex(self.secret)) self.private_key = secp256k1.PrivateKey(bytes.fromhex(self.secret))
self.private_key_hex = self.secret self.private_key_hex = self.secret
self.public_key = self.private_key.pubkey self.public_key = self.private_key.pubkey
if not self.public_key:
raise Exception("Error generating pubkey")
self.public_key_hex = self.public_key.serialize().hex()[2:] self.public_key_hex = self.public_key.serialize().hex()[2:]
self.task = None
async def close(self): async def close(self):
self.shutdown = True self.shutdown = True
if not self.ws:
raise Exception("Websocket not connected")
await self.ws.close() await self.ws.close()
if self.task:
self.task.cancel()
self.connected = False self.connected = False
async def _wait_for_connection(self): async def _wait_for_connection(self):
@ -159,22 +186,20 @@ class NWCWallet :
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
async def start(self): async def start(self):
asyncio.create_task(self._run()) self.task = asyncio.create_task(self._run())
await self._wait_for_connection() await self._wait_for_connection()
def _is_shutting_down(self): def _is_shutting_down(self):
return self.shutdown return self.shutdown
def _get_new_subid(self) -> str: def _get_new_subid(self) -> str:
subid = "lnbitsnwcstest" + str(self.subscriptions_count) subid = "lnbitsnwcstest" + 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
@ -188,15 +213,19 @@ class NWCWallet :
res_filter = { res_filter = {
"kinds": [23195], "kinds": [23195],
"authors": [self.provider_pub_hex], "authors": [self.provider_pub_hex],
"since": int(time.time()) "since": int(time.time()),
} }
await self.ws.send(self._json_dumps(["REQ", self.sub_id, res_filter])) await self.ws.send(
self._json_dumps(["REQ", self.sub_id, res_filter])
)
while not self._is_shutting_down() and not ws.closed: while not self._is_shutting_down() and not ws.closed:
try: try:
reply = await ws.recv() reply = await ws.recv()
if isinstance(reply, bytes):
reply = reply.decode("utf-8")
try: try:
await self._on_message(ws, reply) await self._on_message(ws, reply)
except Exception as e: except Exception:
pass pass
except Exception as e: except Exception as e:
logger.debug("Error receiving message: " + str(e)) logger.debug("Error receiving message: " + str(e))
@ -210,33 +239,29 @@ class NWCWallet :
else: else:
break break
def _encrypt_content(self, content: str, pubkey_hex: str, iv_seed: Optional[int] = None) -> str: def _encrypt_content(
pubkey = secp256k1.PublicKey( self, content: str, pubkey_hex: str, iv_seed: Optional[int] = None
bytes.fromhex("02" + pubkey_hex), True) ) -> str:
shared = pubkey.tweak_mul(bytes.fromhex( pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
self.private_key_hex)).serialize()[1:] shared = pubkey.tweak_mul(bytes.fromhex(self.private_key_hex)).serialize()[1:]
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)
content_bytes = content.encode("utf-8") content_bytes = content.encode("utf-8")
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:
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:]
(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"))
aes = AES.new(shared, AES.MODE_CBC, iv) aes = AES.new(shared, AES.MODE_CBC, iv)
decrypted_bytes = aes.decrypt(encrypted_content) decrypted_bytes = aes.decrypt(encrypted_content)
@ -248,45 +273,50 @@ class NWCWallet :
logger.debug("Received message: " + message) logger.debug("Received message: " + message)
msg = json.loads(message) msg = json.loads(message)
if msg[0] == "EVENT": # Event message if msg[0] == "EVENT": # Event message
sub_id = msg[1]
event = msg[2] event = msg[2]
nwc_pubkey = event["pubkey"] nwc_pubkey = event["pubkey"]
content = self._decrypt_content(event["content"], nwc_pubkey) content = self._decrypt_content(event["content"], nwc_pubkey)
content = json.loads(content) content = json.loads(content)
self.event_queue.append({ self.event_queue.append(
{
"created_at": event["created_at"], "created_at": event["created_at"],
"content": content, "content": content,
"result": content["result"] if "result" in content else None, "result": content["result"] if "result" in content else None,
"error": content["error"] if "error" in content else None, "error": content["error"] if "error" in content else None,
"method": content["result_type"], "method": content["result_type"],
"tags": event["tags"] "tags": event["tags"],
}) }
)
def _json_dumps(self, data: Union[Dict, list]) -> str: def _json_dumps(self, data: Union[Dict, list]) -> str:
if isinstance(data, Dict): if isinstance(data, Dict):
data = {k: v for k, v in data.items() if v is not None} data = {k: v for k, v in data.items() if v is not None}
return json.dumps(data, separators=(',', ':'), ensure_ascii=False) return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
def _sign_event(self, event: Dict) -> Dict: def _sign_event(self, event: Dict) -> Dict:
signature_data = self._json_dumps([ signature_data = self._json_dumps(
[
0, 0,
self.public_key_hex, self.public_key_hex,
event["created_at"], event["created_at"],
event["kind"], event["kind"],
event["tags"], event["tags"],
event["content"] 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 send_event(self, method, params):
async def sendEvent(self, method,params): if not self.ws:
raise Exception("Websocket not connected")
await self._wait_for_connection() await self._wait_for_connection()
event = { event = {
"created_at": int(time.time()), "created_at": int(time.time()),
@ -294,19 +324,19 @@ class NWCWallet :
"tags": [ "tags": [
["p", self.provider_pub_hex], ["p", self.provider_pub_hex],
], ],
"content": json.dumps({ "content": json.dumps({"method": method, "params": params}),
"method": method,
"params": params
})
} }
logger.debug("Sending event: " + str(event)) logger.debug("Sending event: " + str(event))
event["content"] = self._encrypt_content(event["content"], self.provider_pub_hex) event["content"] = self._encrypt_content(
event["content"], self.provider_pub_hex
)
self._sign_event(event) self._sign_event(event)
logger.debug("Sending event (encrypted): " + str(event)) logger.debug("Sending event (encrypted): " + str(event))
await self.ws.send(self._json_dumps(["EVENT", event])) await self.ws.send(self._json_dumps(["EVENT", event]))
async def waitFor(self, result_type, callback=None, on_error_callback=None, timeout=10): async def wait_for(
self, result_type, callback=None, on_error_callback=None, timeout=10
):
now = time.time() now = time.time()
while True: while True:
for i in range(len(self.event_queue)): for i in range(len(self.event_queue)):
@ -328,7 +358,6 @@ class NWCWallet :
raise Exception("Timeout") raise Exception("Timeout")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create(): async def test_create():
await check_services() await check_services()
@ -350,13 +379,17 @@ async def test_make_invoice():
nwc = await create_nwc("wallet1", "test_make_invoice", ["invoice"], [], 0) nwc = await create_nwc("wallet1", "test_make_invoice", ["invoice"], [], 0)
wallet1 = NWCWallet(nwc["pairing"]) wallet1 = NWCWallet(nwc["pairing"])
await wallet1.start() await wallet1.start()
await wallet1.sendEvent("make_invoice", {"amount": 1, "description": "test 123", "expiry": 1000}) await wallet1.send_event(
result, tags, error = await wallet1.waitFor("make_invoice") "make_invoice", {"amount": 1, "description": "test 123", "expiry": 1000}
)
result, tags, error = await wallet1.wait_for("make_invoice")
logger.info(error) logger.info(error)
assert error, "Expected internal error, because amount is too low" assert error, "Expected internal error, because amount is too low"
await wallet1.sendEvent("make_invoice", {"amount": 123000, "description":"test 123", "expiry": 1000}) await wallet1.send_event(
result, tags, error = await wallet1.waitFor("make_invoice") "make_invoice", {"amount": 123000, "description": "test 123", "expiry": 1000}
)
result, tags, error = await wallet1.wait_for("make_invoice")
assert not error assert not error
assert result["type"] == "incoming" assert result["type"] == "incoming"
assert result["description"] == "test 123" assert result["description"] == "test 123"
@ -384,9 +417,10 @@ async def test_lookup_invoice():
wallet1 = NWCWallet(nwc["pairing"]) wallet1 = NWCWallet(nwc["pairing"])
await wallet1.start() await wallet1.start()
await wallet1.send_event(
await wallet1.sendEvent("make_invoice", {"amount": 123000, "description": "test 123", "expiry": 1000}) "make_invoice", {"amount": 123000, "description": "test 123", "expiry": 1000}
result, tags, error = await wallet1.waitFor("make_invoice") )
result, tags, error = await wallet1.wait_for("make_invoice")
assert not error assert not error
assert result["type"] == "incoming" assert result["type"] == "incoming"
assert result["description"] == "test 123" assert result["description"] == "test 123"
@ -401,8 +435,8 @@ async def test_lookup_invoice():
wallet2 = NWCWallet(nwc2["pairing"]) wallet2 = NWCWallet(nwc2["pairing"])
await wallet2.start() await wallet2.start()
await wallet2.sendEvent("lookup_invoice", {"invoice": result["invoice"]}) await wallet2.send_event("lookup_invoice", {"invoice": result["invoice"]})
result, tags, error = await wallet2.waitFor("lookup_invoice") result, tags, error = await wallet2.wait_for("lookup_invoice")
assert not error assert not error
assert result["type"] == "incoming" assert result["type"] == "incoming"
assert result["description"] == "test 123" assert result["description"] == "test 123"
@ -423,13 +457,11 @@ async def test_get_info():
await check_services() await check_services()
nwc = await create_nwc("wallet1", "test_get_info", ["info"], [], 0) nwc = await create_nwc("wallet1", "test_get_info", ["info"], [], 0)
wallet1 = NWCWallet(nwc["pairing"]) wallet1 = NWCWallet(nwc["pairing"])
await wallet1.start() await wallet1.start()
await wallet1.send_event("get_info", {})
await wallet1.sendEvent("get_info", {}) result, tags, error = await wallet1.wait_for("get_info")
result, tags, error = await wallet1.waitFor("get_info")
assert not error assert not error
assert result["alias"] == "LNBits_NWC_SP" assert result["alias"] == "LNBits_NWC_SP"
assert result["color"] == "" assert result["color"] == ""
@ -446,48 +478,43 @@ async def test_permisions():
await check_services() await check_services()
nwc = await create_nwc("wallet1", "test_permisions1", ["info"], [], 0) nwc = await create_nwc("wallet1", "test_permisions1", ["info"], [], 0)
nwc2 = await create_nwc("wallet1", "test_permisions2", ["pay", "invoice"], [], 0) nwc2 = await create_nwc("wallet1", "test_permisions2", ["pay", "invoice"], [], 0)
nwc3 = await create_nwc("wallet1", "test_permisions3", ["info" , "pay", "invoice"], [], 0) nwc3 = await create_nwc(
"wallet1", "test_permisions3", ["info", "pay", "invoice"], [], 0
)
wallet1 = NWCWallet(nwc["pairing"]) wallet1 = NWCWallet(nwc["pairing"])
wallet2 = NWCWallet(nwc2["pairing"]) wallet2 = NWCWallet(nwc2["pairing"])
wallet3 = NWCWallet(nwc3["pairing"]) wallet3 = NWCWallet(nwc3["pairing"])
await wallet1.start() await wallet1.start()
await wallet1.send_event("get_info", {})
await wallet1.sendEvent("get_info", {}) result, tags, error = await wallet1.wait_for("get_info")
result, tags, error = await wallet1.waitFor("get_info")
assert not error assert not error
await wallet1.sendEvent("make_invoice", { await wallet1.send_event(
"amount": 123000, "make_invoice", {"amount": 123000, "description": "test 123", "expiry": 1000}
"description": "test 123", )
"expiry": 1000 result, tags, error = await wallet1.wait_for("make_invoice")
})
result, tags, error = await wallet1.waitFor("make_invoice")
assert error assert error
await wallet1.close() await wallet1.close()
await wallet2.start() await wallet2.start()
await wallet2.sendEvent("get_info", {}) await wallet2.send_event("get_info", {})
result, tags, error = await wallet2.waitFor("get_info") result, tags, error = await wallet2.wait_for("get_info")
assert error assert error
await wallet2.send_event(
await wallet2.sendEvent("make_invoice", { "make_invoice", {"amount": 123000, "description": "test 123", "expiry": 1000}
"amount": 123000, )
"description": "test 123", result, tags, error = await wallet2.wait_for("make_invoice")
"expiry": 1000
})
result, tags, error = await wallet2.waitFor("make_invoice")
assert not error assert not error
await wallet2.close() await wallet2.close()
await wallet3.start() await wallet3.start()
await wallet3.sendEvent("get_info", {}) await wallet3.send_event("get_info", {})
result, tags, error = await wallet3.waitFor("get_info") result, tags, error = await wallet3.wait_for("get_info")
assert not error assert not error
assert "make_invoice" in result["methods"] assert "make_invoice" in result["methods"]
assert "pay_invoice" in result["methods"] assert "pay_invoice" in result["methods"]
@ -499,25 +526,25 @@ async def test_permisions():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pay_invoice_and_balance(): async def test_pay_invoice_and_balance():
await check_services() await check_services()
nwc = await create_nwc("wallet1", "test_pay_invoice_and_balance", ["invoice", "balance"], [], 0) nwc = await create_nwc(
nwc2 = await create_nwc("wallet2", "test_pay_invoice_and_balance", ["pay", "balance"], [], 0) "wallet1", "test_pay_invoice_and_balance", ["invoice", "balance"], [], 0
)
nwc2 = await create_nwc(
"wallet2", "test_pay_invoice_and_balance", ["pay", "balance"], [], 0
)
wallet1 = NWCWallet(nwc["pairing"]) wallet1 = NWCWallet(nwc["pairing"])
await wallet1.start() await wallet1.start()
await refresh_wallet_balances() await refresh_wallet_balances()
wallet1_balance = wallets["wallet1"]["balance_msats"] wallet1_balance = wallets["wallet1"]["balance_msats"]
wallet2_balance = wallets["wallet2"]["balance_msats"] wallet2_balance = wallets["wallet2"]["balance_msats"]
await wallet1.send_event(
"make_invoice", {"amount": 123000, "description": "test 123"}
)
await wallet1.sendEvent("make_invoice", { result, tags, error = await wallet1.wait_for("make_invoice")
"amount": 123000,
"description": "test 123"
})
result, tags, error = await wallet1.waitFor("make_invoice")
assert not error assert not error
assert result["invoice"] assert result["invoice"]
@ -525,10 +552,8 @@ async def test_pay_invoice_and_balance():
wallet2 = NWCWallet(nwc2["pairing"]) wallet2 = NWCWallet(nwc2["pairing"])
await wallet2.start() await wallet2.start()
await wallet2.sendEvent("pay_invoice", { await wallet2.send_event("pay_invoice", {"invoice": invoice})
"invoice": invoice result, tags, error = await wallet2.wait_for("pay_invoice")
})
result, tags, error = await wallet2.waitFor("pay_invoice")
assert not error assert not error
assert result["preimage"] assert result["preimage"]
@ -539,13 +564,13 @@ async def test_pay_invoice_and_balance():
assert wallet1_balance_new == wallet1_balance + 123000 assert wallet1_balance_new == wallet1_balance + 123000
assert wallet2_balance_new == wallet2_balance - 123000 assert wallet2_balance_new == wallet2_balance - 123000
await wallet1.sendEvent("get_balance", {}) await wallet1.send_event("get_balance", {})
result, tags, error = await wallet1.waitFor("get_balance") result, tags, error = await wallet1.wait_for("get_balance")
assert not error assert not error
assert result["balance"] == wallet1_balance_new assert result["balance"] == wallet1_balance_new
await wallet2.sendEvent("get_balance", {}) await wallet2.send_event("get_balance", {})
result, tags, error = await wallet2.waitFor("get_balance") result, tags, error = await wallet2.wait_for("get_balance")
assert not error assert not error
assert result["balance"] == wallet2_balance_new assert result["balance"] == wallet2_balance_new
@ -555,9 +580,15 @@ async def test_pay_invoice_and_balance():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_multi_pay_invoices(): async def test_multi_pay_invoices():
nwc1 = await create_nwc("wallet1", "test_multi_pay_invoices", ["invoice", "pay", "balance"], [], 0) nwc1 = await create_nwc(
nwc2 = await create_nwc("wallet2", "test_multi_pay_invoices", ["invoice", "pay", "balance"], [], 0) "wallet1", "test_multi_pay_invoices", ["invoice", "pay", "balance"], [], 0
nwc3 = await create_nwc("wallet3", "test_multi_pay_invoices", ["invoice", "pay", "balance"], [], 0) )
nwc2 = await create_nwc(
"wallet2", "test_multi_pay_invoices", ["invoice", "pay", "balance"], [], 0
)
nwc3 = await create_nwc(
"wallet3", "test_multi_pay_invoices", ["invoice", "pay", "balance"], [], 0
)
wallet1 = NWCWallet(nwc1["pairing"]) wallet1 = NWCWallet(nwc1["pairing"])
wallet2 = NWCWallet(nwc2["pairing"]) wallet2 = NWCWallet(nwc2["pairing"])
@ -572,44 +603,44 @@ async def test_multi_pay_invoices():
wallet2_balance = wallets["wallet2"]["balance_msats"] wallet2_balance = wallets["wallet2"]["balance_msats"]
wallet3_balance = wallets["wallet3"]["balance_msats"] wallet3_balance = wallets["wallet3"]["balance_msats"]
await wallet1.sendEvent("make_invoice", { await wallet1.send_event(
"amount": 123000, "make_invoice", {"amount": 123000, "description": "test 123"}
"description": "test 123" )
})
result, tags, error = await wallet1.waitFor("make_invoice") result, tags, error = await wallet1.wait_for("make_invoice")
assert not error assert not error
assert result["invoice"] assert result["invoice"]
invoice1 = result["invoice"] invoice1 = result["invoice"]
await wallet1.sendEvent("make_invoice", { await wallet1.send_event(
"amount": 123000, "make_invoice", {"amount": 123000, "description": "test 123"}
"description": "test 123" )
}) result, tags, error = await wallet1.wait_for("make_invoice")
result, tags, error = await wallet1.waitFor("make_invoice")
assert not error assert not error
assert result["invoice"] assert result["invoice"]
invoice2 = result["invoice"] invoice2 = result["invoice"]
await wallet2.sendEvent("make_invoice", { await wallet2.send_event(
"amount": 123000, "make_invoice", {"amount": 123000, "description": "test 123"}
"description": "test 123" )
}) result, tags, error = await wallet2.wait_for("make_invoice")
result, tags, error = await wallet2.waitFor("make_invoice")
assert not error assert not error
assert result["invoice"] assert result["invoice"]
invoice3 = result["invoice"] invoice3 = result["invoice"]
await wallet3.sendEvent("multi_pay_invoice", { await wallet3.send_event(
"multi_pay_invoice",
{
"invoices": [ "invoices": [
{"id": "invoice1", "invoice": invoice1, "amount": 123000}, {"id": "invoice1", "invoice": invoice1, "amount": 123000},
{"id": "invoice2", "invoice": invoice2, "amount": 123000}, {"id": "invoice2", "invoice": invoice2, "amount": 123000},
{ "invoice": invoice3} {"invoice": invoice3},
] ]
}) },
result, tags, error = await wallet3.waitFor("multi_pay_invoice") )
result, tags, error = await wallet3.wait_for("multi_pay_invoice")
assert not error assert not error
d_tag = [t[1] for t in tags if t[0] == "d"][0] d_tag = next((t[1] for t in tags if t[0] == "d"), None)
if d_tag == "invoice1": if d_tag == "invoice1":
assert result["preimage"] assert result["preimage"]
elif d_tag == "invoice2": elif d_tag == "invoice2":
@ -617,8 +648,7 @@ async def test_multi_pay_invoices():
elif d_tag == invoice3: elif d_tag == invoice3:
assert result["preimage"] assert result["preimage"]
else: else:
assert False raise AssertionError("Unexpected d tag")
await refresh_wallet_balances() await refresh_wallet_balances()
wallet1_balance_new = wallets["wallet1"]["balance_msats"] wallet1_balance_new = wallets["wallet1"]["balance_msats"]
@ -629,20 +659,18 @@ async def test_multi_pay_invoices():
assert wallet2_balance_new == wallet2_balance + 123000 assert wallet2_balance_new == wallet2_balance + 123000
assert wallet3_balance_new == wallet3_balance - 123000 - 123000 - 123000 assert wallet3_balance_new == wallet3_balance - 123000 - 123000 - 123000
await wallet1.send_event("get_balance", {})
result, tags, error = await wallet1.wait_for("get_balance")
await wallet1.sendEvent("get_balance", {})
result, tags, error = await wallet1.waitFor("get_balance")
assert not error assert not error
assert result["balance"] == wallet1_balance_new assert result["balance"] == wallet1_balance_new
await wallet2.sendEvent("get_balance", {}) await wallet2.send_event("get_balance", {})
result, tags, error = await wallet2.waitFor("get_balance") result, tags, error = await wallet2.wait_for("get_balance")
assert not error assert not error
assert result["balance"] == wallet2_balance_new assert result["balance"] == wallet2_balance_new
await wallet3.sendEvent("get_balance", {}) await wallet3.send_event("get_balance", {})
result, tags, error = await wallet3.waitFor("get_balance") result, tags, error = await wallet3.wait_for("get_balance")
assert not error assert not error
assert result["balance"] == wallet3_balance_new assert result["balance"] == wallet3_balance_new
@ -651,14 +679,14 @@ async def test_multi_pay_invoices():
await wallet3.close() await wallet3.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_insufficient_balance(): async def test_insufficient_balance():
nwc1 = await create_nwc("wallet1", "test_insufficient_balance", ["invoice", "pay", "balance"], [], 0) nwc1 = await create_nwc(
nwc2 = await create_nwc("wallet2", "test_insufficient_balance", ["invoice", "pay", "balance"], [], 0) "wallet1", "test_insufficient_balance", ["invoice", "pay", "balance"], [], 0
)
nwc2 = await create_nwc(
"wallet2", "test_insufficient_balance", ["invoice", "pay", "balance"], [], 0
)
await refresh_wallet_balances() await refresh_wallet_balances()
wallet1_balance = wallets["wallet1"]["balance_msats"] wallet1_balance = wallets["wallet1"]["balance_msats"]
amount_to_spend = wallet1_balance + 1000 amount_to_spend = wallet1_balance + 1000
@ -667,19 +695,16 @@ async def test_insufficient_balance():
await wallet1.start() await wallet1.start()
await wallet2.start() await wallet2.start()
await wallet2.sendEvent("make_invoice", { await wallet2.send_event(
"amount": amount_to_spend, "make_invoice", {"amount": amount_to_spend, "description": "test 123"}
"description": "test 123" )
}) result, tags, error = await wallet2.wait_for("make_invoice")
result, tags, error = await wallet2.waitFor("make_invoice")
assert not error assert not error
assert result["invoice"] assert result["invoice"]
invoice = result["invoice"] invoice = result["invoice"]
await wallet1.sendEvent("pay_invoice", { await wallet1.send_event("pay_invoice", {"invoice": invoice})
"invoice": invoice result, tags, error = await wallet1.wait_for("pay_invoice")
})
result, tags, error = await wallet1.waitFor("pay_invoice")
logger.info(error) logger.info(error)
logger.info(result) logger.info(result)
logger.info(amount_to_spend) logger.info(amount_to_spend)
@ -694,81 +719,82 @@ async def test_insufficient_balance():
await wallet2.close() await wallet2.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_expiry(): async def test_expiry():
nwc = await create_nwc("wallet3", "test_expiry", ["invoice", "pay", "balance"], [], 1) nwc = await create_nwc(
"wallet3", "test_expiry", ["invoice", "pay", "balance"], [], 1
)
await asyncio.sleep(2) await asyncio.sleep(2)
wallet3 = NWCWallet(nwc["pairing"]) wallet3 = NWCWallet(nwc["pairing"])
await wallet3.start() await wallet3.start()
await wallet3.sendEvent("make_invoice", { await wallet3.send_event(
"amount": 123000, "make_invoice", {"amount": 123000, "description": "test 123"}
"description": "test 123" )
}) result, tags, error = await wallet3.wait_for("make_invoice")
result, tags, error = await wallet3.waitFor("make_invoice")
assert error assert error
assert error["code"] == "UNAUTHORIZED" , "Expected UNAUTHORIZED error, because the NWC expired" assert (
error["code"] == "UNAUTHORIZED"
), "Expected UNAUTHORIZED error, because the NWC expired"
await wallet3.close() await wallet3.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_budget(): async def test_budget():
nwc1 = await create_nwc("wallet1", "test_expiry", ["invoice", "pay", "balance"], [], 0) nwc1 = await create_nwc(
nwc3 = await create_nwc("wallet3", "test_expiry", ["invoice", "pay", "balance"], [ "wallet1", "test_expiry", ["invoice", "pay", "balance"], [], 0
)
nwc3 = await create_nwc(
"wallet3",
"test_expiry",
["invoice", "pay", "balance"],
[
{ {
"budget_msats": 100000, "budget_msats": 100000,
"refresh_window": 3600, "refresh_window": 3600,
"created_at": time.time() "created_at": int(time.time()),
} }
], 0) ],
0,
)
wallet1 = NWCWallet(nwc1["pairing"]) wallet1 = NWCWallet(nwc1["pairing"])
wallet3 = NWCWallet(nwc3["pairing"]) wallet3 = NWCWallet(nwc3["pairing"])
await wallet3.start() await wallet3.start()
await wallet1.start() await wallet1.start()
await wallet1.sendEvent("make_invoice", { await wallet1.send_event(
"amount": 101000, "make_invoice", {"amount": 101000, "description": "Invalid"}
"description": "Invalid" )
}) result, tags, error = await wallet1.wait_for("make_invoice")
result, tags, error = await wallet1.waitFor("make_invoice")
assert not error assert not error
await wallet3.sendEvent("pay_invoice", { await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
"invoice": result["invoice"] result, tags, error = await wallet3.wait_for("pay_invoice")
})
result, tags, error = await wallet3.waitFor("pay_invoice")
assert error assert error
assert error["code"] == "QUOTA_EXCEEDED" , "Expected QUOTA_EXCEEDED error, because the budget was exceeded" assert (
error["code"] == "QUOTA_EXCEEDED"
), "Expected QUOTA_EXCEEDED error, because the budget was exceeded"
await wallet1.sendEvent("make_invoice", { await wallet1.send_event("make_invoice", {"amount": 99000, "description": "Valid"})
"amount": 99000, result, tags, error = await wallet1.wait_for("make_invoice")
"description": "Valid"
})
result, tags, error = await wallet1.waitFor("make_invoice")
assert not error assert not error
await wallet3.sendEvent("pay_invoice", { await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
"invoice": result["invoice"] result, tags, error = await wallet3.wait_for("pay_invoice")
})
result, tags, error = await wallet3.waitFor("pay_invoice")
assert not error, "Expected successful payment, because the budget was not exceeded" assert not error, "Expected successful payment, because the budget was not exceeded"
assert result["preimage"] assert result["preimage"]
await wallet1.sendEvent("make_invoice", { await wallet1.send_event(
"amount": 100000-99000+1000, "make_invoice", {"amount": 100000 - 99000 + 1000, "description": "Invalid"}
"description": "Invalid" )
})
result, tags, error = await wallet1.waitFor("make_invoice") result, tags, error = await wallet1.wait_for("make_invoice")
assert not error assert not error
await wallet3.sendEvent("pay_invoice", { await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
"invoice": result["invoice"] result, tags, error = await wallet3.wait_for("pay_invoice")
})
result, tags, error = await wallet3.waitFor("pay_invoice")
assert error assert error
assert error["code"] == "QUOTA_EXCEEDED" , "Expected QUOTA_EXCEEDED error, because the budget was exceeded" assert (
error["code"] == "QUOTA_EXCEEDED"
), "Expected QUOTA_EXCEEDED error, because the budget was exceeded"
await wallet3.close() await wallet3.close()
await wallet1.close() await wallet1.close()
@ -776,63 +802,51 @@ async def test_budget():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_budget_refresh(): async def test_budget_refresh():
nwc1 = await create_nwc("wallet1", "test_expiry", ["invoice", "pay", "balance"], [], 0) nwc1 = await create_nwc(
nwc3 = await create_nwc("wallet3", "test_expiry", ["invoice", "pay", "balance"], [ { "wallet1", "test_expiry", ["invoice", "pay", "balance"], [], 0
"budget_msats": 100000, )
"refresh_window": 5, nwc3 = await create_nwc(
"created_at": time.time() "wallet3",
}], 0) "test_expiry",
["invoice", "pay", "balance"],
[{"budget_msats": 100000, "refresh_window": 5, "created_at": int(time.time())}],
0,
)
wallet1 = NWCWallet(nwc1["pairing"]) wallet1 = NWCWallet(nwc1["pairing"])
wallet3 = NWCWallet(nwc3["pairing"]) wallet3 = NWCWallet(nwc3["pairing"])
await wallet3.start() await wallet3.start()
await wallet1.start() await wallet1.start()
await wallet1.sendEvent("make_invoice", { await wallet1.send_event(
"amount": 100000, "make_invoice", {"amount": 100000, "description": "Invalid"}
"description": "Invalid" )
}) result, tags, error = await wallet1.wait_for("make_invoice")
result, tags, error = await wallet1.waitFor("make_invoice")
assert not error assert not error
await wallet1.sendEvent("make_invoice", { await wallet1.send_event(
"amount": 100000, "make_invoice", {"amount": 100000, "description": "Invalid"}
"description": "Invalid" )
}) result2, tags, error = await wallet1.wait_for("make_invoice")
result2, tags, error = await wallet1.waitFor("make_invoice")
assert not error assert not error
await wallet3.sendEvent("pay_invoice", { await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
"invoice": result["invoice"] result, tags, error = await wallet3.wait_for("pay_invoice")
})
result, tags, error = await wallet3.waitFor("pay_invoice")
assert not error, "Expected successful payment, because the budget was not exceeded" assert not error, "Expected successful payment, because the budget was not exceeded"
await wallet3.send_event("pay_invoice", {"invoice": result2["invoice"]})
await wallet3.sendEvent("pay_invoice", { result, tags, error = await wallet3.wait_for("pay_invoice")
"invoice": result2["invoice"]
})
result, tags, error = await wallet3.waitFor("pay_invoice")
assert error assert error
assert error["code"] == "QUOTA_EXCEEDED", "Expected QUOTA_EXCEEDED error, because the budget was exceeded" assert (
error["code"] == "QUOTA_EXCEEDED"
), "Expected QUOTA_EXCEEDED error, because the budget was exceeded"
await asyncio.sleep(5) await asyncio.sleep(5)
await wallet1.sendEvent("make_invoice", { await wallet1.send_event("make_invoice", {"amount": 100000, "description": "Valid"})
"amount": 100000, result, tags, error = await wallet1.wait_for("make_invoice")
"description": "Valid"
})
result, tags, error = await wallet1.waitFor("make_invoice")
assert not error assert not error
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
await wallet3.sendEvent("pay_invoice", { result, tags, error = await wallet3.wait_for("pay_invoice")
"invoice": result["invoice"]
})
result, tags, error = await wallet3.waitFor("pay_invoice")
assert not error, "Expected successful payment, because the budget was refreshed" assert not error, "Expected successful payment, because the budget was refreshed"
await wallet3.close() await wallet3.close()
await wallet1.close() await wallet1.close()

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._send = _send_pass
nwc_service_provider2.addRequestListener( nwc_service_provider2.add_request_listener("pay_invoice", _handle_pay_invoice)
"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)

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,26 +1,30 @@
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),
@ -29,53 +33,56 @@ async def api_get_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:
@ -90,6 +97,8 @@ async def api_get_pairing_url(
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
@ -98,40 +107,47 @@ async def api_get_pairing_url(
# 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,
): ):
@ -140,23 +156,23 @@ async def api_get_all_config_nwc(
# 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)