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",
"image": "mcr.microsoft.com/devcontainers/python:1-3.9-bullseye",
"features": {
"ghcr.io/devcontainers-contrib/features/poetry:2": {
}
},
"mounts": [
"source=${localWorkspaceFolder}/.devcontainer/start.sh,target=/start-lnbits.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"
],
"containerEnv": {
"IS_DEV_CONTAINER": "true",
},
"postCreateCommand": "/bin/bash /setup.sh ${containerWorkspaceFolder}",
"postStartCommand": "/bin/bash /start-lnbits.sh",
"forwardPorts": [
5000
],
"customizations": {
"vscode": {
"settings": {
// "python.pythonPath": "/opt/python/bin/python3.9"
}
}
},
"runArgs": ["--network", "host"]
"name": "lnbits_nwc_provider",
"image": "mcr.microsoft.com/devcontainers/python:1-3.9-bullseye",
"features": {
"ghcr.io/devcontainers-contrib/features/poetry:2": {}
},
"mounts": [
"source=${localWorkspaceFolder}/.devcontainer/start.sh,target=/start-lnbits.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"
],
"containerEnv": {
"IS_DEV_CONTAINER": "true"
},
"postCreateCommand": "/bin/bash /setup.sh ${containerWorkspaceFolder}",
"postStartCommand": "/bin/bash /start-lnbits.sh",
"forwardPorts": [5000],
"customizations": {
"vscode": {
"settings": {
"python.analysis.extraPaths": ["/home/vscode/lnbits"],
"python.pythonPath": "/home/vscode/python"
// "python.pythonPath": "/opt/python/bin/python3.9"
}
}
},
"runArgs": ["--network", "host"]
}

View file

@ -1,4 +1,5 @@
#!/bin/bash
echo $PYTHONPATH
CONTAINER_WORKSPACE_FOLDER=$1
cd $CONTAINER_WORKSPACE_FOLDER
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
sudo bash /tmp/nodesource_setup.sh
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
cd lnbits
echo $PWD
git checkout 0.12.8
poetry env use python3.9
# export VENV_PATH=$(poetry env info -p)
# sudo ln -s $VENV_PATH /opt/python
POETRY_PYTHON_PATH=$(poetry env info -p)/bin/python
ln -sf $POETRY_PYTHON_PATH /home/vscode/python
make bundle
poetry install --no-interaction
mkdir -p lnbits/extensions/
@ -23,4 +28,7 @@ if [ ! -d lnbits/extensions/nwcprovider ] ; then
ln -s $CONTAINER_WORKSPACE_FOLDER lnbits/extensions/nwcprovider
fi
cd $CONTAINER_WORKSPACE_FOLDER
poetry install --no-interaction
npm i prettier
npm i pyright

View file

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

View file

@ -4,7 +4,6 @@ on:
- "v[0-9]+.[0-9]+.[0-9]+"
jobs:
release:
runs-on: ubuntu-latest
steps:
@ -23,7 +22,7 @@ jobs:
with:
token: ${{ secrets.EXT_GITHUB }}
repository: lnbits/lnbits-extensions
path: './lnbits-extensions'
path: "./lnbits-extensions"
- name: setup git user
run: |

View file

@ -4,7 +4,6 @@ on:
- "v[0-9]+.[0-9]+.[0-9]+"
jobs:
release:
runs-on: ubuntu-latest
steps:
@ -23,7 +22,7 @@ jobs:
with:
token: ${{ secrets.EXT_GITHUB }}
repository: lnbits/lnbits-extensions
path: './lnbits-extensions'
path: "./lnbits-extensions"
- name: setup git user
run: |

View file

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

4
.gitignore vendored
View file

@ -2,3 +2,7 @@ __pycache__
lnbits
tests/integration/strfry-data
tests/integration/lnbits_itest_data
node_modules
.pytest_cache
.ruff_cache
.mypy_cache

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

@ -0,0 +1 @@
{}

47
Makefile Normal file
View file

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

View file

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

View file

@ -1,18 +1,22 @@
import asyncio
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 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] = []
nwcprovider_ext: APIRouter = APIRouter(
prefix="/nwcprovider", tags=["NWC Service Provider"]
)
nwcprovider_ext.include_router(nwcprovider_router)
nwcprovider_ext.include_router(nwcprovider_api_router)
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():
for task in scheduled_tasks:
try:
@ -53,6 +38,9 @@ def nwcprovider_start():
task = create_permanent_unique_task("ext_nwcprovider", handle_nwc)
scheduled_tasks.append(task)
task = create_permanent_unique_task(
"ext_nwcprovider_execution_queue", handle_execution_queue)
"ext_nwcprovider_execution_queue", handle_execution_queue
)
scheduled_tasks.append(task)
__all__ = ["db"]

169
crud.py
View file

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

View file

@ -1,5 +1,6 @@
import secp256k1
async def m001_initial(db):
"""
Initial tables
@ -24,19 +25,23 @@ async def m001_initial(db):
pubkey TEXT NOT NULL,
amount_msats INTEGER NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY(pubkey) REFERENCES {db.references_schema}keys(pubkey) ON DELETE CASCADE
FOREIGN KEY(pubkey)
REFERENCES {db.references_schema}keys(pubkey)
ON DELETE CASCADE
);
"""
)
await db.execute(
f"""
f"""
CREATE TABLE nwcprovider.logs (
id {db.serial_primary_key},
pubkey TEXT NOT NULL,
payload TEXT 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,
refresh_window 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):
"""
Config table
@ -69,6 +75,7 @@ async def m002_config(db):
"""
)
async def m003_default_config(db):
"""
Default config
@ -83,11 +90,10 @@ async def m003_default_config(db):
"""
INSERT INTO nwcprovider.config (key, value) VALUES ('provider_key', ?);
""",
(new_private_key,)
(new_private_key,),
)
async def m004_default_config2(db):
"""
Default config
@ -97,7 +103,7 @@ async def m004_default_config2(db):
"""
INSERT INTO nwcprovider.config (key, value) VALUES ('relay_alias', ?);
""",
('',)
("",),
)

