diff --git a/.gitignore b/.gitignore
index acd0773..f92a874 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,4 @@ data
.vscode
dump
.venv
+.claude
diff --git a/crud.py b/crud.py
index 872d0d6..ab4d3a6 100644
--- a/crud.py
+++ b/crud.py
@@ -53,6 +53,7 @@ async def create_nwc(data: CreateNWCKey) -> NWCKey:
permissions=" ".join(data.permissions),
created_at=int(time.time()),
last_used=int(time.time()),
+ lud16=data.lud16,
)
await db.insert("nwcprovider.keys", nwckey_entry)
if data.budgets:
diff --git a/migrations.py b/migrations.py
index 1a3a9b7..0afa062 100644
--- a/migrations.py
+++ b/migrations.py
@@ -119,3 +119,10 @@ async def m006_default_config3(db):
""",
{"value": "0"},
)
+
+
+async def m007_add_lud16(db):
+ """
+ Add lud16 column to keys table for lightning address support
+ """
+ await db.execute("ALTER TABLE nwcprovider.keys ADD COLUMN lud16 TEXT")
diff --git a/models.py b/models.py
index fa52baa..93f7fb2 100644
--- a/models.py
+++ b/models.py
@@ -15,6 +15,7 @@ class NWCKey(BaseModel):
permissions: str
created_at: int
last_used: int
+ lud16: str | None = None
def get_permissions(self) -> list[str]:
try:
@@ -67,6 +68,7 @@ class CreateNWCKey(BaseModel):
expires_at: int
permissions: list[str]
budgets: list[NWCNewBudget] | None = None
+ lud16: str | None = None
class DeleteNWC(BaseModel):
@@ -102,6 +104,7 @@ class NWCRegistrationRequest(BaseModel):
description: str
expires_at: int
budgets: list[NWCNewBudget]
+ lud16: str | None = None
class NWCGetResponse(BaseModel):
@@ -109,6 +112,13 @@ class NWCGetResponse(BaseModel):
budgets: list[NWCBudget]
+class NWCGetAllResponse(BaseModel):
+ data: NWCKey
+ budgets: list[NWCBudget]
+ wallet_id: str
+ wallet_name: str
+
+
class NWCConfig(BaseModel):
key: str
value: str
diff --git a/nwcp.py b/nwcp.py
index efa5279..6a7a385 100644
--- a/nwcp.py
+++ b/nwcp.py
@@ -103,6 +103,12 @@ def __init__(
# List of supported methods
self.supported_methods: list[str] = []
+ # List of supported notification types
+ self.supported_notifications: list[str] = [
+ "payment_received",
+ "payment_sent",
+ ]
+
# Keep track of the number of subscriptions (used for unique subid)
self.subscriptions_count: int = 0
@@ -308,11 +314,14 @@ async def _send_info_event(self):
"""
Build and publish the NWC service info event (kind 13194).
"""
+ tags = [["p", self.public_key_hex]]
+ if self.supported_notifications:
+ tags.append(["notifications", " ".join(self.supported_notifications)])
event = {
"kind": 13194,
"content": " ".join(self.supported_methods),
"created_at": int(time.time()),
- "tags": [["p", self.public_key_hex]],
+ "tags": tags,
}
self._sign_event(event)
await self._send(["EVENT", event])
@@ -415,6 +424,32 @@ def _extract_expiration_from_tags(self, tags: list) -> int:
pass
return expiration
+ async def send_notification(
+ self, client_pubkey: str, notification_type: str, notification: dict
+ ):
+ """
+ Send a NIP-47 notification event (kind 23196) to a client.
+
+ Args:
+ client_pubkey: The client's public key hex.
+ notification_type: The notification type (e.g. "payment_received").
+ notification: The notification payload dict.
+ """
+ content = self._json_dumps(
+ {
+ "notification_type": notification_type,
+ "notification": notification,
+ }
+ )
+ event: dict = {
+ "kind": 23196,
+ "created_at": int(time.time()),
+ "tags": [["p", client_pubkey]],
+ "content": self.private_key.encrypt_message(content, client_pubkey),
+ }
+ self._sign_event(event)
+ await self._send(["EVENT", event])
+
async def _on_event_message(self, msg):
if not self.sub:
return
diff --git a/permission.py b/permission.py
index 9caf17b..60b6be6 100644
--- a/permission.py
+++ b/permission.py
@@ -30,4 +30,9 @@
"default": True,
},
"info": {"name": "Read account info", "methods": ["get_info"], "default": True},
+ "notifications": {
+ "name": "Receive payment notifications",
+ "methods": [],
+ "default": True,
+ },
}
diff --git a/pyproject.toml b/pyproject.toml
index dc659bf..abdcfa2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -23,6 +23,9 @@ package-mode = false
[tool.mypy]
plugins = ["pydantic.mypy"]
+exclude = [
+ "tests/standalone/",
+]
[[tool.mypy.overrides]]
module = [
diff --git a/static/image/Bildschirmfoto 2026-02-03 um 17.25.24.png b/static/image/Bildschirmfoto 2026-02-03 um 17.25.24.png
new file mode 100644
index 0000000..fd79541
Binary files /dev/null and b/static/image/Bildschirmfoto 2026-02-03 um 17.25.24.png differ
diff --git a/static/image/NWC_Banner_Extension.png b/static/image/NWC_Banner_Extension.png
new file mode 100644
index 0000000..824036a
Binary files /dev/null and b/static/image/NWC_Banner_Extension.png differ
diff --git a/static/js/admin.js b/static/js/admin.js
index e9a405c..56ae919 100644
--- a/static/js/admin.js
+++ b/static/js/admin.js
@@ -1,7 +1,6 @@
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
- delimiters: ['${', '}'],
data: function () {
return {
config: {},
diff --git a/static/js/index.js b/static/js/index.js
index d593469..58eac8c 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -1,20 +1,32 @@
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
- delimiters: ['${', '}'],
data: function () {
return {
- selectedWallet: null,
+ selectedWallet: 'all',
+ dialogWallet: null,
nodePermissions: [],
nwcEntries: [],
nwcsTable: {
columns: [
+ {
+ name: 'wallet_name',
+ align: 'left',
+ label: 'Wallet',
+ field: 'wallet_name'
+ },
{
name: 'description',
align: 'left',
label: 'Description',
field: 'description'
},
+ {
+ name: 'lud16',
+ align: 'left',
+ label: 'Lightning Address',
+ field: 'lud16'
+ },
{name: 'status', align: 'left', label: 'Status', field: 'status'},
{
name: 'last_used',
@@ -36,7 +48,7 @@ window.app = Vue.createApp({
}
],
pagination: {
- rowsPerPage: 10
+ rowsPerPage: 0
}
},
connectDialog: {
@@ -58,22 +70,58 @@ window.app = Vue.createApp({
connectionInfoDialog: {
show: false,
data: {}
- }
+ },
+ lud16OptionsAll: [],
+ lud16Loading: false
}
},
+ computed: {
+ walletOptions() {
+ // Count connections per wallet
+ const counts = {}
+ for (const entry of this.nwcEntries) {
+ if (entry.wallet_id) {
+ counts[entry.wallet_id] = (counts[entry.wallet_id] || 0) + 1
+ }
+ }
+
+ const options = [{label: 'All Wallets', value: 'all', count: null}]
+ for (const wallet of this.g.user.wallets) {
+ options.push({
+ label: wallet.name,
+ value: wallet.id,
+ count: counts[wallet.id] || 0
+ })
+ }
+ return options
+ },
+ visibleColumns() {
+ if (this.selectedWallet === 'all') {
+ return this.nwcsTable.columns.map(c => c.name)
+ }
+ return this.nwcsTable.columns
+ .filter(c => c.name !== 'wallet_name')
+ .map(c => c.name)
+ }
+ },
methods: {
showConnectDialog() {
- const wallet = this.getWallet()
- if (!wallet) {
- Quasar.Notify.create({
- type: 'negative',
- message: 'Please select a wallet first'
- })
- return
+ // When "All Wallets" is selected, default dialog wallet to first wallet
+ if (this.selectedWallet === 'all') {
+ if (this.g.user.wallets && this.g.user.wallets.length > 0) {
+ this.dialogWallet = this.g.user.wallets[0].id
+ } else {
+ Quasar.Notify.create({
+ type: 'negative',
+ message: 'No wallets found'
+ })
+ return
+ }
} else {
- this.connectDialog.show = true
+ this.dialogWallet = this.selectedWallet
}
+ this.connectDialog.show = true
},
openConnectionInfoDialog(data) {
this.connectionInfoDialog.data = data
@@ -89,6 +137,10 @@ window.app = Vue.createApp({
go(url) {
window.open(url, '_blank')
},
+ switchToWallet(walletId) {
+ // Switch to the selected wallet in the dropdown
+ this.selectedWallet = walletId
+ },
async copyPairingUrl() {
const url = this.pairingDialog.data.pairingUrl
if (url) {
@@ -119,7 +171,8 @@ window.app = Vue.createApp({
expires_at: Date.now() + 1000 * 60 * 60 * 24 * 7,
neverExpires: true,
permissions: [],
- budgets: []
+ budgets: [],
+ lud16: ''
}
for (const permission of this.nodePermissions) {
this.connectDialog.data.permissions.push({
@@ -128,6 +181,44 @@ window.app = Vue.createApp({
value: permission.value
})
}
+ this.lud16OptionsAll = []
+ this.loadLightningAddresses()
+ },
+ getDialogWallet() {
+ for (let i = 0; i < this.g.user.wallets.length; i++) {
+ if (this.g.user.wallets[i].id === this.dialogWallet) {
+ return this.g.user.wallets[i]
+ }
+ }
+ return null
+ },
+ async loadLightningAddresses() {
+ const wallet = this.getDialogWallet()
+ if (!wallet) {
+ this.lud16OptionsAll = []
+ return
+ }
+ this.lud16Loading = true
+ try {
+ const response = await LNbits.api.request(
+ 'GET',
+ '/nwcprovider/api/v1/lnaddresses',
+ wallet.adminkey
+ )
+ if (response.data && response.data.length > 0) {
+ this.lud16OptionsAll = response.data.map(addr => ({
+ label: addr.description || addr.username,
+ value: addr.address
+ }))
+ } else {
+ this.lud16OptionsAll = []
+ }
+ } catch (error) {
+ console.warn('Could not load lightning addresses:', error)
+ this.lud16OptionsAll = []
+ } finally {
+ this.lud16Loading = false
+ }
},
deleteBudget(index) {
this.connectDialog.data.budgets.splice(index, 1)
@@ -168,25 +259,42 @@ window.app = Vue.createApp({
}
return out
},
- deleteNWC: async function (pubkey) {
+ deleteNWC: async function (row) {
Quasar.Dialog.create({
title: 'Confirm Deletion',
- message: 'Are you sure you want to delete this connection?',
+ message: 'This will disconnect the app. Are you sure?',
cancel: true,
persistent: true
})
.onOk(async () => {
try {
- const wallet = this.getWallet()
+ // When in "All Wallets" view, use the wallet_id from the row
+ let adminkey
+ if (this.selectedWallet === 'all') {
+ const wallet = this.g.user.wallets.find(
+ w => w.id === row.wallet_id
+ )
+ if (!wallet) {
+ Quasar.Notify.create({
+ type: 'negative',
+ message: 'Could not find wallet for this connection'
+ })
+ return
+ }
+ adminkey = wallet.adminkey
+ } else {
+ const wallet = this.getWallet()
+ adminkey = wallet.adminkey
+ }
const response = await LNbits.api.request(
'DELETE',
- `/nwcprovider/api/v1/nwc/${pubkey}`,
- wallet.adminkey
+ `/nwcprovider/api/v1/nwc/${row.pubkey}`,
+ adminkey
)
this.loadNwcs()
Quasar.Notify.create({
type: 'positive',
- message: 'Deleted successfully'
+ message: 'Connection removed'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
@@ -197,17 +305,32 @@ window.app = Vue.createApp({
})
},
loadNwcs: async function () {
- const wallet = this.getWallet()
- if (!wallet) {
- this.nwcs = []
- return
+ // Get any wallet's adminkey for API calls (needed for "all" view)
+ let adminkey
+ if (this.selectedWallet === 'all') {
+ if (this.g.user.wallets && this.g.user.wallets.length > 0) {
+ adminkey = this.g.user.wallets[0].adminkey
+ } else {
+ this.nwcs = []
+ this.nwcEntries = []
+ return
+ }
+ } else {
+ const wallet = this.getWallet()
+ if (!wallet) {
+ this.nwcs = []
+ this.nwcEntries = []
+ return
+ }
+ adminkey = wallet.adminkey
}
+
try {
- const response = await LNbits.api.request(
- 'GET',
- '/nwcprovider/api/v1/nwc?include_expired=true&calculate_spent_budget=true',
- wallet.adminkey
- )
+ const endpoint =
+ this.selectedWallet === 'all'
+ ? '/nwcprovider/api/v1/nwc/all?include_expired=true&calculate_spent_budget=true'
+ : '/nwcprovider/api/v1/nwc?include_expired=true&calculate_spent_budget=true'
+ const response = await LNbits.api.request('GET', endpoint, adminkey)
this.nwcs = response.data
} catch (error) {
this.nwcs = []
@@ -216,7 +339,7 @@ window.app = Vue.createApp({
const response = await LNbits.api.request(
'GET',
'/nwcprovider/api/v1/permissions',
- wallet.adminkey
+ adminkey
)
const permissions = []
for (const [key, value] of Object.entries(response.data)) {
@@ -228,7 +351,7 @@ window.app = Vue.createApp({
}
this.nodePermissions = permissions
} catch (error) {
- Lnbits.utils.notifyApiError(error)
+ LNbits.utils.notifyApiError(error)
}
this.loadConnectDialogData()
const newTableEntries = []
@@ -250,13 +373,16 @@ window.app = Vue.createApp({
)
const nwcTableEntry = {
description: nwc.data.description,
+ lud16: nwc.data.lud16 || '-',
created_at: t,
expires_at: e,
last_used: l,
pubkey: nwc.data.pubkey,
permissions: nwc.data.permissions,
budgets: [],
- status: 'Active'
+ status: 'Active',
+ wallet_id: nwc.wallet_id || null,
+ wallet_name: nwc.wallet_name || ''
}
if (
nwc.data.expires_at > 0 &&
@@ -290,23 +416,34 @@ window.app = Vue.createApp({
}
newTableEntries.push(nwcTableEntry)
}
+ // Sort by wallet name when showing all wallets
+ if (this.selectedWallet === 'all') {
+ newTableEntries.sort((a, b) =>
+ a.wallet_name.localeCompare(b.wallet_name)
+ )
+ }
this.nwcEntries = newTableEntries
},
closePairingDialog() {
this.pairingDialog.show = false
},
- async showPairingDialog(secret) {
- let response = await LNbits.api.request(
- 'GET',
- '/nwcprovider/api/v1/pairing/{SECRET}'
- )
- response = response.data
- response = response.replace('{SECRET}', secret)
- this.pairingDialog.data.pairingUrl = response
+ getLud16Value() {
+ const lud16 = this.connectDialog.data.lud16
+ return lud16 && lud16.trim() ? lud16.trim() : null
+ },
+ async showPairingDialog(secret, lud16) {
+ let url = `/nwcprovider/api/v1/pairing/${secret}`
+ if (lud16) {
+ url += `?lud16=${encodeURIComponent(lud16)}`
+ }
+ let response = await LNbits.api.request('GET', url)
+ this.pairingDialog.data.pairingUrl = response.data
this.pairingDialog.show = true
},
async confirmConnectDialog() {
const keyPair = await this.generateKeyPair()
+ // Save lud16 before dialog closes (closeConnectDialog resets it)
+ const lud16 = this.getLud16Value()
// timestamp
let expires_at = 0
if (!this.connectDialog.data.neverExpires) {
@@ -317,7 +454,8 @@ window.app = Vue.createApp({
permissions: [],
description: this.connectDialog.data.description,
expires_at: expires_at,
- budgets: []
+ budgets: [],
+ lud16: lud16
}
for (const permission of this.connectDialog.data.permissions) {
if (permission.value) data.permissions.push(permission.key)
@@ -348,7 +486,15 @@ window.app = Vue.createApp({
created_at: new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000
})
}
- const wallet = this.getWallet()
+ // Use dialogWallet for creating connections (handles "All Wallets" case)
+ const wallet = this.getDialogWallet()
+ if (!wallet) {
+ Quasar.Notify.create({
+ type: 'negative',
+ message: 'Select a wallet first'
+ })
+ return
+ }
try {
const response = await LNbits.api.request(
@@ -366,7 +512,7 @@ window.app = Vue.createApp({
LNbits.utils.notifyApiError('Error creating nwc pairing')
return
}
- this.showPairingDialog(keyPair.privKey)
+ this.showPairingDialog(keyPair.privKey, lud16)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
@@ -375,11 +521,20 @@ window.app = Vue.createApp({
},
created: function () {
+ // Default to "All Wallets" to show all connections
+ this.selectedWallet = 'all'
+ // Load connections on initial page load
this.loadNwcs()
},
watch: {
selectedWallet(newValue, oldValue) {
this.loadNwcs()
+ },
+ dialogWallet(newValue, oldValue) {
+ // Reload lightning addresses when dialog wallet changes
+ if (newValue && this.connectDialog.show) {
+ this.loadLightningAddresses()
+ }
}
}
})
diff --git a/tasks.py b/tasks.py
index 412f2b5..62e2cdd 100644
--- a/tasks.py
+++ b/tasks.py
@@ -14,12 +14,13 @@
from lnbits.db import Filters
from lnbits.exceptions import PaymentError
from lnbits.settings import settings
+from lnbits.tasks import register_invoice_listener
from lnbits.wallets.base import PaymentStatus
from loguru import logger
-from .crud import get_config_nwc, get_nwc, tracked_spend_nwc
+from .crud import get_config_nwc, get_nwc, get_wallet_nwcs, tracked_spend_nwc
from .execution_queue import execution_queue
-from .models import GetNWC, NWCKey, TrackedSpendNWC
+from .models import GetNWC, GetWalletNWC, NWCKey, TrackedSpendNWC
from .nwcp import NWCServiceProvider
from .paranoia import (
assert_boolean,
@@ -59,6 +60,84 @@ async def _check(nwc: NWCKey | None, method: str) -> dict | None:
return None
+def _build_transaction_data(payment: Payment) -> dict:
+ """
+ Build the NIP-47 transaction data dict from a Payment object.
+ Used for lookup_invoice, list_transactions, and notification payloads.
+ """
+ invoice_data = bolt11_decode(payment.bolt11)
+ is_settled = not payment.pending
+ timestamp = int(payment.time.timestamp()) or int(invoice_data.date)
+ expiry = int(payment.expiry.timestamp()) if payment.expiry else timestamp + 3600
+ preimage = (
+ payment.preimage
+ or "0000000000000000000000000000000000000000000000000000000000000000"
+ )
+ # Derive NIP-47 state from Payment status
+ if payment.success:
+ state = "settled"
+ elif payment.failed:
+ state = "failed"
+ elif payment.is_expired:
+ state = "expired"
+ else:
+ state = "pending"
+ res: dict = {
+ "type": "outgoing" if payment.is_out else "incoming",
+ "state": state,
+ "invoice": payment.bolt11,
+ # Fallback chain so a human-readable description reaches the NWC client.
+ "description": (
+ (payment.extra or {}).get("comment")
+ or invoice_data.description
+ or payment.memo
+ ),
+ "preimage": preimage if is_settled or payment.is_in else None,
+ "payment_hash": payment.payment_hash,
+ "amount": abs(payment.msat),
+ "fees_paid": abs(payment.fee),
+ "created_at": timestamp,
+ "expires_at": expiry,
+ "settled_at": timestamp if is_settled else None,
+ "metadata": {},
+ }
+ if invoice_data.description_hash:
+ res["description_hash"] = invoice_data.description_hash
+ return res
+
+
+async def _send_notification_to_wallet(
+ sp: NWCServiceProvider,
+ wallet_id: str,
+ notification_type: str,
+ notification: dict,
+ exclude_pubkey: str | None = None,
+):
+ """
+ Send a notification to all NWC keys on a wallet that have the
+ 'notifications' permission. Best-effort: errors are logged, never raised.
+ """
+ try:
+ nwc_keys = await get_wallet_nwcs(GetWalletNWC(wallet=wallet_id))
+ for nwc_key in nwc_keys:
+ if exclude_pubkey and nwc_key.pubkey == exclude_pubkey:
+ continue
+ permissions = nwc_key.get_permissions()
+ if "notifications" not in permissions:
+ continue
+ try:
+ await sp.send_notification(
+ nwc_key.pubkey, notification_type, notification
+ )
+ except Exception as e:
+ logger.debug(
+ f"Failed to send {notification_type} notification "
+ f"to {nwc_key.pubkey[:8]}...: {e}"
+ )
+ except Exception as e:
+ logger.debug(f"Failed to send notifications for wallet {wallet_id}: {e}")
+
+
async def _process_invoice(
wallet_id: str,
pubkey: str,
@@ -112,7 +191,7 @@ async def execute_payment() -> str:
payment_status: PaymentStatus | None = None
while wait_for_preimage:
payment_status = await check_transaction_status(wallet_id, payment_hash)
- if payment_status.success:
+ if payment_status and payment_status.success:
break
if payment_status.failed:
return {
@@ -169,13 +248,37 @@ async def _on_pay_invoice(
if error:
return [(None, error, [])]
preimage = res.get("preimage")
+ payment_hash = res.get("payment_hash")
out = {
"preimage": preimage,
}
- # await log_nwc(pubkey, payload)
+ await _notify_payment_sent(sp, nwc.wallet, pubkey, payment_hash)
return [(out, None, [])]
+async def _notify_payment_sent(
+ sp: NWCServiceProvider,
+ wallet_id: str,
+ exclude_pubkey: str,
+ payment_hash: str | None,
+) -> None:
+ """Send payment_sent notification to other keys on this wallet."""
+ try:
+ if payment_hash:
+ payment = await get_wallet_payment(wallet_id, payment_hash)
+ if payment:
+ notification = _build_transaction_data(payment)
+ await _send_notification_to_wallet(
+ sp,
+ wallet_id,
+ "payment_sent",
+ notification,
+ exclude_pubkey=exclude_pubkey,
+ )
+ except Exception as e:
+ logger.debug(f"Failed to send payment_sent notification: {e}")
+
+
async def _on_multi_pay_invoice(
sp: NWCServiceProvider, pubkey: str, payload: dict
) -> list[tuple[dict | None, dict | None, list]]:
@@ -221,14 +324,16 @@ async def _on_multi_pay_invoice(
if error:
results.append((None, error, []))
else:
+ payment_hash = res.get("payment_hash")
r = (
{
"preimage": res.get("preimage"),
},
None,
- [["d", invoice_id if invoice_id else res.get("payment_hash")]],
+ [["d", invoice_id if invoice_id else payment_hash]],
)
results.append(r)
+ await _notify_payment_sent(sp, nwc.wallet, pubkey, payment_hash)
except Exception as e:
results.append((None, {"code": "INTERNAL", "message": str(e)}, []))
# await log_nwc(pubkey, payload)
@@ -341,31 +446,7 @@ async def _on_lookup_invoice(
payment = await get_wallet_payment(nwc.wallet, payment_hash)
if not payment:
raise Exception("Payment not found")
- invoice_data = bolt11_decode(payment.bolt11)
- is_settled = not payment.pending
- timestamp = int(payment.time.timestamp()) or int(invoice_data.date)
- expiry = int(payment.expiry.timestamp()) if payment.expiry else timestamp + 3600
- preimage = (
- payment.preimage
- or "0000000000000000000000000000000000000000000000000000000000000000"
- )
- res: dict = {
- "type": "outgoing" if payment.is_out else "incoming",
- "invoice": payment.bolt11,
- "description": (
- invoice_data.description if invoice_data.description else payment.memo
- ),
- "preimage": preimage if is_settled or payment.is_in else None,
- "payment_hash": payment.payment_hash,
- "amount": abs(payment.msat),
- "fees_paid": abs(payment.fee),
- "created_at": timestamp,
- "expires_at": expiry,
- "settled_at": timestamp if is_settled else None,
- "metadata": {},
- }
- if invoice_data.description_hash:
- res["description_hash"] = invoice_data.description_hash
+ res = _build_transaction_data(payment)
# await log_nwc(pubkey, payload)
return [(res, None, [])]
@@ -416,32 +497,8 @@ async def _on_list_transactions(
offset=offset,
)
transactions: list[dict] = []
- p: Payment
for p in history:
- invoice_data = bolt11_decode(p.bolt11)
- is_settled = not p.pending
- timestamp = int(p.time.timestamp()) or invoice_data.date
- transactions.append(
- {
- "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_hash": invoice_data.description_hash,
- "preimage": p.preimage if is_settled or p.is_in else None,
- "payment_hash": p.payment_hash,
- "amount": abs(p.msat),
- "fees_paid": p.fee,
- "created_at": timestamp,
- "settled_at": timestamp if is_settled else None,
- "metadata": {},
- }
- )
+ transactions.append(_build_transaction_data(p))
# await log_nwc(pubkey, payload)
return [({"transactions": transactions}, None, [])]
@@ -494,6 +551,10 @@ async def _on_get_info(
if spm in allowed_methods:
account_methods.append(spm)
break
+ # Include supported notifications if key has notifications permission
+ notifications = []
+ if "notifications" in permissions:
+ notifications = sp.supported_notifications
# await log_nwc(pubkey, payload)
return [
(
@@ -504,6 +565,7 @@ async def _on_get_info(
"block_height": 0,
"block_hash": "",
"methods": account_methods,
+ "notifications": notifications,
},
None,
[],
@@ -511,6 +573,26 @@ async def _on_get_info(
]
+async def _on_invoice_paid(sp: NWCServiceProvider, payment: Payment):
+ """Handle an incoming payment by sending payment_received notifications."""
+ try:
+ notification = _build_transaction_data(payment)
+ await _send_notification_to_wallet(
+ sp, payment.wallet_id, "payment_received", notification
+ )
+ except Exception as e:
+ logger.debug(f"Failed to handle invoice paid notification: {e}")
+
+
+async def _notification_listener(sp: NWCServiceProvider):
+ """Listen for incoming paid invoices and send payment_received notifications."""
+ queue: asyncio.Queue = asyncio.Queue()
+ register_invoice_listener(queue, "ext_nwcprovider")
+ while True:
+ payment = await queue.get()
+ await _on_invoice_paid(sp, payment)
+
+
async def handle_nwc():
priv_key = await get_config_nwc("provider_key")
relay = await get_config_nwc("relay")
@@ -528,10 +610,12 @@ async def handle_nwc():
# nwcsp.addRequestListener("multi_pay_keysend", _on_multi_pay_keysend)
###
await nwcsp.start()
+ notification_task = asyncio.create_task(_notification_listener(nwcsp))
try:
while True:
await asyncio.sleep(3600)
except asyncio.CancelledError:
+ notification_task.cancel()
await nwcsp.cleanup()
raise
diff --git a/templates/nwcprovider/admin.html b/templates/nwcprovider/admin.html
index 81576b4..0ff4709 100644
--- a/templates/nwcprovider/admin.html
+++ b/templates/nwcprovider/admin.html
@@ -22,7 +22,7 @@
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'"
+ :hint="'URL of the Nostr relay for dispatching and receiving NWC events. Use public relays or a custom one. Enter nostrclient to use the Nostr Client extension'"
>
@@ -41,7 +41,7 @@
@@ -51,7 +51,7 @@
+ >
+
+
+
+
+
+
+
+
+
+
+
Connected Apps
round
/>
+
+
+
+
+ Connect apps to enable automated payments within your budget limits.
+
+
+
+
+
-
+
-
- ${ col.label }
-
+
+
-
+
- ${ col.value }
+
+
+
+ Click to view this wallet
+
+
-
+
Connected Apps
flat
dense
size="xs"
- @click="deleteNWC(props.row.pubkey)"
+ @click="deleteNWC(props.row)"
icon="cancel"
color="pink"
>
@@ -98,65 +154,143 @@ Connected Apps
+
+
+
+
+
- NWC Service Provider
-
+
NWC Service Provider
+
+ Connect your wallet to any NWC-compatible app
+
+
Nostr Wallet Connect (NWC) is an open protocol to connect Lightning
wallets to apps. This extension allows you to use your LNbits wallet
with
any NWC compatible app.
-
+
Before you can use this extension, you need to configure it.
Read the User Guide
to get started.
-
- Connecting a NWC App
-
- Once you have configured the extension, you can connect a NWC
- compatible app by following these steps:
-
-
- -
- In the NWC Service Provider extension, select the
- wallet you want to connect.
-
- - Click the "+" button to add a new connection.
- -
- Enter a description, expiry date (optional), permissions, and
- limits.
-
- - Click "Connect" to create the connection.
- -
- Use the generated pairing URL or QR code to connect your chosen app.
-
-
-
-
-
-
-
- Swagger API
+
+
+
+
+ Connecting a NWC App
+
+
+ Once configured, connect a NWC compatible app:
+
+
+
+
+ 1
+
+ Select the wallet you want to connect
+
+
+
+ 2
+
+ Click the "+" button to add a new connection
+
+
+
+ 3
+
+ Set a description, expiry, permissions, and spending
+ limits
+
+
+
+ 4
+
+ Click "Connect" to create the connection
+
+
+
+ 5
+
+ Scan QR or paste URL in your app
+
+
+
+
+
+
+
+
+ API Documentation
+
+
+
+
+
@@ -166,38 +300,43 @@ Connecting a NWC App
>
- Info
+ Connection Details
Description
- ${connectionInfoDialog.data.description}
+
Last used
- ${connectionInfoDialog.data.last_used}
+
Expires
- ${connectionInfoDialog.data.expires_at}
+
Created
- ${connectionInfoDialog.data.created_at}
+
Permissions
-
- ${connectionInfoDialog.data.permissions}
-
+
Info
- No limits
+ No spending limits
- ${budget.used_budget_sats} / ${budget.budget_sats}
- ${budget.refresh_window}
+
+
@@ -257,10 +402,10 @@ Info
- Please scan this QR code with a supported app
+ Scan this QR code with your compatible app
- Connect only with app you trust!
+ Connect only with an app that you trust!
@@ -278,14 +423,13 @@ Info
- Pairing
+ Pair Connection
- Complete the last step of the setup by pasting or scanning your
- connection's pairing secret in the desired app to finalise the
- connection.
+ Paste the connection URL or scan the QR code in your compatible app to
+ complete the pairing.
- Connect only with app you trust!
+ Connect only with an app that you trust!
@@ -337,21 +481,104 @@ Advanced
- Add connection
+ New Connection
+
+
+
+
+
+
+ Clear
+
+
+ Select address (lud16) from Paylinks
+
+
+ Select Lightning Address from Paylinks
+ extension
+
+
+
+
+
+ No addresses available
+
+
+ Create one in the Paylinks extension
or enter an
+ address manually
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Add connection
- Authorize the app to
+ Authorize the connected app to
Add connection
- Limit the spendable amount
+ Set a spending limit
No limitNo spending limit
@@ -407,9 +633,9 @@ Add connection
{label: 'Weekly', value: 'Weekly'},
{label: 'Monthly', value: 'Monthly'},
{label: 'Yearly', value: 'Yearly'},
- {label: 'Never', value: 'Never'}
+ {label: 'One-time', value: 'Never'}
]"
- label="Expires"
+ label="Resets"
v-model="budget.expiry"
>
diff --git a/tests/integration/test_all.py b/tests/integration/test_all.py
index fb33c1f..1248768 100644
--- a/tests/integration/test_all.py
+++ b/tests/integration/test_all.py
@@ -4,6 +4,7 @@
import random
import time
from typing import Union
+from urllib.parse import unquote
import bolt11
import httpx
@@ -152,14 +153,16 @@ def __init__(self, pairing_url):
# Extract from Pairing url nostr+walletconnect://provider_pub?relay=relay&secret=secret
self.pairing_url = pairing_url
self.provider_pub_hex = pairing_url.split("://")[1].split("?")[0]
- self.relay = pairing_url.split("relay=")[1].split("&")[0]
+ self.relay = unquote(pairing_url.split("relay=")[1].split("&")[0])
self.secret = pairing_url.split("secret=")[1]
self.ws = None
self.connected = False
self.shutdown = False
self.event_queue = []
+ self.notification_queue = []
self.subscriptions_count = 0
self.sub_id = ""
+ self.notif_sub_id = ""
self.private_key = PrivateKey.from_hex(self.secret)
self.private_key_hex = self.secret
self.public_key = self.private_key.public_key
@@ -218,6 +221,16 @@ async def _run(self):
await self.ws.send(
self._json_dumps(["REQ", self.sub_id, res_filter])
)
+ # Subscribe to notification events (kind 23196)
+ self.notif_sub_id = self._get_new_subid()
+ notif_filter = {
+ "kinds": [23196],
+ "#p": [self.public_key_hex],
+ "since": int(time.time()),
+ }
+ await self.ws.send(
+ self._json_dumps(["REQ", self.notif_sub_id, notif_filter])
+ )
while not self._is_shutting_down() and not ws.closed:
try:
reply = await ws.recv()
@@ -243,20 +256,35 @@ async def _on_message(self, _, message: str):
logger.debug("Received message: " + message)
msg = json.loads(message)
if msg[0] == "EVENT": # Event message
+ sub_id = msg[1]
event = msg[2]
nwc_pubkey = event["pubkey"]
content = self.private_key.decrypt_message(event["content"], nwc_pubkey)
content = json.loads(content)
- self.event_queue.append(
- {
- "created_at": event["created_at"],
- "content": content,
- "result": content["result"] if "result" in content else None,
- "error": content["error"] if "error" in content else None,
- "method": content["result_type"],
- "tags": event["tags"],
- }
- )
+ if sub_id == self.notif_sub_id and event["kind"] == 23196:
+ # Notification event
+ self.notification_queue.append(
+ {
+ "created_at": event["created_at"],
+ "content": content,
+ "notification_type": content.get("notification_type"),
+ "notification": content.get("notification"),
+ "tags": event["tags"],
+ "kind": event["kind"],
+ }
+ )
+ else:
+ # Response event
+ self.event_queue.append(
+ {
+ "created_at": event["created_at"],
+ "content": content,
+ "result": content["result"] if "result" in content else None,
+ "error": content["error"] if "error" in content else None,
+ "method": content["result_type"],
+ "tags": event["tags"],
+ }
+ )
def _json_dumps(self, data: Union[dict, list]) -> str:
if isinstance(data, dict):
@@ -326,6 +354,25 @@ async def wait_for(
if timeout > 0 and time.time() > now + timeout:
raise Exception("Timeout")
+ async def wait_for_notification(self, notification_type, timeout=30):
+ """Wait for a specific notification type to arrive."""
+ now = time.time()
+ while True:
+ for i in range(len(self.notification_queue)):
+ n = self.notification_queue[i]
+ if n["notification_type"] == notification_type:
+ self.notification_queue.pop(i)
+ return n["notification"], n["tags"]
+ await asyncio.sleep(0.5)
+ if time.time() > now + timeout:
+ raise Exception(f"Timeout waiting for {notification_type} notification")
+
+ def has_notification(self, notification_type):
+ """Check if a notification of the given type is already queued."""
+ return any(
+ n["notification_type"] == notification_type for n in self.notification_queue
+ )
+
@pytest.mark.asyncio
async def test_create():
@@ -1106,3 +1153,213 @@ async def test_list_transactions():
finally:
await wallet1.close()
await wallet2.close()
+
+
+# ===== NIP-47 Notification Tests =====
+
+
+@pytest.mark.asyncio
+async def test_payment_received_notification():
+ """Test that payment_received notification is sent when an invoice is paid."""
+ await check_services()
+
+ # Create two NWC keys: one to create invoice, one to listen for notifications
+ # Both on wallet1 with notifications permission
+ nwc_invoicer = await create_nwc(
+ "wallet1", "notif_invoicer", ["invoice", "notifications"], [], 0
+ )
+ nwc_payer = await create_nwc("wallet2", "notif_payer", ["pay"], [], 0)
+
+ invoicer = NWCWallet(nwc_invoicer["pairing"])
+ payer = NWCWallet(nwc_payer["pairing"])
+
+ try:
+ await invoicer.start()
+ await payer.start()
+
+ # Create invoice on wallet1
+ await invoicer.send_event(
+ "make_invoice", {"amount": 5000, "description": "notif test"}
+ )
+ result, _, error = await invoicer.wait_for("make_invoice")
+ assert not error
+ invoice = result["invoice"]
+
+ # Pay from wallet2
+ await payer.send_event("pay_invoice", {"invoice": invoice})
+ _, _, error = await payer.wait_for("pay_invoice")
+ assert not error
+
+ # Invoicer should receive payment_received notification
+ notification, tags = await invoicer.wait_for_notification(
+ "payment_received", timeout=30
+ )
+ assert notification["type"] == "incoming"
+ assert notification["state"] == "settled"
+ assert notification["amount"] == 5000
+ assert notification["payment_hash"]
+ assert notification["settled_at"]
+ assert notification["invoice"]
+
+ # Verify tags: p tag present, no e tag
+ p_tags = [t for t in tags if t[0] == "p"]
+ e_tags = [t for t in tags if t[0] == "e"]
+ assert len(p_tags) == 1
+ assert p_tags[0][1] == invoicer.public_key_hex
+ assert len(e_tags) == 0, "Notification must not have e tag"
+
+ finally:
+ await invoicer.close()
+ await payer.close()
+
+
+@pytest.mark.asyncio
+async def test_payment_sent_notification():
+ """Test that payment_sent notification is sent to other keys on the same wallet."""
+ await check_services()
+
+ # Two NWC keys on wallet2: one pays, the other should get notified
+ nwc_sender = await create_nwc(
+ "wallet2", "notif_sender", ["pay", "notifications"], [], 0
+ )
+ nwc_observer = await create_nwc(
+ "wallet2", "notif_observer", ["notifications"], [], 0
+ )
+ # wallet1 creates the invoice to be paid
+ nwc_invoicer = await create_nwc(
+ "wallet1", "notif_invoice_creator", ["invoice"], [], 0
+ )
+
+ sender = NWCWallet(nwc_sender["pairing"])
+ observer = NWCWallet(nwc_observer["pairing"])
+ invoicer = NWCWallet(nwc_invoicer["pairing"])
+
+ try:
+ await sender.start()
+ await observer.start()
+ await invoicer.start()
+
+ # Create invoice on wallet1
+ await invoicer.send_event(
+ "make_invoice", {"amount": 3000, "description": "sent notif test"}
+ )
+ result, _, error = await invoicer.wait_for("make_invoice")
+ assert not error
+ invoice = result["invoice"]
+
+ # Pay from wallet2 via sender key
+ await sender.send_event("pay_invoice", {"invoice": invoice})
+ _, _, error = await sender.wait_for("pay_invoice")
+ assert not error
+
+ # Observer (other key on wallet2) should get payment_sent notification
+ notification, _tags = await observer.wait_for_notification(
+ "payment_sent", timeout=30
+ )
+ assert notification["type"] == "outgoing"
+ assert notification["state"] == "settled"
+ assert notification["amount"] == 3000
+ assert notification["payment_hash"]
+
+ # Sender should NOT get a payment_sent notification (they got the response)
+ await asyncio.sleep(3)
+ assert not sender.has_notification(
+ "payment_sent"
+ ), "Sender should not receive payment_sent notification"
+
+ finally:
+ await sender.close()
+ await observer.close()
+ await invoicer.close()
+
+
+@pytest.mark.asyncio
+async def test_no_notification_without_permission():
+ """Test that notifications are not sent to keys without notifications permission."""
+ await check_services()
+
+ # Key WITHOUT notifications permission
+ nwc_no_notif = await create_nwc("wallet1", "no_notif_key", ["invoice"], [], 0)
+ # Key WITH notifications permission (to verify notifications work)
+ nwc_with_notif = await create_nwc(
+ "wallet1", "with_notif_key", ["notifications"], [], 0
+ )
+ nwc_payer = await create_nwc("wallet2", "notif_perm_payer", ["pay"], [], 0)
+
+ no_notif = NWCWallet(nwc_no_notif["pairing"])
+ with_notif = NWCWallet(nwc_with_notif["pairing"])
+ payer = NWCWallet(nwc_payer["pairing"])
+
+ try:
+ await no_notif.start()
+ await with_notif.start()
+ await payer.start()
+
+ # Create invoice on wallet1
+ await no_notif.send_event(
+ "make_invoice", {"amount": 2000, "description": "perm test"}
+ )
+ result, _, error = await no_notif.wait_for("make_invoice")
+ assert not error
+ invoice = result["invoice"]
+
+ # Pay from wallet2
+ await payer.send_event("pay_invoice", {"invoice": invoice})
+ _, _, error = await payer.wait_for("pay_invoice")
+ assert not error
+
+ # Key WITH permission should get notification
+ notification, _ = await with_notif.wait_for_notification(
+ "payment_received", timeout=30
+ )
+ assert notification["amount"] == 2000
+
+ # Key WITHOUT permission should NOT get notification
+ await asyncio.sleep(3)
+ assert not no_notif.has_notification(
+ "payment_received"
+ ), "Key without notifications permission should not receive notifications"
+
+ finally:
+ await no_notif.close()
+ await with_notif.close()
+ await payer.close()
+
+
+@pytest.mark.asyncio
+async def test_get_info_includes_notifications():
+ """Test that get_info response includes notifications field."""
+ await check_services()
+
+ # Key with info + notifications permissions
+ nwc_with = await create_nwc(
+ "wallet1", "info_notif_test", ["info", "notifications"], [], 0
+ )
+ # Key with info but WITHOUT notifications permission
+ nwc_without = await create_nwc("wallet1", "info_no_notif_test", ["info"], [], 0)
+
+ wallet_with = NWCWallet(nwc_with["pairing"])
+ wallet_without = NWCWallet(nwc_without["pairing"])
+
+ try:
+ await wallet_with.start()
+ await wallet_without.start()
+
+ # Key with notifications permission should see supported notifications
+ await wallet_with.send_event("get_info", {})
+ result, _, error = await wallet_with.wait_for("get_info")
+ assert not error
+ assert "notifications" in result
+ assert "payment_received" in result["notifications"]
+ assert "payment_sent" in result["notifications"]
+
+ # Key without notifications permission should see empty list
+ await wallet_without.send_event("get_info", {})
+ result, _, error = await wallet_without.wait_for("get_info")
+ assert not error
+ assert "notifications" in result
+ assert result["notifications"] == []
+
+ finally:
+ await wallet_with.close()
+ await wallet_without.close()
diff --git a/tests/standalone/test_notification_helpers.py b/tests/standalone/test_notification_helpers.py
new file mode 100644
index 0000000..e51e785
--- /dev/null
+++ b/tests/standalone/test_notification_helpers.py
@@ -0,0 +1,506 @@
+# pyright: reportAttributeAccessIssue=false, reportArgumentType=false
+"""
+Test notification helper logic: permission filtering, data structures.
+Mocks lnbits to run standalone.
+"""
+
+import asyncio
+import os
+import sys
+import types
+from datetime import datetime, timezone
+
+PROJECT_DIR = os.path.dirname(
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+)
+sys.path.insert(0, PROJECT_DIR)
+
+# ── Mock external dependencies BEFORE any project imports ──
+
+# Mock loguru
+loguru_mod = types.ModuleType("loguru")
+
+
+class _MockLogger:
+ def debug(self, *a, **kw):
+ pass
+
+ def info(self, *a, **kw):
+ pass
+
+ def warning(self, *a, **kw):
+ pass
+
+ def error(self, *a, **kw):
+ pass
+
+ def exception(self, *a, **kw):
+ pass
+
+
+loguru_mod.logger = _MockLogger()
+sys.modules["loguru"] = loguru_mod
+
+# Mock pydantic (needed by models.py)
+pydantic_mod = types.ModuleType("pydantic")
+
+
+class BaseModel:
+ def __init__(self, **kwargs):
+ for k, v in kwargs.items():
+ setattr(self, k, v)
+
+ def __init_subclass__(cls, **kwargs):
+ super().__init_subclass__(**kwargs)
+
+
+pydantic_mod.BaseModel = BaseModel
+sys.modules["pydantic"] = pydantic_mod
+
+# Mock all lnbits modules
+lnbits = types.ModuleType("lnbits")
+lnbits.helpers = types.ModuleType("lnbits.helpers")
+lnbits.helpers.encrypt_internal_message = lambda x: x
+lnbits.settings = types.ModuleType("lnbits.settings")
+lnbits.tasks = types.ModuleType("lnbits.tasks")
+lnbits.tasks.register_invoice_listener = lambda q, n: None
+lnbits.core = types.ModuleType("lnbits.core")
+lnbits.core.crud = types.ModuleType("lnbits.core.crud")
+lnbits.core.models = types.ModuleType("lnbits.core.models")
+lnbits.core.services = types.ModuleType("lnbits.core.services")
+lnbits.db = types.ModuleType("lnbits.db")
+lnbits.exceptions = types.ModuleType("lnbits.exceptions")
+lnbits.wallets = types.ModuleType("lnbits.wallets")
+lnbits.wallets.base = types.ModuleType("lnbits.wallets.base")
+
+
+class MockSettings:
+ port = 5000
+ lnbits_running = True
+ lnbits_site_title = "TestLNbits"
+
+
+lnbits.settings.settings = MockSettings()
+
+
+class MockFilters:
+ def where(self, x):
+ pass
+
+ def values(self, x):
+ pass
+
+
+lnbits.db.Filters = MockFilters
+
+
+class MockPaymentError(Exception):
+ def __init__(self, message="", status="failed"):
+ self.message = message
+ self.status = status
+
+
+lnbits.exceptions.PaymentError = MockPaymentError
+
+
+# Mock lnbits.core.crud functions
+async def _mock_get_payments(*a, **kw):
+ return []
+
+
+async def _mock_get_wallet(*a, **kw):
+ return None
+
+
+async def _mock_get_wallet_payment(*a, **kw):
+ return None
+
+
+lnbits.core.crud.get_payments = _mock_get_payments
+lnbits.core.crud.get_wallet = _mock_get_wallet
+lnbits.core.crud.get_wallet_payment = _mock_get_wallet_payment
+
+
+# Mock lnbits.core.services functions
+async def _mock_svc(*a, **kw):
+ return None
+
+
+lnbits.core.services.check_transaction_status = _mock_svc
+lnbits.core.services.create_invoice = _mock_svc
+lnbits.core.services.pay_invoice = _mock_svc
+
+
+# Mock lnbits.wallets.base.PaymentStatus
+class MockPaymentStatus:
+ def __init__(self, paid=False):
+ self.paid = paid
+
+
+lnbits.wallets.base.PaymentStatus = MockPaymentStatus
+
+
+# Mock lnbits.db.Database
+class MockDatabase:
+ def __init__(self, *a, **kw):
+ pass
+
+
+lnbits.db.Database = MockDatabase
+
+
+# Mock Payment class
+class MockPayment:
+ def __init__(self, **kwargs):
+ self.bolt11 = kwargs.get("bolt11", "lnbc50n1...")
+ self.pending = kwargs.get("pending", False)
+ self.payment_hash = kwargs.get("payment_hash", "abc123")
+ self.memo = kwargs.get("memo", "test payment")
+ self.preimage = kwargs.get("preimage", "pre123")
+ self.is_out = kwargs.get("is_out", False)
+ self.is_in = kwargs.get("is_in", True)
+ self.msat = kwargs.get("msat", 5000)
+ self.fee = kwargs.get("fee", 0)
+ self.wallet_id = kwargs.get("wallet_id", "wallet1")
+ self.extra = kwargs.get("extra", {})
+ self.time = kwargs.get("time", datetime(2023, 11, 14, tzinfo=timezone.utc))
+ self.expiry = kwargs.get("expiry", None)
+ self.success = not self.pending
+ self.failed = False
+ self.is_expired = False
+
+
+lnbits.core.models.Payment = MockPayment
+
+# Register all lnbits modules
+for name, mod in [
+ ("lnbits", lnbits),
+ ("lnbits.helpers", lnbits.helpers),
+ ("lnbits.settings", lnbits.settings),
+ ("lnbits.tasks", lnbits.tasks),
+ ("lnbits.core", lnbits.core),
+ ("lnbits.core.crud", lnbits.core.crud),
+ ("lnbits.core.models", lnbits.core.models),
+ ("lnbits.core.services", lnbits.core.services),
+ ("lnbits.db", lnbits.db),
+ ("lnbits.exceptions", lnbits.exceptions),
+ ("lnbits.wallets", lnbits.wallets),
+ ("lnbits.wallets.base", lnbits.wallets.base),
+]:
+ sys.modules[name] = mod
+
+# Mock bolt11
+bolt11_mod = types.ModuleType("bolt11")
+
+
+class MockDecoded:
+ def __init__(self):
+ self.date = 1700000000
+ self.description = "test"
+ self.description_hash = None
+ self.amount_msat = 5000
+ self.payment_hash = "abc123"
+
+
+bolt11_mod.decode = lambda x: MockDecoded()
+sys.modules["bolt11"] = bolt11_mod
+
+# ── Set up the nwcprovider package so relative imports in tasks.py work ──
+
+# Create the package module
+nwcprovider_pkg = types.ModuleType("nwcprovider")
+nwcprovider_pkg.__path__ = [PROJECT_DIR]
+nwcprovider_pkg.__package__ = "nwcprovider"
+sys.modules["nwcprovider"] = nwcprovider_pkg
+
+# Import submodules that tasks.py needs (they're importable from sys.path)
+import execution_queue as _execution_queue # noqa: E402
+import nwcp as _nwcp # noqa: E402
+import paranoia as _paranoia # noqa: E402
+import permission as _permission # noqa: E402
+
+# Register as package submodules
+sys.modules["nwcprovider.paranoia"] = _paranoia
+sys.modules["nwcprovider.nwcp"] = _nwcp
+sys.modules["nwcprovider.permission"] = _permission
+sys.modules["nwcprovider.execution_queue"] = _execution_queue
+nwcprovider_pkg.paranoia = _paranoia
+nwcprovider_pkg.nwcp = _nwcp
+nwcprovider_pkg.permission = _permission
+nwcprovider_pkg.execution_queue = _execution_queue
+
+# Import models.py (needs pydantic mock above, no relative imports)
+import models as _models # noqa: E402
+
+sys.modules["nwcprovider.models"] = _models
+nwcprovider_pkg.models = _models
+
+# Import crud.py via importlib (it uses relative imports)
+import importlib # noqa: E402
+
+
+def _load_as_submodule(name, filename):
+ spec = importlib.util.spec_from_file_location(
+ f"nwcprovider.{name}",
+ os.path.join(PROJECT_DIR, filename),
+ submodule_search_locations=[],
+ )
+ mod = importlib.util.module_from_spec(spec)
+ mod.__package__ = "nwcprovider"
+ sys.modules[f"nwcprovider.{name}"] = mod
+ setattr(nwcprovider_pkg, name, mod)
+ spec.loader.exec_module(mod)
+ return mod
+
+
+_crud = _load_as_submodule("crud", "crud.py")
+_tasks_mod = _load_as_submodule("tasks", "tasks.py")
+
+# Aliases for convenience
+tasks = _tasks_mod
+NWCServiceProvider = _nwcp.NWCServiceProvider
+nwc_permissions = _permission.nwc_permissions
+
+
+# ── Mock NWCKey for tests ──
+
+
+class MockNWCKey:
+ def __init__(self, pubkey, wallet, permissions_str):
+ self.pubkey = pubkey
+ self.wallet = wallet
+ self.permissions = permissions_str
+
+ def get_permissions(self):
+ return self.permissions.split(" ")
+
+
+# ── Tests ──
+
+
+def test_permission_includes_notifications():
+ """notifications permission exists with empty methods list."""
+ assert "notifications" in nwc_permissions
+ perm = nwc_permissions["notifications"]
+ assert perm["methods"] == []
+ assert perm["default"] is True
+ assert perm["name"] == "Receive payment notifications"
+ print(" PASS: notifications permission exists")
+
+
+def test_build_transaction_data_settled():
+ """_build_transaction_data for a settled incoming payment."""
+ payment = MockPayment(
+ pending=False,
+ is_out=False,
+ is_in=True,
+ msat=5000,
+ fee=0,
+ payment_hash="abc123",
+ preimage="pre123",
+ memo="coffee",
+ )
+ result = tasks._build_transaction_data(payment)
+
+ assert result["type"] == "incoming"
+ assert result["state"] == "settled"
+ assert result["amount"] == 5000
+ assert result["payment_hash"] == "abc123"
+ assert result["preimage"] == "pre123"
+ assert result["settled_at"] is not None
+ assert result["fees_paid"] == 0
+ assert "metadata" in result
+ print(" PASS: _build_transaction_data settled incoming")
+
+
+def test_build_transaction_data_pending():
+ """_build_transaction_data for a pending outgoing payment."""
+ payment = MockPayment(
+ pending=True,
+ is_out=True,
+ is_in=False,
+ msat=-3000,
+ fee=-10,
+ payment_hash="def456",
+ preimage=None,
+ )
+ result = tasks._build_transaction_data(payment)
+
+ assert result["type"] == "outgoing"
+ assert result["state"] == "pending"
+ assert result["amount"] == 3000
+ assert result["preimage"] is None, "Pending outgoing should have no preimage"
+ assert result["settled_at"] is None
+ print(" PASS: _build_transaction_data pending outgoing")
+
+
+async def test_send_notification_to_wallet_filters_permissions():
+ """_send_notification_to_wallet only sends to keys with notifications permission."""
+ sp = NWCServiceProvider(
+ "d7b5232fba0e02e32cfe26f20cdf2c803b27ecd81052c2dd5d17e5e1a333fe58",
+ "ws://localhost:7777",
+ )
+
+ sent_to = []
+
+ async def mock_send_notification(client_pubkey, notification_type, notification):
+ sent_to.append(client_pubkey)
+
+ sp.send_notification = mock_send_notification
+
+ key_with_notif1 = MockNWCKey("pub_a", "wallet1", "pay notifications")
+ key_with_notif2 = MockNWCKey("pub_b", "wallet1", "invoice notifications")
+ key_without_notif = MockNWCKey("pub_c", "wallet1", "pay invoice")
+
+ original_get_wallet_nwcs = tasks.get_wallet_nwcs
+
+ async def mock_get_wallet_nwcs(data):
+ return [key_with_notif1, key_with_notif2, key_without_notif]
+
+ tasks.get_wallet_nwcs = mock_get_wallet_nwcs
+
+ try:
+ await tasks._send_notification_to_wallet(
+ sp, "wallet1", "payment_received", {"amount": 1000}
+ )
+
+ assert "pub_a" in sent_to, "Key with notifications perm should receive"
+ assert "pub_b" in sent_to, "Key with notifications perm should receive"
+ assert (
+ "pub_c" not in sent_to
+ ), "Key without notifications perm should NOT receive"
+ assert len(sent_to) == 2
+ print(" PASS: permission filtering works")
+ finally:
+ tasks.get_wallet_nwcs = original_get_wallet_nwcs
+
+
+async def test_send_notification_excludes_pubkey():
+ """_send_notification_to_wallet skips exclude_pubkey."""
+ sp = NWCServiceProvider(
+ "d7b5232fba0e02e32cfe26f20cdf2c803b27ecd81052c2dd5d17e5e1a333fe58",
+ "ws://localhost:7777",
+ )
+
+ sent_to = []
+
+ async def mock_send_notification(client_pubkey, notification_type, notification):
+ sent_to.append(client_pubkey)
+
+ sp.send_notification = mock_send_notification
+
+ key1 = MockNWCKey("pub_sender", "wallet1", "pay notifications")
+ key2 = MockNWCKey("pub_observer", "wallet1", "notifications")
+
+ original_get_wallet_nwcs = tasks.get_wallet_nwcs
+
+ async def mock_get_wallet_nwcs(data):
+ return [key1, key2]
+
+ tasks.get_wallet_nwcs = mock_get_wallet_nwcs
+
+ try:
+ await tasks._send_notification_to_wallet(
+ sp,
+ "wallet1",
+ "payment_sent",
+ {"amount": 1000},
+ exclude_pubkey="pub_sender",
+ )
+
+ assert "pub_sender" not in sent_to, "Excluded pubkey should not receive"
+ assert "pub_observer" in sent_to, "Non-excluded key should receive"
+ assert len(sent_to) == 1
+ print(" PASS: exclude_pubkey works")
+ finally:
+ tasks.get_wallet_nwcs = original_get_wallet_nwcs
+
+
+async def test_send_notification_error_isolation():
+ """One key failing doesn't prevent other keys from getting notifications."""
+ sp = NWCServiceProvider(
+ "d7b5232fba0e02e32cfe26f20cdf2c803b27ecd81052c2dd5d17e5e1a333fe58",
+ "ws://localhost:7777",
+ )
+
+ sent_to = []
+ call_count = 0
+
+ async def mock_send_notification(client_pubkey, notification_type, notification):
+ nonlocal call_count
+ call_count += 1
+ if client_pubkey == "pub_fail":
+ raise Exception("Simulated relay error")
+ sent_to.append(client_pubkey)
+
+ sp.send_notification = mock_send_notification
+
+ key_fail = MockNWCKey("pub_fail", "wallet1", "notifications")
+ key_ok = MockNWCKey("pub_ok", "wallet1", "notifications")
+
+ original_get_wallet_nwcs = tasks.get_wallet_nwcs
+
+ async def mock_get_wallet_nwcs(data):
+ return [key_fail, key_ok]
+
+ tasks.get_wallet_nwcs = mock_get_wallet_nwcs
+
+ try:
+ await tasks._send_notification_to_wallet(
+ sp, "wallet1", "payment_received", {"amount": 1000}
+ )
+
+ assert call_count == 2, "Both keys should be attempted"
+ assert "pub_ok" in sent_to, "Second key should succeed despite first failing"
+ assert "pub_fail" not in sent_to
+ print(" PASS: error isolation between keys")
+ finally:
+ tasks.get_wallet_nwcs = original_get_wallet_nwcs
+
+
+def main():
+ print("\n=== NIP-47 Notification Helper Tests ===\n")
+ passed = 0
+ failed = 0
+
+ # Sync tests
+ for test_fn in [
+ test_permission_includes_notifications,
+ test_build_transaction_data_settled,
+ test_build_transaction_data_pending,
+ ]:
+ try:
+ test_fn()
+ passed += 1
+ except Exception as e:
+ print(f" FAIL: {test_fn.__name__}: {e}")
+ import traceback
+
+ traceback.print_exc()
+ failed += 1
+
+ # Async tests
+ for test_fn in [
+ test_send_notification_to_wallet_filters_permissions,
+ test_send_notification_excludes_pubkey,
+ test_send_notification_error_isolation,
+ ]:
+ try:
+ asyncio.run(test_fn())
+ passed += 1
+ except Exception as e:
+ print(f" FAIL: {test_fn.__name__}: {e}")
+ import traceback
+
+ traceback.print_exc()
+ failed += 1
+
+ print(f"\n{'=' * 40}")
+ print(f"Results: {passed} passed, {failed} failed")
+ if failed > 0:
+ sys.exit(1)
+ print("All tests passed!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/standalone/test_notifications_standalone.py b/tests/standalone/test_notifications_standalone.py
new file mode 100644
index 0000000..4fb1319
--- /dev/null
+++ b/tests/standalone/test_notifications_standalone.py
@@ -0,0 +1,250 @@
+# pyright: reportAttributeAccessIssue=false, reportArgumentType=false
+"""
+Standalone test for NIP-47 notification event construction.
+Runs without lnbits installed - tests nwcp.py directly.
+"""
+
+import asyncio
+import json
+import os
+import sys
+
+# Add parent to path so we can import nwcp directly
+sys.path.insert(
+ 0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+)
+
+# Mock lnbits modules before importing nwcp
+import types
+
+# Create mock lnbits.helpers
+lnbits = types.ModuleType("lnbits")
+lnbits.helpers = types.ModuleType("lnbits.helpers")
+lnbits.helpers.encrypt_internal_message = lambda x: x
+lnbits.settings = types.ModuleType("lnbits.settings")
+
+
+class MockSettings:
+ port = 5000
+ lnbits_running = True
+
+
+lnbits.settings.settings = MockSettings()
+sys.modules["lnbits"] = lnbits
+sys.modules["lnbits.helpers"] = lnbits.helpers
+sys.modules["lnbits.settings"] = lnbits.settings
+
+from nwcp import NWCServiceProvider # noqa: E402
+
+
+def test_supported_notifications():
+ """supported_notifications list contains both types."""
+ sp = NWCServiceProvider(
+ "d7b5232fba0e02e32cfe26f20cdf2c803b27ecd81052c2dd5d17e5e1a333fe58",
+ "ws://localhost:7777",
+ )
+ assert "payment_received" in sp.supported_notifications
+ assert "payment_sent" in sp.supported_notifications
+ print(" PASS: supported_notifications")
+
+
+def test_info_event_tags():
+ """Info event includes notifications tag."""
+ sp = NWCServiceProvider(
+ "d7b5232fba0e02e32cfe26f20cdf2c803b27ecd81052c2dd5d17e5e1a333fe58",
+ "ws://localhost:7777",
+ )
+ sp.add_request_listener("pay_invoice", lambda *a: None)
+
+ # Build the tags the same way _on_connection does
+ tags = [["p", sp.public_key_hex]]
+ if sp.supported_notifications:
+ tags.append(["notifications", " ".join(sp.supported_notifications)])
+
+ notif_tags = [t for t in tags if t[0] == "notifications"]
+ assert len(notif_tags) == 1
+ assert "payment_received" in notif_tags[0][1]
+ assert "payment_sent" in notif_tags[0][1]
+ print(" PASS: info event notifications tag")
+
+
+async def test_send_notification():
+ """send_notification builds correct kind 23196 event."""
+ sp1 = NWCServiceProvider(
+ "d7b5232fba0e02e32cfe26f20cdf2c803b27ecd81052c2dd5d17e5e1a333fe58",
+ "ws://localhost:7777",
+ )
+ sp2 = NWCServiceProvider(
+ "ce40821040275f72f3074a89770db3e2744b189f204807c867840eb58565de51",
+ "ws://localhost:7777",
+ )
+
+ sent_events = []
+
+ async def capture_send(data):
+ sent_events.append(data)
+
+ sp1._send = capture_send
+
+ notification = {
+ "type": "incoming",
+ "state": "settled",
+ "invoice": "lnbc1000n1...",
+ "payment_hash": "abc123def456",
+ "amount": 5000,
+ "created_at": 1700000000,
+ "settled_at": 1700000000,
+ }
+
+ await sp1.send_notification(sp2.public_key_hex, "payment_received", notification)
+
+ assert len(sent_events) == 1
+ event_data = sent_events[0]
+
+ # Should be ["EVENT", {...}]
+ assert event_data[0] == "EVENT"
+ event = event_data[1]
+
+ # Kind 23196
+ assert event["kind"] == 23196, f"Expected kind 23196, got {event['kind']}"
+
+ # Valid signature
+ assert sp2._verify_event(event), "Event signature verification failed"
+
+ # Tags: only p tag, no e tag
+ tags = event["tags"]
+ p_tags = [t for t in tags if t[0] == "p"]
+ e_tags = [t for t in tags if t[0] == "e"]
+ assert len(p_tags) == 1, f"Expected 1 p tag, got {len(p_tags)}"
+ assert p_tags[0][1] == sp2.public_key_hex
+ assert len(e_tags) == 0, f"Notifications must not have e tags, got {len(e_tags)}"
+
+ # Decrypt and verify content
+ content = sp2.private_key.decrypt_message(event["content"], sp1.public_key_hex)
+ content = json.loads(content)
+ assert content["notification_type"] == "payment_received"
+ assert content["notification"]["type"] == "incoming"
+ assert content["notification"]["state"] == "settled"
+ assert content["notification"]["payment_hash"] == "abc123def456"
+ assert content["notification"]["amount"] == 5000
+ print(" PASS: send_notification event structure")
+
+
+async def test_send_notification_payment_sent():
+ """send_notification works for payment_sent type."""
+ sp1 = NWCServiceProvider(
+ "d7b5232fba0e02e32cfe26f20cdf2c803b27ecd81052c2dd5d17e5e1a333fe58",
+ "ws://localhost:7777",
+ )
+ sp2 = NWCServiceProvider(
+ "ce40821040275f72f3074a89770db3e2744b189f204807c867840eb58565de51",
+ "ws://localhost:7777",
+ )
+
+ sent_events = []
+
+ async def capture_send(data):
+ sent_events.append(data)
+
+ sp1._send = capture_send
+
+ notification = {
+ "type": "outgoing",
+ "state": "settled",
+ "invoice": "lnbc2000n1...",
+ "payment_hash": "def789",
+ "amount": 2000,
+ "fees_paid": 10,
+ "created_at": 1700000000,
+ "settled_at": 1700000000,
+ }
+
+ await sp1.send_notification(sp2.public_key_hex, "payment_sent", notification)
+
+ assert len(sent_events) == 1
+ event = sent_events[0][1]
+ assert event["kind"] == 23196
+
+ content = sp2.private_key.decrypt_message(event["content"], sp1.public_key_hex)
+ content = json.loads(content)
+ assert content["notification_type"] == "payment_sent"
+ assert content["notification"]["type"] == "outgoing"
+ assert content["notification"]["fees_paid"] == 10
+ print(" PASS: payment_sent notification")
+
+
+async def test_encrypt_decrypt_roundtrip():
+ """Notification content can be decrypted by the client."""
+ sp = NWCServiceProvider(
+ "d7b5232fba0e02e32cfe26f20cdf2c803b27ecd81052c2dd5d17e5e1a333fe58",
+ "ws://localhost:7777",
+ )
+ client = NWCServiceProvider(
+ "ce40821040275f72f3074a89770db3e2744b189f204807c867840eb58565de51",
+ "ws://localhost:7777",
+ )
+
+ sent_events = []
+
+ async def capture_send(data):
+ sent_events.append(data)
+
+ sp._send = capture_send
+
+ # Send notification from sp to client
+ await sp.send_notification(
+ client.public_key_hex,
+ "payment_received",
+ {"type": "incoming", "amount": 42000},
+ )
+
+ event = sent_events[0][1]
+
+ # Client decrypts using their private key and the sp's public key
+ decrypted = client.private_key.decrypt_message(event["content"], sp.public_key_hex)
+ parsed = json.loads(decrypted)
+ assert parsed["notification"]["amount"] == 42000
+
+ # SP can also decrypt (NIP-04 is symmetric)
+ decrypted2 = sp.private_key.decrypt_message(event["content"], client.public_key_hex)
+ parsed2 = json.loads(decrypted2)
+ assert parsed2["notification"]["amount"] == 42000
+ print(" PASS: encrypt/decrypt roundtrip")
+
+
+def main():
+ print("\n=== NIP-47 Notification Tests ===\n")
+ passed = 0
+ failed = 0
+
+ # Sync tests
+ for test_fn in [test_supported_notifications, test_info_event_tags]:
+ try:
+ test_fn()
+ passed += 1
+ except Exception as e:
+ print(f" FAIL: {test_fn.__name__}: {e}")
+ failed += 1
+
+ # Async tests
+ for test_fn in [
+ test_send_notification,
+ test_send_notification_payment_sent,
+ test_encrypt_decrypt_roundtrip,
+ ]:
+ try:
+ asyncio.run(test_fn())
+ passed += 1
+ except Exception as e:
+ print(f" FAIL: {test_fn.__name__}: {e}")
+ failed += 1
+
+ print(f"\n{'=' * 40}")
+ print(f"Results: {passed} passed, {failed} failed")
+ if failed > 0:
+ sys.exit(1)
+ print("All tests passed!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/unit/test_nwcp.py b/tests/unit/test_nwcp.py
index 800e853..e6c5b61 100644
--- a/tests/unit/test_nwcp.py
+++ b/tests/unit/test_nwcp.py
@@ -205,3 +205,67 @@ async def _send_capture(obj):
# Nothing should have been sent because connected=False.
assert len(sent) == 0
+
+
+@pytest.mark.asyncio
+async def test_send_notification(nwc_service_provider, nwc_service_provider2):
+ """Test that send_notification builds a correct kind 23196 event."""
+ sent_events = []
+
+ async def _capture_send(data):
+ sent_events.append(data)
+
+ nwc_service_provider2._send = _capture_send
+
+ notification = {
+ "type": "incoming",
+ "state": "settled",
+ "invoice": "lnbc1000...",
+ "payment_hash": "abc123",
+ "amount": 1000,
+ "created_at": 1234567890,
+ "settled_at": 1234567890,
+ }
+
+ await nwc_service_provider2.send_notification(
+ nwc_service_provider.public_key_hex,
+ "payment_received",
+ notification,
+ )
+
+ assert len(sent_events) == 1
+ event_data = sent_events[0]
+ # Should be ["EVENT", {...}]
+ assert event_data[0] == "EVENT"
+ event = event_data[1]
+
+ # Verify kind 23196
+ assert event["kind"] == 23196
+
+ # Verify signature
+ assert nwc_service_provider._verify_event(event)
+
+ # Verify tags: only ["p", client_pubkey], no ["e", ...] tag
+ tags = event["tags"]
+ p_tags = [t for t in tags if t[0] == "p"]
+ e_tags = [t for t in tags if t[0] == "e"]
+ assert len(p_tags) == 1
+ assert p_tags[0][1] == nwc_service_provider.public_key_hex
+ assert len(e_tags) == 0, "Notification events must not have an 'e' tag"
+
+ # Decrypt and verify content
+ content = nwc_service_provider.private_key.decrypt_message(
+ event["content"], nwc_service_provider2.public_key_hex
+ )
+ content = json.loads(content)
+ assert content["notification_type"] == "payment_received"
+ assert content["notification"]["type"] == "incoming"
+ assert content["notification"]["state"] == "settled"
+ assert content["notification"]["payment_hash"] == "abc123"
+ assert content["notification"]["amount"] == 1000
+
+
+def test_supported_notifications(nwc_service_provider):
+ """Test that supported_notifications is set correctly."""
+ assert "payment_received" in nwc_service_provider.supported_notifications
+ assert "payment_sent" in nwc_service_provider.supported_notifications
diff --git a/views_api.py b/views_api.py
index cd85962..04a20b2 100644
--- a/views_api.py
+++ b/views_api.py
@@ -1,9 +1,12 @@
from http import HTTPStatus
+from urllib.parse import quote
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
+from lnbits.core.crud import get_wallets
from lnbits.core.models import WalletTypeInfo
from lnbits.decorators import check_admin, require_admin_key
+from loguru import logger
from pynostr.key import PrivateKey
from .crud import (
@@ -22,6 +25,7 @@
GetBudgetsNWC,
GetNWC,
GetWalletNWC,
+ NWCGetAllResponse,
NWCGetResponse,
NWCRegistrationRequest,
)
@@ -33,6 +37,13 @@
)
from .permission import nwc_permissions
+try:
+ from lnbits.extensions.lnurlp.crud import ( # type: ignore[import-not-found]
+ get_pay_links,
+ )
+except ImportError:
+ get_pay_links = None
+
nwcprovider_api_router = APIRouter()
@@ -70,6 +81,43 @@ async def api_get_nwcs(
return out
+## Get nwc keys for all wallets belonging to the user
+@nwcprovider_api_router.get("/api/v1/nwc/all")
+async def api_get_all_nwcs(
+ include_expired: bool = False,
+ calculate_spent_budget: bool = False,
+ wallet: WalletTypeInfo = Depends(require_admin_key),
+) -> list[NWCGetAllResponse]:
+ """Get all NWC connections across all wallets for the current user."""
+ user_id = wallet.wallet.user
+
+ # Get all wallets for this user
+ user_wallets = await get_wallets(user_id)
+
+ out = []
+ for user_wallet in user_wallets:
+ wallet_id = user_wallet.id
+ wallet_name = user_wallet.name
+
+ wallet_nwcs = GetWalletNWC(wallet=wallet_id, include_expired=include_expired)
+ nwcs = await get_wallet_nwcs(wallet_nwcs)
+
+ for nwc in nwcs:
+ budgets_nwc = GetBudgetsNWC(
+ pubkey=nwc.pubkey, calculate_spent=calculate_spent_budget
+ )
+ budgets = await get_budgets_nwc(budgets_nwc)
+ res = NWCGetAllResponse(
+ data=nwc,
+ budgets=budgets,
+ wallet_id=wallet_id,
+ wallet_name=wallet_name,
+ )
+ out.append(res)
+
+ return out
+
+
# Get a nwc key
@nwcprovider_api_router.get("/api/v1/nwc/{pubkey}")
async def api_get_nwc(
@@ -100,10 +148,14 @@ async def api_get_nwc(
# Get pairing url for given secret
@nwcprovider_api_router.get("/api/v1/pairing/{secret}")
-async def api_get_pairing_url(req: Request, secret: str) -> str:
+async def api_get_pairing_url(
+ req: Request, secret: str, lud16: str | None = None
+) -> str:
# hardening #
assert_sane_string(secret)
+ if lud16:
+ assert_sane_string(lud16)
# ## #
pprivkey: str | None = await get_config_nwc("provider_key")
@@ -132,9 +184,10 @@ async def api_get_pairing_url(req: Request, secret: str) -> str:
ppubkey = ppk.hex()
url = "nostr+walletconnect://"
url += ppubkey
- url += "?relay=" + relay
+ url += "?relay=" + quote(relay, safe="")
url += "&secret=" + secret
- # lud16=?
+ if lud16:
+ url += "&lud16=" + quote(lud16, safe="")
return url
@@ -163,6 +216,7 @@ async def api_register_nwc(
expires_at=data.expires_at,
permissions=data.permissions,
budgets=data.budgets,
+ lud16=data.lud16,
)
)
budgets = await get_budgets_nwc(GetBudgetsNWC(pubkey=pubkey))
@@ -219,3 +273,44 @@ async def api_set_config_nwc(req: Request):
for key, value in data.items():
await set_config_nwc(key, value)
return await api_get_all_config_nwc()
+
+
+# Get available lightning addresses from lnurlp extension
+@nwcprovider_api_router.get("/api/v1/lnaddresses")
+async def api_get_lightning_addresses(
+ req: Request,
+ wallet: WalletTypeInfo = Depends(require_admin_key),
+) -> list[dict]:
+ """
+ Fetch available lightning addresses from lnurlp extension for this wallet.
+ Returns list of {address, username, description} for dropdown selection.
+ """
+ wallet_id = wallet.wallet.id
+
+ # hardening #
+ assert_valid_wallet_id(wallet_id)
+ # ## #
+
+ if get_pay_links is None:
+ logger.warning("lnurlp extension not available for lightning address lookup")
+ return []
+
+ try:
+ pay_links = await get_pay_links([wallet_id])
+ domain = req.url.netloc
+
+ addresses = []
+ for link in pay_links:
+ if link.username:
+ addresses.append(
+ {
+ "address": f"{link.username}@{domain}",
+ "username": link.username,
+ "description": link.description or "",
+ }
+ )
+
+ return addresses
+ except Exception as e:
+ logger.error(f"Error fetching lightning addresses: {e}")
+ return []