From 8dddb5d5389efac0c661bb5f47645bf679299bea Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 4 Mar 2026 17:11:47 +0100 Subject: [PATCH 1/5] plugin: nwc: unify budget_allows_spend and add_to_budget There is now only add_to_budget which will return None if the budget doesn't allow for this spend. It will always add the spend to the budget, even if the user has no budget so it can be used for display purposes (and simpler code, only one path). --- electrum/plugins/nwc/nwcserver.py | 37 +++++++++++++------------------ 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/electrum/plugins/nwc/nwcserver.py b/electrum/plugins/nwc/nwcserver.py index c0682c382..a050746c2 100644 --- a/electrum/plugins/nwc/nwcserver.py +++ b/electrum/plugins/nwc/nwcserver.py @@ -816,9 +816,9 @@ class NWCServer(Logger, EventListener): elif invoice.get_amount_msat() is None: invoice.set_amount_msat(amount_msat) - if not self.budget_allows_spend(request_pub, msat_requested=amount_msat or invoice.get_amount_msat()): + budget_item = self.add_to_budget(request_pub, msat_requested=amount_msat or invoice.get_amount_msat()) + if not budget_item: return self.get_error_response("QUOTA_EXCEEDED", "Payment exceeds daily limit") - budget_item = self.add_to_budget(request_pub, amount_msat=amount_msat or invoice.get_amount_msat()) self.wallet.save_invoice(invoice) success = None @@ -848,18 +848,6 @@ class NWCServer(Logger, EventListener): self.logger.info(f"failed to pay invoice request from NWC: {log}") return response - def add_to_budget(self, client_pub: str, *, amount_msat: int) -> list[int]: - """ - If client_pub has a budget, check if the amount is within the budget and add it to the budget. - Return True if the payment is allowed (within the budget) - """ - if 'budget_spends' not in self.connections[client_pub]: - self.connections[client_pub]['budget_spends'] = [] - # tuples don't work because jsondb converts them to lists on reload - budget_item = [amount_msat, int(time.time())] - self.connections[client_pub]['budget_spends'].append(budget_item) - return budget_item - def remove_from_budget(self, client_pub: str, budget_item: list[int]) -> None: assert len(budget_item) == 2, budget_item budget_spends = self.connections[client_pub].get('budget_spends', []) @@ -888,14 +876,21 @@ class NWCServer(Logger, EventListener): continue # could happen if there is a race return used_budget - def budget_allows_spend(self, client_pub: str, *, msat_requested: int) -> bool: + def add_to_budget(self, client_pub: str, *, msat_requested: int) -> Optional[list[int]]: + if 'budget_spends' not in self.connections[client_pub]: + self.connections[client_pub]['budget_spends'] = [] + + # check if budget allows this spend client_budget_sat: Optional[int] = self.connections[client_pub].get('daily_limit_sat') - if client_budget_sat is None: - return True # unlimited budget - used_budget_msat: int = self.get_used_budget_msat(client_pub) - if used_budget_msat + msat_requested > client_budget_sat * 1000: - return False - return True + if client_budget_sat is not None: + used_budget_msat: int = self.get_used_budget_msat(client_pub) + if used_budget_msat + msat_requested > client_budget_sat * 1000: + return None + + # tuples don't work because jsondb converts them to lists on reload + budget_item = [msat_requested, int(time.time())] + self.connections[client_pub]['budget_spends'].append(budget_item) + return budget_item async def publish_info_event(self): """ From 8ccdc3f7afa2fbd32c65ce1daa6eef22cc11c747 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 5 Mar 2026 09:58:53 +0100 Subject: [PATCH 2/5] plugin: nwc: consider routing fees for payment budget Include the lightning routing fees into the payment budget accounting to prevent untrusted nwc clients from exceeding the budget by crafting a high fee route. --- electrum/plugins/nwc/nwcserver.py | 56 +++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/electrum/plugins/nwc/nwcserver.py b/electrum/plugins/nwc/nwcserver.py index a050746c2..bd825094e 100644 --- a/electrum/plugins/nwc/nwcserver.py +++ b/electrum/plugins/nwc/nwcserver.py @@ -42,7 +42,7 @@ from electrum.util import log_exceptions, ca_path, OldTaskGroup, get_asyncio_loo get_running_loop, run_sync_function_on_asyncio_thread from electrum.invoices import Invoice, Request, PR_UNKNOWN, PR_PAID, BaseInvoice, PR_INFLIGHT, PR_FAILED, PR_EXPIRED, PR_UNPAID from electrum import constants -from electrum.lnutil import RECEIVED +from electrum.lnutil import RECEIVED, PaymentFeeBudget if TYPE_CHECKING: from aiohttp_socks import ProxyConnector @@ -780,7 +780,7 @@ class NWCServer(Logger, EventListener): payment_info = self.get_payment_info(key) if not payment_info: return - _, fee_msat, _, settled_at = payment_info + _, _, fee_msat, settled_at = payment_info assert key == invoice.rhash, f"{key=!r} != {invoice.rhash=!r}" notification = { @@ -816,25 +816,43 @@ class NWCServer(Logger, EventListener): elif invoice.get_amount_msat() is None: invoice.set_amount_msat(amount_msat) - budget_item = self.add_to_budget(request_pub, msat_requested=amount_msat or invoice.get_amount_msat()) - if not budget_item: - return self.get_error_response("QUOTA_EXCEEDED", "Payment exceeds daily limit") + # add the maximum allowed fee to the budget and update it with the actual fee once the payment succeeds + # to prevent exceeding the budget through high fees + payment_amount_msat = amount_msat or invoice.get_amount_msat() + fee_budget = PaymentFeeBudget.from_invoice_amount( + config=self.wallet.config, + invoice_amount_msat=payment_amount_msat, + ) + budget_amount_msat = payment_amount_msat + fee_budget.fee_msat + try: + budget_item = self.add_to_budget(request_pub, msat_requested=budget_amount_msat) + except ValueError as e: + return self.get_error_response("QUOTA_EXCEEDED", str(e)) self.wallet.save_invoice(invoice) success = None try: success, log = await self.wallet.lnworker.pay_invoice( invoice=invoice, - amount_msat=amount_msat + amount_msat=amount_msat, + budget=fee_budget, ) except Exception as e: self.logger.exception(f"failed to pay nwc invoice") return self.get_error_response("PAYMENT_FAILED", str(e)) finally: - if success is False: - # If the user shuts down or the application crashes before the payment ends, it will not - # get deducted from the budget, even if the htlcs later get failed on wallet restart. + # note: if the user shuts down or the application crashes before the payment ends, it will not + # get deducted from the budget, even if the htlcs later get failed on wallet restart. + if success: + # replace the spend in the budget, this time using the actual (lower) fees that have been paid + info = self.get_payment_info(invoice.rhash) + assert info, "info should exist after successful payment" + _, total_amount_msat, _, _ = info + assert payment_amount_msat <= total_amount_msat <= budget_amount_msat + self._update_budget_item_amount(request_pub, old_budget_item=budget_item, new_msat=total_amount_msat) + else: self.remove_from_budget(request_pub, budget_item) + preimage: bytes = self.wallet.lnworker.get_preimage(bytes.fromhex(invoice.rhash)) response = {} if not success or not preimage: @@ -853,7 +871,9 @@ class NWCServer(Logger, EventListener): budget_spends = self.connections[client_pub].get('budget_spends', []) try: budget_spends.remove(budget_item) + self.logger.debug(f"removed {budget_item=} from {client_pub[:4]}...{client_pub[-4:]} budget") except ValueError: + self.logger.debug(f"failed to remove {budget_item=} from {client_pub[:4]}...{client_pub[-4:]} budget: not found") pass def get_used_budget_msat(self, client_pub: str) -> int: @@ -876,7 +896,7 @@ class NWCServer(Logger, EventListener): continue # could happen if there is a race return used_budget - def add_to_budget(self, client_pub: str, *, msat_requested: int) -> Optional[list[int]]: + def add_to_budget(self, client_pub: str, *, msat_requested: int) -> list[int]: if 'budget_spends' not in self.connections[client_pub]: self.connections[client_pub]['budget_spends'] = [] @@ -885,13 +905,27 @@ class NWCServer(Logger, EventListener): if client_budget_sat is not None: used_budget_msat: int = self.get_used_budget_msat(client_pub) if used_budget_msat + msat_requested > client_budget_sat * 1000: - return None + raise ValueError("spend exceeds daily budget") + self.logger.debug(f"adding {msat_requested=} msat to {client_pub[:4]}...{client_pub[-4:]} budget") # tuples don't work because jsondb converts them to lists on reload budget_item = [msat_requested, int(time.time())] self.connections[client_pub]['budget_spends'].append(budget_item) return budget_item + def _update_budget_item_amount(self, client_pub: str, *, old_budget_item: list[int], new_msat: int) -> None: + assert len(old_budget_item) == 2, old_budget_item + budget_spends = self.connections[client_pub].setdefault('budget_spends', []) + try: + budget_spends.remove(old_budget_item) + except ValueError: + self.logger.debug( + f"failed to rm and update {old_budget_item=} from {client_pub[:4]}...{client_pub[-4:]} budget: not found") + return + # tuples don't work because jsondb converts them to lists on reload + new_budget_item = [new_msat, int(time.time())] + budget_spends.append(new_budget_item) + async def publish_info_event(self): """ Publishes the info event according to spec, announcing the supported methods. From 9b4d4d61bb7b5c5e28f5d3c3e8f000008dc36d6c Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 5 Mar 2026 10:48:45 +0100 Subject: [PATCH 3/5] plugin: nwc: add update timer to connection list --- electrum/plugins/nwc/qt.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/electrum/plugins/nwc/qt.py b/electrum/plugins/nwc/qt.py index 1d2dcef92..8dc412b88 100644 --- a/electrum/plugins/nwc/qt.py +++ b/electrum/plugins/nwc/qt.py @@ -32,7 +32,7 @@ from PyQt6.QtWidgets import ( QTextEdit, QApplication, QSpinBox, QSizePolicy, QComboBox, QLineEdit, ) from PyQt6.QtGui import QPixmap, QImage -from PyQt6.QtCore import Qt +from PyQt6.QtCore import Qt, QTimer from electrum.i18n import _ from electrum.plugin import hook @@ -197,6 +197,11 @@ class Plugin(NWCServerPlugin): vbox.addLayout(footer_buttons) d.setLayout(main_layout) + # update the list from time to time to see if budgets have changed + refresh_timer = QTimer(d) + refresh_timer.timeout.connect(update_connections_list) + refresh_timer.start(5_000) # msec + return bool(d.exec()) def connection_info_input_dialog(self, window) -> Optional[str]: From f53203d171477abd6ee4574bf631f0c2888b95f0 Mon Sep 17 00:00:00 2001 From: f321x Date: Fri, 6 Mar 2026 11:54:45 +0100 Subject: [PATCH 4/5] plugin: nwc: consider inflight htlcs in get_payment_info Also consider htlcs that are still inflight when calculating the amount/fee of a payment to prevent calculating an incorrect payment value/fee when calling get_payment_info directly after a payment succeeds (one htlc settled, but others could still be inflight). This assumes that once a payments htlc got settled all other inflight htlcs will also get settled. --- electrum/plugins/nwc/nwcserver.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/electrum/plugins/nwc/nwcserver.py b/electrum/plugins/nwc/nwcserver.py index bd825094e..2abffe77b 100644 --- a/electrum/plugins/nwc/nwcserver.py +++ b/electrum/plugins/nwc/nwcserver.py @@ -979,13 +979,15 @@ class NWCServer(Logger, EventListener): def get_payment_info(self, payment_hash: str) \ -> Optional[Tuple[PaymentDirection, int, Optional[int], int]]: payment_hash: bytes = bytes.fromhex(payment_hash) - payments = self.wallet.lnworker.get_payments(status='settled') + payments = self.wallet.lnworker.get_payments(status=None) plist = payments.get(payment_hash) - if plist: + if plist and any(htlc.status == 'settled' for htlc in plist): direction = plist[0].direction info = self.wallet.lnworker.get_payment_info(payment_hash, direction=direction) if info: - dir, amount, fee, ts = self.wallet.lnworker.get_payment_value(info, plist) + # assumes inflight htlcs will get settled and counts them into the payment values + active_htlcs = [htlc for htlc in plist if htlc.status in ('settled', 'inflight')] + dir, amount, fee, ts = self.wallet.lnworker.get_payment_value(info, active_htlcs) fee = abs(fee) if fee else None return dir, abs(amount), fee, ts return None From f64923b472d67a2812c2d12ce98bd0435a089c53 Mon Sep 17 00:00:00 2001 From: f321x Date: Fri, 6 Mar 2026 14:42:42 +0100 Subject: [PATCH 5/5] plugin: nwc: lookup_invoice: fix exc, include b11 Fixes exception in lookup_invoice: ``` 62.69 | E | plugins.nwc.nwcserver.NWCServer | Error handling nwc request Traceback (most recent call last): File "/home/user/code/vibecoding_vm/electrum/electrum/plugins/nwc/nwcserver.py", line 381, in run_request_task await task File "/home/user/code/vibecoding_vm/electrum/electrum/util.py", line 1211, in wrapper return await func(*args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/user/code/vibecoding_vm/electrum/electrum/plugins/nwc/nwcserver.py", line 526, in handle_lookup_invoice info = self.wallet.lnworker.get_payment_info(invoice.payment_hash, direction=RECEIVED) ^^^^^^^^^^^^^^^^^^^^ AttributeError: 'Invoice' object has no attribute 'payment_hash' ``` Always includes bolt11 invoice in response, even if the client already sent it to us in the request. It doesn't seem useful and is marked optional in the spec but https://sandbox.albylabs.com considers the response invalid if the invoice is not included. --- electrum/plugins/nwc/nwcserver.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/electrum/plugins/nwc/nwcserver.py b/electrum/plugins/nwc/nwcserver.py index 2abffe77b..9e278d008 100644 --- a/electrum/plugins/nwc/nwcserver.py +++ b/electrum/plugins/nwc/nwcserver.py @@ -523,7 +523,7 @@ class NWCServer(Logger, EventListener): b11 = invoice.lightning_invoice elif self.wallet.get_request(invoice.rhash): direction = "incoming" - info = self.wallet.lnworker.get_payment_info(invoice.payment_hash, direction=RECEIVED) + info = self.wallet.lnworker.get_payment_info(bytes.fromhex(invoice.rhash), direction=RECEIVED) _, b11 = self.wallet.lnworker.get_bolt11_invoice( payment_info=info, message=invoice.message, @@ -539,11 +539,10 @@ class NWCServer(Logger, EventListener): "created_at": invoice.time, "expires_at": invoice.get_expiration_date(), "fees_paid": 0, + "invoice": b11, "metadata": {} } } - if payment_hash: # if client requested by payment hash we add the invoice - response['result']['invoice'] = b11 if nip47_status := self.invoice_status_to_nip47_state(status): response['result']['state'] = nip47_status