diff --git a/.devcontainer/pre-setup.sh b/.devcontainer/pre-setup.sh
index a6e490a..6a2b99e 100644
--- a/.devcontainer/pre-setup.sh
+++ b/.devcontainer/pre-setup.sh
@@ -1,11 +1,6 @@
#!/bin/bash
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 install -y curl
sudo apt-get install -y docker.io
diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh
index 04053c0..261f66b 100644
--- a/.devcontainer/setup.sh
+++ b/.devcontainer/setup.sh
@@ -3,11 +3,6 @@ echo $PYTHONPATH
CONTAINER_WORKSPACE_FOLDER=$1
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
echo $PWD
if [ ! -d ./lnbits ] ; then
@@ -30,4 +25,3 @@ cd $CONTAINER_WORKSPACE_FOLDER
poetry install --no-interaction
npm i prettier
npm i pyright
-pip install uv
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 054d6ec..7fa81f8 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -7,7 +7,6 @@ on:
jobs:
test:
runs-on: ubuntu-latest
- timeout-minutes: 15
steps:
- name: Checkout code
@@ -51,16 +50,3 @@ 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
diff --git a/README.md b/README.md
index 1265fed..b222469 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,3 @@
-
-
-
-
-
-
-
-[](./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/).
@@ -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.
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`).
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
-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:
@@ -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 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)
diff --git a/config.json b/config.json
index 187cb5d..035ccbc 100644
--- a/config.json
+++ b/config.json
@@ -1,11 +1,8 @@
{
- "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.3-aio.1",
+ "version": "1.1.0",
"min_lnbits_version": "1.4.0",
"contributors": [
{
@@ -25,10 +22,6 @@
}
],
"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"
},
@@ -41,9 +34,5 @@
],
"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",
- "paid_features": "",
- "tags": ["Nostr", "Wallet"],
- "donate": "",
- "hidden": false
+ "license": "MIT"
}
diff --git a/description.md b/description.md
index edb14d1..bc65726 100644
--- a/description.md
+++ b/description.md
@@ -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:
-
-- 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.
+Easily connect your LNbits wallets via https://nwc.dev/
diff --git a/models.py b/models.py
index fa52baa..a84c46f 100644
--- a/models.py
+++ b/models.py
@@ -39,7 +39,7 @@ class NWCBudget(BaseModel):
c = int(time.time())
if self.refresh_window <= 0: # never refresh
# return a timestamp in the future
- return self.created_at, c + 21000000
+ return c, c + 21000000
# calculate the next refresh timestamp
elapsed = c - self.created_at
passed_cycles = elapsed // self.refresh_window
diff --git a/nwcp.py b/nwcp.py
index bb3afa6..26eeab3 100644
--- a/nwcp.py
+++ b/nwcp.py
@@ -27,7 +27,6 @@ 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]:
"""
@@ -50,12 +49,11 @@ 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())
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 now - event["created_at"] > expire:
del self.events[event_id]
@@ -67,11 +65,6 @@ 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
@@ -128,14 +121,8 @@ class NWCServiceProvider:
# Garbage collection loop
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
- self.sub: MainSubscription | None = None
+ self.sub = None
self.rate_limit: dict[str, RateLimit] = {}
# websocket connection
@@ -152,8 +139,6 @@ 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)
@@ -200,7 +185,6 @@ class NWCServiceProvider:
"""
self.reconnect_task = asyncio.create_task(self._connect_to_relay())
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:
"""
@@ -282,16 +266,11 @@ 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
"""
- sub = self._create_subscription()
+ self.sub = MainSubscription()
# Create requests subscription
req_filter = {
"kinds": [23194],
@@ -299,17 +278,17 @@ class NWCServiceProvider:
# Since the last handle_missed_events seconds (handles reboots)
"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)
res_filter = {
"kinds": [23195],
"authors": [self.public_key_hex],
"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
- await self._send(["REQ", sub.requests_sub_id, req_filter])
- await self._send(["REQ", sub.responses_sub_id, res_filter])
+ await self._send(["REQ", self.sub.requests_sub_id, req_filter])
+ await self._send(["REQ", self.sub.responses_sub_id, res_filter])
async def _on_connection(self, _):
"""
@@ -317,14 +296,6 @@ class NWCServiceProvider:
methods and subscribe to nip67 events.
"""
# 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 = {
"kind": 13194,
"content": " ".join(self.supported_methods),
@@ -333,38 +304,13 @@ class NWCServiceProvider:
}
self._sign_event(event)
await self._send(["EVENT", event])
-
- async def _info_event_loop(self):
- """
- 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))
+ # Resubscribe to nwc events
+ await self._subscribe()
async def _handle_request(self, event: dict) -> list[dict]:
"""
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
@@ -392,7 +338,7 @@ class NWCServiceProvider:
for result in results:
r = result[0]
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}
outs.append(out)
except Exception as e:
@@ -406,13 +352,11 @@ class NWCServiceProvider:
content["result"] = out["result"]
if "error" in out:
content["error"] = out["error"]
- raw_tags = out.get("tags")
- tags = list(raw_tags) if isinstance(raw_tags, list) else []
# Prepare response event
res: dict = {
"kind": 23195,
"created_at": int(time.time()),
- "tags": tags,
+ "tags": out.get("tags", []),
"content": self._json_dumps(content),
}
# Reference request
@@ -434,30 +378,6 @@ 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:
- 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):
if not self.sub:
return
@@ -467,7 +387,7 @@ class NWCServiceProvider:
if not self._verify_event(event):
raise Exception("Invalid event signature")
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
if expiration > 0 and expiration < int(time.time()):
logger.debug("Event expired")
@@ -486,7 +406,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:
- self._dispatch_request(event)
+ await self._handle_request(event)
elif event["kind"] == 23195 and sub_id == self.sub.responses_sub_id:
# Ensure the response is from this service provider
if event["pubkey"] != self.public_key_hex:
@@ -514,7 +434,7 @@ class NWCServiceProvider:
if self.sub.requests_eose and self.sub.responses_eose:
stales = self.sub.get_stale()
for stale in stales:
- self._dispatch_request(stale)
+ await self._handle_request(stale)
async def _on_closed_message(self, msg):
if not self.sub:
@@ -667,17 +587,6 @@ class NWCServiceProvider:
self.gc_task.cancel()
except Exception as 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
try:
if self.ws:
diff --git a/package-lock.json b/package-lock.json
index 11f3af5..06a81d1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,8 +9,8 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
- "prettier": "^3.8.1",
- "pyright": "^1.1.408"
+ "prettier": "^3.2.5",
+ "pyright": "^1.1.358"
}
},
"node_modules/fsevents": {
@@ -28,9 +28,9 @@
}
},
"node_modules/prettier": {
- "version": "3.8.1",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
- "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
+ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
@@ -43,9 +43,9 @@
}
},
"node_modules/pyright": {
- "version": "1.1.408",
- "resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.408.tgz",
- "integrity": "sha512-N61pxaLLCsPcUuPPHMNIrGoZgGBgrbjBX5UqkaT5UV8NVZdL7ExsO6N3ectv1DzAUsLOzdlyqoYtX76u8eF4YA==",
+ "version": "1.1.405",
+ "resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.405.tgz",
+ "integrity": "sha512-hgy12kLZ1oAMtl9LTsByHftg3AD6Pouwu5rBsQlqYQqCCdGBgaQm9XDAPDap7ayWe9W+NWrUwO7Zy1K7uXoE2A==",
"license": "MIT",
"bin": {
"pyright": "index.js",
diff --git a/package.json b/package.json
index 1ff8b61..97f219d 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
"author": "",
"license": "ISC",
"dependencies": {
- "prettier": "^3.8.1",
- "pyright": "^1.1.408"
+ "prettier": "^3.2.5",
+ "pyright": "^1.1.358"
}
}
diff --git a/paranoia.py b/paranoia.py
index 205debe..5ebca7c 100644
--- a/paranoia.py
+++ b/paranoia.py
@@ -2,12 +2,6 @@
from loguru import logger
ENABLE_HARDENING = True
-WHITELISTED_NON_PRINTABLE_CHARS = {
- "\n", # newline
- "\r", # carriage return
- "\t", # tab
- "\xa0", # non-breaking space ( )
-}
def panic(reason: str):
@@ -23,17 +17,8 @@ def assert_printable(v: str):
return
if not isinstance(v, str):
panic("not a string " + str(v))
- for ch in v:
- # check if printable
- 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})")
+ if not v.isprintable():
+ panic("string contains non-printable characters")
# Check if number is valid int and not NaN
diff --git a/pyproject.toml b/pyproject.toml
index dc659bf..42556ae 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -83,8 +83,8 @@ classmethod-decorators = [
# [tool.ruff.lint.extend-per-file-ignores]
# "views_api.py" = ["F401"]
-[tool.ruff.lint.mccabe]
-max-complexity = 11
+# [tool.ruff.lint.mccabe]
+# max-complexity = 10
[tool.ruff.lint.flake8-bugbear]
# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.
diff --git a/static/image/nwc_thumbnail.png b/static/image/nwc_thumbnail.png
deleted file mode 100644
index 31b1c7d..0000000
Binary files a/static/image/nwc_thumbnail.png and /dev/null differ
diff --git a/static/js/admin.js b/static/js/admin.js
index e9a405c..2e18b9b 100644
--- a/static/js/admin.js
+++ b/static/js/admin.js
@@ -4,7 +4,7 @@ window.app = Vue.createApp({
delimiters: ['${', '}'],
data: function () {
return {
- config: {},
+ entries: [],
columns: [
{
name: 'key',
@@ -30,10 +30,21 @@ window.app = Vue.createApp({
fetchConfig() {
this.entries = []
LNbits.api
- .request('GET', '/nwcprovider/api/v1/config')
+ .request(
+ 'GET',
+ '/nwcprovider/api/v1/config',
+ this.g.user.wallets[0].adminkey
+ )
.then(response => {
- this.config = response.data
- console.log('Config fetched:', this.config)
+ const newEntries = []
+ 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) {
console.error('Error fetching config:', error)
@@ -41,15 +52,14 @@ window.app = Vue.createApp({
},
async saveConfig() {
const data = {}
- for (const [key, value] of Object.entries(this.config)) {
- data[key] = value
+ for (const entry of this.entries) {
+ data[entry.key] = entry.value
}
- console.log('Saving config:', data)
try {
const response = await LNbits.api.request(
'POST',
'/nwcprovider/api/v1/config',
- null,
+ this.g.user.wallets[0].adminkey,
data
)
Quasar.Notify.create({
diff --git a/static/routes.json b/static/routes.json
deleted file mode 100644
index e69de29..0000000
diff --git a/tasks.py b/tasks.py
index 76aab58..a19a0d8 100644
--- a/tasks.py
+++ b/tasks.py
@@ -34,10 +34,6 @@ 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
@@ -114,24 +110,11 @@ 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:
break
- if payment_status.failed:
- 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,
- )
+ await asyncio.sleep(0.05)
if not payment_status:
raise Exception("Payment status not found")
return {
@@ -240,7 +223,6 @@ 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
@@ -264,11 +246,9 @@ async def _on_make_invoice(
# Ensures amount is provided
if not amount_msats:
raise Exception("Missing amount")
- # 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
+ description = params.get("description", "")
+ description_hash = params.get("description_hash", None)
+ expiry = params.get("expiry", None)
# hardening #
assert_valid_msats(amount_msats)
@@ -286,7 +266,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") if description else None,
+ unhashed_description=description.encode("utf-8"),
expiry=expiry,
)
payment_hash = payment.payment_hash
@@ -396,12 +376,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") 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 ""
+ 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", "")
# hardening #
assert_valid_positive_int(tfrom)
@@ -437,11 +417,7 @@ async def _on_list_transactions(
{
"type": "outgoing" if p.is_out else "incoming",
"invoice": p.bolt11,
- # 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": invoice_data.description,
"description_hash": invoice_data.description_hash,
"preimage": p.preimage if is_settled or p.is_in else None,
"payment_hash": p.payment_hash,
@@ -452,7 +428,6 @@ async def _on_list_transactions(
"metadata": {},
}
)
- await asyncio.sleep(0)
# await log_nwc(pubkey, payload)
return [({"transactions": transactions}, None, [])]
diff --git a/templates/nwcprovider/admin.html b/templates/nwcprovider/admin.html
index 81576b4..171a6d0 100644
--- a/templates/nwcprovider/admin.html
+++ b/templates/nwcprovider/admin.html
@@ -6,56 +6,21 @@
-
+
- NWC Service Provider Configuration
+ NWC Service Provider - Config
-
+
-
+
-
-
+ ${entry.key}
-
-
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/tests/integration/.env b/tests/integration/.env
index 1fe75bd..05905b6 100644
--- a/tests/integration/.env
+++ b/tests/integration/.env
@@ -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.
# 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
-# integration fixture focused on the extension under test.
-LNBITS_EXTENSIONS_DEFAULT_INSTALL=""
+LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
@@ -251,4 +249,4 @@ LOG_ROTATION="100 MB"
LOG_RETENTION="3 months"
# for database cleanup commands
-# CLEANUP_WALLETS_DAYS=90
+# CLEANUP_WALLETS_DAYS=90
\ No newline at end of file
diff --git a/tests/integration/start.sh b/tests/integration/start.sh
index 2a669c1..825b90e 100644
--- a/tests/integration/start.sh
+++ b/tests/integration/start.sh
@@ -15,16 +15,10 @@ if [ "`cat .v039fk_lnbits_integration_test_folder`" != "yes v039fk_lnbits_integr
exit 1
fi
-# 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
+# Start nostr Relay
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 \
@@ -34,18 +28,9 @@ ghcr.io/hoytech/strfry:latest
rm -Rf lnbits_itest_data
unzip data.zip
-# 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
+id=$(id -u)
+gid=$(id -g)
-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 \
@@ -60,50 +45,24 @@ 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"
-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 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
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
- # 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"
-
- 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"
+ ARGS="-d"
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"
+
diff --git a/tests/integration/test_all.py b/tests/integration/test_all.py
index 2752e01..56d9717 100644
--- a/tests/integration/test_all.py
+++ b/tests/integration/test_all.py
@@ -12,10 +12,6 @@ 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",
@@ -45,29 +41,35 @@ wallets = {
async def check_services():
- async def wait_for_service(name: str, url: str):
- deadline = time.monotonic() + SERVICE_STARTUP_TIMEOUT_SECONDS
+ # wait for http server in localhost:7777
+ while True:
try:
async with httpx.AsyncClient() as client:
- 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
+ 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)
- await wait_for_service("nostr relay", "http://localhost:7777")
- await wait_for_service("LNbits", "http://localhost:5002")
+ # 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)
async def get_wallet_balance(w: str):
@@ -177,21 +179,15 @@ class NWCWallet:
async def _wait_for_connection(self):
while not self.connected:
- await asyncio.sleep(0.2)
+ try:
+ await asyncio.sleep(0.2)
+ except asyncio.CancelledError:
+ logger.debug("Connection wait cancelled")
+ return
async def start(self):
self.task = asyncio.create_task(self._run())
- 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
+ await self._wait_for_connection()
def _is_shutting_down(self):
return self.shutdown
@@ -308,11 +304,7 @@ 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=NWC_RESPONSE_TIMEOUT_SECONDS,
+ self, result_type, callback=None, on_error_callback=None, timeout=60000
):
now = time.time()
while True:
@@ -830,77 +822,6 @@ async def test_budget_refresh():
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
diff --git a/tests/unit/test_nwcp.py b/tests/unit/test_nwcp.py
index a631037..3e23eb6 100644
--- a/tests/unit/test_nwcp.py
+++ b/tests/unit/test_nwcp.py
@@ -1,8 +1,6 @@
-import asyncio
import json
import random
import string
-import time
import pytest
from loguru import logger
@@ -76,18 +74,6 @@ 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(
@@ -100,7 +86,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": int(time.time()),
+ "created_at": 1234567890,
}
signed = nwc_service_provider._sign_event(event)
@@ -114,7 +100,6 @@ 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
@@ -140,187 +125,3 @@ async def test_handle(nwc_service_provider, nwc_service_provider2):
p_tag = [tag for tag in tags if tag[0] == "p"]
assert len(p_tag) == 1
assert p_tag[0][1] == nwc_service_provider.public_key_hex
-
-
-@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
diff --git a/tests/unit/test_tasks.py b/tests/unit/test_tasks.py
deleted file mode 100644
index 6dfeb55..0000000
--- a/tests/unit/test_tasks.py
+++ /dev/null
@@ -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