View file

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

View file

@ -1,76 +1,85 @@
import asyncio
import base64
import hashlib
import json
from typing import Dict
import secp256k1
from loguru import logger
from lnbits.settings import settings
import random
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.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 typing import List, Dict, Optional
from lnbits.helpers import encrypt_internal_message
from lnbits.settings import settings
from loguru import logger
class MainSubscription:
def __init__(self):
self.requests_sub_id = None
self.responses_sub_id = None
self.requests_sub_id: Optional[str] = None
self.responses_sub_id: Optional[str] = None
self.requests_eose = False
self.responses_eose = False
self.events:Dict[str, Dict] = {}
self.responses:List[str] = []
self.events: Dict[str, Dict] = {}
self.responses: List[str] = []
def getStale(self) -> List[Dict]:
def get_stale(self) -> List[Dict]:
"""
Get all the pending events that do not have a response yet.
"""
pending_events = []
for [id, event] in self.events.items():
if not id in self.responses:
for [event_id, event] in self.events.items():
if event_id not in self.responses:
pending_events.append(event)
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)
"""
if not event_id in self.responses:
if event_id not in self.responses:
self.responses.append(event_id)
class NWCServiceProvider:
def __init__(self, private_key:str=None, relay:str=None):
if not relay: # Connect to nostrclient
def __init__(self, private_key: Optional[str] = None, relay: Optional[str] = None):
if not relay: # Connect to nostrclient
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":
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
if not private_key: # Create random key
if not private_key: # Create random key
private_key = bytes.hex(secp256k1._gen_private_key())
self.private_key = secp256k1.PrivateKey(bytes.fromhex(private_key))
self.private_key_hex = private_key
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:]
# List of supported methods
self.supported_methods = []
self.supported_methods: List[str] = []
# 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
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)
self.reconnect_task = None
@ -87,26 +96,39 @@ class NWCServiceProvider:
# if True the instance is shutting down
self.shutdown = False
logger.info("NWC Service is ready. relay: "+str(self.relay)+" pubkey: " +
self.public_key_hex)
logger.info(
"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.
"""
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.
Args:
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.request_listeners[method] = l
self.request_listeners[method] = listener
async def start(self):
"""
@ -114,7 +136,6 @@ class NWCServiceProvider:
"""
self.reconnect_task = asyncio.create_task(self._connect_to_relay())
def _json_dumps(self, data: Union[Dict, list]) -> str:
"""
Converts a Python dictionary to a JSON string with compact encoding.
@ -127,8 +148,7 @@ class NWCServiceProvider:
"""
if isinstance(data, Dict):
data = {k: v for k, v in data.items() if v is not None}
return json.dumps(data, separators=(',', ':'), ensure_ascii=False)
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
def _is_shutting_down(self) -> bool:
"""
@ -136,14 +156,15 @@ class NWCServiceProvider:
"""
return self.shutdown or not settings.lnbits_running
async def _send(self, data: Dict):
async def _send(self, data: List[Any]):
"""
Sends data to the relay.
Args:
data (Dict): The data to be sent.
"""
if not self.ws:
raise Exception("Websocket connection is not established")
if self._is_shutting_down():
logger.warning("Trying to send data while shutting down")
return
@ -151,7 +172,6 @@ class NWCServiceProvider:
tx = self._json_dumps(data)
await self.ws.send(tx)
def _get_new_subid(self) -> str:
"""
Generates a unique subscription id.
@ -159,17 +179,16 @@ class NWCServiceProvider:
Returns:
str: The generated 64 characters long subscription id (eg. lnbits0abc...)
"""
subid = "lnbitsnwcs"+str(self.subscriptions_count)
subid = "lnbitsnwcs" + str(self.subscriptions_count)
self.subscriptions_count += 1
maxLength = 64
max_length = 64
chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
n = maxLength - len(subid)
n = max_length - len(subid)
if n > 0:
for i in range(n):
for _ in range(n):
subid += chars[random.randint(0, len(chars) - 1)]
return subid
async def _wait_for_connection(self):
"""
Waits until the connection is established.
@ -180,7 +199,6 @@ class NWCServiceProvider:
logger.debug("Waiting for connection...")
await asyncio.sleep(1)
async def _subscribe(self):
"""
[Re]Subscribe to receive nip 47 requests and responses from the relay
@ -191,40 +209,37 @@ class NWCServiceProvider:
"kinds": [23194],
"#p": [self.public_key_hex],
# 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()
# Create responses subscription (needed to track previosly responded requests)
res_filter = {
"kinds": [23195],
"authors": [self.public_key_hex],
"since": int(time.time()) - 3*60*60
"since": int(time.time()) - 3 * 60 * 60,
}
self.sub.responses_sub_id = self._get_new_subid()
# Subscribe
await self._send(["REQ", self.sub.requests_sub_id, req_filter])
await self._send(["REQ", self.sub.responses_sub_id, res_filter])
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
event = {
"kind": 13194,
"content": " ".join(self.supported_methods),
"created_at": int(time.time()),
"tags": [
["p", self.public_key_hex]
]
"tags": [["p", self.public_key_hex]],
}
self._sign_event(event)
await self._send(["EVENT", event])
# Resubscribe to nwc events
await self._subscribe()
async def _handle_request(self, event: Dict) -> List[Dict]:
"""
Handle a nwc request
@ -237,34 +252,30 @@ class NWCServiceProvider:
content = json.loads(content)
# Handle request
method = content["method"]
l = self.request_listeners.get(method, None)
outs = []
if not l:
outs.append({
"error": {
"code": "NOT_IMPLEMENTED",
"message": "Method "+method+" is not implemented by this service provider"
listener = self.request_listeners.get(method, None)
outs: List[Dict[str, Any]] = []
if not listener:
outs.append(
{
"error": {
"code": "NOT_IMPLEMENTED",
"message": "Method "
+ method
+ " is not implemented by this service provider",
}
}
})
)
else:
try:
results = await l(self, nwc_pubkey, content)
results = await listener(self, nwc_pubkey, content)
for result in results:
r = result[0]
e = result[1]
t = result[2] if len(result) > 2 else None
out = {}
if r: out["result"] = r
if e: out["error"] = e
if t: out["tags"] = t
out = {"result": r, "error": e, "tags": t}
outs.append(out)
except Exception as e:
outs.append({
"error":{
"code": "INTERNAL",
"message": str(e)
}
})
outs.append({"error": {"code": "INTERNAL", "message": str(e)}})
sent_events = []
for out in outs:
# Finalize output
@ -275,7 +286,7 @@ class NWCServiceProvider:
if "error" in out:
content["error"] = out["error"]
# Prepare response event
res = {
res: Dict = {
"kind": 23195,
"created_at": int(time.time()),
"tags": out.get("tags", []),
@ -286,16 +297,94 @@ class NWCServiceProvider:
# Reference user
res["tags"].append(["p", nwc_pubkey])
# Finalize response event
print(res)
res["content"] = self._encrypt_content(res["content"], nwc_pubkey)
self._sign_event(res)
# 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
await self._send(["EVENT", res])
# Track sent events
sent_events.append(res)
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):
"""
Handle incoming messages from the relay.
@ -303,113 +392,63 @@ class NWCServiceProvider:
try:
msg = json.loads(message)
if msg[0] == "EVENT": # Event message
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 = -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])
await self._on_event_message(msg)
elif msg[0] == "EOSE":
sub_id = msg[1]
# Track EOSE
if sub_id == self.sub.requests_sub_id:
self.sub.requests_eose = True
elif sub_id == self.sub.responses_sub_id:
self.sub.responses_eose = True
# When both EOSE are receives, handle all the stale requests
# Note: All the requests that were received prior to the service connection
# and do not have a response yet, are considered stale, we will process them now
if self.sub.requests_eose and self.sub.responses_eose:
stales = self.sub.getStale()
for stale in stales:
await self._handle_request(stale)
await self._on_eose_message(msg)
elif msg[0] == "CLOSED":
# 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...")
self._subscribe()
await self._on_closed_message(msg)
elif msg[0] == "NOTICE":
# A message from the relay, mostly useless, but we log it anyway
logger.info("Notice from relay "+self.relay+": "+str(msg[1]))
logger.info("Notice from relay " + self.relay + ": " + str(msg[1]))
elif msg[0] == "OK":
pass
else:
raise Exception("Unknown message type")
except Exception as e:
logger.error("Error parsing event: "+str(e))
logger.error("Error parsing event: " + str(e))
async def _connect_to_relay(self):
"""
Initiate websocket connection to the relay.
"""
await asyncio.sleep(1)
logger.debug("Connecting to NWC relay "+self.relay)
while not self._is_shutting_down(): # Reconnect until the instance is shutting down
logger.debug('Creating new connection...')
logger.debug("Connecting to NWC relay " + self.relay)
while (
not self._is_shutting_down()
): # Reconnect until the instance is shutting down
logger.debug("Creating new connection...")
try:
async with websockets.connect(self.relay) as ws:
self.ws = ws
self.connected = True
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:
reply = await ws.recv()
if isinstance(reply, bytes):
reply = reply.decode("utf-8")
await self._on_message(ws, reply)
except Exception as e:
logger.debug("Error receiving message: " + str(e))
break
logger.debug("Connection to NWC relay closed")
except Exception as e:
logger.error("Error connecting to NWC relay: "+str(e))
logger.error("Error connecting to NWC relay: " + str(e))
await asyncio.sleep(5)
# 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
if not self._is_shutting_down():
# Wait some time before reconnecting
logger.debug("Reconnecting to NWC relay in 5 seconds...")
await asyncio.sleep(5)
def _encrypt_content(self, content: str, pubkey_hex:str, iv_seed: Optional[int]=None) -> str:
def _encrypt_content(
self, content: str, pubkey_hex: str, iv_seed: Optional[int] = None
) -> str:
"""
Encrypts the content for the given public key
@ -420,16 +459,14 @@ class NWCServiceProvider:
Returns:
str: The encrypted content.
"""
pubkey = secp256k1.PublicKey(
bytes.fromhex("02" + pubkey_hex), True)
shared = pubkey.tweak_mul(bytes.fromhex(
self.private_key_hex)).serialize()[1:]
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
shared = pubkey.tweak_mul(bytes.fromhex(self.private_key_hex)).serialize()[1:]
# random iv (16B)
if not iv_seed:
iv = Random.new().read(AES.block_size)
else:
iv = hashlib.sha256(iv_seed.to_bytes(32, byteorder='big')).digest()
iv = iv[:AES.block_size]
iv = hashlib.sha256(iv_seed.to_bytes(32, byteorder="big")).digest()
iv = iv[: AES.block_size]
aes = AES.new(shared, AES.MODE_CBC, iv)
@ -438,14 +475,12 @@ class NWCServiceProvider:
# padding
content_bytes = pad(content_bytes, AES.block_size)
encrypted_b64 = base64.b64encode(
aes.encrypt(content_bytes)).decode("ascii")
ivB64 = base64.b64encode(iv).decode("ascii")
encrypted_content = encrypted_b64 + "?iv=" + ivB64
encrypted_b64 = base64.b64encode(aes.encrypt(content_bytes)).decode("ascii")
iv_b64 = base64.b64encode(iv).decode("ascii")
encrypted_content = encrypted_b64 + "?iv=" + iv_b64
return encrypted_content
def _decrypt_content(self, content: str , pubkey_hex:str) -> str:
def _decrypt_content(self, content: str, pubkey_hex: str) -> str:
"""
Decrypts the content for the given public key
@ -456,15 +491,12 @@ class NWCServiceProvider:
Returns:
str: The decrypted content.
"""
pubkey = secp256k1.PublicKey(
bytes.fromhex("02" + pubkey_hex), True)
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
shared = pubkey.tweak_mul(bytes.fromhex(
self.private_key_hex)).serialize()[1:]
shared = pubkey.tweak_mul(bytes.fromhex(self.private_key_hex)).serialize()[1:]
# extract iv and content
(encrypted_content_b64, iv_b64) = content.split("?iv=")
encrypted_content = base64.b64decode(
encrypted_content_b64.encode("ascii"))
encrypted_content = base64.b64decode(encrypted_content_b64.encode("ascii"))
iv = base64.b64decode(iv_b64.encode("ascii"))
# Decrypt
aes = AES.new(shared, AES.MODE_CBC, iv)
@ -473,7 +505,6 @@ class NWCServiceProvider:
decrypted = decrypted_bytes.decode("utf-8")
return decrypted
def _verify_event(self, event: Dict) -> bool:
"""
Verify the event signature
@ -484,24 +515,27 @@ class NWCServiceProvider:
Returns:
bool: True if the event signature is valid, False otherwise.
"""
signature_data = self._json_dumps([
0,
event["pubkey"],
event["created_at"],
event["kind"],
event["tags"],
event["content"]
])
signature_data = self._json_dumps(
[
0,
event["pubkey"],
event["created_at"],
event["kind"],
event["tags"],
event["content"],
]
)
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
if event_id != event["id"]: # Invalid event id
return False
pubkeyHex = event["pubkey"]
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkeyHex), True)
if not pubkey.schnorr_verify(bytes.fromhex(event_id), bytes.fromhex(event["sig"]), None, raw=True):
pubkey_hex = event["pubkey"]
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
if not pubkey.schnorr_verify(
bytes.fromhex(event_id), bytes.fromhex(event["sig"]), None, raw=True
):
return False
return True
def _sign_event(self, event: Dict) -> Dict:
"""
Signs the event (in place)
@ -512,25 +546,27 @@ class NWCServiceProvider:
Returns:
Dict: The input event with the signature added.
"""
signature_data = self._json_dumps([
0,
self.public_key_hex,
event["created_at"],
event["kind"],
event["tags"],
event["content"]
])
signature_data = self._json_dumps(
[
0,
self.public_key_hex,
event["created_at"],
event["kind"],
event["tags"],
event["content"],
]
)
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
event["id"] = event_id
event["pubkey"] = self.public_key_hex
signature = (self.private_key.schnorr_sign(
bytes.fromhex(event_id), None, raw=True)).hex()
signature = (
self.private_key.schnorr_sign(bytes.fromhex(event_id), None, raw=True)
).hex()
event["sig"] = signature
return event
async def cleanup(self):
logger.debug("Closing NWC Service Provider connection")
self.shutdown = True # Mark for shutdown
@ -539,10 +575,10 @@ class NWCServiceProvider:
if self.reconnect_task:
self.reconnect_task.cancel()
except Exception as e:
logger.warning("Error closing reconnection task: "+str(e))
logger.warning("Error closing reconnection task: " + str(e))
# close the websocket
try:
if self.ws:
await self.ws.close()
except Exception as e:
logger.warning("Error closing websocket connection: "+str(e))
logger.warning("Error closing websocket connection: " + str(e))

