Compare commits
14 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c36689506 | |||
| 5f4dde6b53 | |||
|
|
bf55b46f76 | ||
|
|
b3500f63d9 |
||
|
|
9362bf53ae | ||
|
|
5af8335fdd | ||
|
|
e9d3c48aec | ||
|
|
f4b96107fa | ||
|
|
d432d74c88 | ||
|
|
8f7aa33d4f |
||
|
|
9674110d04 |
||
|
|
11cfbe8772 |
||
|
|
14a96271ad |
||
|
|
0204f8ff85 |
12 changed files with 483 additions and 75 deletions
14
.github/workflows/test.yml
vendored
14
.github/workflows/test.yml
vendored
|
|
@ -7,6 +7,7 @@ on:
|
|||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
|
@ -50,3 +51,16 @@ jobs:
|
|||
pip install pytest-asyncio
|
||||
cd $cdir
|
||||
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,3 +1,13 @@
|
|||
<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)
|
||||
|
||||
Easily connect your LNbits wallets via [NWC](https://nwc.dev/).
|
||||
|
|
@ -23,7 +33,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.
|
||||
|
||||
1. Choose a Nostr relay that supports NWC connections.
|
||||
2. Open the **NWC Service Provider settings** (gear icon in the top-right corner).
|
||||
2. Navigate to the **NWC Service Provider admin page** at `/nwcprovider/admin` (requires LNbits admin privileges).
|
||||
1. Enter your chosen relay URL in the **Nostr Relay URL** field (e.g. `wss://relay.nostrconnect.com`).
|
||||
2. Click **Save**.
|
||||
|
||||
|
|
@ -50,7 +60,7 @@ This is the easiest option for most users. It allows you to run LNbits on a priv
|
|||
|
||||
# Extension Configuration
|
||||
|
||||
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.
|
||||
The configuration page of the NWC Service Provider extension is available at `/nwcprovider/admin` and requires LNbits admin privileges.
|
||||
|
||||
### Configuration Options:
|
||||
|
||||
|
|
@ -68,3 +78,10 @@ The "Configuration" page of the NWC Service Provider extension can be accessed b
|
|||
> 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`.
|
||||
|
||||
## 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)
|
||||
|
|
|
|||
15
config.json
15
config.json
|
|
@ -1,8 +1,11 @@
|
|||
{
|
||||
"id": "nwcprovider",
|
||||
"name": "NWC Service Provider",
|
||||
"repo": "https://github.com/lnbits/nwcprovider",
|
||||
"short_description": "A NWC service provider for LNbits",
|
||||
"description": "",
|
||||
"tile": "/nwcprovider/static/image/nwcprovider.png",
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.3-aio.1",
|
||||
"min_lnbits_version": "1.4.0",
|
||||
"contributors": [
|
||||
{
|
||||
|
|
@ -22,6 +25,10 @@
|
|||
}
|
||||
],
|
||||
"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"
|
||||
},
|
||||
|
|
@ -34,5 +41,9 @@
|
|||
],
|
||||
"description_md": "https://raw.githubusercontent.com/lnbits/nwcprovider/main/description.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,3 +1,10 @@
|
|||
NWC Service Provider Extension for https://github.com/lnbits/lnbits
|
||||
Connect your LNbits wallet to apps using the Nostr Wallet Connect protocol.
|
||||
|
||||
Easily connect your LNbits wallets via https://nwc.dev/
|
||||
Its functions include:
|
||||
|
||||
- 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.
|
||||
|
|
|
|||
65
nwcp.py
65
nwcp.py
|
|
@ -27,6 +27,7 @@ class MainSubscription:
|
|||
self.responses_eose = False
|
||||
self.events: dict[str, dict] = {}
|
||||
self.responses: list[str] = []
|
||||
self.seen_requests: dict[str, int] = {}
|
||||
|
||||
def get_stale(self) -> list[dict]:
|
||||
"""
|
||||
|
|
@ -49,6 +50,7 @@ class MainSubscription:
|
|||
"""
|
||||
Garbage collection, remove all the events that have a response older
|
||||
than expire seconds (defaults to 1 hour if 0 or None)
|
||||
and all seen requests that are expired
|
||||
"""
|
||||
expire = expire or 1 * 60 * 60
|
||||
now = int(time.time())
|
||||
|
|
@ -65,6 +67,11 @@ class MainSubscription:
|
|||
if len(deleted_ids) > 0:
|
||||
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:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
|
|
@ -124,8 +131,11 @@ class NWCServiceProvider:
|
|||
# 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
|
||||
self.sub = None
|
||||
self.sub: MainSubscription | None = None
|
||||
self.rate_limit: dict[str, RateLimit] = {}
|
||||
|
||||
# websocket connection
|
||||
|
|
@ -142,6 +152,8 @@ class NWCServiceProvider:
|
|||
# (handles reboots)
|
||||
self.handle_missed_events = handle_missed_events
|
||||
|
||||
self.event_max_age = self.handle_missed_events or 5 * 60
|
||||
|
||||
logger.info(
|
||||
"NWC Service is ready. relay: "
|
||||
+ str(self.relay)
|
||||
|
|
@ -270,11 +282,16 @@ class NWCServiceProvider:
|
|||
await asyncio.sleep(limit.backoff)
|
||||
limit.last_attempt_time = int(time.time())
|
||||
|
||||
def _create_subscription(self) -> MainSubscription:
|
||||
sub = MainSubscription()
|
||||
self.sub = sub
|
||||
return sub
|
||||
|
||||
async def _subscribe(self):
|
||||
"""
|
||||
[Re]Subscribe to receive nip 47 requests and responses from the relay
|
||||
"""
|
||||
self.sub = MainSubscription()
|
||||
sub = self._create_subscription()
|
||||
# Create requests subscription
|
||||
req_filter = {
|
||||
"kinds": [23194],
|
||||
|
|
@ -282,17 +299,17 @@ class NWCServiceProvider:
|
|||
# Since the last handle_missed_events seconds (handles reboots)
|
||||
"since": int(time.time()) - self.handle_missed_events,
|
||||
}
|
||||
self.sub.requests_sub_id = self._get_new_subid()
|
||||
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()) - self.handle_missed_events,
|
||||
}
|
||||
self.sub.responses_sub_id = self._get_new_subid()
|
||||
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])
|
||||
await self._send(["REQ", sub.requests_sub_id, req_filter])
|
||||
await self._send(["REQ", sub.responses_sub_id, res_filter])
|
||||
|
||||
async def _on_connection(self, _):
|
||||
"""
|
||||
|
|
@ -335,6 +352,19 @@ class NWCServiceProvider:
|
|||
"""
|
||||
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"]
|
||||
content = event["content"]
|
||||
# Decrypt the content
|
||||
|
|
@ -404,6 +434,19 @@ class NWCServiceProvider:
|
|||
sent_events.append(res)
|
||||
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:
|
||||
|
|
@ -443,7 +486,7 @@ class NWCServiceProvider:
|
|||
# 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)
|
||||
self._dispatch_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:
|
||||
|
|
@ -471,7 +514,7 @@ class NWCServiceProvider:
|
|||
if self.sub.requests_eose and self.sub.responses_eose:
|
||||
stales = self.sub.get_stale()
|
||||
for stale in stales:
|
||||
await self._handle_request(stale)
|
||||
self._dispatch_request(stale)
|
||||
|
||||
async def _on_closed_message(self, msg):
|
||||
if not self.sub:
|
||||
|
|
@ -629,6 +672,12 @@ class NWCServiceProvider:
|
|||
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
|
||||
try:
|
||||
if self.ws:
|
||||
|
|
|
|||
BIN
static/image/nwc_thumbnail.png
Normal file
BIN
static/image/nwc_thumbnail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 498 KiB |
41
tasks.py
41
tasks.py
|
|
@ -34,6 +34,10 @@ from .paranoia import (
|
|||
)
|
||||
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:
|
||||
# check
|
||||
|
|
@ -110,6 +114,7 @@ async def _process_invoice(
|
|||
True # currently required by nip 47 specs, might change in future
|
||||
)
|
||||
payment_status: PaymentStatus | None = None
|
||||
poll_interval = PAYMENT_STATUS_POLL_INITIAL_INTERVAL_SECONDS
|
||||
while wait_for_preimage:
|
||||
payment_status = await check_transaction_status(wallet_id, payment_hash)
|
||||
if payment_status.success:
|
||||
|
|
@ -122,7 +127,11 @@ async def _process_invoice(
|
|||
},
|
||||
"in_budget": in_budget,
|
||||
}
|
||||
await asyncio.sleep(0.05)
|
||||
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:
|
||||
raise Exception("Payment status not found")
|
||||
return {
|
||||
|
|
@ -231,6 +240,7 @@ async def _on_multi_pay_invoice(
|
|||
results.append(r)
|
||||
except Exception as e:
|
||||
results.append((None, {"code": "INTERNAL", "message": str(e)}, []))
|
||||
await asyncio.sleep(0)
|
||||
# await log_nwc(pubkey, payload)
|
||||
return results
|
||||
|
||||
|
|
@ -254,9 +264,11 @@ async def _on_make_invoice(
|
|||
# Ensures amount is provided
|
||||
if not amount_msats:
|
||||
raise Exception("Missing amount")
|
||||
description = params.get("description", "")
|
||||
description_hash = params.get("description_hash", None)
|
||||
expiry = params.get("expiry", None)
|
||||
# Optional params may arrive as explicit JSON null, which dict.get does
|
||||
# not default, so coerce here rather than trusting the fallback.
|
||||
description = params.get("description") or ""
|
||||
description_hash = params.get("description_hash") or None
|
||||
expiry = params.get("expiry") or None
|
||||
|
||||
# hardening #
|
||||
assert_valid_msats(amount_msats)
|
||||
|
|
@ -274,7 +286,7 @@ async def _on_make_invoice(
|
|||
currency="sat",
|
||||
memo=description,
|
||||
description_hash=bytes.fromhex(description_hash) if description_hash else None,
|
||||
unhashed_description=description.encode("utf-8"),
|
||||
unhashed_description=description.encode("utf-8") if description else None,
|
||||
expiry=expiry,
|
||||
)
|
||||
payment_hash = payment.payment_hash
|
||||
|
|
@ -384,12 +396,12 @@ async def _on_list_transactions(
|
|||
if not nwc:
|
||||
raise Exception("Pubkey has no associated wallet")
|
||||
params = payload.get("params", 0)
|
||||
tfrom = params.get("from", 0)
|
||||
tuntil = params.get("until", int(time.time()))
|
||||
limit = params.get("limit", 10)
|
||||
offset = params.get("offset", 0)
|
||||
unpaid = params.get("unpaid", False)
|
||||
tx_type = params.get("type", "")
|
||||
tfrom = params.get("from") or 0
|
||||
tuntil = params.get("until") or int(time.time())
|
||||
limit = params.get("limit") or 10
|
||||
offset = params.get("offset") or 0
|
||||
unpaid = params.get("unpaid") or False
|
||||
tx_type = params.get("type") or ""
|
||||
|
||||
# hardening #
|
||||
assert_valid_positive_int(tfrom)
|
||||
|
|
@ -425,7 +437,11 @@ async def _on_list_transactions(
|
|||
{
|
||||
"type": "outgoing" if p.is_out else "incoming",
|
||||
"invoice": p.bolt11,
|
||||
"description": invoice_data.description,
|
||||
# Fallback chain so a human-readable description reaches
|
||||
# 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,
|
||||
"preimage": p.preimage if is_settled or p.is_in else None,
|
||||
"payment_hash": p.payment_hash,
|
||||
|
|
@ -436,6 +452,7 @@ async def _on_list_transactions(
|
|||
"metadata": {},
|
||||
}
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
# await log_nwc(pubkey, payload)
|
||||
return [({"transactions": transactions}, None, [])]
|
||||
|
||||
|
|
|
|||
|
|
@ -199,7 +199,9 @@ 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.
|
||||
# The extension must be removed from this list in order to not be re-installed.
|
||||
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
||||
# The tpos extension is no longer shipped with the LNbits dev tree. Keep the
|
||||
# integration fixture focused on the extension under test.
|
||||
LNBITS_EXTENSIONS_DEFAULT_INSTALL=""
|
||||
|
||||
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
|
||||
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
|
||||
|
|
@ -249,4 +251,4 @@ LOG_ROTATION="100 MB"
|
|||
LOG_RETENTION="3 months"
|
||||
|
||||
# for database cleanup commands
|
||||
# CLEANUP_WALLETS_DAYS=90
|
||||
# CLEANUP_WALLETS_DAYS=90
|
||||
|
|
|
|||
|
|
@ -15,10 +15,16 @@ if [ "`cat .v039fk_lnbits_integration_test_folder`" != "yes v039fk_lnbits_integr
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# Start nostr Relay
|
||||
# Start nostr Relay. The image defaults to the `strfry` user (UID 1000),
|
||||
# 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 \
|
||||
-d \
|
||||
--rm \
|
||||
--user $id:$gid \
|
||||
-v $PWD/strfry.conf:/etc/strfry.conf:Z \
|
||||
-v $PWD/strfry-data:/app/strfry-db:Z \
|
||||
-p 7777:7777 \
|
||||
|
|
@ -28,9 +34,18 @@ ghcr.io/hoytech/strfry:latest
|
|||
rm -Rf lnbits_itest_data
|
||||
unzip data.zip
|
||||
|
||||
id=$(id -u)
|
||||
gid=$(id -g)
|
||||
# The fixture was created with the standalone tpos extension installed. tpos
|
||||
# is no longer part of the LNbits dev tree, so leaving its database metadata in
|
||||
# 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 \
|
||||
-d \
|
||||
|
|
@ -45,24 +60,50 @@ docker run --name=lnbits_nwcprovider_ext_lnbits_test \
|
|||
-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"
|
||||
|
||||
docker network create lnbits_nwcprovider_ext_test_network || true
|
||||
docker network connect lnbits_nwcprovider_ext_test_network lnbits_nwcprovider_ext_nostr_test --alias nostr|| true
|
||||
docker network connect lnbits_nwcprovider_ext_test_network lnbits_nwcprovider_ext_lnbits_test --alias lnbits|| true
|
||||
if ! docker network inspect lnbits_nwcprovider_ext_test_network >/dev/null 2>&1; then
|
||||
docker network create lnbits_nwcprovider_ext_test_network
|
||||
fi
|
||||
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 "bash /pre-setup.sh"
|
||||
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 "ln -s /app/.env \$HOME/lnbits/.env"
|
||||
|
||||
|
||||
ARGS=""
|
||||
if [ "$HEADLESS" != "" ];
|
||||
then
|
||||
ARGS="-d"
|
||||
fi
|
||||
# Keep the server log inside the container so a failed health check can
|
||||
# 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"
|
||||
|
||||
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"
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ from loguru import logger
|
|||
from pynostr.key import PrivateKey
|
||||
from websockets.legacy.client import connect
|
||||
|
||||
SERVICE_STARTUP_TIMEOUT_SECONDS = 180
|
||||
NWC_CONNECTION_TIMEOUT_SECONDS = 60
|
||||
NWC_RESPONSE_TIMEOUT_SECONDS = 60
|
||||
|
||||
wallets = {
|
||||
"wallet1": {
|
||||
"name": "wallet1",
|
||||
|
|
@ -41,35 +45,29 @@ wallets = {
|
|||
|
||||
|
||||
async def check_services():
|
||||
# wait for http server in localhost:7777
|
||||
while True:
|
||||
async def wait_for_service(name: str, url: str):
|
||||
deadline = time.monotonic() + SERVICE_STARTUP_TIMEOUT_SECONDS
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get("http://localhost:7777")
|
||||
assert resp.status_code == 200
|
||||
break
|
||||
except Exception:
|
||||
logger.info("Waiting for nostr relay @ http://localhost:7777")
|
||||
logger.info(
|
||||
"""Please start the required services by running\
|
||||
`bash start.sh` if you haven't already"""
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
while True:
|
||||
try:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code == 200:
|
||||
return
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError(
|
||||
f"Timed out waiting for {name} at {url}. "
|
||||
"Start the integration services with `bash start.sh`."
|
||||
)
|
||||
logger.info(f"Waiting for {name} @ {url}")
|
||||
await asyncio.sleep(1)
|
||||
except httpx.HTTPError as exc:
|
||||
raise RuntimeError(f"Unable to check {name} at {url}: {exc}") from exc
|
||||
|
||||
# wait lnbits @ localhost:5000
|
||||
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)
|
||||
await wait_for_service("nostr relay", "http://localhost:7777")
|
||||
await wait_for_service("LNbits", "http://localhost:5002")
|
||||
|
||||
|
||||
async def get_wallet_balance(w: str):
|
||||
|
|
@ -179,15 +177,21 @@ class NWCWallet:
|
|||
|
||||
async def _wait_for_connection(self):
|
||||
while not self.connected:
|
||||
try:
|
||||
await asyncio.sleep(0.2)
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Connection wait cancelled")
|
||||
return
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
async def start(self):
|
||||
self.task = asyncio.create_task(self._run())
|
||||
await self._wait_for_connection()
|
||||
try:
|
||||
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):
|
||||
return self.shutdown
|
||||
|
|
@ -304,7 +308,11 @@ class NWCWallet:
|
|||
await self.ws.send(self._json_dumps(["EVENT", event]))
|
||||
|
||||
async def wait_for(
|
||||
self, result_type, callback=None, on_error_callback=None, timeout=60000
|
||||
self,
|
||||
result_type,
|
||||
callback=None,
|
||||
on_error_callback=None,
|
||||
timeout=NWC_RESPONSE_TIMEOUT_SECONDS,
|
||||
):
|
||||
now = time.time()
|
||||
while True:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import asyncio
|
|||
import json
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from loguru import logger
|
||||
|
|
@ -75,6 +76,18 @@ def test_signverify(nwc_service_provider, nwc_service_provider2):
|
|||
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
|
||||
async def test_handle(nwc_service_provider, nwc_service_provider2):
|
||||
content = nwc_service_provider._json_dumps(
|
||||
|
|
@ -87,7 +100,7 @@ async def test_handle(nwc_service_provider, nwc_service_provider2):
|
|||
"kind": 23194,
|
||||
"content": content,
|
||||
"tags": [["p", nwc_service_provider2.public_key_hex]],
|
||||
"created_at": 1234567890,
|
||||
"created_at": int(time.time()),
|
||||
}
|
||||
signed = nwc_service_provider._sign_event(event)
|
||||
|
||||
|
|
@ -101,6 +114,7 @@ async def test_handle(nwc_service_provider, nwc_service_provider2):
|
|||
pass
|
||||
|
||||
nwc_service_provider2._send = _send_pass
|
||||
nwc_service_provider2._create_subscription()
|
||||
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
|
||||
|
|
@ -128,6 +142,111 @@ async def test_handle(nwc_service_provider, nwc_service_provider2):
|
|||
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."""
|
||||
|
|
|
|||
|
|
@ -29,3 +29,126 @@ async def test_process_invoice_returns_payment_failed_on_failed_status(monkeypat
|
|||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue