From 3956bff0684f6060fbe8c823c3ee921928cdcaa5 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 3 Mar 2026 13:58:59 +0100 Subject: [PATCH] plugin: nwc: do budget accounting in msat Keep track of the spent amount in msat instead of sat to prevent issues due to rounding. The budget is still specified in sat. --- electrum/plugins/nwc/nwcserver.py | 24 ++++++++++++------------ electrum/plugins/nwc/qt.py | 4 +++- electrum/wallet_db.py | 17 ++++++++++++++++- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/electrum/plugins/nwc/nwcserver.py b/electrum/plugins/nwc/nwcserver.py index b189abfe7..48e024534 100644 --- a/electrum/plugins/nwc/nwcserver.py +++ b/electrum/plugins/nwc/nwcserver.py @@ -811,7 +811,7 @@ 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, invoice.get_amount_sat()): + 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") self.wallet.save_invoice(invoice) @@ -828,7 +828,7 @@ class NWCServer(Logger, EventListener): if not success or not preimage: return self.get_error_response("PAYMENT_FAILED", str(log)) else: - self.add_to_budget(request_pub, invoice.get_amount_sat()) + self.add_to_budget(request_pub, amount_msat=amount_msat or invoice.get_amount_msat()) response['result'] = { 'preimage': preimage.hex(), } @@ -838,7 +838,7 @@ 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_sat: int) -> None: + def add_to_budget(self, client_pub: str, *, amount_msat: int) -> None: """ 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,34 +846,34 @@ 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_sat, int(time.time())]) + self.connections[client_pub]['budget_spends'].append([amount_msat, int(time.time())]) - def get_used_budget(self, client_pub: str) -> int: + def get_used_budget_msat(self, client_pub: str) -> int: """ - Returns the used budget for the given client_pubkey. + Returns the used budget for the given client_pubkey in millisatoshi. """ if 'budget_spends' not in self.connections[client_pub]: return 0 used_budget: int = 0 budget_spends = self.connections[client_pub]['budget_spends'] - for amount, timestamp in list(budget_spends): + for amount_msat, timestamp in list(budget_spends): if timestamp > int(time.time()) - 24 * 3600: - used_budget += amount + used_budget += amount_msat elif timestamp < int(time.time()) - 24 * 3600: # remove old expense try: - budget_spends.remove([amount, timestamp]) + budget_spends.remove([amount_msat, timestamp]) except ValueError: self.logger.debug("", exc_info=True) continue # could happen if there is a race return used_budget - def budget_allows_spend(self, client_pub: str, sats_to_spend: int) -> bool: + def budget_allows_spend(self, client_pub: str, *, msat_requested: int) -> bool: 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: int = self.get_used_budget(client_pub) - if used_budget + sats_to_spend > client_budget_sat: + 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 diff --git a/electrum/plugins/nwc/qt.py b/electrum/plugins/nwc/qt.py index f58f46584..1d2dcef92 100644 --- a/electrum/plugins/nwc/qt.py +++ b/electrum/plugins/nwc/qt.py @@ -25,6 +25,7 @@ from typing import TYPE_CHECKING, Optional from functools import partial from datetime import datetime +from decimal import Decimal from PyQt6.QtWidgets import ( QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QTreeWidget, QTreeWidgetItem, @@ -117,7 +118,8 @@ class Plugin(NWCServerPlugin): else: budget = self.config.format_amount(conn['daily_limit_sat']) used = self.config.format_amount( - self.nwc_server.get_used_budget(conn['client_pub'])) + amount_sat=round(Decimal(self.nwc_server.get_used_budget_msat(conn['client_pub'])) / 1000) + ) limit = f"{used}/{budget}" item = QTreeWidgetItem( [ diff --git a/electrum/wallet_db.py b/electrum/wallet_db.py index 1ba3dbab5..be55c405b 100644 --- a/electrum/wallet_db.py +++ b/electrum/wallet_db.py @@ -69,7 +69,7 @@ class WalletUnfinished(WalletFileException): # seed_version is now used for the version of the wallet file OLD_SEED_VERSION = 4 # electrum versions < 2.0 NEW_SEED_VERSION = 11 # electrum versions >= 2.0 -FINAL_SEED_VERSION = 69 # electrum >= 2.7 will set this to prevent +FINAL_SEED_VERSION = 70 # electrum >= 2.7 will set this to prevent # old versions from overwriting new format @@ -244,6 +244,7 @@ class WalletDBUpgrader(Logger): self._convert_version_67() self._convert_version_68() self._convert_version_69() + self._convert_version_70() self.put('seed_version', FINAL_SEED_VERSION) # just to be sure def _convert_wallet_type(self): @@ -1385,6 +1386,20 @@ class WalletDBUpgrader(Logger): self.data['lightning_payments'] = new_payment_infos self.data['seed_version'] = 69 + def _convert_version_70(self): + """ + Converts spending budget values of nwc plugin from sat to msat. + """ + if not self._is_upgrade_method_needed(69, 69): + return + nwc_connections = self.data.get('plugin_data', {}).get('nwc', {}).get('connections', {}) + for pubkey, connection in nwc_connections.items(): + new_budget_spends = [] + for amount_sat, timestamp in connection.get('budget_spends', []): + new_budget_spends.append([amount_sat * 1000, timestamp]) + connection['budget_spends'] = new_budget_spends + self.data['seed_version'] = 70 + def _convert_imported(self): if not self._is_upgrade_method_needed(0, 13): return