61
package-lock.json generated Normal file
View file

@ -0,0 +1,61 @@
{
"name": "nwcprovider",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nwcprovider",
"version": "1.0.0",
"dependencies": {
"prettier": "^3.3.3",
"pyright": "^1.1.379"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/prettier": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pyright": {
"version": "1.1.379",
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.379.tgz",
"integrity": "sha512-n0X+IMqot6zL5b54vfU9GattS8jM9IOh8TRFho1k/6VoyjrpzQ7TnU6PtZzwEZNJaZi5izoLIDeMnGmbin8n8Q==",
"license": "MIT",
"bin": {
"pyright": "index.js",
"pyright-langserver": "langserver.index.js"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
}
}
}

8
package.json Normal file
View file

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

View file

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

341
tasks.py
View file

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

View file

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

View file

@ -8,8 +8,14 @@
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-select v-model="selectedWallet" filled dense emit-value :options="g.user.walletOptions"
label="Wallet *"></q-select>
<q-select
v-model="selectedWallet"
filled
dense
emit-value
:options="g.user.walletOptions"
label="Wallet *"
></q-select>
</q-card-section>
</q-card>
@ -19,16 +25,28 @@
<div class="col items-center">
<h5 class="text-subtitle1">Connected Apps</h5>
</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>
<q-table dense flat :data="nwcEntries" row-key="id" :columns="nwcsTable.columns"
:pagination.sync="nwcsTable.pagination">
<q-table
dense
flat
:data="nwcEntries"
row-key="id"
:columns="nwcsTable.columns"
:pagination.sync="nwcsTable.pagination"
>
<nwcprovider v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
${ col.label }
</q-th>
</q-tr>
</nwcprovider>
@ -38,20 +56,30 @@
<div>${ col.value }</div>
</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-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-btn>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
@ -62,80 +90,101 @@
<q-card-section>
<h6 class="text-subtitle1 q-my-none">NWC Service provider</h6>
<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>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
<q-item clickable @click="go('../docs#/NWC%20Service%20Provider')"
class="q-flex items-center justify-between">
<q-item
clickable
@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-item-section>Swagger API</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item v-if="this.g.user.admin" clickable @click="go('/nwcprovider/admin')"
class="q-flex items-center justify-between">
<q-item
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-item-section>Settings</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="connectionInfoDialog.show" @hide="closeConnectionInfoDialog">
<q-dialog
v-model="connectionInfoDialog.show"
@hide="closeConnectionInfoDialog"
>
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-card-section class="q-ma-none">
<h3 class="q-ma-none q-mb-md text-center">Info</h3>
<q-list>
<q-item>
<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-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-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-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-section>Permissions</q-item-section>
<q-item-section side>
${connectionInfoDialog.data.permissions}
</q-item-section>
</q-item>
<q-item>
<q-item-section>Limits
<q-item-section
>Limits
<q-markup-table class="q-mt-md">
<thead>
<q-tr>
<q-th>
Budget (sats)
<div style="font-size: smaller;">used/max</div>
<div style="font-size: smaller">used/max</div>
</q-th>
<q-th>Refresh</q-th>
</q-tr>
</thead>
<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-tr>
<q-tr v-else v-for="budget in connectionInfoDialog.data.budgets">
<q-td class="text-center">${budget.used_budget_sats} / ${budget.budget_sats}</q-td>
<q-tr
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-tr>
</tbody>
@ -144,27 +193,44 @@
</q-item>
</q-list>
<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-section>
</q-card>
</q-dialog>
<q-dialog v-model="pairingQrDialog.show" @hide="closePairingQrDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-card-section class="q-ma-none">
<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-card-section>
<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 text-caption ">Connect only with app you trust!</p>
<p class="q-ma-none text-center">
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-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>
</q-dialog>
@ -173,33 +239,58 @@
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-card-section class="q-ma-none">
<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
pairing secret in the desired
app to
finalise the connection.</p>
<p class=" q-ma-none text-center text-caption ">Connect only with app you trust!</p>
<p class="q-ma-none text-center">
Complete the last step of the setup by pasting or scanning your
connection's pairing secret in the desired app to finalise the
connection.
</p>
<p class="q-ma-none text-center text-caption">
Connect only with app you trust!
</p>
</q-card-section>
<q-card-actions>
<q-btn label="Open in Supported App" color="primary" class="full-width" icon="launch"
@click="openPairingUrl()"></q-btn>
<q-btn
label="Open in Supported App"
color="primary"
class="full-width"
icon="launch"
@click="openPairingUrl()"
></q-btn>
</q-card-actions>
<q-card-section class="q-ma-none">
<h5 class="q-ma-none text-center">Advanced</h5>
</q-card-section>
<q-card-actions>
<q-btn label="Copy Pairing URL" flat color="grey" class="full-width" icon="content_copy"
@click="copyPairingUrl()"></q-btn>
<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-btn label="Show QR Code" flat color="grey" class="full-width" icon="qr_code_scanner"
@click="showPairingQR()"></q-btn>
<q-btn
label="Show QR Code"
flat
color="grey"
class="full-width"
icon="qr_code_scanner"
@click="showPairingQR()"
></q-btn>
</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>
</q-dialog>
@ -209,42 +300,78 @@
<h3 class="q-ma-none q-mb-xl text-center">Add connection</h3>
<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">
<q-input :disable="connectDialog.data.neverExpires" v-model="connectDialog.data.expires_at" 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>
<q-input
:disable="connectDialog.data.neverExpires"
v-model="connectDialog.data.expires_at"
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>
<q-list dense bordered padding>
<q-item>
<q-item-label header>Authorize the app to</q-item-label>
</q-item>
<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-list>
<q-list dense bordered padding>
<q-item>
<q-item-label header>Limit the spendable amount</q-item-label>
</q-item>
<q-item 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
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>
<template v-else>
<q-item v-for="(budget,index) in connectDialog.data.budgets">
<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-select filled dense emit-value :options="[
<q-select
filled
dense
emit-value
:options="[
{label: 'Daily', value: 'Daily'},
{label: 'Weekly', value: 'Weekly'},
{label: 'Monthly', value: 'Monthly'},
{label: 'Yearly', value: 'Yearly'},
{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 side>
<q-btn flat icon="delete" @click="deleteBudget(index)"></q-btn>
@ -252,16 +379,25 @@
</q-item>
</template>
<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-list>
<q-card-actions class="q-gutter-sm">
<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>
<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-form>
</q-card>
@ -269,16 +405,14 @@
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<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;
</script>
<script>
new Vue({
el: '#vue',
el: "#vue",
mixins: [windowMixin],
delimiters: ['${', '}'],
delimiters: ["${", "}"],
data: function () {
return {
selectedWallet: null,
@ -286,15 +420,35 @@
nwcEntries: [],
nwcsTable: {
columns: [
{ name: 'description', align: 'left', label: 'Description', field: 'description' },
{ name: 'status', align: 'left', label: 'Status', field: 'status' },
{ name: 'last_used', align: 'left', label: 'Last used', field: 'last_used' },
{ name: 'created_at', align: 'left', label: 'Created', field: 'created_at' },
{ name: 'expires_at', align: 'left', label: 'Expires', field: 'expires_at' }
{
name: "description",
align: "left",
label: "Description",
field: "description",
},
{ name: "status", align: "left", label: "Status", field: "status" },
{
name: "last_used",
align: "left",
label: "Last used",
field: "last_used",
},
{
name: "created_at",
align: "left",
label: "Created",
field: "created_at",
},
{
name: "expires_at",
align: "left",
label: "Expires",
field: "expires_at",
},
],
pagination: {
rowsPerPage: 10
}
rowsPerPage: 10,
},
},
connectDialog: {
show: false,
@ -303,28 +457,30 @@
pairingDialog: {
show: false,
data: {
pairingUrl: ''
}
pairingUrl: "",
},
},
pairingQrDialog: {
show: false,
data: {
pairingUrl: ''
}
pairingUrl: "",
},
},
connectionInfoDialog: {
show: false,
data: {}
data: {},
},
}
};
},
methods: {
showConnectDialog() {
const wallet = this.getWallet();
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;
} else {
this.connectDialog.show = true;
@ -339,24 +495,28 @@
},
openPairingUrl() {
const url = this.pairingDialog.data.pairingUrl;
if (url) window.open(url, '_blank');
if (url) window.open(url, "_blank");
},
go(url) {
window.open(url, '_blank');
window.open(url, "_blank");
},
async copyPairingUrl() {
const url = this.pairingDialog.data.pairingUrl;
if (url) {
try {
await navigator.clipboard.writeText(url);
this.$q.notify({ color: 'primary', message: 'URL copied to clipboard!' });
this.$q.notify({
color: "primary",
message: "URL copied to clipboard!",
});
} catch (err) {
this.$q.notify({ color: 'red', message: 'Failed to copy URL.' });
this.$q.notify({ color: "red", message: "Failed to copy URL." });
}
}
},
showPairingQR() {
this.pairingQrDialog.data.pairingUrl = this.pairingDialog.data.pairingUrl;
this.pairingQrDialog.data.pairingUrl =
this.pairingDialog.data.pairingUrl;
this.pairingQrDialog.show = true;
},
closePairingQrDialog() {
@ -364,33 +524,34 @@
},
loadConnectDialogData() {
this.connectDialog.data = {
description: '',
description: "",
expires_at: Date.now() + 1000 * 60 * 60 * 24 * 7,
neverExpires: true,
permissions: [],
budgets: []
budgets: [],
};
for (const permission of this.nodePermissions) {
this.connectDialog.data.permissions.push({
key: permission.key,
name: permission.name,
value: permission.value
value: permission.value,
});
}
},
deleteBudget(index) {
this.connectDialog.data.budgets.splice(index, 1)
this.connectDialog.data.budgets.splice(index, 1);
},
addBudget() {
this.connectDialog.data.budgets.push({
budget_sats: 1000,
used_budget_sats: 0,
created_at: new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
expiration: 'never'
})
created_at:
new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
expiration: "never",
});
},
closeConnectDialog() {
this.connectDialog.show = false
this.connectDialog.show = false;
this.loadConnectDialogData();
},
getWallet: function () {
@ -405,7 +566,7 @@
},
async generateKeyPair() {
while (!window.NobleSecp256k1) {
await new Promise(resolve => setTimeout(resolve, 1));
await new Promise((resolve) => setTimeout(resolve, 1));
}
const privKeyBytes = window.NobleSecp256k1.utils.randomPrivateKey();
const pubKeyBytes = window.NobleSecp256k1.getPublicKey(privKeyBytes);
@ -413,62 +574,67 @@
privKeyBytes: privKeyBytes,
pubKeyBytes: pubKeyBytes,
privKey: window.NobleSecp256k1.etc.bytesToHex(privKeyBytes),
pubKey: window.NobleSecp256k1.etc.bytesToHex(pubKeyBytes.slice(1))
pubKey: window.NobleSecp256k1.etc.bytesToHex(pubKeyBytes.slice(1)),
};
return out;
},
deleteNWC: async function (pubkey) {
this.$q.dialog({
title: 'Confirm Deletion',
message: 'Are you sure you want to delete this connection?',
cancel: true,
persistent: true
}).onOk(async () => {
try {
const wallet = this.getWallet();
const response = await LNbits.api.request('DELETE', `/nwcprovider/api/v1/nwc/${pubkey}`, wallet.adminkey);
this.loadNwcs();
this.$q.notify({ type: 'positive', message: 'Deleted successfully' });
} catch (error) {
LNbits.utils.notifyApiError(error);
}
}).onCancel(() => {
// User canceled the operation
});
this.$q
.dialog({
title: "Confirm Deletion",
message: "Are you sure you want to delete this connection?",
cancel: true,
persistent: true,
})
.onOk(async () => {
try {
const wallet = this.getWallet();
const response = await LNbits.api.request(
"DELETE",
`/nwcprovider/api/v1/nwc/${pubkey}`,
wallet.adminkey,
);
this.loadNwcs();
this.$q.notify({
type: "positive",
message: "Deleted successfully",
});
} catch (error) {
LNbits.utils.notifyApiError(error);
}
})
.onCancel(() => {
// User canceled the operation
});
},
loadNwcs: async function () {
const wallet = this.getWallet();
if (!wallet) {
this.nwcs = [];
return;
}
try {
const response = await LNbits.api
.request(
'GET',
'/nwcprovider/api/v1/nwc?includeExpired=true&calculateSpendBudget=true',
wallet.adminkey
)
const response = await LNbits.api.request(
"GET",
"/nwcprovider/api/v1/nwc?includeExpired=true&calculateSpendBudget=true",
wallet.adminkey,
);
this.nwcs = response.data;
} catch (error) {
this.nwcs = [];
}
try {
const response = await LNbits.api.request(
'GET',
'/nwcprovider/api/v1/permissions',
wallet.adminkey
)
"GET",
"/nwcprovider/api/v1/permissions",
wallet.adminkey,
);
const permissions = [];
for (const [key, value] of Object.entries(response.data)) {
permissions.push({
key: key,
name: value.name,
value: value.default
value: value.default,
});
}
this.nodePermissions = permissions;
@ -480,15 +646,18 @@
for (const nwc of this.nwcs) {
const t = Quasar.utils.date.formatDate(
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(
new Date(nwc.data.expires_at * 1000),
'YYYY-MM-DD HH:mm'
) : "Never";
const e =
nwc.data.expires_at > 0
? Quasar.utils.date.formatDate(
new Date(nwc.data.expires_at * 1000),
"YYYY-MM-DD HH:mm",
)
: "Never";
const l = Quasar.utils.date.formatDate(
new Date(nwc.data.last_used * 1000),
'YYYY-MM-DD HH:mm'
"YYYY-MM-DD HH:mm",
);
const nwcTableEntry = {
description: nwc.data.description,
@ -498,47 +667,49 @@
pubkey: nwc.data.pubkey,
permissions: nwc.data.permissions,
budgets: [],
status: "Active"
}
if(nwc.data.expires_at > 0 && nwc.data.expires_at < new Date().getTime() / 1000){
status: "Active",
};
if (
nwc.data.expires_at > 0 &&
nwc.data.expires_at < new Date().getTime() / 1000
) {
nwcTableEntry.status = "Expired";
}
for (const budget of nwc.budgets) {
const createdAt = Quasar.utils.date.formatDate(
new Date(budget.created_at * 1000),
'YYYY-MM-DD HH:mm'
"YYYY-MM-DD HH:mm",
);
let refreshWindow = budget.refresh_window;
if (refreshWindow <= 0) {
refreshWindow = 'Never';
refreshWindow = "Never";
} else if (refreshWindow == 60 * 60 * 24) {
refreshWindow = 'Daily';
refreshWindow = "Daily";
} else if (refreshWindow == 60 * 60 * 24 * 7) {
refreshWindow = 'Weekly';
refreshWindow = "Weekly";
} else if (refreshWindow == 60 * 60 * 24 * 30) {
refreshWindow = 'Monthly';
refreshWindow = "Monthly";
} else if (refreshWindow == 60 * 60 * 24 * 365) {
refreshWindow = 'Yearly';
refreshWindow = "Yearly";
}
nwcTableEntry.budgets.push({
budget_sats: budget.budget_msats / 1000,
used_budget_sats: budget.used_budget_msats / 1000,
created_at: createdAt,
refresh_window: refreshWindow
refresh_window: refreshWindow,
});
}
newTableEntries.push(nwcTableEntry);
}
this.nwcEntries = newTableEntries;
},
closePairingDialog() {
this.pairingDialog.show = false;
},
async showPairingDialog(secret) {
let response = await LNbits.api.request(
'GET',
'/nwcprovider/api/v1/pairing/{SECRET}'
"GET",
"/nwcprovider/api/v1/pairing/{SECRET}",
);
response = response.data;
response = response.replace("{SECRET}", secret);
@ -550,70 +721,79 @@
// timestamp
let expires_at = 0;
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 = {
permissions: [],
description: this.connectDialog.data.description,
expires_at: expires_at,
budgets: []
budgets: [],
};
for (const permission of this.connectDialog.data.permissions) {
if (permission.value) data.permissions.push(permission.key);
}
for (const budget of this.connectDialog.data.budgets) {
const budget_msats = budget.budget_sats * 1000;
let refresh_window = 0
let refresh_window = 0;
switch (budget.expiry) {
case 'Daily':
case "Daily":
refresh_window = 60 * 60 * 24;
break;
case 'Weekly':
case "Weekly":
refresh_window = 60 * 60 * 24 * 7;
break;
case 'Monthly':
case "Monthly":
refresh_window = 60 * 60 * 24 * 30;
break;
case 'Yearly':
case "Yearly":
refresh_window = 60 * 60 * 24 * 365;
break;
case 'Never':
case "Never":
refresh_window = 0;
break;
}
data.budgets.push({
budget_msats: budget_msats,
refresh_window: refresh_window,
created_at: new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
created_at:
new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
});
}
const wallet = this.getWallet();
try {
const response = await LNbits.api
.request('PUT', '/nwcprovider/api/v1/nwc/' + keyPair.pubKey, wallet.adminkey, data);
this.closeConnectDialog()
if (!response.data || !response.data.data || !response.data.data.pubkey) {
const response = await LNbits.api.request(
"PUT",
"/nwcprovider/api/v1/nwc/" + keyPair.pubKey,
wallet.adminkey,
data,
);
this.closeConnectDialog();
if (
!response.data ||
!response.data.data ||
!response.data.data.pubkey
) {
LNbits.utils.notifyApiError("Error creating nwc pairing");
return;
}
this.showPairingDialog(keyPair.privKey);
} catch (error) {
LNbits.utils.notifyApiError(error)
LNbits.utils.notifyApiError(error);
}
this.loadNwcs();
}
},
},
created: function () {
this.loadNwcs()
this.loadNwcs();
},
watch: {
selectedWallet(newValue, oldValue) {
this.loadNwcs();
},
},
})
});
</script>
{% endblock %}

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,20 +1,26 @@
from fastapi import Depends, Request
from fastapi import APIRouter, Depends, Request
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.decorators import check_admin
from . import nwcprovider_ext,nwcprovider_renderer
from lnbits.decorators import check_admin, check_user_exists
from lnbits.helpers import template_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)):
return nwcprovider_renderer().TemplateResponse(
"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)):
return nwcprovider_renderer().TemplateResponse(
"nwcprovider/admin.html", {"request": request, "user": user.dict()}

View file

@ -1,88 +1,95 @@
from http import HTTPStatus
import json
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 typing import Dict, List, Optional
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
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
nwcprovider_api_router = APIRouter()
# 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(
req: Request,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Dict:
return nwc_permissions
return nwc_permissions
## 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(
req: Request,
includeExpired: Optional[bool] = False,
calculateSpendBudget: Optional[bool] = False,
include_expired: bool = False,
calculate_spent_budget: bool = False,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
wallet_id = wallet.wallet.id
nwcs = await get_wallet_nwcs(wallet_id, includeExpired)
nwcs = await get_wallet_nwcs(wallet_id, include_expired)
out = []
for nwc in nwcs:
budgets = await get_budgets_nwc(nwc.pubkey, calculateSpendBudget)
res = NWCGetResponse(
data=nwc,
budgets=budgets
)
budgets = await get_budgets_nwc(nwc.pubkey, calculate_spent_budget)
res = NWCGetResponse(data=nwc, budgets=budgets)
out.append(res)
return out
return out
# 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(
req: Request,
pubkey: str,
includeExpired: Optional[bool] = False,
wallet: WalletTypeInfo = Depends(require_admin_key)
include_expired: Optional[bool] = False,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> NWCGetResponse:
wallet_id = wallet.wallet.id
nwc = await get_nwc(pubkey, wallet_id, includeExpired)
res = NWCGetResponse(
data=nwc,
budgets=await get_budgets_nwc(pubkey)
)
nwc = await get_nwc(pubkey, wallet_id, include_expired)
if not nwc:
raise Exception("Pubkey has no associated wallet")
res = NWCGetResponse(data=nwc, budgets=await get_budgets_nwc(pubkey))
return res
# Get pairing url for given secret
@nwcprovider_ext.get("/api/v1/pairing/{secret}", status_code=HTTPStatus.OK, response_model=str)
async def api_get_pairing_url(
req: Request,
secret: str
) -> str:
pprivkey = await get_config_nwc("provider_key")
@nwcprovider_api_router.get(
"/api/v1/pairing/{secret}", status_code=HTTPStatus.OK, response_model=str
)
async def api_get_pairing_url(req: Request, secret: str) -> str:
pprivkey: Optional[str] = await get_config_nwc("provider_key")
if not pprivkey:
raise Exception("Extension is not configured")
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:
relay = relay_alias
else:
if relay == "nostrclient":
scheme = req.url.scheme # http or https
netloc = req.url.netloc # hostname and port
if scheme=="http":
if scheme == "http":
scheme = "ws"
else:
scheme = "wss"
@ -90,48 +97,57 @@ async def api_get_pairing_url(
relay = f"{scheme}://{netloc}"
psk = secp256k1.PrivateKey(bytes.fromhex(pprivkey))
ppk = psk.pubkey
if not ppk:
raise Exception("Error generating pubkey")
ppubkey = ppk.serialize().hex()[2:]
url = "nostr+walletconnect://"
url += ppubkey
url += "?relay="+relay
url += "&secret="+secret
#lud16=?
url += "?relay=" + relay
url += "&secret=" + secret
# lud16=?
return url
## 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(
req: Request,
pubkey: str,
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
nwc = await create_nwc(pubkey, wallet_id, registration_data.description, registration_data.expires_at, registration_data.permissions, registration_data.budgets)
budgets = await get_budgets_nwc(pubkey)
res = NWCGetResponse(
data=nwc,
budgets=budgets
nwc = await create_nwc(
pubkey,
wallet_id,
registration_data.description,
registration_data.expires_at,
registration_data.permissions,
registration_data.budgets,
)
budgets = await get_budgets_nwc(pubkey)
res = NWCGetResponse(data=nwc, budgets=budgets)
return res
# 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(
req: Request,
pubkey: str,
wallet: WalletTypeInfo=Depends(require_admin_key)
req: Request, pubkey: str, wallet: WalletTypeInfo = Depends(require_admin_key)
):
wallet_id = wallet.wallet.id
await delete_nwc(pubkey, wallet_id)
return JSONResponse(content={"message": f"NWC key {pubkey} deleted successfully."})
# 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(
req: Request,
):
@ -140,23 +156,23 @@ async def api_get_all_config_nwc(
# Get config
@nwcprovider_ext.get("/api/v1/config/{key}", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)])
async def api_get_config_nwc(
req: Request,
key:str
):
@nwcprovider_api_router.get(
"/api/v1/config/{key}",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def api_get_config_nwc(req: Request, key: str):
config = await get_config_nwc(key)
out = {}
out[key] = config
return out
# Set config
@nwcprovider_ext.post("/api/v1/config", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)])
async def api_set_config_nwc(
req: Request
):
@nwcprovider_api_router.post(
"/api/v1/config", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)]
)
async def api_set_config_nwc(req: Request):
data = await req.json()
for key, value in data.items():
await set_config_nwc(key, value)