Compare commits
No commits in common. "main" and "v1.0.3" have entirely different histories.
26 changed files with 1357 additions and 2868 deletions
|
|
@ -1,11 +1,6 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# workaround for devimage
|
|
||||||
sudo find /etc/apt/sources.list.d -maxdepth 1 -type f -exec \
|
|
||||||
sh -c 'grep -q "dl.yarnpkg.com/debian" "$1" && rm -f "$1" || true' _ {} \;
|
|
||||||
sudo sed -i '/dl.yarnpkg.com\/debian/d' /etc/apt/sources.list || true
|
|
||||||
|
|
||||||
sudo apt update -y
|
sudo apt update -y
|
||||||
sudo apt install -y curl
|
sudo apt install -y curl
|
||||||
sudo apt-get install -y docker.io
|
sudo apt-get install -y docker.io
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,6 @@ echo $PYTHONPATH
|
||||||
CONTAINER_WORKSPACE_FOLDER=$1
|
CONTAINER_WORKSPACE_FOLDER=$1
|
||||||
cd $CONTAINER_WORKSPACE_FOLDER
|
cd $CONTAINER_WORKSPACE_FOLDER
|
||||||
|
|
||||||
# workaround for devimage
|
|
||||||
sudo find /etc/apt/sources.list.d -maxdepth 1 -type f -exec \
|
|
||||||
sh -c 'grep -q "dl.yarnpkg.com/debian" "$1" && rm -f "$1" || true' _ {} \;
|
|
||||||
sudo sed -i '/dl.yarnpkg.com\/debian/d' /etc/apt/sources.list || true
|
|
||||||
|
|
||||||
cd $HOME
|
cd $HOME
|
||||||
echo $PWD
|
echo $PWD
|
||||||
if [ ! -d ./lnbits ] ; then
|
if [ ! -d ./lnbits ] ; then
|
||||||
|
|
@ -15,7 +10,7 @@ if [ ! -d ./lnbits ] ; then
|
||||||
fi
|
fi
|
||||||
cd lnbits
|
cd lnbits
|
||||||
echo $PWD
|
echo $PWD
|
||||||
git checkout dev
|
git checkout v1.0.0-rc7
|
||||||
poetry env use 3.12
|
poetry env use 3.12
|
||||||
POETRY_PYTHON_PATH=$(poetry env info -p)/bin/python
|
POETRY_PYTHON_PATH=$(poetry env info -p)/bin/python
|
||||||
ln -sf $POETRY_PYTHON_PATH /home/vscode/python
|
ln -sf $POETRY_PYTHON_PATH /home/vscode/python
|
||||||
|
|
@ -30,4 +25,3 @@ cd $CONTAINER_WORKSPACE_FOLDER
|
||||||
poetry install --no-interaction
|
poetry install --no-interaction
|
||||||
npm i prettier
|
npm i prettier
|
||||||
npm i pyright
|
npm i pyright
|
||||||
pip install uv
|
|
||||||
|
|
|
||||||
14
.github/workflows/test.yml
vendored
14
.github/workflows/test.yml
vendored
|
|
@ -7,7 +7,6 @@ on:
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|
@ -51,16 +50,3 @@ jobs:
|
||||||
pip install pytest-asyncio
|
pip install pytest-asyncio
|
||||||
cd $cdir
|
cd $cdir
|
||||||
pytest tests/integration/*.py -s
|
pytest tests/integration/*.py -s
|
||||||
|
|
||||||
- name: Show integration service logs
|
|
||||||
if: failure()
|
|
||||||
run: |
|
|
||||||
docker ps -a
|
|
||||||
docker logs --tail 200 lnbits_nwcprovider_ext_nostr_test || true
|
|
||||||
docker exec lnbits_nwcprovider_ext_lnbits_test tail -n 200 /tmp/lnbits.log || true
|
|
||||||
|
|
||||||
- name: Stop integration services
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
docker rm -f lnbits_nwcprovider_ext_lnbits_test lnbits_nwcprovider_ext_nostr_test || true
|
|
||||||
docker network rm lnbits_nwcprovider_ext_test_network || true
|
|
||||||
|
|
|
||||||
21
README.md
21
README.md
|
|
@ -1,13 +1,3 @@
|
||||||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
|
||||||
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:280px">
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
[](./LICENSE)
|
|
||||||
[](https://github.com/lnbits/lnbits)
|
|
||||||
|
|
||||||
# NWC Service Provider Extension for [LNbits](https://github.com/lnbits/lnbits)
|
# NWC Service Provider Extension for [LNbits](https://github.com/lnbits/lnbits)
|
||||||
|
|
||||||
Easily connect your LNbits wallets via [NWC](https://nwc.dev/).
|
Easily connect your LNbits wallets via [NWC](https://nwc.dev/).
|
||||||
|
|
@ -33,7 +23,7 @@ Before you can start using the extension, you need to configure a Nostr relay.
|
||||||
This is the easiest option for most users. It allows you to run LNbits on a private network while connecting to NWC apps through a public Nostr relay.
|
This is the easiest option for most users. It allows you to run LNbits on a private network while connecting to NWC apps through a public Nostr relay.
|
||||||
|
|
||||||
1. Choose a Nostr relay that supports NWC connections.
|
1. Choose a Nostr relay that supports NWC connections.
|
||||||
2. Navigate to the **NWC Service Provider admin page** at `/nwcprovider/admin` (requires LNbits admin privileges).
|
2. Open the **NWC Service Provider settings** (gear icon in the top-right corner).
|
||||||
1. Enter your chosen relay URL in the **Nostr Relay URL** field (e.g. `wss://relay.nostrconnect.com`).
|
1. Enter your chosen relay URL in the **Nostr Relay URL** field (e.g. `wss://relay.nostrconnect.com`).
|
||||||
2. Click **Save**.
|
2. Click **Save**.
|
||||||
|
|
||||||
|
|
@ -60,7 +50,7 @@ This is the easiest option for most users. It allows you to run LNbits on a priv
|
||||||
|
|
||||||
# Extension Configuration
|
# Extension Configuration
|
||||||
|
|
||||||
The configuration page of the NWC Service Provider extension is available at `/nwcprovider/admin` and requires LNbits admin privileges.
|
The "Configuration" page of the NWC Service Provider extension can be accessed by clicking the gear icon in the top-right corner of the extension page.
|
||||||
|
|
||||||
### Configuration Options:
|
### Configuration Options:
|
||||||
|
|
||||||
|
|
@ -78,10 +68,3 @@ The configuration page of the NWC Service Provider extension is available at `/n
|
||||||
> For example, in shared or community lnbits instances, where users are unaware of this functionality, they might assume a payment has failed and attempt to pay a new invoice with a different wallet, only for the instance to come back online and process the original payment request, potentially leading to duplicate payments.
|
> For example, in shared or community lnbits instances, where users are unaware of this functionality, they might assume a payment has failed and attempt to pay a new invoice with a different wallet, only for the instance to come back online and process the original payment request, potentially leading to duplicate payments.
|
||||||
>
|
>
|
||||||
> For this reason, unless you are trying to tackle this specific issue, it is recommended to leave this setting at `0`.
|
> For this reason, unless you are trying to tackle this specific issue, it is recommended to leave this setting at `0`.
|
||||||
|
|
||||||
## Powered by LNbits
|
|
||||||
|
|
||||||
[LNbits](https://lnbits.com) is a free and open-source lightning accounts system.
|
|
||||||
|
|
||||||
[](https://shop.lnbits.com/)
|
|
||||||
[](https://my.lnbits.com/login)
|
|
||||||
|
|
|
||||||
16
config.json
16
config.json
|
|
@ -1,12 +1,8 @@
|
||||||
{
|
{
|
||||||
"id": "nwcprovider",
|
|
||||||
"name": "NWC Service Provider",
|
"name": "NWC Service Provider",
|
||||||
"repo": "https://github.com/lnbits/nwcprovider",
|
|
||||||
"short_description": "A NWC service provider for LNbits",
|
"short_description": "A NWC service provider for LNbits",
|
||||||
"description": "",
|
|
||||||
"tile": "/nwcprovider/static/image/nwcprovider.png",
|
"tile": "/nwcprovider/static/image/nwcprovider.png",
|
||||||
"version": "1.1.3-aio.1",
|
"min_lnbits_version": "1.0.0",
|
||||||
"min_lnbits_version": "1.4.0",
|
|
||||||
"contributors": [
|
"contributors": [
|
||||||
{
|
{
|
||||||
"name": "Riccardo Balbo",
|
"name": "Riccardo Balbo",
|
||||||
|
|
@ -25,10 +21,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"images": [
|
"images": [
|
||||||
{
|
|
||||||
"uri": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/static/image/nwc_thumbnail.png",
|
|
||||||
"link": "https://www.youtube.com/watch?v=0c77d2q-_PQ"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"uri": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/static/image/1.png"
|
"uri": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/static/image/1.png"
|
||||||
},
|
},
|
||||||
|
|
@ -41,9 +33,5 @@
|
||||||
],
|
],
|
||||||
"description_md": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/description.md",
|
"description_md": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/description.md",
|
||||||
"terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/toc.md",
|
"terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/toc.md",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"paid_features": "",
|
|
||||||
"tags": ["Nostr", "Wallet"],
|
|
||||||
"donate": "",
|
|
||||||
"hidden": false
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,3 @@
|
||||||
Connect your LNbits wallet to apps using the Nostr Wallet Connect protocol.
|
NWC Service Provider Extension for https://github.com/lnbits/lnbits
|
||||||
|
|
||||||
Its functions include:
|
Easily connect your LNbits wallets via https://nwc.dev/
|
||||||
|
|
||||||
- Exposing your wallet via the NWC protocol
|
|
||||||
- Connecting to NWC-compatible applications
|
|
||||||
- Managing wallet connection permissions
|
|
||||||
- Supporting the nwc.dev standard
|
|
||||||
|
|
||||||
Enables seamless integration with NWC-compatible apps like Alby, Amethyst, Jumble, Buho and other Nostr clients that support wallet connections.
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from coincurve import PrivateKey
|
import secp256k1
|
||||||
|
|
||||||
|
|
||||||
async def m001_initial(db):
|
async def m001_initial(db):
|
||||||
|
|
@ -72,14 +72,14 @@ async def m003_default_config(db):
|
||||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
private_key = PrivateKey()
|
new_private_key = bytes.hex(secp256k1._gen_private_key())
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO nwcprovider.config (key, value)
|
INSERT INTO nwcprovider.config (key, value)
|
||||||
VALUES ('provider_key', :provider_key)
|
VALUES ('provider_key', :provider_key)
|
||||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
|
||||||
""",
|
""",
|
||||||
{"provider_key": private_key.to_hex()},
|
{"provider_key": new_private_key},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ class NWCBudget(BaseModel):
|
||||||
c = int(time.time())
|
c = int(time.time())
|
||||||
if self.refresh_window <= 0: # never refresh
|
if self.refresh_window <= 0: # never refresh
|
||||||
# return a timestamp in the future
|
# return a timestamp in the future
|
||||||
return self.created_at, c + 21000000
|
return c, c + 21000000
|
||||||
# calculate the next refresh timestamp
|
# calculate the next refresh timestamp
|
||||||
elapsed = c - self.created_at
|
elapsed = c - self.created_at
|
||||||
passed_cycles = elapsed // self.refresh_window
|
passed_cycles = elapsed // self.refresh_window
|
||||||
|
|
|
||||||
220
nwcp.py
220
nwcp.py
|
|
@ -1,4 +1,5 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
|
|
@ -6,11 +7,13 @@ import time
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Any, Union
|
from typing import Any, Union
|
||||||
|
|
||||||
from coincurve import PublicKeyXOnly
|
import secp256k1
|
||||||
|
from Cryptodome import Random
|
||||||
|
from Cryptodome.Cipher import AES
|
||||||
|
from Cryptodome.Util.Padding import pad, unpad
|
||||||
from lnbits.helpers import encrypt_internal_message
|
from lnbits.helpers import encrypt_internal_message
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pynostr.key import PrivateKey
|
|
||||||
from websockets.legacy.client import connect
|
from websockets.legacy.client import connect
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -27,7 +30,6 @@ class MainSubscription:
|
||||||
self.responses_eose = False
|
self.responses_eose = False
|
||||||
self.events: dict[str, dict] = {}
|
self.events: dict[str, dict] = {}
|
||||||
self.responses: list[str] = []
|
self.responses: list[str] = []
|
||||||
self.seen_requests: dict[str, int] = {}
|
|
||||||
|
|
||||||
def get_stale(self) -> list[dict]:
|
def get_stale(self) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -50,12 +52,11 @@ class MainSubscription:
|
||||||
"""
|
"""
|
||||||
Garbage collection, remove all the events that have a response older
|
Garbage collection, remove all the events that have a response older
|
||||||
than expire seconds (defaults to 1 hour if 0 or None)
|
than expire seconds (defaults to 1 hour if 0 or None)
|
||||||
and all seen requests that are expired
|
|
||||||
"""
|
"""
|
||||||
expire = expire or 1 * 60 * 60
|
expire = expire or 1 * 60 * 60
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
deleted_ids = []
|
deleted_ids = []
|
||||||
for event_id, event in list(self.events.items()):
|
for [event_id, event] in self.events.items():
|
||||||
if event_id in self.responses:
|
if event_id in self.responses:
|
||||||
if now - event["created_at"] > expire:
|
if now - event["created_at"] > expire:
|
||||||
del self.events[event_id]
|
del self.events[event_id]
|
||||||
|
|
@ -67,11 +68,6 @@ class MainSubscription:
|
||||||
if len(deleted_ids) > 0:
|
if len(deleted_ids) > 0:
|
||||||
logger.debug("Garbage collected " + str(len(deleted_ids)) + " events")
|
logger.debug("Garbage collected " + str(len(deleted_ids)) + " events")
|
||||||
|
|
||||||
# Clean seen requests
|
|
||||||
for event_id, expiry in list(self.seen_requests.items()):
|
|
||||||
if expiry < now:
|
|
||||||
del self.seen_requests[event_id]
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
arbitrary_types_allowed = True
|
arbitrary_types_allowed = True
|
||||||
|
|
||||||
|
|
@ -79,7 +75,7 @@ class MainSubscription:
|
||||||
class NWCServiceProvider:
|
class NWCServiceProvider:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
private_key_hex: str | None = None,
|
private_key: str | None = None,
|
||||||
relay: str | None = None,
|
relay: str | None = None,
|
||||||
handle_missed_events: int = 0,
|
handle_missed_events: int = 0,
|
||||||
):
|
):
|
||||||
|
|
@ -94,18 +90,15 @@ class NWCServiceProvider:
|
||||||
)
|
)
|
||||||
self.relay = relay
|
self.relay = relay
|
||||||
|
|
||||||
if not private_key_hex: # Create random key
|
if not private_key: # Create random key
|
||||||
self.private_key = PrivateKey()
|
private_key = bytes.hex(secp256k1._gen_private_key())
|
||||||
self.private_key_hex = self.private_key.hex()
|
|
||||||
else:
|
|
||||||
self.private_key = PrivateKey.from_hex(private_key_hex)
|
|
||||||
self.private_key_hex = private_key_hex
|
|
||||||
|
|
||||||
self.public_key = self.private_key.public_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:
|
if not self.public_key:
|
||||||
raise Exception("Invalid public key")
|
raise Exception("Invalid public key")
|
||||||
|
self.public_key_hex = self.public_key.serialize().hex()[2:]
|
||||||
self.public_key_hex = self.public_key.hex()
|
|
||||||
|
|
||||||
# List of supported methods
|
# List of supported methods
|
||||||
self.supported_methods: list[str] = []
|
self.supported_methods: list[str] = []
|
||||||
|
|
@ -128,14 +121,8 @@ class NWCServiceProvider:
|
||||||
# Garbage collection loop
|
# Garbage collection loop
|
||||||
self.gc_task = None
|
self.gc_task = None
|
||||||
|
|
||||||
# Periodic info event resend loop
|
|
||||||
self.info_event_task = None
|
|
||||||
|
|
||||||
# Requests are handled independently from the relay receive loop.
|
|
||||||
self.request_tasks: set[asyncio.Task[list[dict]]] = set()
|
|
||||||
|
|
||||||
# Subscription
|
# Subscription
|
||||||
self.sub: MainSubscription | None = None
|
self.sub = None
|
||||||
self.rate_limit: dict[str, RateLimit] = {}
|
self.rate_limit: dict[str, RateLimit] = {}
|
||||||
|
|
||||||
# websocket connection
|
# websocket connection
|
||||||
|
|
@ -152,8 +139,6 @@ class NWCServiceProvider:
|
||||||
# (handles reboots)
|
# (handles reboots)
|
||||||
self.handle_missed_events = handle_missed_events
|
self.handle_missed_events = handle_missed_events
|
||||||
|
|
||||||
self.event_max_age = self.handle_missed_events or 5 * 60
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"NWC Service is ready. relay: "
|
"NWC Service is ready. relay: "
|
||||||
+ str(self.relay)
|
+ str(self.relay)
|
||||||
|
|
@ -200,7 +185,6 @@ class NWCServiceProvider:
|
||||||
"""
|
"""
|
||||||
self.reconnect_task = asyncio.create_task(self._connect_to_relay())
|
self.reconnect_task = asyncio.create_task(self._connect_to_relay())
|
||||||
self.gc_task = asyncio.create_task(self._gc_loop())
|
self.gc_task = asyncio.create_task(self._gc_loop())
|
||||||
self.info_event_task = asyncio.create_task(self._info_event_loop())
|
|
||||||
|
|
||||||
def _json_dumps(self, data: Union[dict, list]) -> str:
|
def _json_dumps(self, data: Union[dict, list]) -> str:
|
||||||
"""
|
"""
|
||||||
|
|
@ -282,16 +266,11 @@ class NWCServiceProvider:
|
||||||
await asyncio.sleep(limit.backoff)
|
await asyncio.sleep(limit.backoff)
|
||||||
limit.last_attempt_time = int(time.time())
|
limit.last_attempt_time = int(time.time())
|
||||||
|
|
||||||
def _create_subscription(self) -> MainSubscription:
|
|
||||||
sub = MainSubscription()
|
|
||||||
self.sub = sub
|
|
||||||
return sub
|
|
||||||
|
|
||||||
async def _subscribe(self):
|
async def _subscribe(self):
|
||||||
"""
|
"""
|
||||||
[Re]Subscribe to receive nip 47 requests and responses from the relay
|
[Re]Subscribe to receive nip 47 requests and responses from the relay
|
||||||
"""
|
"""
|
||||||
sub = self._create_subscription()
|
self.sub = MainSubscription()
|
||||||
# Create requests subscription
|
# Create requests subscription
|
||||||
req_filter = {
|
req_filter = {
|
||||||
"kinds": [23194],
|
"kinds": [23194],
|
||||||
|
|
@ -299,17 +278,17 @@ class NWCServiceProvider:
|
||||||
# Since the last handle_missed_events seconds (handles reboots)
|
# Since the last handle_missed_events seconds (handles reboots)
|
||||||
"since": int(time.time()) - self.handle_missed_events,
|
"since": int(time.time()) - self.handle_missed_events,
|
||||||
}
|
}
|
||||||
sub.requests_sub_id = self._get_new_subid()
|
self.sub.requests_sub_id = self._get_new_subid()
|
||||||
# Create responses subscription (needed to track previosly responded requests)
|
# Create responses subscription (needed to track previosly responded requests)
|
||||||
res_filter = {
|
res_filter = {
|
||||||
"kinds": [23195],
|
"kinds": [23195],
|
||||||
"authors": [self.public_key_hex],
|
"authors": [self.public_key_hex],
|
||||||
"since": int(time.time()) - self.handle_missed_events,
|
"since": int(time.time()) - self.handle_missed_events,
|
||||||
}
|
}
|
||||||
sub.responses_sub_id = self._get_new_subid()
|
self.sub.responses_sub_id = self._get_new_subid()
|
||||||
# Subscribe
|
# Subscribe
|
||||||
await self._send(["REQ", sub.requests_sub_id, req_filter])
|
await self._send(["REQ", self.sub.requests_sub_id, req_filter])
|
||||||
await self._send(["REQ", sub.responses_sub_id, res_filter])
|
await self._send(["REQ", self.sub.responses_sub_id, res_filter])
|
||||||
|
|
||||||
async def _on_connection(self, _):
|
async def _on_connection(self, _):
|
||||||
"""
|
"""
|
||||||
|
|
@ -317,14 +296,6 @@ class NWCServiceProvider:
|
||||||
methods and subscribe to nip67 events.
|
methods and subscribe to nip67 events.
|
||||||
"""
|
"""
|
||||||
# Send info event
|
# Send info event
|
||||||
await self._send_info_event()
|
|
||||||
# Resubscribe to nwc events
|
|
||||||
await self._subscribe()
|
|
||||||
|
|
||||||
async def _send_info_event(self):
|
|
||||||
"""
|
|
||||||
Build and publish the NWC service info event (kind 13194).
|
|
||||||
"""
|
|
||||||
event = {
|
event = {
|
||||||
"kind": 13194,
|
"kind": 13194,
|
||||||
"content": " ".join(self.supported_methods),
|
"content": " ".join(self.supported_methods),
|
||||||
|
|
@ -333,42 +304,17 @@ class NWCServiceProvider:
|
||||||
}
|
}
|
||||||
self._sign_event(event)
|
self._sign_event(event)
|
||||||
await self._send(["EVENT", event])
|
await self._send(["EVENT", event])
|
||||||
|
# Resubscribe to nwc events
|
||||||
async def _info_event_loop(self):
|
await self._subscribe()
|
||||||
"""
|
|
||||||
Periodically resend the service info event (kind 13194) so that the
|
|
||||||
provider can recover if the relay silently dropped the event without
|
|
||||||
closing the WebSocket connection.
|
|
||||||
"""
|
|
||||||
while not self._is_shutting_down():
|
|
||||||
await asyncio.sleep(60)
|
|
||||||
if self.connected and not self._is_shutting_down():
|
|
||||||
try:
|
|
||||||
await self._send_info_event()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Error resending info event: " + str(e))
|
|
||||||
|
|
||||||
async def _handle_request(self, event: dict) -> list[dict]:
|
async def _handle_request(self, event: dict) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Handle a nwc request
|
Handle a nwc request
|
||||||
"""
|
"""
|
||||||
if not self.sub:
|
|
||||||
raise Exception("Subscription is not established")
|
|
||||||
sub = self.sub
|
|
||||||
|
|
||||||
expire = sub.seen_requests.get(event["id"])
|
|
||||||
if expire or event["created_at"] < int(time.time() - self.event_max_age):
|
|
||||||
raise Exception("Event is too old or already handled")
|
|
||||||
|
|
||||||
expiration = self._extract_expiration_from_tags(event["tags"])
|
|
||||||
if expiration <= 0:
|
|
||||||
expiration = int(time.time() + self.event_max_age)
|
|
||||||
sub.seen_requests[event["id"]] = expiration
|
|
||||||
|
|
||||||
nwc_pubkey = event["pubkey"]
|
nwc_pubkey = event["pubkey"]
|
||||||
content = event["content"]
|
content = event["content"]
|
||||||
# Decrypt the content
|
# Decrypt the content
|
||||||
content = self.private_key.decrypt_message(content, nwc_pubkey)
|
content = self._decrypt_content(content, nwc_pubkey)
|
||||||
# Deserialize content
|
# Deserialize content
|
||||||
content = json.loads(content)
|
content = json.loads(content)
|
||||||
# Handle request
|
# Handle request
|
||||||
|
|
@ -392,7 +338,7 @@ class NWCServiceProvider:
|
||||||
for result in results:
|
for result in results:
|
||||||
r = result[0]
|
r = result[0]
|
||||||
e = result[1]
|
e = result[1]
|
||||||
t = result[2] if len(result) > 2 else []
|
t = result[2] if len(result) > 2 else None
|
||||||
out = {"result": r, "error": e, "tags": t}
|
out = {"result": r, "error": e, "tags": t}
|
||||||
outs.append(out)
|
outs.append(out)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -406,13 +352,11 @@ class NWCServiceProvider:
|
||||||
content["result"] = out["result"]
|
content["result"] = out["result"]
|
||||||
if "error" in out:
|
if "error" in out:
|
||||||
content["error"] = out["error"]
|
content["error"] = out["error"]
|
||||||
raw_tags = out.get("tags")
|
|
||||||
tags = list(raw_tags) if isinstance(raw_tags, list) else []
|
|
||||||
# Prepare response event
|
# Prepare response event
|
||||||
res: dict = {
|
res: dict = {
|
||||||
"kind": 23195,
|
"kind": 23195,
|
||||||
"created_at": int(time.time()),
|
"created_at": int(time.time()),
|
||||||
"tags": tags,
|
"tags": out.get("tags", []),
|
||||||
"content": self._json_dumps(content),
|
"content": self._json_dumps(content),
|
||||||
}
|
}
|
||||||
# Reference request
|
# Reference request
|
||||||
|
|
@ -420,9 +364,7 @@ class NWCServiceProvider:
|
||||||
# Reference user
|
# Reference user
|
||||||
res["tags"].append(["p", nwc_pubkey])
|
res["tags"].append(["p", nwc_pubkey])
|
||||||
# Finalize response event
|
# Finalize response event
|
||||||
res["content"] = self.private_key.encrypt_message(
|
res["content"] = self._encrypt_content(res["content"], nwc_pubkey)
|
||||||
res["content"], nwc_pubkey
|
|
||||||
)
|
|
||||||
self._sign_event(res)
|
self._sign_event(res)
|
||||||
|
|
||||||
# Register response for this request, so we knows it is not stale
|
# Register response for this request, so we knows it is not stale
|
||||||
|
|
@ -434,30 +376,6 @@ class NWCServiceProvider:
|
||||||
sent_events.append(res)
|
sent_events.append(res)
|
||||||
return sent_events
|
return sent_events
|
||||||
|
|
||||||
def _log_request_task_exception(self, task: asyncio.Future[list[dict]]) -> None:
|
|
||||||
if task.cancelled():
|
|
||||||
return
|
|
||||||
exception = task.exception()
|
|
||||||
if exception:
|
|
||||||
logger.error("Error handling request: " + str(exception))
|
|
||||||
|
|
||||||
def _dispatch_request(self, event: dict) -> None:
|
|
||||||
task = asyncio.create_task(self._handle_request(event))
|
|
||||||
self.request_tasks.add(task)
|
|
||||||
task.add_done_callback(self.request_tasks.discard)
|
|
||||||
task.add_done_callback(self._log_request_task_exception)
|
|
||||||
|
|
||||||
def _extract_expiration_from_tags(self, tags: list) -> int:
|
|
||||||
expiration = -1
|
|
||||||
for tag in tags:
|
|
||||||
try:
|
|
||||||
if tag[0] == "expiration" and len(tag) > 1:
|
|
||||||
expiration = int(tag[1])
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return expiration
|
|
||||||
|
|
||||||
async def _on_event_message(self, msg):
|
async def _on_event_message(self, msg):
|
||||||
if not self.sub:
|
if not self.sub:
|
||||||
return
|
return
|
||||||
|
|
@ -467,7 +385,7 @@ class NWCServiceProvider:
|
||||||
if not self._verify_event(event):
|
if not self._verify_event(event):
|
||||||
raise Exception("Invalid event signature")
|
raise Exception("Invalid event signature")
|
||||||
tags = event["tags"]
|
tags = event["tags"]
|
||||||
expiration = self._extract_expiration_from_tags(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
|
# Handle event expiration if the relay doesn't support nip 40
|
||||||
if expiration > 0 and expiration < int(time.time()):
|
if expiration > 0 and expiration < int(time.time()):
|
||||||
logger.debug("Event expired")
|
logger.debug("Event expired")
|
||||||
|
|
@ -486,7 +404,7 @@ class NWCServiceProvider:
|
||||||
# already handled or stale, all stale requests will be handled
|
# already handled or stale, all stale requests will be handled
|
||||||
# later when eose is received
|
# later when eose is received
|
||||||
if self.sub.requests_eose and self.sub.responses_eose:
|
if self.sub.requests_eose and self.sub.responses_eose:
|
||||||
self._dispatch_request(event)
|
await self._handle_request(event)
|
||||||
elif event["kind"] == 23195 and sub_id == self.sub.responses_sub_id:
|
elif event["kind"] == 23195 and sub_id == self.sub.responses_sub_id:
|
||||||
# Ensure the response is from this service provider
|
# Ensure the response is from this service provider
|
||||||
if event["pubkey"] != self.public_key_hex:
|
if event["pubkey"] != self.public_key_hex:
|
||||||
|
|
@ -514,7 +432,7 @@ class NWCServiceProvider:
|
||||||
if self.sub.requests_eose and self.sub.responses_eose:
|
if self.sub.requests_eose and self.sub.responses_eose:
|
||||||
stales = self.sub.get_stale()
|
stales = self.sub.get_stale()
|
||||||
for stale in stales:
|
for stale in stales:
|
||||||
self._dispatch_request(stale)
|
await self._handle_request(stale)
|
||||||
|
|
||||||
async def _on_closed_message(self, msg):
|
async def _on_closed_message(self, msg):
|
||||||
if not self.sub:
|
if not self.sub:
|
||||||
|
|
@ -595,6 +513,65 @@ class NWCServiceProvider:
|
||||||
logger.debug("Reconnecting to NWC relay...")
|
logger.debug("Reconnecting to NWC relay...")
|
||||||
await self._ratelimit("connecting")
|
await self._ratelimit("connecting")
|
||||||
|
|
||||||
|
def _encrypt_content(
|
||||||
|
self, content: str, pubkey_hex: str, iv_seed: int | None = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Encrypts the content for the given public key
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content (str): The content to be encrypted.
|
||||||
|
pubkey_hex (str): The public key in hex format.
|
||||||
|
|
||||||
|
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:]
|
||||||
|
# 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]
|
||||||
|
|
||||||
|
aes = AES.new(shared, AES.MODE_CBC, iv)
|
||||||
|
|
||||||
|
content_bytes = content.encode("utf-8")
|
||||||
|
|
||||||
|
# padding
|
||||||
|
content_bytes = pad(content_bytes, AES.block_size)
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""
|
||||||
|
Decrypts the content for the given public key
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content (str): The encrypted content.
|
||||||
|
pubkey_hex (str): The public key in hex format.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The decrypted content.
|
||||||
|
"""
|
||||||
|
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
|
||||||
|
|
||||||
|
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"))
|
||||||
|
iv = base64.b64decode(iv_b64.encode("ascii"))
|
||||||
|
# Decrypt
|
||||||
|
aes = AES.new(shared, AES.MODE_CBC, iv)
|
||||||
|
decrypted_bytes = aes.decrypt(encrypted_content)
|
||||||
|
decrypted_bytes = unpad(decrypted_bytes, AES.block_size)
|
||||||
|
decrypted = decrypted_bytes.decode("utf-8")
|
||||||
|
return decrypted
|
||||||
|
|
||||||
def _verify_event(self, event: dict) -> bool:
|
def _verify_event(self, event: dict) -> bool:
|
||||||
"""
|
"""
|
||||||
Verify the event signature
|
Verify the event signature
|
||||||
|
|
@ -619,8 +596,10 @@ class NWCServiceProvider:
|
||||||
if event_id != event["id"]: # Invalid event id
|
if event_id != event["id"]: # Invalid event id
|
||||||
return False
|
return False
|
||||||
pubkey_hex = event["pubkey"]
|
pubkey_hex = event["pubkey"]
|
||||||
pubkey = PublicKeyXOnly(bytes.fromhex(pubkey_hex))
|
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
|
||||||
if not pubkey.verify(bytes.fromhex(event["sig"]), bytes.fromhex(event_id)):
|
if not pubkey.schnorr_verify(
|
||||||
|
bytes.fromhex(event_id), bytes.fromhex(event["sig"]), None, raw=True
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
@ -649,8 +628,10 @@ class NWCServiceProvider:
|
||||||
event["id"] = event_id
|
event["id"] = event_id
|
||||||
event["pubkey"] = self.public_key_hex
|
event["pubkey"] = self.public_key_hex
|
||||||
|
|
||||||
signature = self.private_key.sign(bytes.fromhex(event_id))
|
signature = (
|
||||||
event["sig"] = signature.hex() # type: ignore
|
self.private_key.schnorr_sign(bytes.fromhex(event_id), None, raw=True)
|
||||||
|
).hex()
|
||||||
|
event["sig"] = signature
|
||||||
return event
|
return event
|
||||||
|
|
||||||
async def cleanup(self):
|
async def cleanup(self):
|
||||||
|
|
@ -667,17 +648,6 @@ class NWCServiceProvider:
|
||||||
self.gc_task.cancel()
|
self.gc_task.cancel()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Error closing gc loop: " + str(e))
|
logger.warning("Error closing gc loop: " + str(e))
|
||||||
try:
|
|
||||||
if self.info_event_task:
|
|
||||||
self.info_event_task.cancel()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Error closing info event loop: " + str(e))
|
|
||||||
request_tasks = list(self.request_tasks)
|
|
||||||
for task in request_tasks:
|
|
||||||
task.cancel()
|
|
||||||
if request_tasks:
|
|
||||||
await asyncio.gather(*request_tasks, return_exceptions=True)
|
|
||||||
self.request_tasks.clear()
|
|
||||||
# close the websocket
|
# close the websocket
|
||||||
try:
|
try:
|
||||||
if self.ws:
|
if self.ws:
|
||||||
|
|
|
||||||
16
package-lock.json
generated
16
package-lock.json
generated
|
|
@ -9,8 +9,8 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.2.5",
|
||||||
"pyright": "^1.1.408"
|
"pyright": "^1.1.358"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
|
|
@ -28,9 +28,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prettier": {
|
"node_modules/prettier": {
|
||||||
"version": "3.8.1",
|
"version": "3.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
|
||||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
|
|
@ -43,9 +43,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pyright": {
|
"node_modules/pyright": {
|
||||||
"version": "1.1.408",
|
"version": "1.1.405",
|
||||||
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.408.tgz",
|
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.405.tgz",
|
||||||
"integrity": "sha512-N61pxaLLCsPcUuPPHMNIrGoZgGBgrbjBX5UqkaT5UV8NVZdL7ExsO6N3ectv1DzAUsLOzdlyqoYtX76u8eF4YA==",
|
"integrity": "sha512-hgy12kLZ1oAMtl9LTsByHftg3AD6Pouwu5rBsQlqYQqCCdGBgaQm9XDAPDap7ayWe9W+NWrUwO7Zy1K7uXoE2A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"pyright": "index.js",
|
"pyright": "index.js",
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.2.5",
|
||||||
"pyright": "^1.1.408"
|
"pyright": "^1.1.358"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
paranoia.py
19
paranoia.py
|
|
@ -2,12 +2,6 @@
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
ENABLE_HARDENING = True
|
ENABLE_HARDENING = True
|
||||||
WHITELISTED_NON_PRINTABLE_CHARS = {
|
|
||||||
"\n", # newline
|
|
||||||
"\r", # carriage return
|
|
||||||
"\t", # tab
|
|
||||||
"\xa0", # non-breaking space ( )
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def panic(reason: str):
|
def panic(reason: str):
|
||||||
|
|
@ -23,17 +17,8 @@ def assert_printable(v: str):
|
||||||
return
|
return
|
||||||
if not isinstance(v, str):
|
if not isinstance(v, str):
|
||||||
panic("not a string " + str(v))
|
panic("not a string " + str(v))
|
||||||
for ch in v:
|
if not v.isprintable():
|
||||||
# check if printable
|
panic("string contains non-printable characters")
|
||||||
if ch.isprintable():
|
|
||||||
continue
|
|
||||||
|
|
||||||
# check if whitelisted non-printable
|
|
||||||
if ch in WHITELISTED_NON_PRINTABLE_CHARS:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Anything else is rejected
|
|
||||||
panic(f"string contains non-printable character: (0x{ord(ch):04X})")
|
|
||||||
|
|
||||||
|
|
||||||
# Check if number is valid int and not NaN
|
# Check if number is valid int and not NaN
|
||||||
|
|
|
||||||
3054
poetry.lock
generated
3054
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -7,8 +7,11 @@ authors = [{ name = "Riccardo Balbo", email = "oc@rblb.it" }]
|
||||||
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/nwcprovider" }
|
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/nwcprovider" }
|
||||||
dependencies = [ "lnbits>1" ]
|
dependencies = [ "lnbits>1" ]
|
||||||
|
|
||||||
[dependency-groups]
|
[tool.poetry]
|
||||||
dev = [
|
package-mode = false
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
dev-dependencies = [
|
||||||
"black",
|
"black",
|
||||||
"pytest-asyncio",
|
"pytest-asyncio",
|
||||||
"pytest",
|
"pytest",
|
||||||
|
|
@ -18,15 +21,12 @@ dev = [
|
||||||
"pytest-md",
|
"pytest-md",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.poetry]
|
|
||||||
package-mode = false
|
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
plugins = ["pydantic.mypy"]
|
plugins = ["pydantic.mypy"]
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = [
|
module = [
|
||||||
"pynostr.*",
|
"secp256k1.*",
|
||||||
]
|
]
|
||||||
ignore_missing_imports = "True"
|
ignore_missing_imports = "True"
|
||||||
|
|
||||||
|
|
@ -83,8 +83,8 @@ classmethod-decorators = [
|
||||||
# [tool.ruff.lint.extend-per-file-ignores]
|
# [tool.ruff.lint.extend-per-file-ignores]
|
||||||
# "views_api.py" = ["F401"]
|
# "views_api.py" = ["F401"]
|
||||||
|
|
||||||
[tool.ruff.lint.mccabe]
|
# [tool.ruff.lint.mccabe]
|
||||||
max-complexity = 11
|
# max-complexity = 10
|
||||||
|
|
||||||
[tool.ruff.lint.flake8-bugbear]
|
[tool.ruff.lint.flake8-bugbear]
|
||||||
# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.
|
# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 498 KiB |
|
|
@ -4,7 +4,7 @@ window.app = Vue.createApp({
|
||||||
delimiters: ['${', '}'],
|
delimiters: ['${', '}'],
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
config: {},
|
entries: [],
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
name: 'key',
|
name: 'key',
|
||||||
|
|
@ -30,10 +30,21 @@ window.app = Vue.createApp({
|
||||||
fetchConfig() {
|
fetchConfig() {
|
||||||
this.entries = []
|
this.entries = []
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request('GET', '/nwcprovider/api/v1/config')
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/nwcprovider/api/v1/config',
|
||||||
|
this.g.user.wallets[0].adminkey
|
||||||
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
this.config = response.data
|
const newEntries = []
|
||||||
console.log('Config fetched:', this.config)
|
for (const [key, value] of Object.entries(response.data)) {
|
||||||
|
newEntries.push({
|
||||||
|
key: key,
|
||||||
|
value: value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.entries.length = 0
|
||||||
|
this.entries.push(...newEntries)
|
||||||
})
|
})
|
||||||
.catch(function (error) {
|
.catch(function (error) {
|
||||||
console.error('Error fetching config:', error)
|
console.error('Error fetching config:', error)
|
||||||
|
|
@ -41,15 +52,14 @@ window.app = Vue.createApp({
|
||||||
},
|
},
|
||||||
async saveConfig() {
|
async saveConfig() {
|
||||||
const data = {}
|
const data = {}
|
||||||
for (const [key, value] of Object.entries(this.config)) {
|
for (const entry of this.entries) {
|
||||||
data[key] = value
|
data[entry.key] = entry.value
|
||||||
}
|
}
|
||||||
console.log('Saving config:', data)
|
|
||||||
try {
|
try {
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
'/nwcprovider/api/v1/config',
|
'/nwcprovider/api/v1/config',
|
||||||
null,
|
this.g.user.wallets[0].adminkey,
|
||||||
data
|
data
|
||||||
)
|
)
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
|
|
|
||||||
49
tasks.py
49
tasks.py
|
|
@ -34,10 +34,6 @@ from .paranoia import (
|
||||||
)
|
)
|
||||||
from .permission import nwc_permissions
|
from .permission import nwc_permissions
|
||||||
|
|
||||||
PAYMENT_STATUS_POLL_INITIAL_INTERVAL_SECONDS = 1.0
|
|
||||||
PAYMENT_STATUS_POLL_MAX_INTERVAL_SECONDS = 60.0
|
|
||||||
PAYMENT_STATUS_POLL_BACKOFF_MULTIPLIER = 2.0
|
|
||||||
|
|
||||||
|
|
||||||
async def _check(nwc: NWCKey | None, method: str) -> dict | None:
|
async def _check(nwc: NWCKey | None, method: str) -> dict | None:
|
||||||
# check
|
# check
|
||||||
|
|
@ -114,24 +110,11 @@ async def _process_invoice(
|
||||||
True # currently required by nip 47 specs, might change in future
|
True # currently required by nip 47 specs, might change in future
|
||||||
)
|
)
|
||||||
payment_status: PaymentStatus | None = None
|
payment_status: PaymentStatus | None = None
|
||||||
poll_interval = PAYMENT_STATUS_POLL_INITIAL_INTERVAL_SECONDS
|
|
||||||
while wait_for_preimage:
|
while wait_for_preimage:
|
||||||
payment_status = await check_transaction_status(wallet_id, payment_hash)
|
payment_status = await check_transaction_status(wallet_id, payment_hash)
|
||||||
if payment_status.success:
|
if payment_status.success:
|
||||||
break
|
break
|
||||||
if payment_status.failed:
|
await asyncio.sleep(0.05)
|
||||||
return {
|
|
||||||
"error": {
|
|
||||||
"code": "PAYMENT_FAILED",
|
|
||||||
"message": "Payment failed.",
|
|
||||||
},
|
|
||||||
"in_budget": in_budget,
|
|
||||||
}
|
|
||||||
await asyncio.sleep(poll_interval)
|
|
||||||
poll_interval = min(
|
|
||||||
poll_interval * PAYMENT_STATUS_POLL_BACKOFF_MULTIPLIER,
|
|
||||||
PAYMENT_STATUS_POLL_MAX_INTERVAL_SECONDS,
|
|
||||||
)
|
|
||||||
if not payment_status:
|
if not payment_status:
|
||||||
raise Exception("Payment status not found")
|
raise Exception("Payment status not found")
|
||||||
return {
|
return {
|
||||||
|
|
@ -240,7 +223,6 @@ async def _on_multi_pay_invoice(
|
||||||
results.append(r)
|
results.append(r)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
results.append((None, {"code": "INTERNAL", "message": str(e)}, []))
|
results.append((None, {"code": "INTERNAL", "message": str(e)}, []))
|
||||||
await asyncio.sleep(0)
|
|
||||||
# await log_nwc(pubkey, payload)
|
# await log_nwc(pubkey, payload)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
@ -264,11 +246,9 @@ async def _on_make_invoice(
|
||||||
# Ensures amount is provided
|
# Ensures amount is provided
|
||||||
if not amount_msats:
|
if not amount_msats:
|
||||||
raise Exception("Missing amount")
|
raise Exception("Missing amount")
|
||||||
# Optional params may arrive as explicit JSON null, which dict.get does
|
description = params.get("description", "")
|
||||||
# not default, so coerce here rather than trusting the fallback.
|
description_hash = params.get("description_hash", None)
|
||||||
description = params.get("description") or ""
|
expiry = params.get("expiry", None)
|
||||||
description_hash = params.get("description_hash") or None
|
|
||||||
expiry = params.get("expiry") or None
|
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
assert_valid_msats(amount_msats)
|
assert_valid_msats(amount_msats)
|
||||||
|
|
@ -286,7 +266,7 @@ async def _on_make_invoice(
|
||||||
currency="sat",
|
currency="sat",
|
||||||
memo=description,
|
memo=description,
|
||||||
description_hash=bytes.fromhex(description_hash) if description_hash else None,
|
description_hash=bytes.fromhex(description_hash) if description_hash else None,
|
||||||
unhashed_description=description.encode("utf-8") if description else None,
|
unhashed_description=description.encode("utf-8"),
|
||||||
expiry=expiry,
|
expiry=expiry,
|
||||||
)
|
)
|
||||||
payment_hash = payment.payment_hash
|
payment_hash = payment.payment_hash
|
||||||
|
|
@ -396,12 +376,12 @@ async def _on_list_transactions(
|
||||||
if not nwc:
|
if not nwc:
|
||||||
raise Exception("Pubkey has no associated wallet")
|
raise Exception("Pubkey has no associated wallet")
|
||||||
params = payload.get("params", 0)
|
params = payload.get("params", 0)
|
||||||
tfrom = params.get("from") or 0
|
tfrom = params.get("from", 0)
|
||||||
tuntil = params.get("until") or int(time.time())
|
tuntil = params.get("until", int(time.time()))
|
||||||
limit = params.get("limit") or 10
|
limit = params.get("limit", 10)
|
||||||
offset = params.get("offset") or 0
|
offset = params.get("offset", 0)
|
||||||
unpaid = params.get("unpaid") or False
|
unpaid = params.get("unpaid", False)
|
||||||
tx_type = params.get("type") or ""
|
tx_type = params.get("type", "")
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
assert_valid_positive_int(tfrom)
|
assert_valid_positive_int(tfrom)
|
||||||
|
|
@ -437,11 +417,7 @@ async def _on_list_transactions(
|
||||||
{
|
{
|
||||||
"type": "outgoing" if p.is_out else "incoming",
|
"type": "outgoing" if p.is_out else "incoming",
|
||||||
"invoice": p.bolt11,
|
"invoice": p.bolt11,
|
||||||
# Fallback chain so a human-readable description reaches
|
"description": invoice_data.description,
|
||||||
# the NWC client. Mirror of `_on_lookup_invoice`
|
|
||||||
"description": (
|
|
||||||
(p.extra or {}).get("comment") or invoice_data.description or p.memo
|
|
||||||
),
|
|
||||||
"description_hash": invoice_data.description_hash,
|
"description_hash": invoice_data.description_hash,
|
||||||
"preimage": p.preimage if is_settled or p.is_in else None,
|
"preimage": p.preimage if is_settled or p.is_in else None,
|
||||||
"payment_hash": p.payment_hash,
|
"payment_hash": p.payment_hash,
|
||||||
|
|
@ -452,7 +428,6 @@ async def _on_list_transactions(
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
await asyncio.sleep(0)
|
|
||||||
# await log_nwc(pubkey, payload)
|
# await log_nwc(pubkey, payload)
|
||||||
return [({"transactions": transactions}, None, [])]
|
return [({"transactions": transactions}, None, [])]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,56 +6,21 @@
|
||||||
<div class="col-12 q-gutter-y-md">
|
<div class="col-12 q-gutter-y-md">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="row items-center wrap q-mb-md">
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h5 class="text-subtitle1 q-my-none">
|
<h5 class="text-subtitle1 q-my-none">
|
||||||
NWC Service Provider Configuration
|
NWC Service Provider - Config
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<q-markup-table flat wrap-cells="true">
|
<q-markup-table flat>
|
||||||
<tbody>
|
<tbody>
|
||||||
<q-tr>
|
<q-tr v-for="entry in entries" :key="entry.key">
|
||||||
<q-td>
|
<q-td>
|
||||||
<q-input
|
<span> ${entry.key} </span>
|
||||||
v-model="config.relay"
|
|
||||||
label="Nostr Relay URL"
|
|
||||||
filled
|
|
||||||
wrap
|
|
||||||
:hint="'URL of the Nostr relay for dispatching and receiving NWC events. Use public relays or a custom one. Specify `nostrclient` to use the Nostr Client extension'"
|
|
||||||
>
|
|
||||||
</q-input>
|
|
||||||
</q-td>
|
</q-td>
|
||||||
</q-tr>
|
|
||||||
<q-tr>
|
|
||||||
<q-td>
|
<q-td>
|
||||||
<q-input
|
<q-input v-model="entry.value" />
|
||||||
filled
|
|
||||||
label="Relay Alias"
|
|
||||||
v-model="config.relay_alias"
|
|
||||||
:hint="'Relay URL to display in pairing URLs. If your relay has a different public URL than the one set in \'Nostr Relay URL\' set it here.'"
|
|
||||||
/>
|
|
||||||
</q-td>
|
|
||||||
</q-tr>
|
|
||||||
<q-tr>
|
|
||||||
<q-td>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
label="NWC Provider Secret Key "
|
|
||||||
v-model="config.provider_key"
|
|
||||||
:hint="'The secret key for the NWC Service Provider. You don\'t need to change this unless your key has been compromised.'"
|
|
||||||
/>
|
|
||||||
</q-td>
|
|
||||||
</q-tr>
|
|
||||||
<q-tr>
|
|
||||||
<q-td>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
label="Time Period To Handle Missed Events"
|
|
||||||
v-model="config.handle_missed_events"
|
|
||||||
type="number"
|
|
||||||
:hint="'Number of seconds to look back for processing events missed while offline. Setting it to 0 disables this functionality.'"
|
|
||||||
/>
|
|
||||||
</q-td>
|
</q-td>
|
||||||
</q-tr>
|
</q-tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
|
|
@ -199,9 +199,7 @@ LNBITS_HIDE_API=false
|
||||||
|
|
||||||
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
|
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
|
||||||
# The extension must be removed from this list in order to not be re-installed.
|
# The extension must be removed from this list in order to not be re-installed.
|
||||||
# The tpos extension is no longer shipped with the LNbits dev tree. Keep the
|
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
||||||
# integration fixture focused on the extension under test.
|
|
||||||
LNBITS_EXTENSIONS_DEFAULT_INSTALL=""
|
|
||||||
|
|
||||||
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
|
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
|
||||||
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
|
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
|
||||||
|
|
|
||||||
|
|
@ -15,18 +15,12 @@ if [ "`cat .v039fk_lnbits_integration_test_folder`" != "yes v039fk_lnbits_integr
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Start nostr Relay. The image defaults to the `strfry` user (UID 1000),
|
# Start nostr Relay
|
||||||
# which is not necessarily the user running the CI job. Create the bind mount
|
|
||||||
# first and run the relay as the current user so LMDB can initialize its files.
|
|
||||||
id=$(id -u)
|
|
||||||
gid=$(id -g)
|
|
||||||
mkdir -p strfry-data
|
|
||||||
docker run --name=lnbits_nwcprovider_ext_nostr_test \
|
docker run --name=lnbits_nwcprovider_ext_nostr_test \
|
||||||
-d \
|
-d \
|
||||||
--rm \
|
--rm \
|
||||||
--user $id:$gid \
|
-v $PWD/strfry.conf:/etc/strfry.conf \
|
||||||
-v $PWD/strfry.conf:/etc/strfry.conf:Z \
|
-v $PWD/strfry-data:/app/strfry-db \
|
||||||
-v $PWD/strfry-data:/app/strfry-db:Z \
|
|
||||||
-p 7777:7777 \
|
-p 7777:7777 \
|
||||||
ghcr.io/hoytech/strfry:latest
|
ghcr.io/hoytech/strfry:latest
|
||||||
|
|
||||||
|
|
@ -34,18 +28,9 @@ ghcr.io/hoytech/strfry:latest
|
||||||
rm -Rf lnbits_itest_data
|
rm -Rf lnbits_itest_data
|
||||||
unzip data.zip
|
unzip data.zip
|
||||||
|
|
||||||
# The fixture was created with the standalone tpos extension installed. tpos
|
id=$(id -u)
|
||||||
# is no longer part of the LNbits dev tree, so leaving its database metadata in
|
gid=$(id -g)
|
||||||
# the fixture makes current LNbits attempt to import a module that is absent.
|
|
||||||
# The integration suite only exercises nwcprovider.
|
|
||||||
python3 - <<'PY'
|
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
with sqlite3.connect("lnbits_itest_data/database.sqlite3") as conn:
|
|
||||||
conn.execute("DELETE FROM installed_extensions WHERE id = 'tpos'")
|
|
||||||
conn.execute("DELETE FROM dbversions WHERE db = 'tpos'")
|
|
||||||
PY
|
|
||||||
rm -f lnbits_itest_data/ext_tpos.sqlite3 lnbits_itest_data/zips/tpos.zip
|
|
||||||
|
|
||||||
docker run --name=lnbits_nwcprovider_ext_lnbits_test \
|
docker run --name=lnbits_nwcprovider_ext_lnbits_test \
|
||||||
-d \
|
-d \
|
||||||
|
|
@ -60,50 +45,24 @@ docker run --name=lnbits_nwcprovider_ext_lnbits_test \
|
||||||
-v ${PWD}/../../.devcontainer/pre-setup.sh:/pre-setup.sh:ro \
|
-v ${PWD}/../../.devcontainer/pre-setup.sh:/pre-setup.sh:ro \
|
||||||
mcr.microsoft.com/devcontainers/python:1-3.12 bash -c "while true; do sleep 1000; done"
|
mcr.microsoft.com/devcontainers/python:1-3.12 bash -c "while true; do sleep 1000; done"
|
||||||
|
|
||||||
if ! docker network inspect lnbits_nwcprovider_ext_test_network >/dev/null 2>&1; then
|
docker network create lnbits_nwcprovider_ext_test_network || true
|
||||||
docker network create lnbits_nwcprovider_ext_test_network
|
docker network connect lnbits_nwcprovider_ext_test_network lnbits_nwcprovider_ext_nostr_test --alias nostr|| true
|
||||||
fi
|
docker network connect lnbits_nwcprovider_ext_test_network lnbits_nwcprovider_ext_lnbits_test --alias lnbits|| true
|
||||||
docker network connect lnbits_nwcprovider_ext_test_network lnbits_nwcprovider_ext_nostr_test --alias nostr
|
|
||||||
docker network connect lnbits_nwcprovider_ext_test_network lnbits_nwcprovider_ext_lnbits_test --alias lnbits
|
|
||||||
|
|
||||||
docker exec -u root lnbits_nwcprovider_ext_lnbits_test bash -c "id -u $id &>/dev/null || useradd -m -u $id tester"
|
docker exec -u root lnbits_nwcprovider_ext_lnbits_test bash -c "id -u $id &>/dev/null || useradd -m -u $id tester"
|
||||||
docker exec -u root lnbits_nwcprovider_ext_lnbits_test bash -c "bash /pre-setup.sh"
|
docker exec -u root lnbits_nwcprovider_ext_lnbits_test bash -c "bash /pre-setup.sh"
|
||||||
docker exec --user $id:$gid lnbits_nwcprovider_ext_lnbits_test bash -c "curl -sSL https://install.python-poetry.org | python3 -"
|
docker exec --user $id:$gid lnbits_nwcprovider_ext_lnbits_test bash -c "curl -sSL https://install.python-poetry.org | python3 -"
|
||||||
|
|
||||||
|
set +e
|
||||||
docker exec --user $id:$gid lnbits_nwcprovider_ext_lnbits_test bash -c "export PATH=\"\$HOME/.local/bin:\$PATH\" && bash /setup.sh /nwcprovider"
|
docker exec --user $id:$gid lnbits_nwcprovider_ext_lnbits_test bash -c "export PATH=\"\$HOME/.local/bin:\$PATH\" && bash /setup.sh /nwcprovider"
|
||||||
docker exec --user $id:$gid lnbits_nwcprovider_ext_lnbits_test bash -c "ln -s /app/.env \$HOME/lnbits/.env"
|
docker exec --user $id:$gid lnbits_nwcprovider_ext_lnbits_test bash -c "ln -s /app/.env \$HOME/lnbits/.env"
|
||||||
|
|
||||||
|
|
||||||
|
ARGS=""
|
||||||
if [ "$HEADLESS" != "" ];
|
if [ "$HEADLESS" != "" ];
|
||||||
then
|
then
|
||||||
# Keep the server log inside the container so a failed health check can
|
ARGS="-d"
|
||||||
# show the actual startup error instead of silently swallowing it.
|
|
||||||
docker exec --user $id:$gid -d lnbits_nwcprovider_ext_lnbits_test bash -c "export PATH=\"\$HOME/.local/bin:\$PATH\" && cd \$HOME/lnbits && poetry run lnbits > /tmp/lnbits.log 2>&1"
|
|
||||||
|
|
||||||
wait_for_http() {
|
|
||||||
local service="$1"
|
|
||||||
local url="$2"
|
|
||||||
local timeout_seconds="$3"
|
|
||||||
local deadline=$((SECONDS + timeout_seconds))
|
|
||||||
|
|
||||||
until curl --fail --silent --show-error --max-time 2 "$url" >/dev/null 2>&1; do
|
|
||||||
if [ "$SECONDS" -ge "$deadline" ]; then
|
|
||||||
echo "Timed out waiting for $service at $url" >&2
|
|
||||||
docker ps -a >&2
|
|
||||||
if [ "$service" = "LNbits" ]; then
|
|
||||||
docker exec lnbits_nwcprovider_ext_lnbits_test tail -n 100 /tmp/lnbits.log >&2 || true
|
|
||||||
else
|
|
||||||
docker logs --tail 100 lnbits_nwcprovider_ext_nostr_test >&2 || true
|
|
||||||
fi
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
# LNbits may need a few minutes for a fresh database migration.
|
|
||||||
wait_for_http "nostr relay" "http://localhost:7777" 180
|
|
||||||
wait_for_http "LNbits" "http://localhost:5002" 180
|
|
||||||
else
|
|
||||||
docker exec --user $id:$gid lnbits_nwcprovider_ext_lnbits_test bash -c "export PATH=\"\$HOME/.local/bin:\$PATH\" && cd \$HOME/lnbits && poetry run lnbits"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
docker exec --user $id:$gid $ARGS lnbits_nwcprovider_ext_lnbits_test bash -c "export PATH=\"\$HOME/.local/bin:\$PATH\" && cd \$HOME/lnbits && poetry run lnbits"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ relay {
|
||||||
port = 7777
|
port = 7777
|
||||||
|
|
||||||
# Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required)
|
# Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required)
|
||||||
nofiles = 0
|
nofiles = 1000000
|
||||||
|
|
||||||
# HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case)
|
# HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case)
|
||||||
realIpHeader = ""
|
realIpHeader = ""
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
|
|
@ -8,14 +9,13 @@ from typing import Union
|
||||||
import bolt11
|
import bolt11
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
import secp256k1
|
||||||
|
from Cryptodome import Random
|
||||||
|
from Cryptodome.Cipher import AES
|
||||||
|
from Cryptodome.Util.Padding import pad, unpad
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pynostr.key import PrivateKey
|
|
||||||
from websockets.legacy.client import connect
|
from websockets.legacy.client import connect
|
||||||
|
|
||||||
SERVICE_STARTUP_TIMEOUT_SECONDS = 180
|
|
||||||
NWC_CONNECTION_TIMEOUT_SECONDS = 60
|
|
||||||
NWC_RESPONSE_TIMEOUT_SECONDS = 60
|
|
||||||
|
|
||||||
wallets = {
|
wallets = {
|
||||||
"wallet1": {
|
"wallet1": {
|
||||||
"name": "wallet1",
|
"name": "wallet1",
|
||||||
|
|
@ -45,29 +45,35 @@ wallets = {
|
||||||
|
|
||||||
|
|
||||||
async def check_services():
|
async def check_services():
|
||||||
async def wait_for_service(name: str, url: str):
|
# wait for http server in localhost:7777
|
||||||
deadline = time.monotonic() + SERVICE_STARTUP_TIMEOUT_SECONDS
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
resp = await client.get(url)
|
async with httpx.AsyncClient() as client:
|
||||||
if resp.status_code == 200:
|
resp = await client.get("http://localhost:7777")
|
||||||
return
|
assert resp.status_code == 200
|
||||||
except httpx.HTTPError:
|
break
|
||||||
pass
|
except Exception:
|
||||||
if time.monotonic() >= deadline:
|
logger.info("Waiting for nostr relay @ http://localhost:7777")
|
||||||
raise RuntimeError(
|
logger.info(
|
||||||
f"Timed out waiting for {name} at {url}. "
|
"""Please start the required services by running\
|
||||||
"Start the integration services with `bash start.sh`."
|
`bash start.sh` if you haven't already"""
|
||||||
)
|
)
|
||||||
logger.info(f"Waiting for {name} @ {url}")
|
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
raise RuntimeError(f"Unable to check {name} at {url}: {exc}") from exc
|
|
||||||
|
|
||||||
await wait_for_service("nostr relay", "http://localhost:7777")
|
# wait lnbits @ localhost:5000
|
||||||
await wait_for_service("LNbits", "http://localhost:5002")
|
while True:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get("http://localhost:5002")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
logger.info("Waiting for lnbits @ http://localhost:5002")
|
||||||
|
logger.info(
|
||||||
|
"""Please start the required services by running\
|
||||||
|
`bash start.sh` if you haven't already"""
|
||||||
|
)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
async def get_wallet_balance(w: str):
|
async def get_wallet_balance(w: str):
|
||||||
|
|
@ -89,12 +95,12 @@ async def refresh_wallet_balances():
|
||||||
|
|
||||||
|
|
||||||
def gen_keypair():
|
def gen_keypair():
|
||||||
private_key = PrivateKey()
|
private_key_hex = bytes.hex(secp256k1._gen_private_key())
|
||||||
private_key_hex = private_key.hex()
|
private_key = secp256k1.PrivateKey(bytes.fromhex(private_key_hex))
|
||||||
public_key = private_key.public_key
|
public_key = private_key.pubkey
|
||||||
if not public_key:
|
if not public_key:
|
||||||
raise Exception("Error generating pubkey")
|
raise Exception("Error generating pubkey")
|
||||||
public_key_hex = public_key.hex()
|
public_key_hex = public_key.serialize().hex()[2:]
|
||||||
return {"priv": private_key_hex, "pub": public_key_hex}
|
return {"priv": private_key_hex, "pub": public_key_hex}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -158,12 +164,12 @@ class NWCWallet:
|
||||||
self.event_queue = []
|
self.event_queue = []
|
||||||
self.subscriptions_count = 0
|
self.subscriptions_count = 0
|
||||||
self.sub_id = ""
|
self.sub_id = ""
|
||||||
self.private_key = PrivateKey.from_hex(self.secret)
|
self.private_key = secp256k1.PrivateKey(bytes.fromhex(self.secret))
|
||||||
self.private_key_hex = self.secret
|
self.private_key_hex = self.secret
|
||||||
self.public_key = self.private_key.public_key
|
self.public_key = self.private_key.pubkey
|
||||||
if not self.public_key:
|
if not self.public_key:
|
||||||
raise Exception("Error generating pubkey")
|
raise Exception("Error generating pubkey")
|
||||||
self.public_key_hex = self.public_key.hex()
|
self.public_key_hex = self.public_key.serialize().hex()[2:]
|
||||||
self.task = None
|
self.task = None
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
|
|
@ -177,21 +183,15 @@ class NWCWallet:
|
||||||
|
|
||||||
async def _wait_for_connection(self):
|
async def _wait_for_connection(self):
|
||||||
while not self.connected:
|
while not self.connected:
|
||||||
|
try:
|
||||||
await asyncio.sleep(0.2)
|
await asyncio.sleep(0.2)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.debug("Connection wait cancelled")
|
||||||
|
return
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
self.task = asyncio.create_task(self._run())
|
self.task = asyncio.create_task(self._run())
|
||||||
try:
|
await self._wait_for_connection()
|
||||||
await asyncio.wait_for(
|
|
||||||
self._wait_for_connection(), timeout=NWC_CONNECTION_TIMEOUT_SECONDS
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError as exc:
|
|
||||||
self.task.cancel()
|
|
||||||
await asyncio.gather(self.task, return_exceptions=True)
|
|
||||||
self.task = None
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Timed out connecting to NWC relay {self.relay}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
def _is_shutting_down(self):
|
def _is_shutting_down(self):
|
||||||
return self.shutdown
|
return self.shutdown
|
||||||
|
|
@ -243,13 +243,43 @@ class NWCWallet:
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
def _encrypt_content(
|
||||||
|
self, content: str, pubkey_hex: str, iv_seed: int | None = None
|
||||||
|
) -> str:
|
||||||
|
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
|
||||||
|
shared = pubkey.tweak_mul(bytes.fromhex(self.private_key_hex)).serialize()[1:]
|
||||||
|
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]
|
||||||
|
aes = AES.new(shared, AES.MODE_CBC, iv)
|
||||||
|
content_bytes = content.encode("utf-8")
|
||||||
|
content_bytes = pad(content_bytes, AES.block_size)
|
||||||
|
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:
|
||||||
|
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
|
||||||
|
shared = pubkey.tweak_mul(bytes.fromhex(self.private_key_hex)).serialize()[1:]
|
||||||
|
(encrypted_content_b64, iv_b64) = content.split("?iv=")
|
||||||
|
encrypted_content = base64.b64decode(encrypted_content_b64.encode("ascii"))
|
||||||
|
iv = base64.b64decode(iv_b64.encode("ascii"))
|
||||||
|
aes = AES.new(shared, AES.MODE_CBC, iv)
|
||||||
|
decrypted_bytes = aes.decrypt(encrypted_content)
|
||||||
|
decrypted_bytes = unpad(decrypted_bytes, AES.block_size)
|
||||||
|
decrypted = decrypted_bytes.decode("utf-8")
|
||||||
|
return decrypted
|
||||||
|
|
||||||
async def _on_message(self, _, message: str):
|
async def _on_message(self, _, message: str):
|
||||||
logger.debug("Received message: " + message)
|
logger.debug("Received message: " + message)
|
||||||
msg = json.loads(message)
|
msg = json.loads(message)
|
||||||
if msg[0] == "EVENT": # Event message
|
if msg[0] == "EVENT": # Event message
|
||||||
event = msg[2]
|
event = msg[2]
|
||||||
nwc_pubkey = event["pubkey"]
|
nwc_pubkey = event["pubkey"]
|
||||||
content = self.private_key.decrypt_message(event["content"], nwc_pubkey)
|
content = self._decrypt_content(event["content"], nwc_pubkey)
|
||||||
content = json.loads(content)
|
content = json.loads(content)
|
||||||
self.event_queue.append(
|
self.event_queue.append(
|
||||||
{
|
{
|
||||||
|
|
@ -282,9 +312,10 @@ class NWCWallet:
|
||||||
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
|
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
|
||||||
event["id"] = event_id
|
event["id"] = event_id
|
||||||
event["pubkey"] = self.public_key_hex
|
event["pubkey"] = self.public_key_hex
|
||||||
signature = self.private_key.sign(bytes.fromhex(event_id))
|
signature = (
|
||||||
# type error? returns str but is bytes
|
self.private_key.schnorr_sign(bytes.fromhex(event_id), None, raw=True)
|
||||||
event["sig"] = signature.hex() # type: ignore
|
).hex()
|
||||||
|
event["sig"] = signature
|
||||||
return event
|
return event
|
||||||
|
|
||||||
async def send_event(self, method, params):
|
async def send_event(self, method, params):
|
||||||
|
|
@ -300,7 +331,7 @@ class NWCWallet:
|
||||||
"content": json.dumps({"method": method, "params": params}),
|
"content": json.dumps({"method": method, "params": params}),
|
||||||
}
|
}
|
||||||
logger.debug("Sending event: " + str(event))
|
logger.debug("Sending event: " + str(event))
|
||||||
event["content"] = self.private_key.encrypt_message(
|
event["content"] = self._encrypt_content(
|
||||||
event["content"], self.provider_pub_hex
|
event["content"], self.provider_pub_hex
|
||||||
)
|
)
|
||||||
self._sign_event(event)
|
self._sign_event(event)
|
||||||
|
|
@ -308,11 +339,7 @@ class NWCWallet:
|
||||||
await self.ws.send(self._json_dumps(["EVENT", event]))
|
await self.ws.send(self._json_dumps(["EVENT", event]))
|
||||||
|
|
||||||
async def wait_for(
|
async def wait_for(
|
||||||
self,
|
self, result_type, callback=None, on_error_callback=None, timeout=60000
|
||||||
result_type,
|
|
||||||
callback=None,
|
|
||||||
on_error_callback=None,
|
|
||||||
timeout=NWC_RESPONSE_TIMEOUT_SECONDS,
|
|
||||||
):
|
):
|
||||||
now = time.time()
|
now = time.time()
|
||||||
while True:
|
while True:
|
||||||
|
|
@ -830,77 +857,6 @@ async def test_budget_refresh():
|
||||||
await wallet1.close()
|
await wallet1.close()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_never_refresh_budget_counts_previous_spend():
|
|
||||||
await check_services()
|
|
||||||
nwc1 = await create_nwc(
|
|
||||||
"wallet1",
|
|
||||||
"test_never_refresh_budget_counts_previous_spend",
|
|
||||||
["invoice"],
|
|
||||||
[],
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
nwc3 = await create_nwc(
|
|
||||||
"wallet3",
|
|
||||||
"test_never_refresh_budget_counts_previous_spend",
|
|
||||||
["pay"],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"budget_msats": 100000,
|
|
||||||
"refresh_window": 0,
|
|
||||||
"created_at": int(time.time()),
|
|
||||||
}
|
|
||||||
],
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
wallet1 = NWCWallet(nwc1["pairing"])
|
|
||||||
wallet3 = NWCWallet(nwc3["pairing"])
|
|
||||||
|
|
||||||
try:
|
|
||||||
await wallet1.start()
|
|
||||||
await wallet3.start()
|
|
||||||
|
|
||||||
await wallet1.send_event(
|
|
||||||
"make_invoice", {"amount": 60000, "description": "Within lifetime budget"}
|
|
||||||
)
|
|
||||||
result, _, error = await wallet1.wait_for("make_invoice")
|
|
||||||
assert not error
|
|
||||||
|
|
||||||
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
|
|
||||||
_, _, error = await wallet3.wait_for("pay_invoice")
|
|
||||||
assert not error
|
|
||||||
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.get(
|
|
||||||
"http://localhost:5002/nwcprovider/api/v1/nwc"
|
|
||||||
"?calculate_spent_budget=true",
|
|
||||||
headers={"X-Api-Key": wallets["wallet3"]["admin_key"]},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
payer_nwc = next(
|
|
||||||
item for item in resp.json() if item["data"]["pubkey"] == nwc3["pubkey"]
|
|
||||||
)
|
|
||||||
assert payer_nwc["budgets"][0]["used_budget_msats"] == 60000
|
|
||||||
|
|
||||||
await wallet1.send_event(
|
|
||||||
"make_invoice", {"amount": 50000, "description": "Exceeds lifetime budget"}
|
|
||||||
)
|
|
||||||
result, _, error = await wallet1.wait_for("make_invoice")
|
|
||||||
assert not error
|
|
||||||
|
|
||||||
await wallet3.send_event("pay_invoice", {"invoice": result["invoice"]})
|
|
||||||
_, _, error = await wallet3.wait_for("pay_invoice")
|
|
||||||
assert error
|
|
||||||
assert error["code"] == "QUOTA_EXCEEDED"
|
|
||||||
finally:
|
|
||||||
if wallet3.ws:
|
|
||||||
await wallet3.close()
|
|
||||||
if wallet1.ws:
|
|
||||||
await wallet1.close()
|
|
||||||
|
|
||||||
|
|
||||||
# Mostly AI generated pentests
|
# Mostly AI generated pentests
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -932,7 +888,7 @@ async def test_idor_vulnerability():
|
||||||
f"http://localhost:5002/nwcprovider/api/v1/nwc/{nwc_wallet1['pubkey']}",
|
f"http://localhost:5002/nwcprovider/api/v1/nwc/{nwc_wallet1['pubkey']}",
|
||||||
headers={"X-Api-Key": wallets["wallet2"]["admin_key"]},
|
headers={"X-Api-Key": wallets["wallet2"]["admin_key"]},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 500
|
||||||
assert "Pubkey has no associated wallet" in resp.text
|
assert "Pubkey has no associated wallet" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
import time
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
@ -35,22 +33,25 @@ def test_supported_methods(nwc_service_provider):
|
||||||
|
|
||||||
def test_encrytdecrypt(nwc_service_provider, nwc_service_provider2):
|
def test_encrytdecrypt(nwc_service_provider, nwc_service_provider2):
|
||||||
content = "Hello World"
|
content = "Hello World"
|
||||||
enc_a = nwc_service_provider.private_key.encrypt_message(
|
expected_enc = "qVurNVISSl/9CfREIhk5Lg==?iv=QpCo5dI9gUcoLsSMLA7o7Q=="
|
||||||
content, nwc_service_provider2.public_key_hex
|
enc_a = nwc_service_provider._encrypt_content(
|
||||||
|
content, nwc_service_provider2.public_key_hex, 21
|
||||||
)
|
)
|
||||||
enc_b = nwc_service_provider2.private_key.encrypt_message(
|
enc_b = nwc_service_provider2._encrypt_content(
|
||||||
content, nwc_service_provider.public_key_hex
|
content, nwc_service_provider.public_key_hex, 21
|
||||||
)
|
)
|
||||||
|
|
||||||
dec_a = nwc_service_provider2.private_key.decrypt_message(
|
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.private_key.decrypt_message(
|
dec_b = nwc_service_provider._decrypt_content(
|
||||||
enc_b, nwc_service_provider2.public_key_hex
|
enc_b, nwc_service_provider2.public_key_hex
|
||||||
)
|
)
|
||||||
|
|
||||||
assert dec_a == content
|
assert dec_a == content
|
||||||
assert dec_b == content
|
assert dec_b == content
|
||||||
|
assert enc_a == expected_enc
|
||||||
|
assert enc_b == expected_enc
|
||||||
|
|
||||||
|
|
||||||
def test_signverify(nwc_service_provider, nwc_service_provider2):
|
def test_signverify(nwc_service_provider, nwc_service_provider2):
|
||||||
|
|
@ -76,31 +77,19 @@ def test_signverify(nwc_service_provider, nwc_service_provider2):
|
||||||
assert nwc_service_provider2._verify_event(signed)
|
assert nwc_service_provider2._verify_event(signed)
|
||||||
|
|
||||||
|
|
||||||
def test_default_event_max_age(nwc_service_provider):
|
|
||||||
assert nwc_service_provider.event_max_age == 5 * 60
|
|
||||||
assert (
|
|
||||||
NWCServiceProvider(
|
|
||||||
"d7b5232fba0e02e32cfe26f20cdf2c803b27ecd81052c2dd5d17e5e1a333fe58",
|
|
||||||
"",
|
|
||||||
handle_missed_events=123,
|
|
||||||
).event_max_age
|
|
||||||
== 123
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_handle(nwc_service_provider, nwc_service_provider2):
|
async def test_handle(nwc_service_provider, nwc_service_provider2):
|
||||||
content = nwc_service_provider._json_dumps(
|
content = nwc_service_provider._json_dumps(
|
||||||
{"method": "pay_invoice", "params": {"invoice": "abc"}}
|
{"method": "pay_invoice", "params": {"invoice": "abc"}}
|
||||||
)
|
)
|
||||||
content = nwc_service_provider.private_key.encrypt_message(
|
content = nwc_service_provider._encrypt_content(
|
||||||
content, nwc_service_provider2.public_key_hex
|
content, nwc_service_provider2.public_key_hex, 21
|
||||||
)
|
)
|
||||||
event = {
|
event = {
|
||||||
"kind": 23194,
|
"kind": 23194,
|
||||||
"content": content,
|
"content": content,
|
||||||
"tags": [["p", nwc_service_provider2.public_key_hex]],
|
"tags": [["p", nwc_service_provider2.public_key_hex]],
|
||||||
"created_at": int(time.time()),
|
"created_at": 1234567890,
|
||||||
}
|
}
|
||||||
signed = nwc_service_provider._sign_event(event)
|
signed = nwc_service_provider._sign_event(event)
|
||||||
|
|
||||||
|
|
@ -114,13 +103,12 @@ async def test_handle(nwc_service_provider, nwc_service_provider2):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
nwc_service_provider2._send = _send_pass
|
nwc_service_provider2._send = _send_pass
|
||||||
nwc_service_provider2._create_subscription()
|
|
||||||
nwc_service_provider2.add_request_listener("pay_invoice", _handle_pay_invoice)
|
nwc_service_provider2.add_request_listener("pay_invoice", _handle_pay_invoice)
|
||||||
sent_events = await nwc_service_provider2._handle_request(signed)
|
sent_events = await nwc_service_provider2._handle_request(signed)
|
||||||
assert len(sent_events) == 1
|
assert len(sent_events) == 1
|
||||||
for revent in sent_events:
|
for revent in sent_events:
|
||||||
assert nwc_service_provider2._verify_event(revent)
|
assert nwc_service_provider2._verify_event(revent)
|
||||||
content = nwc_service_provider2.private_key.decrypt_message(
|
content = nwc_service_provider2._decrypt_content(
|
||||||
revent["content"], nwc_service_provider.public_key_hex
|
revent["content"], nwc_service_provider.public_key_hex
|
||||||
)
|
)
|
||||||
logger.debug(event)
|
logger.debug(event)
|
||||||
|
|
@ -140,187 +128,3 @@ async def test_handle(nwc_service_provider, nwc_service_provider2):
|
||||||
p_tag = [tag for tag in tags if tag[0] == "p"]
|
p_tag = [tag for tag in tags if tag[0] == "p"]
|
||||||
assert len(p_tag) == 1
|
assert len(p_tag) == 1
|
||||||
assert p_tag[0][1] == nwc_service_provider.public_key_hex
|
assert p_tag[0][1] == nwc_service_provider.public_key_hex
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_handle_rejects_same_event_replay(
|
|
||||||
nwc_service_provider, nwc_service_provider2
|
|
||||||
):
|
|
||||||
content = nwc_service_provider._json_dumps(
|
|
||||||
{"method": "pay_invoice", "params": {"invoice": "abc"}}
|
|
||||||
)
|
|
||||||
content = nwc_service_provider.private_key.encrypt_message(
|
|
||||||
content, nwc_service_provider2.public_key_hex
|
|
||||||
)
|
|
||||||
event = {
|
|
||||||
"kind": 23194,
|
|
||||||
"content": content,
|
|
||||||
"tags": [["p", nwc_service_provider2.public_key_hex]],
|
|
||||||
"created_at": int(time.time()),
|
|
||||||
}
|
|
||||||
signed = nwc_service_provider._sign_event(event)
|
|
||||||
calls = 0
|
|
||||||
|
|
||||||
async def _handle_pay_invoice(provider, pubkey, content):
|
|
||||||
nonlocal calls
|
|
||||||
calls += 1
|
|
||||||
return [({"preimage": "00000"}, None, [])]
|
|
||||||
|
|
||||||
async def _send_pass(obj):
|
|
||||||
pass
|
|
||||||
|
|
||||||
nwc_service_provider2._send = _send_pass
|
|
||||||
nwc_service_provider2._create_subscription()
|
|
||||||
nwc_service_provider2.add_request_listener("pay_invoice", _handle_pay_invoice)
|
|
||||||
|
|
||||||
await nwc_service_provider2._handle_request(signed)
|
|
||||||
|
|
||||||
with pytest.raises(Exception, match="already handled"):
|
|
||||||
await nwc_service_provider2._handle_request(signed)
|
|
||||||
|
|
||||||
assert calls == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_relay_dispatches_requests_without_waiting_for_previous_request(
|
|
||||||
nwc_service_provider, monkeypatch
|
|
||||||
):
|
|
||||||
sub = nwc_service_provider._create_subscription()
|
|
||||||
sub.requests_sub_id = "requests"
|
|
||||||
sub.requests_eose = True
|
|
||||||
sub.responses_eose = True
|
|
||||||
monkeypatch.setattr(nwc_service_provider, "_verify_event", lambda event: True)
|
|
||||||
|
|
||||||
first_request_finished = asyncio.Event()
|
|
||||||
second_request_finished = asyncio.Event()
|
|
||||||
|
|
||||||
async def _handle_request(event):
|
|
||||||
if event["id"] == "first":
|
|
||||||
await first_request_finished.wait()
|
|
||||||
else:
|
|
||||||
second_request_finished.set()
|
|
||||||
return []
|
|
||||||
|
|
||||||
monkeypatch.setattr(nwc_service_provider, "_handle_request", _handle_request)
|
|
||||||
|
|
||||||
def request(event_id):
|
|
||||||
return json.dumps(
|
|
||||||
[
|
|
||||||
"EVENT",
|
|
||||||
sub.requests_sub_id,
|
|
||||||
{
|
|
||||||
"id": event_id,
|
|
||||||
"kind": 23194,
|
|
||||||
"pubkey": "a" * 64,
|
|
||||||
"content": "",
|
|
||||||
"tags": [["p", nwc_service_provider.public_key_hex]],
|
|
||||||
"created_at": int(time.time()),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
await nwc_service_provider._on_message(None, request("first"))
|
|
||||||
await nwc_service_provider._on_message(None, request("second"))
|
|
||||||
|
|
||||||
await asyncio.wait_for(second_request_finished.wait(), timeout=1)
|
|
||||||
assert not first_request_finished.is_set()
|
|
||||||
|
|
||||||
first_request_finished.set()
|
|
||||||
await asyncio.gather(*list(nwc_service_provider.request_tasks))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cleanup_cancels_pending_request_tasks(nwc_service_provider, monkeypatch):
|
|
||||||
request_started = asyncio.Event()
|
|
||||||
|
|
||||||
async def _handle_request(event):
|
|
||||||
request_started.set()
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
return []
|
|
||||||
|
|
||||||
monkeypatch.setattr(nwc_service_provider, "_handle_request", _handle_request)
|
|
||||||
nwc_service_provider._dispatch_request({"id": "pending"})
|
|
||||||
await request_started.wait()
|
|
||||||
|
|
||||||
await nwc_service_provider.cleanup()
|
|
||||||
|
|
||||||
assert not nwc_service_provider.request_tasks
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_info_event(nwc_service_provider):
|
|
||||||
"""_send_info_event should publish a signed kind-13194 event."""
|
|
||||||
nwc_service_provider.add_request_listener(
|
|
||||||
"pay_invoice", lambda *args, **kwargs: None # type: ignore[arg-type]
|
|
||||||
)
|
|
||||||
|
|
||||||
sent: list[list] = []
|
|
||||||
|
|
||||||
async def _send_capture(obj):
|
|
||||||
sent.append(obj)
|
|
||||||
|
|
||||||
nwc_service_provider._send = _send_capture
|
|
||||||
nwc_service_provider.connected = True
|
|
||||||
|
|
||||||
await nwc_service_provider._send_info_event()
|
|
||||||
|
|
||||||
assert len(sent) == 1
|
|
||||||
msg = sent[0]
|
|
||||||
assert msg[0] == "EVENT"
|
|
||||||
event = msg[1]
|
|
||||||
assert event["kind"] == 13194
|
|
||||||
assert "pay_invoice" in event["content"]
|
|
||||||
assert nwc_service_provider._verify_event(event)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_info_event_loop_resends(nwc_service_provider):
|
|
||||||
"""_info_event_loop should resend the info event while connected."""
|
|
||||||
sent: list[list] = []
|
|
||||||
|
|
||||||
async def _send_capture(obj):
|
|
||||||
sent.append(obj)
|
|
||||||
|
|
||||||
nwc_service_provider._send = _send_capture
|
|
||||||
nwc_service_provider.connected = True
|
|
||||||
|
|
||||||
loop_task = asyncio.create_task(nwc_service_provider._info_event_loop())
|
|
||||||
# Allow the loop to run through one sleep cycle (patched to near-zero).
|
|
||||||
# We drive it by cancelling right after the first send opportunity.
|
|
||||||
await asyncio.sleep(0) # yield to let the task start
|
|
||||||
# Manually trigger a resend call to verify the helper works correctly.
|
|
||||||
await nwc_service_provider._send_info_event()
|
|
||||||
loop_task.cancel()
|
|
||||||
try:
|
|
||||||
await loop_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# At least the manual call went through.
|
|
||||||
assert len(sent) >= 1
|
|
||||||
for msg in sent:
|
|
||||||
assert msg[0] == "EVENT"
|
|
||||||
assert msg[1]["kind"] == 13194
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_info_event_loop_skips_when_disconnected(nwc_service_provider):
|
|
||||||
"""_info_event_loop should not send the info event while disconnected."""
|
|
||||||
sent: list[list] = []
|
|
||||||
|
|
||||||
async def _send_capture(obj):
|
|
||||||
sent.append(obj)
|
|
||||||
|
|
||||||
nwc_service_provider._send = _send_capture
|
|
||||||
nwc_service_provider.connected = False # not connected
|
|
||||||
|
|
||||||
loop_task = asyncio.create_task(nwc_service_provider._info_event_loop())
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
loop_task.cancel()
|
|
||||||
try:
|
|
||||||
await loop_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Nothing should have been sent because connected=False.
|
|
||||||
assert len(sent) == 0
|
|
||||||
|
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from ... import tasks
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_process_invoice_returns_payment_failed_on_failed_status(monkeypatch):
|
|
||||||
async def fake_tracked_spend_nwc(*args, **kwargs):
|
|
||||||
return True, "a" * 64
|
|
||||||
|
|
||||||
async def fake_check_transaction_status(wallet_id: str, payment_hash: str):
|
|
||||||
return SimpleNamespace(success=False, failed=True)
|
|
||||||
|
|
||||||
monkeypatch.setattr(tasks, "tracked_spend_nwc", fake_tracked_spend_nwc)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
tasks, "check_transaction_status", fake_check_transaction_status
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await tasks._process_invoice(
|
|
||||||
wallet_id="wallet123",
|
|
||||||
pubkey="a" * 64,
|
|
||||||
invoice="lnbc1example",
|
|
||||||
amount_msats=1000,
|
|
||||||
description="test",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["error"]["code"] == "PAYMENT_FAILED"
|
|
||||||
assert result["error"]["message"] == "Payment failed."
|
|
||||||
assert result["in_budget"] is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_process_invoice_backs_off_pending_payment_polling_to_configured_max(
|
|
||||||
monkeypatch,
|
|
||||||
):
|
|
||||||
async def fake_tracked_spend_nwc(*args, **kwargs):
|
|
||||||
return True, "a" * 64
|
|
||||||
|
|
||||||
pending = SimpleNamespace(success=False, failed=False)
|
|
||||||
statuses = iter(
|
|
||||||
[pending] * 8
|
|
||||||
+ [
|
|
||||||
SimpleNamespace(
|
|
||||||
success=True,
|
|
||||||
failed=False,
|
|
||||||
preimage="b" * 64,
|
|
||||||
fee_msat=10,
|
|
||||||
paid=True,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
async def fake_check_transaction_status(wallet_id: str, payment_hash: str):
|
|
||||||
return next(statuses)
|
|
||||||
|
|
||||||
sleep_calls: list[float] = []
|
|
||||||
|
|
||||||
async def fake_sleep(delay: float):
|
|
||||||
sleep_calls.append(delay)
|
|
||||||
|
|
||||||
monkeypatch.setattr(tasks, "tracked_spend_nwc", fake_tracked_spend_nwc)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
tasks, "check_transaction_status", fake_check_transaction_status
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(tasks.asyncio, "sleep", fake_sleep)
|
|
||||||
|
|
||||||
result = await tasks._process_invoice(
|
|
||||||
wallet_id="wallet123",
|
|
||||||
pubkey="a" * 64,
|
|
||||||
invoice="lnbc1example",
|
|
||||||
amount_msats=1000,
|
|
||||||
description="test",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert sleep_calls == [1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 60.0, 60.0]
|
|
||||||
assert result["preimage"] == "b" * 64
|
|
||||||
assert result["fee_msats"] == 10
|
|
||||||
assert result["paid"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def _stub_make_invoice(monkeypatch, captured: dict):
|
|
||||||
async def fake_get_nwc(*args, **kwargs):
|
|
||||||
return SimpleNamespace(wallet="wallet123")
|
|
||||||
|
|
||||||
async def fake_check(nwc, method):
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def fake_create_invoice(**kwargs):
|
|
||||||
captured.update(kwargs)
|
|
||||||
return SimpleNamespace(payment_hash="b" * 64, bolt11="lnbc1example")
|
|
||||||
|
|
||||||
async def fake_check_transaction_status(wallet_id: str, payment_hash: str):
|
|
||||||
return SimpleNamespace(preimage=None)
|
|
||||||
|
|
||||||
monkeypatch.setattr(tasks, "get_nwc", fake_get_nwc)
|
|
||||||
monkeypatch.setattr(tasks, "_check", fake_check)
|
|
||||||
monkeypatch.setattr(tasks, "create_invoice", fake_create_invoice)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
tasks, "check_transaction_status", fake_check_transaction_status
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"params",
|
|
||||||
[
|
|
||||||
# rust-nostr, Alby JS SDK: unused optional fields are omitted
|
|
||||||
{"amount": 21000},
|
|
||||||
# Amethyst: unused optional fields are sent as explicit null
|
|
||||||
{
|
|
||||||
"amount": 21000,
|
|
||||||
"description": None,
|
|
||||||
"description_hash": None,
|
|
||||||
"expiry": None,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def test_make_invoice_accepts_absent_or_null_optional_params(monkeypatch, params):
|
|
||||||
captured: dict = {}
|
|
||||||
_stub_make_invoice(monkeypatch, captured)
|
|
||||||
|
|
||||||
[(result, error, _)] = await tasks._on_make_invoice(
|
|
||||||
SimpleNamespace(), "a" * 64, {"params": params}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert error is None
|
|
||||||
assert result["invoice"] == "lnbc1example"
|
|
||||||
assert result["description"] == ""
|
|
||||||
assert "expires_at" not in result
|
|
||||||
assert captured["memo"] == ""
|
|
||||||
assert captured["description_hash"] is None
|
|
||||||
assert captured["unhashed_description"] is None
|
|
||||||
assert captured["expiry"] is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_make_invoice_passes_description_and_expiry_through(monkeypatch):
|
|
||||||
captured: dict = {}
|
|
||||||
_stub_make_invoice(monkeypatch, captured)
|
|
||||||
|
|
||||||
[(result, error, _)] = await tasks._on_make_invoice(
|
|
||||||
SimpleNamespace(),
|
|
||||||
"a" * 64,
|
|
||||||
{"params": {"amount": 21000, "description": "coffee", "expiry": 600}},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert error is None
|
|
||||||
assert captured["memo"] == "coffee"
|
|
||||||
assert captured["unhashed_description"] == b"coffee"
|
|
||||||
assert captured["expiry"] == 600
|
|
||||||
assert result["description"] == "coffee"
|
|
||||||
assert "expires_at" in result
|
|
||||||
12
views_api.py
12
views_api.py
|
|
@ -1,10 +1,10 @@
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
import secp256k1
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from lnbits.core.models import WalletTypeInfo
|
from lnbits.core.models import WalletTypeInfo
|
||||||
from lnbits.decorators import check_admin, require_admin_key
|
from lnbits.decorators import check_admin, require_admin_key
|
||||||
from pynostr.key import PrivateKey
|
|
||||||
|
|
||||||
from .crud import (
|
from .crud import (
|
||||||
create_nwc,
|
create_nwc,
|
||||||
|
|
@ -88,13 +88,11 @@ async def api_get_nwc(
|
||||||
nwc = await get_nwc(
|
nwc = await get_nwc(
|
||||||
GetNWC(pubkey=pubkey, wallet=wallet_id, include_expired=include_expired)
|
GetNWC(pubkey=pubkey, wallet=wallet_id, include_expired=include_expired)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not nwc:
|
if not nwc:
|
||||||
raise ValueError("Pubkey has no associated wallet")
|
raise Exception("Pubkey has no associated wallet")
|
||||||
res = NWCGetResponse(
|
res = NWCGetResponse(
|
||||||
data=nwc, budgets=await get_budgets_nwc(GetBudgetsNWC(pubkey=pubkey))
|
data=nwc, budgets=await get_budgets_nwc(GetBudgetsNWC(pubkey=pubkey))
|
||||||
)
|
)
|
||||||
|
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -125,11 +123,11 @@ async def api_get_pairing_url(req: Request, secret: str) -> str:
|
||||||
scheme = "wss"
|
scheme = "wss"
|
||||||
netloc += "/nostrclient/api/v1/relay"
|
netloc += "/nostrclient/api/v1/relay"
|
||||||
relay = f"{scheme}://{netloc}"
|
relay = f"{scheme}://{netloc}"
|
||||||
psk = PrivateKey.from_hex(pprivkey)
|
psk = secp256k1.PrivateKey(bytes.fromhex(pprivkey))
|
||||||
ppk = psk.public_key
|
ppk = psk.pubkey
|
||||||
if not ppk:
|
if not ppk:
|
||||||
raise Exception("Error generating pubkey")
|
raise Exception("Error generating pubkey")
|
||||||
ppubkey = ppk.hex()
|
ppubkey = ppk.serialize().hex()[2:]
|
||||||
url = "nostr+walletconnect://"
|
url = "nostr+walletconnect://"
|
||||||
url += ppubkey
|
url += ppubkey
|
||||||
url += "?relay=" + relay
|
url += "?relay=" + relay
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue