code quality fixes and cleanup
This commit is contained in:
parent
29b1b17967
commit
2bbbcd4d65
32 changed files with 4943 additions and 1368 deletions
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
3
.github/workflows/main.yml
vendored
3
.github/workflows/main.yml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
60
.github/workflows/test.yml
vendored
60
.github/workflows/test.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,4 +1,8 @@
|
|||
__pycache__
|
||||
lnbits
|
||||
tests/integration/strfry-data
|
||||
tests/integration/lnbits_itest_data
|
||||
tests/integration/lnbits_itest_data
|
||||
node_modules
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.mypy_cache
|
||||
1
.vscode/settings.json
vendored
Normal file
1
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
47
Makefile
Normal file
47
Makefile
Normal 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"
|
||||
10
README.md
10
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
38
__init__.py
38
__init__.py
|
|
@ -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"]
|
||||
|
|
|
|||
181
crud.py
181
crud.py
|
|
@ -1,56 +1,78 @@
|
|||
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(
|
||||
pubkey=pubkey,
|
||||
wallet=wallet_id,
|
||||
description=description,
|
||||
expires_at=expires_at,
|
||||
pubkey=pubkey,
|
||||
wallet=wallet_id,
|
||||
description=description,
|
||||
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,10 +127,10 @@ async def get_nwc(
|
|||
"""
|
||||
UPDATE nwcprovider.keys SET last_used = ? WHERE pubkey = ?
|
||||
""",
|
||||
(int(time.time()), pubkey)
|
||||
(int(time.time()), pubkey),
|
||||
)
|
||||
return NWCKey(**row)
|
||||
|
||||
|
||||
|
||||
async def get_budgets_nwc(pubkey, calculate_spent=False):
|
||||
rows = await db.fetchall(
|
||||
|
|
@ -110,47 +142,35 @@ async def get_budgets_nwc(pubkey, calculate_spent=False):
|
|||
last_cycle, next_cycle = budget.get_timestamp_range()
|
||||
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
|
||||
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
0
data/logs/debug.log
Normal file
0
data/logs/lnbits.log
Normal file
0
data/logs/lnbits.log
Normal file
10
execution_queue.py
Normal file
10
execution_queue.py
Normal 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
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
"repos": [
|
||||
{
|
||||
"id": "nwcprovider",
|
||||
"organisation": "riccardobl",
|
||||
"organisation": "lnbits",
|
||||
"repository": "nwcprovider"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import secp256k1
|
||||
|
||||
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial tables
|
||||
|
|
@ -8,11 +9,11 @@ async def m001_initial(db):
|
|||
"""
|
||||
CREATE TABLE nwcprovider.keys (
|
||||
pubkey TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
permissions TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
|
@ -20,23 +21,27 @@ async def m001_initial(db):
|
|||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE nwcprovider.spent (
|
||||
id {db.serial_primary_key},
|
||||
pubkey TEXT NOT NULL,
|
||||
id {db.serial_primary_key},
|
||||
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
|
||||
created_at INTEGER NOT NULL,
|
||||
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,
|
||||
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
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(pubkey)
|
||||
REFERENCES {db.references_schema}keys(pubkey)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
|
@ -48,14 +53,15 @@ async def m001_initial(db):
|
|||
pubkey TEXT NOT NULL,
|
||||
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
|
||||
created_at INTEGER NOT NULL,
|
||||
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,21 +90,20 @@ 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
|
||||
"""
|
||||
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO nwcprovider.config (key, value) VALUES ('relay_alias', ?);
|
||||
""",
|
||||
('',)
|
||||
("",),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
58
models.py
58
models.py
|
|
@ -1,54 +1,52 @@
|
|||
# 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 sqlite3 import Row
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
|
||||
class NWCKey(BaseModel):
|
||||
pubkey: str
|
||||
pubkey: str
|
||||
wallet: str
|
||||
description: str
|
||||
description: str
|
||||
expires_at: int
|
||||
permissions: str
|
||||
created_at: int
|
||||
last_used: int
|
||||
|
||||
def getPermissions(cls) -> List[str]:
|
||||
try:
|
||||
return cls.permissions.split(" ")
|
||||
except:
|
||||
return []
|
||||
created_at: int
|
||||
last_used: int
|
||||
|
||||
def get_permissions(self) -> List[str]:
|
||||
try:
|
||||
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
|
||||
budget_msats: int
|
||||
refresh_window: int
|
||||
created_at: int
|
||||
id: int
|
||||
pubkey: str
|
||||
budget_msats: int
|
||||
refresh_window: int
|
||||
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
|
||||
# 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
|
||||
return c, c + 21000000
|
||||
# calculate the next refresh timestamp
|
||||
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
|
||||
def from_row(cls, row: Row) -> "NWCBudget":
|
||||
return cls(**dict(row))
|
||||
|
|
@ -81,5 +79,3 @@ class NWCRegistrationRequest(BaseModel):
|
|||
class NWCGetResponse(BaseModel):
|
||||
data: NWCKey
|
||||
budgets: List[NWCBudget]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,81 +1,90 @@
|
|||
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] = []
|
||||
|
||||
def getStale(self) -> List[Dict]:
|
||||
self.events: Dict[str, Dict] = {}
|
||||
self.responses: List[str] = []
|
||||
|
||||
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
|
||||
|
||||
# Subscription
|
||||
# Subscription
|
||||
self.sub = None
|
||||
|
||||
# websocket connection
|
||||
|
|
@ -83,30 +92,43 @@ class NWCServiceProvider:
|
|||
|
||||
# if True the websocket is connected
|
||||
self.connected = False
|
||||
|
||||
|
||||
# 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)
|
||||
|
||||
def getSupportedMethods(self):
|
||||
logger.info(
|
||||
"NWC Service is ready. relay: "
|
||||
+ str(self.relay)
|
||||
+ " pubkey: "
|
||||
+ self.public_key_hex
|
||||
)
|
||||
|
||||
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,21 +136,19 @@ 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.
|
||||
|
||||
Args:
|
||||
data (Dict): The dictionary to be converted.
|
||||
|
||||
|
||||
Returns:
|
||||
str: The compact JSON string.
|
||||
"""
|
||||
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,56 +199,52 @@ 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
|
||||
"""
|
||||
self.sub = MainSubscription()
|
||||
self.sub = MainSubscription()
|
||||
# Create requests subscription
|
||||
req_filter = {
|
||||
"kinds": [23194],
|
||||
"#p": [self.public_key_hex],
|
||||
# Since the last 3 hours (handles reboots)
|
||||
"since": int(time.time()) - 3*60*60
|
||||
"since": 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
|
||||
"""
|
||||
nwc_pubkey = event["pubkey"]
|
||||
nwc_pubkey = event["pubkey"]
|
||||
content = event["content"]
|
||||
# Decrypt the content
|
||||
content = self._decrypt_content(content, nwc_pubkey)
|
||||
|
|
@ -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])
|
||||
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_event_message(msg)
|
||||
elif msg[0] == "EOSE":
|
||||
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...')
|
||||
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...")
|
||||
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,27 +515,30 @@ 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)
|
||||
Signs the event (in place)
|
||||
|
||||
Args:
|
||||
event (Dict): The event to be signed.
|
||||
|
|
@ -512,24 +546,26 @@ 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")
|
||||
|
|
@ -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
61
package-lock.json
generated
Normal 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
8
package.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "nwcprovider",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"prettier": "^3.3.3",
|
||||
"pyright": "^1.1.379"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
2512
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
99
pyproject.toml
Normal file
99
pyproject.toml
Normal 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",
|
||||
]
|
||||
520
static/js/noble-secp256k1.min.js
vendored
520
static/js/noble-secp256k1.min.js
vendored
File diff suppressed because one or more lines are too long
365
tasks.py
365
tasks.py
|
|
@ -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
|
||||
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, [])]
|
||||
if 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:
|
||||
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,103 +133,117 @@ 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
|
||||
if not amount_msats:
|
||||
if not amount_msats:
|
||||
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),
|
||||
currency="sat",
|
||||
wallet_id=nwc.wallet,
|
||||
amount=int(amount_msats / 1000),
|
||||
currency="sat",
|
||||
memo=description,
|
||||
description_hash=bytes.fromhex(description_hash) if description_hash else None,
|
||||
unhashed_description=description.encode("utf-8"),
|
||||
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
|
||||
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
|
||||
preimage = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
res = {
|
||||
"type": "incoming",
|
||||
"invoice": payment_request,
|
||||
"description": description,
|
||||
"description_hash": description_hash,
|
||||
"description_hash": description_hash,
|
||||
"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",
|
||||
complete=True,
|
||||
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, [])]
|
||||
|
||||
break
|
||||
# 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)
|
||||
|
|
@ -363,7 +414,7 @@ async def handle_nwc():
|
|||
await asyncio.sleep(3600)
|
||||
except asyncio.CancelledError:
|
||||
await nwcsp.cleanup()
|
||||
raise
|
||||
raise
|
||||
|
||||
|
||||
async def handle_execution_queue():
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
window.NobleSecp256k1 = NobleSecp256k1;
|
||||
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 %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
return [({"preimage": "00000"}, None, [["r1", "v1"]])]
|
||||
|
||||
},
|
||||
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)
|
||||
|
|
@ -138,7 +134,7 @@ async def test_handle(nwc_service_provider, nwc_service_provider2):
|
|||
e_tag = [tag for tag in tags if tag[0] == "e"]
|
||||
assert len(e_tag) == 1
|
||||
assert e_tag[0][1] == event["id"]
|
||||
|
||||
|
||||
p_tag = [tag for tag in tags if tag[0] == "p"]
|
||||
assert len(p_tag) == 1
|
||||
assert p_tag[0][1] == nwc_service_provider.public_key_hex
|
||||
assert p_tag[0][1] == nwc_service_provider.public_key_hex
|
||||
22
views.py
22
views.py
|
|
@ -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()}
|
||||
|
|
|
|||
174
views_api.py
174
views_api.py
|
|
@ -1,162 +1,178 @@
|
|||
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"
|
||||
netloc += "/nostrclient/api/v1/relay"
|
||||
relay = f"{scheme}://{netloc}"
|
||||
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,
|
||||
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,
|
||||
req: Request,
|
||||
):
|
||||
config = await get_all_config_nwc()
|
||||
return config
|
||||
|
||||
|
||||
# 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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue