plugin: nwc: consider routing fees for payment budget

Include the lightning routing fees into the payment budget accounting
to prevent untrusted nwc clients from exceeding the budget by crafting
a high fee route.
This commit is contained in:
f321x
2026-03-05 09:58:53 +01:00
parent 8dddb5d538
commit 8ccdc3f7af
+45 -11
View File
@@ -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.