From f2351145f076d35ea9e1d5c3afdc079428a1b132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Wed, 17 Jun 2026 15:47:02 +0200 Subject: [PATCH] fix: stop updating internal invoices inside the listener unlike external one (#3984) Co-authored-by: Vlad Stan --- lnbits/core/models/payments.py | 12 +-- lnbits/core/services/fiat_providers.py | 9 +-- lnbits/core/services/payments.py | 44 +++++------ lnbits/tasks.py | 9 ++- lnbits/wallets/lndrest.py | 77 ++++++++++--------- tests/unit/test_fiat_providers.py | 5 +- tests/unit/test_pay_invoice.py | 15 +++- .../wallets/fixtures/json/fixtures_rest.json | 20 ++--- 8 files changed, 100 insertions(+), 91 deletions(-) diff --git a/lnbits/core/models/payments.py b/lnbits/core/models/payments.py index b7c723066..8b1460b09 100644 --- a/lnbits/core/models/payments.py +++ b/lnbits/core/models/payments.py @@ -140,22 +140,18 @@ class Payment(BaseModel): ) # DEPRECATED: in v1.5.0, use service check_payment_status instead - async def check_status( - self, skip_internal_payment_notifications: bool | None = False - ) -> PaymentStatus: + async def check_status(self) -> PaymentStatus: logger.warning("payment.check_status() is deprecated.") from lnbits.core.services.payments import check_payment_status - return await check_payment_status(self, skip_internal_payment_notifications) + return await check_payment_status(self) # DEPRECATED: in v1.5.0, use service check_payment_status instead - async def check_fiat_status( - self, skip_internal_payment_notifications: bool | None = False - ) -> FiatPaymentStatus: + async def check_fiat_status(self) -> FiatPaymentStatus: logger.warning("payment.check_fiat_status() is deprecated.") from lnbits.core.services.fiat_providers import check_fiat_status - return await check_fiat_status(self, skip_internal_payment_notifications) + return await check_fiat_status(self) class PaymentFilters(FilterModel): diff --git a/lnbits/core/services/fiat_providers.py b/lnbits/core/services/fiat_providers.py index 36ec233c3..a425a8136 100644 --- a/lnbits/core/services/fiat_providers.py +++ b/lnbits/core/services/fiat_providers.py @@ -36,9 +36,7 @@ async def handle_fiat_payment_confirmation( logger.warning(e) -async def check_fiat_status( - payment: Payment, skip_internal_payment_notifications: bool | None = False -) -> FiatPaymentStatus: +async def check_fiat_status(payment: Payment) -> FiatPaymentStatus: if not payment.is_internal: return FiatPaymentPendingStatus() if payment.success: @@ -58,10 +56,9 @@ async def check_fiat_status( return FiatPaymentPendingStatus() fiat_status = await fiat_provider.get_invoice_status(checking_id) - if skip_internal_payment_notifications: - return fiat_status - if fiat_status.success: + await handle_fiat_payment_confirmation(payment) + # notify receivers asynchronously from lnbits.tasks import internal_invoice_queue diff --git a/lnbits/core/services/payments.py b/lnbits/core/services/payments.py index 3374a0dd4..52574028c 100644 --- a/lnbits/core/services/payments.py +++ b/lnbits/core/services/payments.py @@ -13,7 +13,6 @@ from lnbits.core.crud.payments import get_daily_stats from lnbits.core.db import db from lnbits.core.models import PaymentDailyStats, PaymentFilters from lnbits.core.models.payments import CreateInvoice -from lnbits.core.services.fiat_providers import handle_fiat_payment_confirmation from lnbits.db import Connection, Filters from lnbits.decorators import check_user_extension_access from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError @@ -630,18 +629,14 @@ async def check_transaction_status( return await check_payment_status(payment) -async def check_payment_status( - payment: Payment, skip_internal_payment_notifications: bool | None = False -) -> PaymentStatus: +async def check_payment_status(payment: Payment) -> PaymentStatus: if payment.is_internal: if payment.success: return PaymentSuccessStatus() if payment.failed: return PaymentFailedStatus() if payment.is_in and payment.fiat_provider: - fiat_status = await check_fiat_status( - payment, skip_internal_payment_notifications - ) + fiat_status = await check_fiat_status(payment) return PaymentStatus(paid=fiat_status.paid) return PaymentPendingStatus() funding_source = get_funding_source() @@ -783,9 +778,14 @@ async def _pay_internal_invoice( await update_payment(internal_payment, conn=conn) logger.success(f"internal payment successful {internal_payment.checking_id}") - await _send_payment_notification_in_background(wallet.id, payment, conn=conn) + await _send_payment_notification_in_background( + wallet.id, payment, conn=conn + ) # notify the sender + await _send_payment_notification_in_background( + internal_payment.wallet_id, internal_payment, conn=conn + ) # notify the receiver - # notify receiver asynchronously + # notify receiver asynchronously (extension listeners) from lnbits.tasks import internal_invoice_queue logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}") @@ -1079,29 +1079,29 @@ async def _send_payment_notification_in_background( send_payment_notification_in_background(wallet, payment) -async def update_invoice_callback(checking_id: str) -> Payment | None: +async def update_invoice_from_paid_invoices_stream(checking_id: str) -> Payment | None: """ - Takes a checking_id of an incoming payment, from either paid_invoices_stream() - or internal_invoice_queue. Checks its status, updates and returns it. - returns None if no payment was found or it not and incoming payment. + Takes a checking_id of an incoming payment from paid_invoices_stream() + Checks its status, updates its status and returns it. + returns None if no incoming payment was found or the status is not successful """ payment = await get_standalone_payment(checking_id, incoming=True) if not payment: - logger.warning(f"No payment found for '{checking_id}'.") + logger.warning(f"No incoming payment found for '{checking_id}'.") return None - if not payment.is_in: - logger.warning(f"Payment '{checking_id}' is not incoming, skipping.") + + status = await check_payment_status(payment) + + if not status.success: + logger.error( + "Unexpected status response from paid_invoices_stream. Skipping update." + ) return None - status = await check_payment_status( - payment, skip_internal_payment_notifications=True - ) payment.fee = status.fee_msat or payment.fee # only overwrite preimage if status.preimage provides it payment.preimage = status.preimage or payment.preimage - payment.status = PaymentState.SUCCESS payment = await update_payment(payment) - if payment.fiat_provider: - await handle_fiat_payment_confirmation(payment) + return payment diff --git a/lnbits/tasks.py b/lnbits/tasks.py index 8d2b05e88..731e0cd03 100644 --- a/lnbits/tasks.py +++ b/lnbits/tasks.py @@ -6,7 +6,10 @@ from collections.abc import Callable, Coroutine from loguru import logger from lnbits.core.models import Payment -from lnbits.core.services.payments import update_invoice_callback +from lnbits.core.services.payments import ( + get_standalone_payment, + update_invoice_from_paid_invoices_stream, +) from lnbits.settings import settings from lnbits.wallets import get_funding_source @@ -112,7 +115,7 @@ async def internal_invoice_listener() -> None: while settings.lnbits_running: checking_id = await internal_invoice_queue.get() logger.info(f"got an internal payment notification {checking_id}") - payment = await update_invoice_callback(checking_id) + payment = await get_standalone_payment(checking_id, incoming=True) if payment: logger.success(f"internal invoice {checking_id} settled") await invoice_callback_dispatcher(payment) @@ -128,7 +131,7 @@ async def invoice_listener() -> None: funding_source = get_funding_source() async for checking_id in funding_source.paid_invoices_stream(): logger.info(f"got a payment notification {checking_id}") - payment = await update_invoice_callback(checking_id) + payment = await update_invoice_from_paid_invoices_stream(checking_id) if payment: logger.success(f"fundingsource invoice {checking_id} settled") await invoice_callback_dispatcher(payment) diff --git a/lnbits/wallets/lndrest.py b/lnbits/wallets/lndrest.py index da72a61f1..c5eb19807 100644 --- a/lnbits/wallets/lndrest.py +++ b/lnbits/wallets/lndrest.py @@ -3,6 +3,7 @@ import base64 import hashlib import json from collections.abc import AsyncGenerator +from typing import Any import httpx from loguru import logger @@ -123,8 +124,8 @@ class LndRestWallet(Wallet): hashlib.sha256(unhashed_description).digest() ).decode("ascii") - preimage, _payment_hash = random_secret_and_hash() - _data["r_hash"] = base64.b64encode(bytes.fromhex(_payment_hash)).decode() + preimage, payment_hash = random_secret_and_hash() + _data["r_hash"] = base64.b64encode(bytes.fromhex(payment_hash)).decode() _data["r_preimage"] = base64.b64encode(bytes.fromhex(preimage)).decode() try: @@ -132,34 +133,7 @@ class LndRestWallet(Wallet): r.raise_for_status() data = r.json() - if len(data) == 0: - return InvoiceResponse(ok=False, error_message="no data") - - if "error" in data: - return InvoiceResponse( - ok=False, error_message=f"""Server error: '{data["error"]}'""" - ) - - if r.is_error: - return InvoiceResponse( - ok=False, error_message=f"Server error: '{r.text}'" - ) - - if "payment_request" not in data or "r_hash" not in data: - return InvoiceResponse( - ok=False, error_message="Server error: 'missing required fields'" - ) - - payment_request = data["payment_request"] - payment_hash = base64.b64decode(data["r_hash"]).hex() - checking_id = payment_hash - return InvoiceResponse( - ok=True, - checking_id=checking_id, - payment_request=payment_request, - preimage=preimage, - ) - + return self._parse_create_invoice_response(r, data, preimage) except json.JSONDecodeError: return InvoiceResponse( ok=False, error_message="Server error: 'invalid json response'" @@ -242,12 +216,13 @@ class LndRestWallet(Wallet): logger.warning(f"Error getting invoice status: {e}") return PaymentPendingStatus() - if r.is_error or data.get("settled") is None: + if r.is_error or data.get("state") is None: # this must also work when checking_id is not a hex recognizable by lnd - # it will return an error and no "settled" attribute on the object + # it will return an error and no "state" attribute on the object + logger.warning(f"Error checking invoice from LND REST API: {r.text}") return PaymentPendingStatus() - if data.get("settled") is True: + if data.get("state") == "SETTLED": return PaymentSuccessStatus() if data.get("state") == "CANCELED": @@ -264,6 +239,7 @@ class LndRestWallet(Wallet): "ascii" ) except ValueError: + logger.warning("Invalid checking_id format, must be hex: {checking_id}") return PaymentPendingStatus() url = f"/v2/router/track/{checking_id}" @@ -311,13 +287,12 @@ class LndRestWallet(Wallet): async for line in r.aiter_lines(): try: inv = json.loads(line)["result"] - if not inv["settled"]: + if not inv.get("state") == "SETTLED": continue + payment_hash = base64.b64decode(inv.get("r_hash")).hex() except Exception as exc: logger.debug(exc) continue - - payment_hash = base64.b64decode(inv["r_hash"]).hex() yield payment_hash except Exception as exc: logger.warning( @@ -363,8 +338,6 @@ class LndRestWallet(Wallet): return InvoiceResponse(ok=False, error_message=str(exc)) payment_request = data["payment_request"] - payment_hash = base64.b64encode(bytes.fromhex(payment_hash)).decode("ascii") - return InvoiceResponse( ok=True, checking_id=payment_hash, payment_request=payment_request ) @@ -399,3 +372,31 @@ class LndRestWallet(Wallet): except Exception as exc: logger.warning(exc) return InvoiceResponse(ok=False, error_message=str(exc)) + + def _parse_create_invoice_response( + self, r: Any, data: dict, preimage: str + ) -> InvoiceResponse: + if not data: + return InvoiceResponse(ok=False, error_message="no data") + if "error" in data: + return InvoiceResponse( + ok=False, error_message=f"Server error: '{data['error']}'" + ) + if r.is_error: + return InvoiceResponse(ok=False, error_message=f"Server error: '{r.text}'") + if "payment_request" not in data or "r_hash" not in data: + return InvoiceResponse( + ok=False, error_message="Server error: 'missing required fields'" + ) + try: + payment_hash = base64.b64decode(data["r_hash"]).hex() + except Exception: + return InvoiceResponse( + ok=False, error_message=f"Unable to b64decode to {data['r_hash']}." + ) + return InvoiceResponse( + ok=True, + checking_id=payment_hash, + payment_request=data["payment_request"], + preimage=preimage, + ) diff --git a/tests/unit/test_fiat_providers.py b/tests/unit/test_fiat_providers.py index 13338ce01..ffabe966b 100644 --- a/tests/unit/test_fiat_providers.py +++ b/tests/unit/test_fiat_providers.py @@ -1744,11 +1744,10 @@ async def test_check_fiat_status_handles_internal_states(mocker: MockerFixture): amount=1000, fee=0, bolt11="bolt11", - status=PaymentState.PENDING, + status=PaymentState.SUCCESS, fiat_provider="stripe", extra={"fiat_checking_id": "stripe_checking_id"}, - ), - skip_internal_payment_notifications=True, + ) ) assert queue_put.await_count == 1 diff --git a/tests/unit/test_pay_invoice.py b/tests/unit/test_pay_invoice.py index 8a5b77d0d..070e2e70c 100644 --- a/tests/unit/test_pay_invoice.py +++ b/tests/unit/test_pay_invoice.py @@ -15,7 +15,12 @@ from lnbits.core.services import create_invoice, create_user_account, pay_invoic from lnbits.core.services.payments import update_wallet_balance from lnbits.exceptions import InvoiceError, PaymentError from lnbits.settings import Settings -from lnbits.tasks import create_task, wait_for_paid_invoices +from lnbits.tasks import ( + create_task, + internal_invoice_listener, + internal_invoice_queue, + wait_for_paid_invoices, +) from lnbits.wallets.base import PaymentResponse from lnbits.wallets.fake import FakeWallet @@ -231,7 +236,15 @@ async def test_notification_for_internal_payment( ): test_name = "test_notification_for_internal_payment" + # Drain stale items left by session-scoped fixtures (e.g. update_wallet_balance) + while not internal_invoice_queue.empty(): + try: + internal_invoice_queue.get_nowait() + except asyncio.QueueEmpty: + break + on_paid_mock = mocker.AsyncMock() + create_task(internal_invoice_listener()) create_task(wait_for_paid_invoices(test_name, on_paid_mock)()) payment = await create_invoice( wallet_id=to_wallet.id, diff --git a/tests/wallets/fixtures/json/fixtures_rest.json b/tests/wallets/fixtures/json/fixtures_rest.json index 3206b0e5b..bd49a4628 100644 --- a/tests/wallets/fixtures/json/fixtures_rest.json +++ b/tests/wallets/fixtures/json/fixtures_rest.json @@ -2073,7 +2073,7 @@ { "response_type": "json", "response": { - "settled": true + "state": "SETTLED" } } ] @@ -2155,8 +2155,15 @@ ] }, "lndrest": { - "description": "lndrest.py doesn't handle the 'failed' status for `get_invoice_status`", - "get_invoice_status_endpoint": [] + "get_invoice_status_endpoint": [ + { + "description": "error status", + "response_type": "json", + "response": { + "state": "CANCELED" + } + } + ] }, "alby": { "description": "alby.py doesn't handle the 'failed' status for `get_invoice_status`", @@ -2243,13 +2250,6 @@ "response_type": "json", "response": {} }, - { - "description": "error status", - "response_type": "json", - "response": { - "seetled": false - } - }, { "description": "bad json", "response_type": "data",