plugin: nwc: consider routing fees for payment budget
What changed, and why it matters
This commit fixes a bug in Electrum's NWC (Nostr Wallet Connect) plugin where a connected client could spend more than its allowed daily budget by deliberately routing a Lightning payment through an expensive path. Previously, only the invoice amount was counted toward the budget, not the routing fees. The fix reserves the maximum expected fee up front and later adjusts the budget to reflect the actual fee paid.
Users running Electrum with the NWC plugin and daily spending budgets should upgrade to a version containing this commit. Review any NWC connections with low daily budgets for unexpected fee spending. No immediate broader action is required beyond normal patching.
Security signals we found
budget bypass via unaccounted routing fees
untrusted NWC client could exceed daily spending limit
fix adds fee reservation and actual-fee reconciliation
payment budget enforcement moved from silent None to explicit ValueError
Evidence from the diff
The NWC server previously called add_to_budget() using only the invoice amount (amount_msat), ignoring Lightning routing fees. A malicious or untrusted NWC client could therefore exceed its configured daily budget by selecting/providing a route with high fees. The patch imports PaymentFeeBudget, computes a fee budget from the invoice amount and wallet config, reserves payment_amount_msat + fee_budget.fee_msat in the budget, passes the fee budget to lnworker.pay_invoice(), and after a successful payment replaces the reserved amount with the actual total amount spent (including real fees). It also changes add_to_budget() to raise ValueError instead of returning None on budget exhaustion.
Changed components
electrum/plugins/nwc/nwcserver.pyNWC (Nostr Wallet Connect) pluginLightning payment budget accountingInspect captured patch +45 / −11
diff --git a/electrum/plugins/nwc/nwcserver.py b/electrum/plugins/nwc/nwcserver.py
index a050746..bd82509 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.
Why this scored 67/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.