What changed, and why it matters
This commit fixes a rounding bug in Electrum's NWC (Nostr Wallet Connect) plugin. The plugin lets users set a daily spending limit in satoshis, but it was internally tracking spent amounts in whole satoshis. Because Lightning invoices are priced in millisatoshis (one-thousandth of a satoshi), rounding down to satoshis could let a user or service spend slightly more than the intended daily limit over many small payments. The patch now tracks the budget in millisatoshis and converts the limit to millisatoshis for comparison, closing the rounding gap. It also migrates existing saved budget data from satoshis to millisatoshis.
Treat as a low-to-moderate security fix. Users relying on NWC daily spending limits should upgrade to a version containing this commit. Review whether any other plugins or wallet components compare satoshi and millisatoshi amounts without explicit conversion. No immediate emergency response is indicated because exploitation requires many authorized payments and only marginally exceeds the configured limit.
Security signals we found
Rounding-induced budget bypass in spending-limit enforcement
Unit mismatch between user-facing satoshi limit and Lightning-native millisatoshi amounts
Database migration to preserve and correct historical budget records
Use of keyword-only arguments to prevent accidental unit confusion in API
Evidence from the diff
The NWC plugin’s budget accounting previously stored and summed amount_sat values. The budget_allows_spend check compared used_budget + sats_to_spend > client_budget_sat. Since Lightning amounts are natively millisatoshis, repeated sub-satoshi amounts would be rounded/truncated when converted to satoshis, allowing cumulative overspend relative to the configured daily_limit_sat. The patch changes add_to_budget, get_used_budget, and budget_allows_spend to use amount_msat/msat_requested, comparing against client_budget_sat * 1000. A wallet DB upgrade routine (_convert_version_70) multiplies existing budget_spends entries by 1000 to preserve history. The Qt UI now divides by 1000 and rounds for display.
Changed components
electrum/plugins/nwc/nwcserver.pyelectrum/plugins/nwc/qt.pyelectrum/wallet_db.pyInspect captured patch +31 / −14
diff --git a/electrum/plugins/nwc/nwcserver.py b/electrum/plugins/nwc/nwcserver.py
index b189abf..48e0245 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 f58f465..1d2dcef 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 1ba3dba..be55c40 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
Why this scored 48/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.