fix: stop updating internal invoices inside the listener unlike external one (#3984)

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
This commit is contained in:
dni ⚡
2026-06-17 16:47:02 +03:00
committed by GitHub
co-authored by Vlad Stan
parent 2eb7d67b2a
commit f2351145f0
8 changed files with 100 additions and 91 deletions
+4 -8
View File
@@ -140,22 +140,18 @@ class Payment(BaseModel):
) )
# DEPRECATED: in v1.5.0, use service check_payment_status instead # DEPRECATED: in v1.5.0, use service check_payment_status instead
async def check_status( async def check_status(self) -> PaymentStatus:
self, skip_internal_payment_notifications: bool | None = False
) -> PaymentStatus:
logger.warning("payment.check_status() is deprecated.") logger.warning("payment.check_status() is deprecated.")
from lnbits.core.services.payments import check_payment_status 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 # DEPRECATED: in v1.5.0, use service check_payment_status instead
async def check_fiat_status( async def check_fiat_status(self) -> FiatPaymentStatus:
self, skip_internal_payment_notifications: bool | None = False
) -> FiatPaymentStatus:
logger.warning("payment.check_fiat_status() is deprecated.") logger.warning("payment.check_fiat_status() is deprecated.")
from lnbits.core.services.fiat_providers import check_fiat_status 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): class PaymentFilters(FilterModel):
+3 -6
View File
@@ -36,9 +36,7 @@ async def handle_fiat_payment_confirmation(
logger.warning(e) logger.warning(e)
async def check_fiat_status( async def check_fiat_status(payment: Payment) -> FiatPaymentStatus:
payment: Payment, skip_internal_payment_notifications: bool | None = False
) -> FiatPaymentStatus:
if not payment.is_internal: if not payment.is_internal:
return FiatPaymentPendingStatus() return FiatPaymentPendingStatus()
if payment.success: if payment.success:
@@ -58,10 +56,9 @@ async def check_fiat_status(
return FiatPaymentPendingStatus() return FiatPaymentPendingStatus()
fiat_status = await fiat_provider.get_invoice_status(checking_id) fiat_status = await fiat_provider.get_invoice_status(checking_id)
if skip_internal_payment_notifications:
return fiat_status
if fiat_status.success: if fiat_status.success:
await handle_fiat_payment_confirmation(payment)
# notify receivers asynchronously # notify receivers asynchronously
from lnbits.tasks import internal_invoice_queue from lnbits.tasks import internal_invoice_queue
+22 -22
View File
@@ -13,7 +13,6 @@ from lnbits.core.crud.payments import get_daily_stats
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models import PaymentDailyStats, PaymentFilters from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.payments import CreateInvoice 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.db import Connection, Filters
from lnbits.decorators import check_user_extension_access from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
@@ -630,18 +629,14 @@ async def check_transaction_status(
return await check_payment_status(payment) return await check_payment_status(payment)
async def check_payment_status( async def check_payment_status(payment: Payment) -> PaymentStatus:
payment: Payment, skip_internal_payment_notifications: bool | None = False
) -> PaymentStatus:
if payment.is_internal: if payment.is_internal:
if payment.success: if payment.success:
return PaymentSuccessStatus() return PaymentSuccessStatus()
if payment.failed: if payment.failed:
return PaymentFailedStatus() return PaymentFailedStatus()
if payment.is_in and payment.fiat_provider: if payment.is_in and payment.fiat_provider:
fiat_status = await check_fiat_status( fiat_status = await check_fiat_status(payment)
payment, skip_internal_payment_notifications
)
return PaymentStatus(paid=fiat_status.paid) return PaymentStatus(paid=fiat_status.paid)
return PaymentPendingStatus() return PaymentPendingStatus()
funding_source = get_funding_source() funding_source = get_funding_source()
@@ -783,9 +778,14 @@ async def _pay_internal_invoice(
await update_payment(internal_payment, conn=conn) await update_payment(internal_payment, conn=conn)
logger.success(f"internal payment successful {internal_payment.checking_id}") 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 from lnbits.tasks import internal_invoice_queue
logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}") 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) 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() Takes a checking_id of an incoming payment from paid_invoices_stream()
or internal_invoice_queue. Checks its status, updates and returns it. Checks its status, updates its status and returns it.
returns None if no payment was found or it not and incoming payment. returns None if no incoming payment was found or the status is not successful
""" """
payment = await get_standalone_payment(checking_id, incoming=True) payment = await get_standalone_payment(checking_id, incoming=True)
if not payment: if not payment:
logger.warning(f"No payment found for '{checking_id}'.") logger.warning(f"No incoming payment found for '{checking_id}'.")
return None 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 return None
status = await check_payment_status(
payment, skip_internal_payment_notifications=True
)
payment.fee = status.fee_msat or payment.fee payment.fee = status.fee_msat or payment.fee
# only overwrite preimage if status.preimage provides it # only overwrite preimage if status.preimage provides it
payment.preimage = status.preimage or payment.preimage payment.preimage = status.preimage or payment.preimage
payment.status = PaymentState.SUCCESS payment.status = PaymentState.SUCCESS
payment = await update_payment(payment) payment = await update_payment(payment)
if payment.fiat_provider:
await handle_fiat_payment_confirmation(payment)
return payment return payment
+6 -3
View File
@@ -6,7 +6,10 @@ from collections.abc import Callable, Coroutine
from loguru import logger from loguru import logger
from lnbits.core.models import Payment 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.settings import settings
from lnbits.wallets import get_funding_source from lnbits.wallets import get_funding_source
@@ -112,7 +115,7 @@ async def internal_invoice_listener() -> None:
while settings.lnbits_running: while settings.lnbits_running:
checking_id = await internal_invoice_queue.get() checking_id = await internal_invoice_queue.get()
logger.info(f"got an internal payment notification {checking_id}") 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: if payment:
logger.success(f"internal invoice {checking_id} settled") logger.success(f"internal invoice {checking_id} settled")
await invoice_callback_dispatcher(payment) await invoice_callback_dispatcher(payment)
@@ -128,7 +131,7 @@ async def invoice_listener() -> None:
funding_source = get_funding_source() funding_source = get_funding_source()
async for checking_id in funding_source.paid_invoices_stream(): async for checking_id in funding_source.paid_invoices_stream():
logger.info(f"got a payment notification {checking_id}") 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: if payment:
logger.success(f"fundingsource invoice {checking_id} settled") logger.success(f"fundingsource invoice {checking_id} settled")
await invoice_callback_dispatcher(payment) await invoice_callback_dispatcher(payment)
+39 -38
View File
@@ -3,6 +3,7 @@ import base64
import hashlib import hashlib
import json import json
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from typing import Any
import httpx import httpx
from loguru import logger from loguru import logger
@@ -123,8 +124,8 @@ class LndRestWallet(Wallet):
hashlib.sha256(unhashed_description).digest() hashlib.sha256(unhashed_description).digest()
).decode("ascii") ).decode("ascii")
preimage, _payment_hash = random_secret_and_hash() preimage, payment_hash = random_secret_and_hash()
_data["r_hash"] = base64.b64encode(bytes.fromhex(_payment_hash)).decode() _data["r_hash"] = base64.b64encode(bytes.fromhex(payment_hash)).decode()
_data["r_preimage"] = base64.b64encode(bytes.fromhex(preimage)).decode() _data["r_preimage"] = base64.b64encode(bytes.fromhex(preimage)).decode()
try: try:
@@ -132,34 +133,7 @@ class LndRestWallet(Wallet):
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
if len(data) == 0: return self._parse_create_invoice_response(r, data, preimage)
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,
)
except json.JSONDecodeError: except json.JSONDecodeError:
return InvoiceResponse( return InvoiceResponse(
ok=False, error_message="Server error: 'invalid json response'" ok=False, error_message="Server error: 'invalid json response'"
@@ -242,12 +216,13 @@ class LndRestWallet(Wallet):
logger.warning(f"Error getting invoice status: {e}") logger.warning(f"Error getting invoice status: {e}")
return PaymentPendingStatus() 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 # 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() return PaymentPendingStatus()
if data.get("settled") is True: if data.get("state") == "SETTLED":
return PaymentSuccessStatus() return PaymentSuccessStatus()
if data.get("state") == "CANCELED": if data.get("state") == "CANCELED":
@@ -264,6 +239,7 @@ class LndRestWallet(Wallet):
"ascii" "ascii"
) )
except ValueError: except ValueError:
logger.warning("Invalid checking_id format, must be hex: {checking_id}")
return PaymentPendingStatus() return PaymentPendingStatus()
url = f"/v2/router/track/{checking_id}" url = f"/v2/router/track/{checking_id}"
@@ -311,13 +287,12 @@ class LndRestWallet(Wallet):
async for line in r.aiter_lines(): async for line in r.aiter_lines():
try: try:
inv = json.loads(line)["result"] inv = json.loads(line)["result"]
if not inv["settled"]: if not inv.get("state") == "SETTLED":
continue continue
payment_hash = base64.b64decode(inv.get("r_hash")).hex()
except Exception as exc: except Exception as exc:
logger.debug(exc) logger.debug(exc)
continue continue
payment_hash = base64.b64decode(inv["r_hash"]).hex()
yield payment_hash yield payment_hash
except Exception as exc: except Exception as exc:
logger.warning( logger.warning(
@@ -363,8 +338,6 @@ class LndRestWallet(Wallet):
return InvoiceResponse(ok=False, error_message=str(exc)) return InvoiceResponse(ok=False, error_message=str(exc))
payment_request = data["payment_request"] payment_request = data["payment_request"]
payment_hash = base64.b64encode(bytes.fromhex(payment_hash)).decode("ascii")
return InvoiceResponse( return InvoiceResponse(
ok=True, checking_id=payment_hash, payment_request=payment_request ok=True, checking_id=payment_hash, payment_request=payment_request
) )
@@ -399,3 +372,31 @@ class LndRestWallet(Wallet):
except Exception as exc: except Exception as exc:
logger.warning(exc) logger.warning(exc)
return InvoiceResponse(ok=False, error_message=str(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,
)
+2 -3
View File
@@ -1744,11 +1744,10 @@ async def test_check_fiat_status_handles_internal_states(mocker: MockerFixture):
amount=1000, amount=1000,
fee=0, fee=0,
bolt11="bolt11", bolt11="bolt11",
status=PaymentState.PENDING, status=PaymentState.SUCCESS,
fiat_provider="stripe", fiat_provider="stripe",
extra={"fiat_checking_id": "stripe_checking_id"}, extra={"fiat_checking_id": "stripe_checking_id"},
), )
skip_internal_payment_notifications=True,
) )
assert queue_put.await_count == 1 assert queue_put.await_count == 1
+14 -1
View File
@@ -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.core.services.payments import update_wallet_balance
from lnbits.exceptions import InvoiceError, PaymentError from lnbits.exceptions import InvoiceError, PaymentError
from lnbits.settings import Settings 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.base import PaymentResponse
from lnbits.wallets.fake import FakeWallet from lnbits.wallets.fake import FakeWallet
@@ -231,7 +236,15 @@ async def test_notification_for_internal_payment(
): ):
test_name = "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() on_paid_mock = mocker.AsyncMock()
create_task(internal_invoice_listener())
create_task(wait_for_paid_invoices(test_name, on_paid_mock)()) create_task(wait_for_paid_invoices(test_name, on_paid_mock)())
payment = await create_invoice( payment = await create_invoice(
wallet_id=to_wallet.id, wallet_id=to_wallet.id,
+10 -10
View File
@@ -2073,7 +2073,7 @@
{ {
"response_type": "json", "response_type": "json",
"response": { "response": {
"settled": true "state": "SETTLED"
} }
} }
] ]
@@ -2155,8 +2155,15 @@
] ]
}, },
"lndrest": { "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": { "alby": {
"description": "alby.py doesn't handle the 'failed' status for `get_invoice_status`", "description": "alby.py doesn't handle the 'failed' status for `get_invoice_status`",
@@ -2243,13 +2250,6 @@
"response_type": "json", "response_type": "json",
"response": {} "response": {}
}, },
{
"description": "error status",
"response_type": "json",
"response": {
"seetled": false
}
},
{ {
"description": "bad json", "description": "bad json",
"response_type": "data", "response_type": "data",