From 5222374046d2a92da371d46c79ce0e61e7e2a42d Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 3 Mar 2026 14:19:31 +0100 Subject: [PATCH] plugin: nwc: improved budget accounting Increase the payment budget before attempting the payment and decrease again if the payment fails. This prevents a race where multiple concurrent payments could pass the budget check before the budget is incremented through any of the other payments. A lock around the budget is not suitable either as then one long stuck payment (hold invoice) would render the budget inaccessible for all other payment attempts. --- electrum/plugins/nwc/nwcserver.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/electrum/plugins/nwc/nwcserver.py b/electrum/plugins/nwc/nwcserver.py index 48e024534..0894bb85f 100644 --- a/electrum/plugins/nwc/nwcserver.py +++ b/electrum/plugins/nwc/nwcserver.py @@ -813,8 +813,10 @@ class NWCServer(Logger, EventListener): if not self.budget_allows_spend(request_pub, msat_requested=amount_msat or invoice.get_amount_msat()): 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 try: success, log = await self.wallet.lnworker.pay_invoice( invoice=invoice, @@ -823,22 +825,25 @@ class NWCServer(Logger, EventListener): 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. + 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: return self.get_error_response("PAYMENT_FAILED", str(log)) - else: - self.add_to_budget(request_pub, amount_msat=amount_msat or invoice.get_amount_msat()) - response['result'] = { - 'preimage': preimage.hex(), - } + response['result'] = { + 'preimage': preimage.hex(), + } if success: self.logger.info(f"paid invoice request from NWC for {invoice.get_amount_sat()} sat") else: 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) -> None: + 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) @@ -846,7 +851,17 @@ class NWCServer(Logger, EventListener): 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 - self.connections[client_pub]['budget_spends'].append([amount_msat, int(time.time())]) + 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', []) + try: + budget_spends.remove(budget_item) + except ValueError: + pass def get_used_budget_msat(self, client_pub: str) -> int: """