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.
This commit is contained in:
f321x
2026-03-03 13:58:59 +01:00
parent 0dc08fdf24
commit 3956bff068
3 changed files with 31 additions and 14 deletions
+12 -12
View File
@@ -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
+3 -1
View File
@@ -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(
[