From d1a431afc8a2e22bc789c7531e2b951639e35a79 Mon Sep 17 00:00:00 2001 From: Arc <33088785+arcbtc@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:29:23 +0100 Subject: [PATCH] fix: harden ambiguous payments (#4110) Co-authored-by: Vlad Stan --- Makefile | 30 +- lnbits/core/services/payments.py | 43 +- lnbits/wallets/alby.py | 18 +- lnbits/wallets/bark.py | 3 +- lnbits/wallets/base.py | 8 + lnbits/wallets/blink.py | 18 +- lnbits/wallets/boltz.py | 162 +++- lnbits/wallets/breez.py | 6 + lnbits/wallets/breez_liquid.py | 54 +- lnbits/wallets/cliche.py | 81 +- lnbits/wallets/clnrest.py | 6 +- lnbits/wallets/corelightning.py | 19 +- lnbits/wallets/eclair.py | 19 + lnbits/wallets/lndgrpc.py | 27 +- lnbits/wallets/lndrest.py | 40 +- lnbits/wallets/lnpay.py | 57 +- lnbits/wallets/lntips.py | 52 +- lnbits/wallets/nwc.py | 2 - lnbits/wallets/opennode.py | 75 +- lnbits/wallets/phoenixd.py | 4 +- lnbits/wallets/spark.py | 32 +- lnbits/wallets/sparkl2.py | 3 +- lnbits/wallets/strike.py | 61 +- lnbits/wallets/zbd.py | 88 +- tests/unit/test_pay_invoice.py | 73 +- tests/unit/test_services_payments.py | 28 + tests/unit/test_wallet_payment_ambiguity.py | 890 ++++++++++++++++++ .../wallets/fixtures/json/fixtures_rest.json | 22 + tests/wallets/fixtures/models.py | 12 +- tests/wallets/test_bark.py | 14 +- 30 files changed, 1678 insertions(+), 269 deletions(-) create mode 100644 tests/unit/test_wallet_payment_ambiguity.py diff --git a/Makefile b/Makefile index beaed5f64..48b903281 100644 --- a/Makefile +++ b/Makefile @@ -93,16 +93,36 @@ migration: uv run python tools/conv.py openapi: + @OPENAPI_SPEC_FILE=$$(mktemp); \ + OPENAPI_DATA_DIR=$$(mktemp -d); \ LNBITS_ADMIN_UI=False \ LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \ - LNBITS_DATA_FOLDER="./tests/data" \ + LNBITS_DATA_FOLDER="$$OPENAPI_DATA_DIR" \ + LNBITS_EXTENSIONS_DEFAULT_INSTALL='[]' \ + LNBITS_EXTENSIONS_DEACTIVATE_ALL=true \ PYTHONUNBUFFERED=1 \ + DEBUG=false \ HOST=0.0.0.0 \ PORT=5003 \ - uv run lnbits & - sleep 15 - curl -s http://0.0.0.0:5003/openapi.json | uv run openapi-spec-validator --errors=all - - # kill -9 %1 + uv run lnbits & \ + OPENAPI_SERVER_PID=$$!; \ + trap 'kill "$$OPENAPI_SERVER_PID" 2>/dev/null || true; wait "$$OPENAPI_SERVER_PID" 2>/dev/null || true; rm -f "$$OPENAPI_SPEC_FILE"; rm -rf "$$OPENAPI_DATA_DIR"' EXIT; \ + OPENAPI_ATTEMPT=0; \ + while [ "$$OPENAPI_ATTEMPT" -lt 60 ]; do \ + if curl --fail --silent --max-time 2 --output "$$OPENAPI_SPEC_FILE" \ + http://127.0.0.1:5003/openapi.json; then \ + uv run openapi-spec-validator --errors=all "$$OPENAPI_SPEC_FILE"; \ + exit $$?; \ + fi; \ + if ! kill -0 "$$OPENAPI_SERVER_PID" 2>/dev/null; then \ + echo "LNbits exited before serving the OpenAPI schema." >&2; \ + exit 1; \ + fi; \ + OPENAPI_ATTEMPT=$$((OPENAPI_ATTEMPT + 1)); \ + sleep 1; \ + done; \ + echo "LNbits did not serve the OpenAPI schema within 60 seconds." >&2; \ + exit 1 bak: # LNBITS_DATABASE_URL=postgres://postgres:postgres@0.0.0.0:5432/postgres diff --git a/lnbits/core/services/payments.py b/lnbits/core/services/payments.py index 95a7eb8ba..4987dd517 100644 --- a/lnbits/core/services/payments.py +++ b/lnbits/core/services/payments.py @@ -850,26 +850,46 @@ async def _pay_external_invoice( ) return payment + # IMPORTANT PAYMENT RULES! + # True -> success + # False-> failed + # None -> pending (any ambigous payment responses MUST be set as pending) + # payment failed - if ( - payment_response.checking_id is None - or payment_response.ok is False - or payment_response.checking_id != checking_id - ): + if payment_response.failed: payment.status = PaymentState.FAILED await update_payment(payment, conn=conn) message = payment_response.error_message or "without an error message." raise PaymentError(f"Payment failed: {message}", status="failed") - if payment_response.success: + # payment successful + elif payment_response.success: payment = await update_payment_success_status( - payment, payment_response, conn=conn + payment, + payment_response, + conn=conn, + new_checking_id=payment_response.checking_id, ) await _send_payment_notification_in_background(wallet.id, payment, conn=conn) - logger.success(f"payment successful {payment_response.checking_id}") + logger.success(f"payment successful {payment.checking_id}") + + # payment pending + else: + if ( + payment_response.checking_id + and payment_response.checking_id != payment.checking_id + ): + payment = await update_payment( + payment, + new_checking_id=payment_response.checking_id, + conn=conn, + ) + logger.warning( + f"payment status unknown {payment.checking_id}: " + f"{payment_response.error_message or 'no error message'}" + ) - payment.checking_id = payment_response.checking_id return payment @@ -877,13 +897,16 @@ async def update_payment_success_status( payment: Payment, status: PaymentStatus, conn: Connection | None = None, + new_checking_id: str | None = None, ) -> Payment: if status.success: service_fee_msat = service_fee(payment.amount, internal=False) payment.status = PaymentState.SUCCESS payment.fee = -(abs(status.fee_msat or 0) + abs(service_fee_msat)) payment.preimage = payment.preimage or status.preimage - payment = await update_payment(payment, conn=conn) + payment = await update_payment( + payment, new_checking_id=new_checking_id, conn=conn + ) return payment diff --git a/lnbits/wallets/alby.py b/lnbits/wallets/alby.py index bc7822c0b..cfd8d6c03 100644 --- a/lnbits/wallets/alby.py +++ b/lnbits/wallets/alby.py @@ -16,6 +16,7 @@ from .base import ( PaymentStatus, StatusResponse, Wallet, + payment_request_was_rejected, ) @@ -146,6 +147,21 @@ class AlbyWallet(Wallet): return PaymentResponse( ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage ) + except httpx.HTTPStatusError as exc: + logger.warning(exc) + rejected = payment_request_was_rejected(exc.response.status_code) + try: + response_message = exc.response.json().get("message", exc.response.text) + except Exception: + response_message = exc.response.text + return PaymentResponse( + ok=False if rejected else None, + error_message=( + response_message + if rejected + else f"Unable to connect to {self.endpoint}." + ), + ) except KeyError as exc: logger.warning(exc) return PaymentResponse( @@ -185,7 +201,7 @@ class AlbyWallet(Wallet): # - https://api.getalby.com/invoices/incoming # - https://api.getalby.com/invoices/outgoing return PaymentStatus( - statuses[data.get("state")], fee_msat=None, preimage=None + statuses.get(data.get("state")), fee_msat=None, preimage=None ) except Exception as e: logger.error(f"Error getting invoice status: {e}") diff --git a/lnbits/wallets/bark.py b/lnbits/wallets/bark.py index 0eff83b41..1b6291be5 100644 --- a/lnbits/wallets/bark.py +++ b/lnbits/wallets/bark.py @@ -21,6 +21,7 @@ from .base import ( PaymentSuccessStatus, StatusResponse, Wallet, + payment_request_was_rejected, ) @@ -396,7 +397,7 @@ class BarkWallet(Wallet): logger.warning(message) return self._pending_payment_response(bolt11, checking_id, message) except httpx.HTTPStatusError as exc: - if exc.response.is_client_error: + if payment_request_was_rejected(exc.response.status_code): return PaymentResponse( ok=False, checking_id=checking_id, diff --git a/lnbits/wallets/base.py b/lnbits/wallets/base.py index 50c084505..d9f4ef025 100644 --- a/lnbits/wallets/base.py +++ b/lnbits/wallets/base.py @@ -15,6 +15,14 @@ if TYPE_CHECKING: from lnbits.nodes.base import Node +def payment_request_was_rejected(status_code: int) -> bool: + """Return whether HTTP rejected the request before payment dispatch.""" + # Generic 400 and 422 responses are provider-specific. They can report an + # existing payment, so adapters must not treat them as terminal based only + # on the status code. Timeouts, conflicts and rate limits are also ambiguous. + return status_code in {401, 403, 404, 405} + + class Feature(Enum): nodemanager = "nodemanager" holdinvoice = "holdinvoice" diff --git a/lnbits/wallets/blink.py b/lnbits/wallets/blink.py index edc7d1c16..b43e90f93 100644 --- a/lnbits/wallets/blink.py +++ b/lnbits/wallets/blink.py @@ -179,14 +179,15 @@ class BlinkWallet(Wallet): try: response = await self._graphql_query(data) - errors = ( - response.get("data", {}) - .get("lnInvoicePaymentSend", {}) - .get("errors", {}) - ) + payment_result = response.get("data", {}).get("lnInvoicePaymentSend", {}) + errors = payment_result.get("errors", {}) if len(errors) > 0: error_message = errors[0].get("message") - return PaymentResponse(ok=False, error_message=error_message) + status = payment_result.get("status") + return PaymentResponse( + ok=False if status in {"FAILURE", "FAILED"} else None, + error_message=error_message, + ) checking_id = bolt11_lib.decode(bolt11).payment_hash @@ -194,7 +195,10 @@ class BlinkWallet(Wallet): fee_msat = payment_status.fee_msat preimage = payment_status.preimage return PaymentResponse( - ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage + ok=payment_status.paid, + checking_id=checking_id, + fee_msat=fee_msat, + preimage=preimage, ) except Exception as exc: logger.info(f"Failed to pay invoice {bolt11}") diff --git a/lnbits/wallets/boltz.py b/lnbits/wallets/boltz.py index c6473f181..050021a6c 100644 --- a/lnbits/wallets/boltz.py +++ b/lnbits/wallets/boltz.py @@ -2,6 +2,8 @@ import asyncio from collections.abc import AsyncGenerator from bolt11.decode import decode +from bolt11.types import Bolt11 +from grpc import StatusCode from grpc.aio import AioRpcError from loguru import logger @@ -124,26 +126,12 @@ class BoltzWallet(Wallet): ) async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + prepared = await self._prepare_payment(bolt11, fee_limit_msat) + if isinstance(prepared, PaymentResponse): + return prepared + pair, invoice = prepared - pair = boltzrpc_pb2.Pair(**{"from": boltzrpc_pb2.LBTC}) try: - pair_info: boltzrpc_pb2.PairInfo - pair_request = boltzrpc_pb2.GetPairInfoRequest( - type=boltzrpc_pb2.SUBMARINE, pair=pair - ) - pair_info = await self.rpc.GetPairInfo(pair_request, metadata=self.metadata) - invoice = decode(bolt11) - - if not invoice.amount_msat: - raise ValueError("amountless invoice") - - service_fee: float = invoice.amount_msat * pair_info.fees.percentage / 100 - estimate = int(service_fee + pair_info.fees.miner_fees * 1000) - if estimate > fee_limit_msat: - error = f"fee of {estimate} msat exceeds limit of {fee_limit_msat} msat" - - return PaymentResponse(ok=False, error_message=error) - request = boltzrpc_pb2.CreateSwapRequest( invoice=bolt11, pair=pair, @@ -165,8 +153,13 @@ class BoltzWallet(Wallet): ) return PaymentResponse(ok=True, checking_id=invoice.payment_hash) except AioRpcError as exc: + return await self._resolve_create_swap_error(invoice, exc) + except Exception as exc: logger.warning(exc) - return PaymentResponse(ok=False, error_message=exc.details()) + return PaymentResponse( + checking_id=invoice.payment_hash, + error_message=str(exc), + ) try: info_request = boltzrpc_pb2.GetSwapInfoRequest(id=response.id) @@ -186,14 +179,87 @@ class BoltzWallet(Wallet): fee_msat=fee_msat, preimage=info.swap.preimage, ) - elif info.swap.error != "": - return PaymentResponse(ok=False, error_message=info.swap.error) - return PaymentResponse( - ok=False, error_message="stream stopped unexpectedly" - ) + if info.swap.state in { + boltzrpc_pb2.ERROR, + boltzrpc_pb2.SERVER_ERROR, + boltzrpc_pb2.REFUNDED, + boltzrpc_pb2.ABANDONED, + }: + return PaymentResponse( + ok=False, + checking_id=invoice.payment_hash, + error_message=info.swap.error or "swap failed", + ) + return PaymentResponse(error_message="stream stopped unexpectedly") except AioRpcError as exc: logger.warning(exc) - return PaymentResponse(ok=False, error_message=exc.details()) + return PaymentResponse(error_message=exc.details()) + + async def _resolve_create_swap_error( + self, invoice: Bolt11, exc: AioRpcError + ) -> PaymentResponse: + logger.warning(exc) + if _is_pre_dispatch_create_swap_error(exc): + status: PaymentStatus = PaymentFailedStatus() + else: + try: + status = await self.get_payment_status(invoice.payment_hash) + except Exception as status_exc: + logger.warning(status_exc) + status = PaymentPendingStatus() + + return PaymentResponse( + ok=status.paid, + checking_id=invoice.payment_hash, + fee_msat=status.fee_msat, + preimage=status.preimage, + error_message=exc.details(), + ) + + async def _prepare_payment( + self, bolt11: str, fee_limit_msat: int + ) -> tuple[boltzrpc_pb2.Pair, Bolt11] | PaymentResponse: + pair = boltzrpc_pb2.Pair(**{"from": boltzrpc_pb2.LBTC}) + try: + invoice = decode(bolt11) + except Exception as exc: + logger.warning(exc) + return PaymentResponse( + ok=False, + error_message=f"invalid bolt11 invoice: {exc}", + ) + + if not invoice.amount_msat: + return PaymentResponse( + ok=False, + checking_id=invoice.payment_hash, + error_message="amountless invoice", + ) + + try: + pair_info: boltzrpc_pb2.PairInfo + pair_request = boltzrpc_pb2.GetPairInfoRequest( + type=boltzrpc_pb2.SUBMARINE, pair=pair + ) + pair_info = await self.rpc.GetPairInfo(pair_request, metadata=self.metadata) + except Exception as exc: + logger.warning(exc) + return PaymentResponse( + ok=False, + checking_id=invoice.payment_hash, + error_message=f"unable to get swap terms: {exc}", + ) + + service_fee: float = invoice.amount_msat * pair_info.fees.percentage / 100 + estimate = int(service_fee + pair_info.fees.miner_fees * 1000) + if estimate > fee_limit_msat: + error = f"fee of {estimate} msat exceeds limit of {fee_limit_msat} msat" + return PaymentResponse( + ok=False, + checking_id=invoice.payment_hash, + error_message=error, + ) + return pair, invoice async def get_invoice_status(self, checking_id: str) -> PaymentStatus: try: @@ -217,10 +283,14 @@ class BoltzWallet(Wallet): fee_msat=fee_msat, preimage=swap.preimage, ) - elif swap.state == boltzrpc_pb2.SwapState.PENDING: - return PaymentPendingStatus() - - return PaymentFailedStatus() + if swap.state in { + boltzrpc_pb2.SwapState.ERROR, + boltzrpc_pb2.SwapState.SERVER_ERROR, + boltzrpc_pb2.SwapState.REFUNDED, + boltzrpc_pb2.SwapState.ABANDONED, + }: + return PaymentFailedStatus() + return PaymentPendingStatus() async def get_payment_status(self, checking_id: str) -> PaymentStatus: try: @@ -231,7 +301,7 @@ class BoltzWallet(Wallet): metadata=self.metadata, ) swap = response.swap - except AioRpcError as exc: + except (AioRpcError, ValueError) as exc: logger.warning(exc) return PaymentPendingStatus() if swap.state == boltzrpc_pb2.SwapState.SUCCESSFUL: @@ -243,10 +313,14 @@ class BoltzWallet(Wallet): fee_msat=fee_msat, preimage=swap.preimage, ) - elif swap.state == boltzrpc_pb2.SwapState.PENDING: - return PaymentPendingStatus() - - return PaymentFailedStatus() + if swap.state in { + boltzrpc_pb2.SwapState.ERROR, + boltzrpc_pb2.SwapState.SERVER_ERROR, + boltzrpc_pb2.SwapState.REFUNDED, + boltzrpc_pb2.SwapState.ABANDONED, + }: + return PaymentFailedStatus() + return PaymentPendingStatus() async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: while settings.lnbits_running: @@ -352,3 +426,23 @@ class BoltzWallet(Wallet): except Exception as e: logger.error(f"❌ Failed to create Boltz wallet: {e}") + + +_PRE_DISPATCH_CREATE_SWAP_ERROR_CODES = { + StatusCode.INVALID_ARGUMENT, + StatusCode.PERMISSION_DENIED, + StatusCode.UNAUTHENTICATED, +} +_PRE_DISPATCH_CREATE_SWAP_ERROR_MESSAGES = ( + "boltz error: could not find route to pay invoice", +) + + +def _is_pre_dispatch_create_swap_error(exc: AioRpcError) -> bool: + if exc.code() in _PRE_DISPATCH_CREATE_SWAP_ERROR_CODES: + return True + + details = (exc.details() or "").lower() + return any( + message in details for message in _PRE_DISPATCH_CREATE_SWAP_ERROR_MESSAGES + ) diff --git a/lnbits/wallets/breez.py b/lnbits/wallets/breez.py index e32cb9b90..dd47e69f1 100644 --- a/lnbits/wallets/breez.py +++ b/lnbits/wallets/breez.py @@ -240,6 +240,12 @@ else: logger.info(ex) return PaymentResponse(error_message=f"exception while payment {exc!s}") + if payment.status == BreezPaymentStatus.FAILED: + return PaymentResponse( + ok=False, + checking_id=invoice.payment_hash, + error_message="payment failed", + ) if payment.status != BreezPaymentStatus.COMPLETE: return PaymentResponse(ok=None, error_message="payment is pending") diff --git a/lnbits/wallets/breez_liquid.py b/lnbits/wallets/breez_liquid.py index c2a649a90..e9c192f0d 100644 --- a/lnbits/wallets/breez_liquid.py +++ b/lnbits/wallets/breez_liquid.py @@ -173,29 +173,44 @@ else: async def pay_invoice( self, bolt11: str, fee_limit_msat: int ) -> PaymentResponse: - invoice_data = bolt11_decode(bolt11) + try: + invoice_data = bolt11_decode(bolt11) + except Exception as exc: + logger.warning(exc) + return PaymentResponse( + ok=False, + error_message=f"invalid bolt11 invoice: {exc}", + ) try: prepare_req = PrepareSendRequest(destination=bolt11) req = self.sdk_services.prepare_send_payment(prepare_req) - - fee_limit_sat = settings.breez_liquid_fee_offset_sat + int( - fee_limit_msat / 1000 + except Exception as exc: + logger.warning(exc) + return PaymentResponse( + ok=False, + checking_id=invoice_data.payment_hash, + error_message=f"unable to prepare payment: {exc}", ) - if req.fees_sat and req.fees_sat > fee_limit_sat: - return PaymentResponse( - ok=False, - error_message=( - f"fee of {req.fees_sat} sat exceeds limit of " - f"{fee_limit_sat} sat" - ), - ) + fee_limit_sat = settings.breez_liquid_fee_offset_sat + int( + fee_limit_msat / 1000 + ) + if req.fees_sat and req.fees_sat > fee_limit_sat: + return PaymentResponse( + ok=False, + checking_id=invoice_data.payment_hash, + error_message=( + f"fee of {req.fees_sat} sat exceeds limit of " + f"{fee_limit_sat} sat" + ), + ) + + try: send_response = self.sdk_services.send_payment( SendPaymentRequest(prepare_response=req) ) - except Exception as exc: logger.warning(exc) return PaymentResponse(error_message=f"Exception while payment: {exc}") @@ -206,6 +221,14 @@ else: fees = req.fees_sat * 1000 if req.fees_sat and req.fees_sat > 0 else 0 + if payment.status in {PaymentState.FAILED, PaymentState.TIMED_OUT}: + return PaymentResponse( + ok=False, + checking_id=checking_id, + fee_msat=fees, + error_message=f"payment {payment.status!s}", + ) + if payment.status != PaymentState.COMPLETE: return await self._wait_for_outgoing_payment(checking_id, fees, 10) @@ -262,7 +285,10 @@ else: fee_msat=int(payment.fees_sat * 1000), preimage=payment.details.preimage, ) - if payment.status == PaymentState.FAILED: + if payment.status in { + PaymentState.FAILED, + PaymentState.TIMED_OUT, + }: return PaymentFailedStatus() return PaymentPendingStatus() except Exception as exc: diff --git a/lnbits/wallets/cliche.py b/lnbits/wallets/cliche.py index a0ef0e3ce..ead40e617 100644 --- a/lnbits/wallets/cliche.py +++ b/lnbits/wallets/cliche.py @@ -104,41 +104,42 @@ class ClicheWallet(Wallet): ) async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: - ws = create_connection(self.endpoint) - ws.send(f"pay-invoice --invoice {bolt11}") - checking_id, fee_msat, preimage, payment_ok = ( - None, - None, - None, - None, - ) - for _ in range(2): - r = ws.recv() - data = json.loads(r) + try: + ws = create_connection(self.endpoint) + ws.send(f"pay-invoice --invoice {bolt11}") checking_id, fee_msat, preimage, payment_ok = ( None, None, None, None, ) + for _ in range(2): + r = ws.recv() + data = json.loads(r) - if data.get("error") is not None: - error_message = data["error"].get("message") - return PaymentResponse(ok=False, error_message=error_message) + if data.get("error") is not None: + error_message = data["error"].get("message") + return PaymentResponse(error_message=error_message) - if data.get("method") == "payment_succeeded": - payment_ok = True - checking_id = data["params"]["payment_hash"] - fee_msat = data["params"]["fee_msatoshi"] - preimage = data["params"]["preimage"] - continue + if data.get("method") == "payment_succeeded": + payment_ok = True + checking_id = data["params"]["payment_hash"] + fee_msat = data["params"]["fee_msatoshi"] + preimage = data["params"]["preimage"] + continue - if data.get("result") is None: - return PaymentResponse(error_message="result is None") + if data.get("result") is None: + return PaymentResponse(error_message="result is None") - return PaymentResponse( - ok=payment_ok, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage - ) + return PaymentResponse( + ok=payment_ok, + checking_id=checking_id, + fee_msat=fee_msat, + preimage=preimage, + ) + except Exception as exc: + logger.warning(exc) + return PaymentResponse(error_message=f"Unable to query {self.endpoint}.") async def get_invoice_status(self, checking_id: str) -> PaymentStatus: ws = create_connection(self.endpoint) @@ -154,21 +155,25 @@ class ClicheWallet(Wallet): return PaymentStatus(statuses[data["result"]["status"]]) async def get_payment_status(self, checking_id: str) -> PaymentStatus: - ws = create_connection(self.endpoint) - ws.send(f"check-payment --hash {checking_id}") - r = ws.recv() - data = json.loads(r) + try: + ws = create_connection(self.endpoint) + ws.send(f"check-payment --hash {checking_id}") + r = ws.recv() + data = json.loads(r) - if data.get("error") is not None and data["error"].get("message"): - logger.error(data["error"]["message"]) + if data.get("error") is not None and data["error"].get("message"): + logger.error(data["error"]["message"]) + return PaymentPendingStatus() + payment = data["result"] + statuses = {"pending": None, "complete": True, "failed": False} + return PaymentStatus( + statuses.get(payment.get("status")), + payment.get("fee_msatoshi"), + payment.get("preimage"), + ) + except Exception as exc: + logger.warning(exc) return PaymentPendingStatus() - payment = data["result"] - statuses = {"pending": None, "complete": True, "failed": False} - return PaymentStatus( - statuses[payment["status"]], - payment.get("fee_msatoshi"), - payment.get("preimage"), - ) async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: while settings.lnbits_running: diff --git a/lnbits/wallets/clnrest.py b/lnbits/wallets/clnrest.py index 8d34cef34..d9b86c370 100644 --- a/lnbits/wallets/clnrest.py +++ b/lnbits/wallets/clnrest.py @@ -19,6 +19,7 @@ from lnbits.utils.crypto import random_secret_and_hash from .base import ( InvoiceResponse, + PaymentFailedStatus, PaymentPendingStatus, PaymentResponse, PaymentStatus, @@ -379,9 +380,12 @@ class CLNRestWallet(Wallet): pay = pays_list[-1] - if pay["status"] == "complete": + status = pay.get("status") + if status == "complete": fee_msat = pay["amount_sent_msat"] - pay["amount_msat"] return PaymentSuccessStatus(fee_msat=fee_msat, preimage=pay["preimage"]) + if status == "failed": + return PaymentFailedStatus() except Exception as exc: logger.warning(f"Error getting payment status: {exc}") diff --git a/lnbits/wallets/corelightning.py b/lnbits/wallets/corelightning.py index e21cb4f13..41d1b6617 100644 --- a/lnbits/wallets/corelightning.py +++ b/lnbits/wallets/corelightning.py @@ -31,6 +31,21 @@ async def run_sync(func) -> Any: return await loop.run_in_executor(None, func) +def _all_payment_attempts_failed(error: object) -> bool: + if not isinstance(error, dict): + return False + + attempts = error.get("attempts") + return ( + isinstance(attempts, list) + and bool(attempts) + and all( + isinstance(attempt, dict) and attempt.get("status") == "failed" + for attempt in attempts + ) + ) + + class CoreLightningWallet(Wallet): """Core Lightning RPC implementation.""" @@ -184,7 +199,9 @@ class CoreLightningWallet(Wallet): logger.warning(exc) try: error_code = exc.error.get("code") # type: ignore - if error_code in self.pay_failure_error_codes: + if error_code in self.pay_failure_error_codes or ( + _all_payment_attempts_failed(exc.error) + ): error_message = exc.error.get("message", error_code) # type: ignore return PaymentResponse( ok=False, error_message=f"Payment failed: {error_message}" diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py index dc5ed590d..88d12dec6 100644 --- a/lnbits/wallets/eclair.py +++ b/lnbits/wallets/eclair.py @@ -22,6 +22,7 @@ from .base import ( PaymentStatus, StatusResponse, Wallet, + payment_request_was_rejected, ) @@ -165,6 +166,24 @@ class EclairWallet(Wallet): checking_id = data["paymentHash"] preimage = data["paymentPreimage"] + except httpx.HTTPStatusError as exc: + error_message = f"Unable to connect to {self.url}." + try: + error_data = exc.response.json() + if isinstance(error_data, dict) and error_data.get("error"): + error_message = str(error_data["error"]) + except json.JSONDecodeError: + pass + + # Eclair uses HTTP 400 for invoice and form validation failures, + # which happen before it dispatches the payment. + rejected = exc.response.status_code == 400 or payment_request_was_rejected( + exc.response.status_code + ) + return PaymentResponse( + ok=False if rejected else None, + error_message=error_message, + ) except json.JSONDecodeError: return PaymentResponse( error_message="Server error: 'invalid json response'" diff --git a/lnbits/wallets/lndgrpc.py b/lnbits/wallets/lndgrpc.py index 089208e61..2641de233 100644 --- a/lnbits/wallets/lndgrpc.py +++ b/lnbits/wallets/lndgrpc.py @@ -198,9 +198,15 @@ class LndWallet(Wallet): ) try: res: Payment = await self.router_rpc.SendPaymentV2(req).read() + except grpc.aio.AioRpcError as exc: + logger.warning(exc) + return PaymentResponse( + ok=False if _is_pre_dispatch_payment_error(exc) else None, + error_message=exc.details() or str(exc), + ) except Exception as exc: logger.warning(exc) - return PaymentResponse(error_message=str(exc)) + return PaymentResponse(ok=None, error_message=str(exc)) if res.status == Payment.PaymentStatus.SUCCEEDED: return PaymentResponse( @@ -378,3 +384,22 @@ class LndWallet(Wallet): ) # If we reach here, the invoice was successfully canceled and payment failed return InvoiceResponse(True, checking_id=payment_hash) + + +_PRE_DISPATCH_PAYMENT_ERROR_CODES = { + grpc.StatusCode.INVALID_ARGUMENT, + grpc.StatusCode.PERMISSION_DENIED, + grpc.StatusCode.UNAUTHENTICATED, +} +_PRE_DISPATCH_PAYMENT_ERROR_MESSAGES = ( + "invoice not for current active network", + "invoice expired", +) + + +def _is_pre_dispatch_payment_error(exc: grpc.aio.AioRpcError) -> bool: + if exc.code() in _PRE_DISPATCH_PAYMENT_ERROR_CODES: + return True + + details = (exc.details() or "").lower() + return any(message in details for message in _PRE_DISPATCH_PAYMENT_ERROR_MESSAGES) diff --git a/lnbits/wallets/lndrest.py b/lnbits/wallets/lndrest.py index 0848a7ad9..bec06bf2b 100644 --- a/lnbits/wallets/lndrest.py +++ b/lnbits/wallets/lndrest.py @@ -26,6 +26,42 @@ from .base import ( ) from .macaroon import load_macaroon +_PRE_DISPATCH_PAYMENT_ERROR_CODES = {3, 7, 16} +_PRE_DISPATCH_PAYMENT_ERROR_MESSAGES = ( + "invoice not for current active network", + "invoice expired", +) + + +def _is_pre_dispatch_payment_error(code: int | None, message: str) -> bool: + # LND's REST gateway uses the numeric gRPC status codes. UNKNOWN (2) + # is ambiguous unless LND returned one of its request-validation errors. + if code in _PRE_DISPATCH_PAYMENT_ERROR_CODES: + return True + + message = message.lower() + return any(error in message for error in _PRE_DISPATCH_PAYMENT_ERROR_MESSAGES) + + +def _payment_response_from_http_error( + exc: httpx.HTTPStatusError, endpoint: str +) -> PaymentResponse: + try: + error = exc.response.json()["error"] + error_code = error.get("code") + error_message = str(error.get("message") or exc) + except (json.JSONDecodeError, KeyError, TypeError, AttributeError): + error_code = None + error_message = f"Unable to connect to {endpoint}." + + logger.warning(f"LndRestWallet pay_invoice POST error: {error_message}.") + return PaymentResponse( + ok=( + False if _is_pre_dispatch_payment_error(error_code, error_message) else None + ), + error_message=error_message, + ) + class LndRestWallet(Wallet): """https://api.lightning.community/#lnd-rest-api-reference""" @@ -162,6 +198,8 @@ class LndRestWallet(Wallet): ) r.raise_for_status() data = r.json() + except httpx.HTTPStatusError as exc: + return _payment_response_from_http_error(exc, self.endpoint) except json.JSONDecodeError: return PaymentResponse( error_message="Server error: 'invalid json response'" @@ -201,7 +239,7 @@ class LndRestWallet(Wallet): elif status == "IN_FLIGHT": return PaymentResponse(ok=None, checking_id=checking_id) return PaymentResponse( - ok=False, + ok=None, checking_id=checking_id, error_message="Server error: 'unknown payment status returned'", ) diff --git a/lnbits/wallets/lnpay.py b/lnbits/wallets/lnpay.py index 738faf9ea..0212f9be8 100644 --- a/lnbits/wallets/lnpay.py +++ b/lnbits/wallets/lnpay.py @@ -15,6 +15,7 @@ from .base import ( PaymentStatus, StatusResponse, Wallet, + payment_request_was_rejected, ) @@ -105,44 +106,58 @@ class LNPayWallet(Wallet): ) async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: - r = await self.client.post( - f"/wallet/{self.wallet_key}/withdraw", - json={"payment_request": bolt11}, - timeout=None, - ) + try: + r = await self.client.post( + f"/wallet/{self.wallet_key}/withdraw", + json={"payment_request": bolt11}, + timeout=None, + ) + except Exception as exc: + logger.warning(exc) + return PaymentResponse(error_message="Unable to connect to LNPay.") try: data = r.json() except Exception: - return PaymentResponse(ok=False, error_message="Got invalid JSON.") + return PaymentResponse(error_message="Got invalid JSON.") if r.is_error: - return PaymentResponse(ok=False, error_message=data["message"]) + return PaymentResponse( + ok=False if payment_request_was_rejected(r.status_code) else None, + error_message=data.get("message", r.text), + ) - checking_id = data["lnTx"]["id"] - fee_msat = 0 - preimage = data["lnTx"]["payment_preimage"] + try: + checking_id = data["lnTx"]["id"] + preimage = data["lnTx"]["payment_preimage"] + except (KeyError, TypeError): + return PaymentResponse( + error_message="LNPay response is missing required payment fields." + ) return PaymentResponse( - ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage + ok=True, checking_id=checking_id, fee_msat=0, preimage=preimage ) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: return await self.get_payment_status(checking_id) async def get_payment_status(self, checking_id: str) -> PaymentStatus: - r = await self.client.get( - url=f"/lntx/{checking_id}", - ) + try: + r = await self.client.get( + url=f"/lntx/{checking_id}", + ) + if r.is_error: + return PaymentPendingStatus() - if r.is_error: + data = r.json() + paid = {0: None, 1: True, -1: False}.get(data.get("settled")) + return PaymentStatus( + paid, data.get("fee_msat"), data.get("payment_preimage") + ) + except Exception as exc: + logger.warning(exc) return PaymentPendingStatus() - data = r.json() - preimage = data["payment_preimage"] - fee_msat = data["fee_msat"] - statuses = {0: None, 1: True, -1: False} - return PaymentStatus(statuses[data["settled"]], fee_msat, preimage) - async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: self.queue: asyncio.Queue = asyncio.Queue(0) while settings.lnbits_running: diff --git a/lnbits/wallets/lntips.py b/lnbits/wallets/lntips.py index 5a15be1a0..1179f2673 100644 --- a/lnbits/wallets/lntips.py +++ b/lnbits/wallets/lntips.py @@ -17,6 +17,7 @@ from .base import ( PaymentStatus, StatusResponse, Wallet, + payment_request_was_rejected, ) @@ -103,26 +104,43 @@ class LnTipsWallet(Wallet): ) async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: - r = await self.client.post( - "/api/v1/payinvoice", - json={"pay_req": bolt11}, - timeout=None, - ) + try: + r = await self.client.post( + "/api/v1/payinvoice", + json={"pay_req": bolt11}, + timeout=None, + ) + except Exception as exc: + logger.warning(exc) + return PaymentResponse( + error_message=f"Unable to connect to {self.endpoint}." + ) if r.is_error: - return PaymentResponse(ok=False, error_message=r.text) + return PaymentResponse( + ok=False if payment_request_was_rejected(r.status_code) else None, + error_message=r.text, + ) - if "error" in r.json(): - try: - data = r.json() - error_message = data["error"] - except Exception: - error_message = r.text - return PaymentResponse(ok=False, error_message=error_message) + try: + response = r.json() + except json.JSONDecodeError: + return PaymentResponse( + error_message="Server error: 'invalid json response'" + ) - data = r.json()["details"] - checking_id = data["payment_hash"] - fee_msat = -data["fee"] - preimage = data["preimage"] + if "error" in response: + error_message = response.get("error") or r.text + return PaymentResponse(error_message=error_message) + + try: + data = response["details"] + checking_id = data["payment_hash"] + fee_msat = -data["fee"] + preimage = data["preimage"] + except (KeyError, TypeError): + return PaymentResponse( + error_message="Server error: 'missing required fields'" + ) return PaymentResponse( ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage ) diff --git a/lnbits/wallets/nwc.py b/lnbits/wallets/nwc.py index 860f38e3b..928ea6518 100644 --- a/lnbits/wallets/nwc.py +++ b/lnbits/wallets/nwc.py @@ -625,8 +625,6 @@ class NWCWallet(Wallet): "QUOTA_EXCEEDED", "RESTRICTED", "UNAUTHORIZED", - "INTERNAL", - "OTHER", "PAYMENT_FAILED", ] failed = e.code in failure_codes diff --git a/lnbits/wallets/opennode.py b/lnbits/wallets/opennode.py index f05349746..95506c3c6 100644 --- a/lnbits/wallets/opennode.py +++ b/lnbits/wallets/opennode.py @@ -15,6 +15,7 @@ from .base import ( PaymentStatus, StatusResponse, Wallet, + payment_request_was_rejected, ) @@ -100,24 +101,38 @@ class OpenNodeWallet(Wallet): ) async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: - r = await self.client.post( - "/v2/withdrawals", - json={"type": "ln", "address": bolt11}, - timeout=None, - ) + try: + r = await self.client.post( + "/v2/withdrawals", + json={"type": "ln", "address": bolt11}, + timeout=None, + ) - if r.is_error: - error_message = r.json()["message"] - logger.warning(error_message) - return PaymentResponse(ok=None, error_message=error_message) + if r.is_error: + error_message = r.json().get("message", r.text) + logger.warning(error_message) + return PaymentResponse( + ok=(False if payment_request_was_rejected(r.status_code) else None), + error_message=error_message, + ) - data = r.json()["data"] - checking_id = data["id"] - fee_msat = -data["fee"] * 1000 - # pending - if data["status"] != "paid": + data = r.json()["data"] + checking_id = data.get("id") + fee = data.get("fee") + fee_msat = -fee * 1000 if fee is not None else None + status = str(data.get("status", "")).lower() + if status in {"paid", "confirmed"}: + return PaymentResponse( + ok=True, checking_id=checking_id, fee_msat=fee_msat + ) + if status in {"error", "failed"}: + return PaymentResponse( + ok=False, checking_id=checking_id, fee_msat=fee_msat + ) return PaymentResponse(ok=None, checking_id=checking_id, fee_msat=fee_msat) - return PaymentResponse(ok=True, checking_id=checking_id, fee_msat=fee_msat) + except Exception as exc: + logger.warning(exc) + return PaymentResponse(error_message="Invalid OpenNode payment response.") async def get_invoice_status(self, checking_id: str) -> PaymentStatus: r = await self.client.get(f"/v1/charge/{checking_id}") @@ -128,22 +143,26 @@ class OpenNodeWallet(Wallet): return PaymentStatus(statuses[data.get("status")]) async def get_payment_status(self, checking_id: str) -> PaymentStatus: - r = await self.client.get(f"/v1/withdrawal/{checking_id}") + try: + r = await self.client.get(f"/v1/withdrawal/{checking_id}") + if r.is_error: + return PaymentPendingStatus() - if r.is_error: + data = r.json()["data"] + statuses = { + "initial": None, + "pending": None, + "confirmed": True, + "error": False, + "failed": False, + } + fee = data.get("fee") + fee_msat = -fee * 1000 if fee is not None else None + return PaymentStatus(statuses.get(data.get("status")), fee_msat) + except Exception as exc: + logger.warning(exc) return PaymentPendingStatus() - data = r.json()["data"] - statuses = { - "initial": None, - "pending": None, - "confirmed": True, - "error": None, - "failed": False, - } - fee_msat = -data.get("fee") * 1000 - return PaymentStatus(statuses[data.get("status")], fee_msat) - async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: self.queue: asyncio.Queue = asyncio.Queue(0) while settings.lnbits_running: diff --git a/lnbits/wallets/phoenixd.py b/lnbits/wallets/phoenixd.py index 0d508b2d1..e386855a0 100644 --- a/lnbits/wallets/phoenixd.py +++ b/lnbits/wallets/phoenixd.py @@ -208,11 +208,11 @@ class PhoenixdWallet(Wallet): logger.warning(msg) return PaymentResponse(ok=None, error_message=msg) except RequestError as exc: - # RequestError is raised when the request never hit the destination server + # RequestError can also be raised after the server received the request. msg = f"Unable to connect to {self.endpoint}." logger.warning(msg) logger.warning(exc) - return PaymentResponse(ok=False, error_message=msg) + return PaymentResponse(ok=None, error_message=msg) except Exception as exc: logger.warning(exc) return PaymentResponse( diff --git a/lnbits/wallets/spark.py b/lnbits/wallets/spark.py index 90dcc05e0..34b68b077 100644 --- a/lnbits/wallets/spark.py +++ b/lnbits/wallets/spark.py @@ -162,11 +162,16 @@ class SparkWallet(Wallet): ) except (SparkError, UnknownError) as exc: - listpays = await self.listpays(bolt11) + try: + listpays = await self.listpays(bolt11) + except (SparkError, UnknownError): + return PaymentResponse(error_message=str(exc)) if not listpays: - return PaymentResponse(ok=False, error_message=str(exc)) + return PaymentResponse(error_message=str(exc)) - pays = listpays["pays"] + pays = listpays.get("pays") + if not isinstance(pays, list): + return PaymentResponse(error_message=str(exc)) if len(pays) == 0: return PaymentResponse(ok=False, error_message=str(exc)) @@ -175,10 +180,12 @@ class SparkWallet(Wallet): payment_hash = pay["payment_hash"] if len(pays) > 1: - raise SparkError( - f"listpays({payment_hash}) returned an unexpected response:" - f" {listpays}" - ) from exc + return PaymentResponse( + error_message=( + f"listpays({payment_hash}) returned an unexpected response:" + f" {listpays}" + ) + ) if pay["status"] == "failed": return PaymentResponse(ok=False, error_message=str(exc)) @@ -203,7 +210,7 @@ class SparkWallet(Wallet): preimage=preimage, ) else: - return PaymentResponse(ok=False, error_message=str(exc)) + return PaymentResponse(error_message=str(exc)) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: try: @@ -214,10 +221,12 @@ class SparkWallet(Wallet): if not r or not r.get("invoices"): return PaymentPendingStatus() - if r["invoices"][0]["status"] == "paid": + status = r["invoices"][0]["status"] + if status == "paid": return PaymentSuccessStatus() - else: + if status == "expired": return PaymentFailedStatus() + return PaymentPendingStatus() async def get_payment_status(self, checking_id: str) -> PaymentStatus: # check if it's 32 bytes hex @@ -249,7 +258,8 @@ class SparkWallet(Wallet): if status == "failed": return PaymentFailedStatus() return PaymentPendingStatus() - raise KeyError("supplied an invalid checking_id") + logger.warning(f"supplied an invalid checking_id: {checking_id}") + return PaymentPendingStatus() async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: url = f"/stream?access-key={self.token}" diff --git a/lnbits/wallets/sparkl2.py b/lnbits/wallets/sparkl2.py index 66c33d3e4..ea1370beb 100644 --- a/lnbits/wallets/sparkl2.py +++ b/lnbits/wallets/sparkl2.py @@ -162,7 +162,6 @@ class SparkL2Wallet(Wallet): checking_id = res.get("checking_id") if not checking_id: return PaymentResponse( - ok=False, error_message="Spark sidecar payment response missing checking_id.", ) status = res.get("status") @@ -178,7 +177,7 @@ class SparkL2Wallet(Wallet): ) except Exception as e: - return PaymentResponse(ok=False, error_message=str(e)) + return PaymentResponse(error_message=str(e)) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: try: diff --git a/lnbits/wallets/strike.py b/lnbits/wallets/strike.py index 8fbbb7e1c..aa2b0170d 100644 --- a/lnbits/wallets/strike.py +++ b/lnbits/wallets/strike.py @@ -237,50 +237,59 @@ class StrikeWallet(Wallet): ok=False, error_message=f"Invalid invoice: {decode_exc!s}" ) + # Creating a quote cannot make the payment. Any failure before the execute + # request is therefore a definite failure of this payment attempt. try: - # 1) Create a payment quote quote_id, error = await self._create_payment_quote(bolt11) if error or not quote_id: return PaymentResponse(ok=False, error_message=error or "Unknown error") + except Exception as exc: + logger.warning(f"Strike quote creation exception: {exc}", exc_info=True) + return PaymentResponse( + ok=False, + error_message=f"Failed to create payment quote: {exc!s}", + ) + + try: + # Keep the quote id while this process is running. Strike only documents + # payment status lookup by payment id, which an ambiguous execute request + # may not return. + self.pending_payments[payment_hash] = quote_id - # 2) Execute the payment quote data, error = await self._execute_payment_quote(quote_id) if error or not data: - return PaymentResponse(ok=False, error_message=error or "Unknown error") + return PaymentResponse(error_message=error or "Unknown error") state = data.get("state", "").upper() payment_id = data.get("paymentId") + checking_id = payment_id or payment_hash # Parse fee - fee_msat = self._parse_payment_fee(data, payment_id or "") + fee_msat = self._parse_payment_fee(data, checking_id) # Handle successful payment if state in {"SUCCEEDED", "COMPLETED"}: preimage = self._extract_preimage(data) return PaymentResponse( ok=True, - checking_id=payment_hash, + checking_id=checking_id, fee_msat=fee_msat, preimage=preimage, ) # Handle failed payment - failed_states = {"CANCELED", "FAILED", "TIMED_OUT"} - if state in failed_states: + if state == "FAILED": logger.warning( f"Strike payment {payment_id} failed with state: {state}" ) return PaymentResponse( ok=False, - checking_id=payment_hash, + checking_id=checking_id, error_message=f"Payment {state.lower()}", ) - # Store mapping for later polling - self.pending_payments[payment_hash] = quote_id - # Treat all other states as pending - return PaymentResponse(ok=None, checking_id=payment_hash) + return PaymentResponse(ok=None, checking_id=payment_id) except httpx.HTTPStatusError as http_exc: logger.warning(f"Strike HTTP error during payment: {http_exc}") @@ -289,7 +298,6 @@ class StrikeWallet(Wallet): f"body: {http_exc.response.text}" ) return PaymentResponse( - ok=False, error_message=f"Strike API error: {http_exc.response.status_code}", ) except Exception as e: @@ -343,7 +351,8 @@ class StrikeWallet(Wallet): quote_id = self.pending_payments.get(checking_id) try: - # Attempt 1: Use quote_id if available (from in-memory store) + # A quote id can only be associated with an invoice hash while this + # process is running. Persisted payment ids are checked below. if quote_id: status = await self._get_payment_status_by_quote_id( checking_id, quote_id @@ -527,10 +536,8 @@ class StrikeWallet(Wallet): return None, error_msg data = e.json() if e.content else {} - payment_id = data.get("paymentId") - if not payment_id: + if not data.get("paymentId"): logger.warning(f"Strike: missing paymentId in response: {data}") - return None, "Strike: missing paymentId in response" return data, None @@ -629,7 +636,7 @@ class StrikeWallet(Wallet): self.pending_payments.pop(checking_id, None) return PaymentFailedStatus() - return None + return PaymentPendingStatus() async def _get_payment_status_by_checking_id( # noqa: C901 self, checking_id: str @@ -693,16 +700,28 @@ class StrikeWallet(Wallet): continue logger.warning( f"Payment '{checking_id}' not a valid Strike payment. " - f"Marked as failed. Response: {r_payment.text}" + "Keeping pending because it may be the invoice payment " + f"hash fallback. Response: {r_payment.text}" ) - self.pending_payments.pop(checking_id, None) - return PaymentFailedStatus() + return PaymentPendingStatus() except Exception as e: logger.warning(e) return PaymentPendingStatus() if r_payment.status_code == 404: + if len(checking_id) == 64: + try: + bytes.fromhex(checking_id) + logger.warning( + f"Payment '{checking_id}' not found, but the identifier may " + "be a legacy invoice payment hash. Keeping pending." + ) + return PaymentPendingStatus() + except ValueError as exc: + logger.warning( + f"Payment identifier '{checking_id}' is not valid hex: {exc}" + ) logger.warning(f"Payment {checking_id} not found. Marking as failed.") self.pending_payments.pop(checking_id, None) return PaymentFailedStatus() diff --git a/lnbits/wallets/zbd.py b/lnbits/wallets/zbd.py index 1377e0db8..f6747abb3 100644 --- a/lnbits/wallets/zbd.py +++ b/lnbits/wallets/zbd.py @@ -3,7 +3,6 @@ import hashlib from collections.abc import AsyncGenerator import httpx -from bolt11 import decode as bolt11_decode from loguru import logger from lnbits.helpers import normalize_endpoint @@ -16,6 +15,7 @@ from .base import ( PaymentStatus, StatusResponse, Wallet, + payment_request_was_rejected, ) @@ -105,30 +105,62 @@ class ZBDWallet(Wallet): async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: # https://api.zebedee.io/v0/payments - r = await self.client.post( - "payments", - json={ - "invoice": bolt11, - "description": "", - "amount": "", - "internalId": "", - "callbackUrl": "", - }, - timeout=40, - ) + try: + r = await self.client.post( + "payments", + json={ + "invoice": bolt11, + "description": "", + "amount": "", + "internalId": "", + "callbackUrl": "", + }, + timeout=40, + ) + except Exception as exc: + logger.warning(exc) + return PaymentResponse(error_message="Unable to query ZBD.") if r.is_error: - error_message = r.json()["message"] - return PaymentResponse(ok=False, error_message=error_message) + try: + error_message = r.json().get("message", r.text) + except Exception: + error_message = r.text + return PaymentResponse( + ok=False if payment_request_was_rejected(r.status_code) else None, + error_message=error_message, + ) - data = r.json() - - checking_id = bolt11_decode(bolt11).payment_hash - fee_msat = -int(data["data"]["fee"]) - preimage = data["data"]["preimage"] + try: + data = r.json()["data"] + checking_id = data.get("id") + fee = data.get("fee") + fee_msat = -int(fee) if fee is not None else None + preimage = data.get("preimage") + status = str(data.get("status", "")).lower() + except Exception as exc: + logger.warning(exc) + return PaymentResponse(error_message="Invalid ZBD payment response.") + if status == "completed": + return PaymentResponse( + ok=True, + checking_id=checking_id, + fee_msat=fee_msat, + preimage=preimage, + ) + if status in {"failed", "expired"}: + return PaymentResponse( + ok=False, + checking_id=checking_id, + fee_msat=fee_msat, + error_message=data.get("errorMessage"), + ) return PaymentResponse( - ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage + ok=None, + checking_id=checking_id, + fee_msat=fee_msat, + error_message=data.get("errorMessage"), ) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: @@ -147,11 +179,20 @@ class ZBDWallet(Wallet): return PaymentStatus(paid=statuses[data.get("status")]) async def get_payment_status(self, checking_id: str) -> PaymentStatus: - r = await self.client.get(f"payments/{checking_id}") + try: + r = await self.client.get(f"payments/{checking_id}") + except Exception as exc: + logger.warning(exc) + return PaymentPendingStatus() + if r.is_error: return PaymentPendingStatus() - data = r.json()["data"] + try: + data = r.json()["data"] + except Exception as exc: + logger.warning(exc) + return PaymentPendingStatus() statuses = { "initial": None, @@ -161,8 +202,7 @@ class ZBDWallet(Wallet): "expired": False, "failed": False, } - - return PaymentStatus(paid=statuses[data.get("status")]) + return PaymentStatus(paid=statuses.get(data.get("status"))) async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: self.queue: asyncio.Queue = asyncio.Queue(0) diff --git a/tests/unit/test_pay_invoice.py b/tests/unit/test_pay_invoice.py index eaa481daf..5fdcfdf73 100644 --- a/tests/unit/test_pay_invoice.py +++ b/tests/unit/test_pay_invoice.py @@ -287,6 +287,10 @@ async def test_pay_failed( "lnbits.wallets.FakeWallet.pay_invoice", AsyncMock(return_value=payment_reponse_failed), ) + mocker.patch( + "lnbits.core.services.payments.get_funding_source", + return_value=external_funding_source, + ) external_invoice = await external_funding_source.create_invoice(2101) assert external_invoice.payment_request @@ -375,25 +379,33 @@ async def test_retry_failed_invoice( @pytest.mark.anyio +@pytest.mark.parametrize("returns_checking_id", [True, False]) async def test_pay_external_invoice_pending( from_wallet: Wallet, mocker: MockerFixture, external_funding_source: FakeWallet, settings: Settings, + returns_checking_id: bool, ): settings.lnbits_reserve_fee_min = 1000 # msats invoice_amount = 2103 external_invoice = await external_funding_source.create_invoice(invoice_amount) assert external_invoice.payment_request assert external_invoice.checking_id - - payment_reponse_pending = PaymentResponse( - ok=None, checking_id=external_invoice.checking_id + backend_checking_id = ( + f"backend_{external_invoice.checking_id}" if returns_checking_id else None ) + expected_checking_id = backend_checking_id or external_invoice.checking_id + + payment_reponse_pending = PaymentResponse(ok=None, checking_id=backend_checking_id) mocker.patch( "lnbits.wallets.FakeWallet.pay_invoice", AsyncMock(return_value=payment_reponse_pending), ) + mocker.patch( + "lnbits.core.services.payments.get_funding_source", + return_value=external_funding_source, + ) ws_notification = mocker.patch( "lnbits.core.services.payments.send_payment_notification_in_background", AsyncMock(return_value=None), @@ -409,7 +421,9 @@ async def test_pay_external_invoice_pending( _payment = await get_standalone_payment(payment.payment_hash) assert _payment assert _payment.status == PaymentState.PENDING.value - assert _payment.checking_id == payment.payment_hash + assert _payment.checking_id == expected_checking_id + assert _payment.payment_hash == external_invoice.checking_id + assert payment.checking_id == expected_checking_id assert _payment.amount == -2103_000 assert _payment.bolt11 == external_invoice.payment_request @@ -565,36 +579,43 @@ async def test_retry_pay_success( @pytest.mark.anyio -async def test_pay_external_invoice_success_bad_checking_id( +async def test_pay_external_invoice_success_with_backend_checking_id( from_wallet: Wallet, mocker: MockerFixture, external_funding_source: FakeWallet ): invoice_amount = 2108 external_invoice = await external_funding_source.create_invoice(invoice_amount) assert external_invoice.payment_request assert external_invoice.checking_id - bad_checking_id = f"bad_{external_invoice.checking_id}" + backend_checking_id = f"backend_{external_invoice.checking_id}" preimage = "0000000000000000000000000000000000000000000000000000000000002108" payment_reponse_success = PaymentResponse( - ok=True, checking_id=bad_checking_id, preimage=preimage + ok=True, checking_id=backend_checking_id, preimage=preimage ) mocker.patch( "lnbits.wallets.FakeWallet.pay_invoice", AsyncMock(return_value=payment_reponse_success), ) + mocker.patch( + "lnbits.core.services.payments.get_funding_source", + return_value=external_funding_source, + ) - with pytest.raises(PaymentError): - await pay_invoice( - wallet_id=from_wallet.id, - payment_request=external_invoice.payment_request, - ) + payment = await pay_invoice( + wallet_id=from_wallet.id, + payment_request=external_invoice.payment_request, + ) - payment = await get_standalone_payment(bad_checking_id) - assert payment is None, "Payment should not be created with bad checking_id" + stored_payment = await get_standalone_payment(external_invoice.checking_id) + assert stored_payment + assert stored_payment.status == PaymentState.SUCCESS.value + assert stored_payment.checking_id == backend_checking_id + assert stored_payment.payment_hash == external_invoice.checking_id + assert payment.checking_id == backend_checking_id @pytest.mark.anyio -async def test_no_checking_id( +async def test_pay_external_invoice_success_without_checking_id( from_wallet: Wallet, mocker: MockerFixture, external_funding_source: FakeWallet ): invoice_amount = 2110 @@ -603,29 +624,33 @@ async def test_no_checking_id( assert external_invoice.checking_id preimage = "0000000000000000000000000000000000000000000000000000000000002110" - payment_reponse_pending = PaymentResponse( + payment_response_success = PaymentResponse( ok=True, checking_id=None, preimage=preimage ) mocker.patch( "lnbits.wallets.FakeWallet.pay_invoice", - AsyncMock(return_value=payment_reponse_pending), + AsyncMock(return_value=payment_response_success), + ) + mocker.patch( + "lnbits.core.services.payments.get_funding_source", + return_value=external_funding_source, ) - with pytest.raises(PaymentError): - await pay_invoice( - wallet_id=from_wallet.id, - payment_request=external_invoice.payment_request, - ) + returned_payment = await pay_invoice( + wallet_id=from_wallet.id, + payment_request=external_invoice.payment_request, + ) payment = await get_standalone_payment(external_invoice.checking_id) assert payment - assert payment.status == PaymentState.FAILED.value + assert payment.status == PaymentState.SUCCESS.value assert payment.checking_id == external_invoice.checking_id assert payment.payment_hash == external_invoice.checking_id assert payment.amount == -2110_000 - assert payment.preimage is None + assert payment.preimage == preimage + assert returned_payment.checking_id == external_invoice.checking_id @pytest.mark.anyio diff --git a/tests/unit/test_services_payments.py b/tests/unit/test_services_payments.py index e2e3e2d49..d2bb8d293 100644 --- a/tests/unit/test_services_payments.py +++ b/tests/unit/test_services_payments.py @@ -43,10 +43,38 @@ from lnbits.wallets.base import ( InvoiceResponse, PaymentFailedStatus, PaymentPendingStatus, + PaymentResponse, + PaymentStatus, PaymentSuccessStatus, ) +@pytest.mark.parametrize( + ("value", "expected"), + [ + (True, (True, False, False)), + (None, (False, True, False)), + (False, (False, False, True)), + ], +) +def test_payment_response_states_are_mutually_exclusive(value, expected): + response = PaymentResponse(ok=value) + assert (response.success, response.pending, response.failed) == expected + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (True, (True, False, False)), + (None, (False, True, False)), + (False, (False, True, True)), + ], +) +def test_payment_status_properties(value, expected): + status = PaymentStatus(paid=value) + assert (status.success, status.pending, status.failed) == expected + + @pytest.mark.anyio async def test_create_payment_request_routes_by_invoice_type(mocker: MockerFixture): wallet_payment = SimpleNamespace(checking_id="wallet") diff --git a/tests/unit/test_wallet_payment_ambiguity.py b/tests/unit/test_wallet_payment_ambiguity.py new file mode 100644 index 000000000..d4c4bc5e9 --- /dev/null +++ b/tests/unit/test_wallet_payment_ambiguity.py @@ -0,0 +1,890 @@ +from types import SimpleNamespace +from typing import Any, cast + +import grpc +import httpx +import pytest +from pyln.client import RpcError +from pytest_mock.plugin import MockerFixture + +import lnbits.wallets.breez as breez_wallet_module +import lnbits.wallets.breez_liquid as breez_liquid_wallet_module +from lnbits.wallets.alby import AlbyWallet +from lnbits.wallets.base import PaymentPendingStatus +from lnbits.wallets.blink import BlinkWallet +from lnbits.wallets.boltz import BoltzWallet +from lnbits.wallets.boltz_grpc_files import boltzrpc_pb2 +from lnbits.wallets.corelightning import CoreLightningWallet +from lnbits.wallets.eclair import EclairWallet +from lnbits.wallets.lnd_grpc_files.lightning_pb2 import Payment as LndPayment +from lnbits.wallets.lndgrpc import LndWallet +from lnbits.wallets.lndrest import LndRestWallet +from lnbits.wallets.lnpay import LNPayWallet +from lnbits.wallets.lntips import LnTipsWallet +from lnbits.wallets.nwc import NWCError, NWCWallet +from lnbits.wallets.opennode import OpenNodeWallet +from lnbits.wallets.phoenixd import PhoenixdWallet +from lnbits.wallets.spark import SparkWallet +from lnbits.wallets.sparkl2 import SparkL2Wallet +from lnbits.wallets.strike import StrikeWallet +from lnbits.wallets.zbd import ZBDWallet + + +def _response(status_code: int, **kwargs) -> httpx.Response: + request = httpx.Request("POST", "https://wallet.test/pay") + return httpx.Response(status_code, request=request, **kwargs) + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("status_code", "expected"), + [ + (400, None), + (401, False), + (403, False), + (404, False), + (405, False), + (408, None), + (409, None), + (422, None), + (429, None), + (500, None), + ], +) +async def test_alby_only_treats_definite_http_rejection_as_failed( + mocker: MockerFixture, status_code: int, expected: bool | None +): + wallet = object.__new__(AlbyWallet) + wallet.endpoint = "https://wallet.test" + cast(Any, wallet).client = SimpleNamespace( + post=mocker.AsyncMock( + return_value=_response(status_code, json={"message": "error"}) + ) + ) + + response = await wallet.pay_invoice("bolt11", 1_000) + + assert response.ok is expected + + +@pytest.mark.anyio +async def test_blink_keeps_unconfirmed_payment_pending(mocker: MockerFixture): + wallet = object.__new__(BlinkWallet) + wallet._wallet_id = "wallet-id" + wallet.endpoint = "https://wallet.test" + mocker.patch( + "lnbits.wallets.blink.bolt11_lib.decode", + return_value=SimpleNamespace(payment_hash="payment-hash"), + ) + mocker.patch.object( + wallet, + "_graphql_query", + return_value={"data": {"lnInvoicePaymentSend": {"errors": []}}}, + ) + mocker.patch.object( + wallet, "get_payment_status", return_value=PaymentPendingStatus() + ) + + response = await wallet.pay_invoice("bolt11", 1_000) + + assert response.ok is None + assert response.checking_id == "payment-hash" + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("status_code", "expected"), + [ + (400, False), + (401, False), + (403, False), + (404, False), + (405, False), + (408, None), + (409, None), + (422, None), + (429, None), + (500, None), + ], +) +async def test_eclair_only_treats_request_rejections_as_failed( + mocker: MockerFixture, status_code: int, expected: bool | None +): + wallet = object.__new__(EclairWallet) + wallet.url = "https://wallet.test" + cast(Any, wallet).client = SimpleNamespace( + post=mocker.AsyncMock( + return_value=_response(status_code, json={"error": "invoice has expired"}) + ) + ) + + response = await wallet.pay_invoice("bolt11", 1_000) + + assert response.ok is expected + assert response.error_message == "invoice has expired" + + +@pytest.mark.anyio +async def test_lndrest_unknown_payment_state_is_pending( + mocker: MockerFixture, settings +): + settings.lnd_rest_allow_self_payment = False + wallet = object.__new__(LndRestWallet) + cast(Any, wallet).client = SimpleNamespace( + post=mocker.AsyncMock( + return_value=_response( + 200, + json={ + "result": { + "status": "FUTURE_STATUS", + "payment_hash": "payment-hash", + "payment_preimage": "", + "fee_msat": "0", + } + }, + ) + ) + ) + + response = await wallet.pay_invoice("bolt11", 1_000) + + assert response.ok is None + assert response.checking_id == "payment-hash" + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("error", "expected"), + [ + ( + { + "code": 2, + "message": "invoice not for current active network 'regtest'", + }, + False, + ), + ({"code": 2, "message": "invoice expired"}, False), + ({"code": 3, "message": "invalid payment request"}, False), + ({"code": 2, "message": "payment stream interrupted"}, None), + ({"code": 14, "message": "transport unavailable"}, None), + ], +) +async def test_lndrest_only_pre_dispatch_rpc_errors_are_failed( + mocker: MockerFixture, + settings, + error: dict, + expected: bool | None, +): + settings.lnd_rest_allow_self_payment = False + wallet = object.__new__(LndRestWallet) + wallet.endpoint = "https://wallet.test" + cast(Any, wallet).client = SimpleNamespace( + post=mocker.AsyncMock( + return_value=_response(500, json={"error": error}), + ) + ) + + response = await wallet.pay_invoice("bolt11", 1_000) + + assert response.ok is expected + assert response.error_message == error["message"] + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("code", "details", "expected"), + [ + ( + grpc.StatusCode.UNKNOWN, + "invoice not for current active network 'regtest'", + False, + ), + (grpc.StatusCode.UNKNOWN, "invoice expired", False), + (grpc.StatusCode.INVALID_ARGUMENT, "invalid payment request", False), + (grpc.StatusCode.PERMISSION_DENIED, "permission denied", False), + (grpc.StatusCode.UNAUTHENTICATED, "invalid macaroon", False), + (grpc.StatusCode.UNAVAILABLE, "transport is closing", None), + (grpc.StatusCode.DEADLINE_EXCEEDED, "deadline exceeded", None), + (grpc.StatusCode.ALREADY_EXISTS, "payment is in flight", None), + (grpc.StatusCode.UNKNOWN, "payment stream interrupted", None), + ], +) +async def test_lndgrpc_only_pre_dispatch_rpc_errors_are_failed( + mocker: MockerFixture, + settings, + code: grpc.StatusCode, + details: str, + expected: bool | None, +): + settings.lnd_grpc_allow_self_payment = False + metadata = grpc.aio.Metadata() + error = grpc.aio.AioRpcError(code, metadata, metadata, details=details) + wallet = object.__new__(LndWallet) + cast(Any, wallet).router_rpc = SimpleNamespace( + SendPaymentV2=mocker.Mock( + return_value=SimpleNamespace(read=mocker.AsyncMock(side_effect=error)) + ) + ) + + response = await wallet.pay_invoice("bolt11", 1_000) + + assert response.ok is expected + + +@pytest.mark.anyio +async def test_lndgrpc_in_flight_payment_is_pending(mocker: MockerFixture, settings): + settings.lnd_grpc_allow_self_payment = False + wallet = object.__new__(LndWallet) + cast(Any, wallet).router_rpc = SimpleNamespace( + SendPaymentV2=mocker.Mock( + return_value=SimpleNamespace( + read=mocker.AsyncMock( + return_value=SimpleNamespace( + status=LndPayment.PaymentStatus.IN_FLIGHT, + payment_hash="payment-hash", + ) + ) + ) + ) + ) + + response = await wallet.pay_invoice("bolt11", 1_000) + + assert response.ok is None + assert response.checking_id == "payment-hash" + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("error", "expected"), + [ + ( + { + "code": 0, + "message": "destination is not reachable", + "attempts": [{"status": "failed"}], + }, + False, + ), + ( + { + "code": 0, + "message": "payment is still running", + "attempts": [{"status": "pending"}], + }, + None, + ), + ({"code": 0, "message": "unclassified RPC error"}, None), + ({"code": 205, "message": "unable to find a route"}, False), + ], +) +async def test_corelightning_only_terminal_rpc_errors_are_failed( + mocker: MockerFixture, + error: dict, + expected: bool | None, +): + wallet = object.__new__(CoreLightningWallet) + wallet.pay = "pay" + wallet.pay_failure_error_codes = [-32602, 201, 203, 205, 206, 207, 210] + cast(Any, wallet).ln = SimpleNamespace( + call=mocker.Mock(side_effect=RpcError("pay", {}, cast(Any, error))) + ) + mocker.patch( + "lnbits.wallets.corelightning.bolt11_decode", + return_value=SimpleNamespace( + payment_hash="payment-hash", + amount_msat=1_000, + description="", + ), + ) + mocker.patch.object( + wallet, "get_payment_status", return_value=PaymentPendingStatus() + ) + + response = await wallet.pay_invoice("bolt11", 1_000) + + assert response.ok is expected + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("status_code", "expected"), + [ + (400, None), + (401, False), + (403, False), + (404, False), + (405, False), + (408, None), + (409, None), + (422, None), + (429, None), + (500, None), + ], +) +async def test_lnpay_only_treats_client_rejection_as_failed( + mocker: MockerFixture, status_code: int, expected: bool | None +): + wallet = object.__new__(LNPayWallet) + wallet.wallet_key = "wallet-key" + cast(Any, wallet).client = SimpleNamespace( + post=mocker.AsyncMock( + return_value=_response(status_code, json={"message": "error"}) + ) + ) + + response = await wallet.pay_invoice("bolt11", 1_000) + + assert response.ok is expected + + +@pytest.mark.anyio +async def test_lnpay_malformed_payment_response_is_pending(mocker: MockerFixture): + wallet = object.__new__(LNPayWallet) + wallet.wallet_key = "wallet-key" + cast(Any, wallet).client = SimpleNamespace( + post=mocker.AsyncMock(return_value=_response(200, content=b"not-json")) + ) + + response = await wallet.pay_invoice("bolt11", 1_000) + + assert response.ok is None + + +@pytest.mark.anyio +@pytest.mark.parametrize("wallet_class", [LnTipsWallet, OpenNodeWallet, ZBDWallet]) +@pytest.mark.parametrize( + ("status_code", "expected"), + [(400, None), (401, False), (422, None)], +) +async def test_http_wallets_only_fail_definite_request_rejections( + mocker: MockerFixture, + wallet_class: type[LnTipsWallet | OpenNodeWallet | ZBDWallet], + status_code: int, + expected: bool | None, +): + wallet = object.__new__(wallet_class) + wallet.endpoint = "https://wallet.test" + cast(Any, wallet).client = SimpleNamespace( + post=mocker.AsyncMock( + return_value=_response(status_code, json={"message": "error"}) + ) + ) + + response = await wallet.pay_invoice("bolt11", 1_000) + + assert response.ok is expected + + +@pytest.mark.anyio +async def test_breez_immediate_failed_state_is_failed(mocker: MockerFixture, settings): + settings.breez_use_trampoline = False + breez_wallet = cast(Any, breez_wallet_module) + wallet = object.__new__(breez_wallet.BreezSdkWallet) + mocker.patch( + "lnbits.wallets.breez.bolt11_decode", + return_value=SimpleNamespace(payment_hash="payment-hash"), + ) + cast(Any, wallet).sdk_services = SimpleNamespace( + send_payment=mocker.Mock( + return_value=SimpleNamespace( + payment=SimpleNamespace(status=breez_wallet.BreezPaymentStatus.FAILED) + ) + ) + ) + + response = await cast(Any, wallet).pay_invoice("bolt11", 1_000) + + assert response.ok is False + assert response.checking_id == "payment-hash" + + +@pytest.mark.anyio +async def test_breez_liquid_timed_out_outgoing_payment_is_failed( + mocker: MockerFixture, +): + breez_liquid_wallet = cast(Any, breez_liquid_wallet_module) + wallet = object.__new__(breez_liquid_wallet.BreezLiquidSdkWallet) + cast(Any, wallet).sdk_services = SimpleNamespace( + get_payment=mocker.Mock( + return_value=SimpleNamespace( + payment_type=breez_liquid_wallet.PaymentType.SEND, + status=breez_liquid_wallet.PaymentState.TIMED_OUT, + ) + ) + ) + + status = await cast(Any, wallet).get_payment_status("payment-hash") + + assert status.paid is False + + +@pytest.mark.anyio +async def test_breez_liquid_prepare_error_is_failed(mocker: MockerFixture): + breez_liquid_wallet = cast(Any, breez_liquid_wallet_module) + wallet = object.__new__(breez_liquid_wallet.BreezLiquidSdkWallet) + mocker.patch( + "lnbits.wallets.breez_liquid.bolt11_decode", + return_value=SimpleNamespace(payment_hash="payment-hash"), + ) + cast(Any, wallet).sdk_services = SimpleNamespace( + prepare_send_payment=mocker.Mock(side_effect=RuntimeError("cannot prepare")) + ) + + response = await cast(Any, wallet).pay_invoice("bolt11", 1_000) + + assert response.ok is False + assert response.checking_id == "payment-hash" + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("code", "expected"), + [("PAYMENT_FAILED", False), ("INTERNAL", None), ("OTHER", None)], +) +async def test_nwc_only_explicit_payment_failure_is_failed( + mocker: MockerFixture, code: str, expected: bool | None +): + wallet = object.__new__(NWCWallet) + cast(Any, wallet).conn = SimpleNamespace( + call=mocker.AsyncMock(side_effect=NWCError(code, "error")) + ) + + response = await wallet.pay_invoice("bolt11", 1_000) + + assert response.ok is expected + + +@pytest.mark.anyio +async def test_phoenix_request_error_is_pending(mocker: MockerFixture): + wallet = object.__new__(PhoenixdWallet) + wallet.endpoint = "https://wallet.test" + request = httpx.Request("POST", "https://wallet.test/payinvoice") + cast(Any, wallet).client = SimpleNamespace( + post=mocker.AsyncMock( + side_effect=httpx.ReadError("read failed", request=request) + ) + ) + + response = await wallet.pay_invoice("bolt11", 1_000) + + assert response.ok is None + + +@pytest.mark.anyio +async def test_spark_sidecar_missing_checking_id_is_pending(mocker: MockerFixture): + wallet = object.__new__(SparkL2Wallet) + mocker.patch.object(wallet, "_request", return_value={"status": "PENDING"}) + + response = await wallet.pay_invoice("not-a-bolt11", 1_000) + + assert response.ok is None + assert response.checking_id is None + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("provider_status", "expected"), + [("unpaid", None), ("expired", False), ("paid", True)], +) +async def test_spark_invoice_uses_exact_terminal_status( + mocker: MockerFixture, + provider_status: str, + expected: bool | None, +): + wallet = object.__new__(SparkWallet) + mocker.patch.object( + wallet, + "listinvoices", + return_value={"invoices": [{"status": provider_status}]}, + ) + + status = await wallet.get_invoice_status("invoice-id") + + assert status.paid is expected + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("state", "expected"), + [ + (boltzrpc_pb2.SwapState.ERROR, False), + (999, None), + ], +) +async def test_boltz_only_known_terminal_swap_state_is_failed( + mocker: MockerFixture, state: int, expected: bool | None +): + wallet = object.__new__(BoltzWallet) + wallet.metadata = None + cast(Any, wallet).rpc = SimpleNamespace( + GetSwapInfo=mocker.AsyncMock( + return_value=SimpleNamespace(swap=SimpleNamespace(state=state)) + ) + ) + + status = await wallet.get_payment_status("00" * 32) + + assert status.paid is expected + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("code", "details", "expected"), + [ + ( + grpc.StatusCode.INVALID_ARGUMENT, + "invalid invoice or lnurl: invalid HRP", + False, + ), + ( + grpc.StatusCode.UNKNOWN, + "boltz error: could not find route to pay invoice", + False, + ), + (grpc.StatusCode.UNKNOWN, "payment response interrupted", None), + (grpc.StatusCode.ALREADY_EXISTS, "swap already exists", None), + ], +) +async def test_boltz_only_pre_dispatch_create_swap_errors_are_failed( + mocker: MockerFixture, + code: grpc.StatusCode, + details: str, + expected: bool | None, +): + metadata = grpc.aio.Metadata() + error = grpc.aio.AioRpcError(code, metadata, metadata, details=details) + wallet = object.__new__(BoltzWallet) + wallet.metadata = None + wallet.wallet_id = 1 + cast(Any, wallet).rpc = SimpleNamespace( + GetPairInfo=mocker.AsyncMock( + return_value=SimpleNamespace( + fees=SimpleNamespace(percentage=0, miner_fees=0) + ) + ), + CreateSwap=mocker.AsyncMock(side_effect=error), + ) + mocker.patch( + "lnbits.wallets.boltz.decode", + return_value=SimpleNamespace( + amount_msat=1_000, + payment_hash="payment-hash", + ), + ) + + response = await wallet.pay_invoice("bolt11", 1_000) + + assert response.ok is expected + assert response.checking_id == "payment-hash" + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("state", "expected"), + [ + (boltzrpc_pb2.ERROR, False), + (boltzrpc_pb2.PENDING, None), + (boltzrpc_pb2.SUCCESSFUL, True), + ], +) +async def test_boltz_resolves_ambiguous_create_swap_error_from_backend_state( + mocker: MockerFixture, + state: int, + expected: bool | None, +): + metadata = grpc.aio.Metadata() + error = grpc.aio.AioRpcError( + grpc.StatusCode.UNKNOWN, + metadata, + metadata, + details='sendrawtransaction RPC error: {"message":"txn-mempool-conflict"}', + ) + payment_hash = "00" * 32 + wallet = object.__new__(BoltzWallet) + wallet.metadata = None + wallet.wallet_id = 1 + cast(Any, wallet).rpc = SimpleNamespace( + GetPairInfo=mocker.AsyncMock( + return_value=SimpleNamespace( + fees=SimpleNamespace(percentage=0, miner_fees=0) + ) + ), + CreateSwap=mocker.AsyncMock(side_effect=error), + GetSwapInfo=mocker.AsyncMock( + return_value=SimpleNamespace( + swap=SimpleNamespace( + state=state, + service_fee=1, + onchain_fee=2, + status="swap status", + preimage="preimage", + ) + ) + ), + ) + mocker.patch( + "lnbits.wallets.boltz.decode", + return_value=SimpleNamespace( + amount_msat=1_000, + payment_hash=payment_hash, + ), + ) + + response = await wallet.pay_invoice("bolt11", 1_000) + + assert response.ok is expected + assert response.checking_id == payment_hash + assert response.fee_msat == (3_000 if expected is True else None) + assert response.preimage == ("preimage" if expected is True else None) + + +@pytest.mark.anyio +async def test_boltz_error_text_without_terminal_state_is_pending( + mocker: MockerFixture, +): + async def swap_updates(): + yield SimpleNamespace( + swap=SimpleNamespace(state=999, error="unrecognized transient error") + ) + + wallet = object.__new__(BoltzWallet) + wallet.metadata = None + wallet.wallet_id = 1 + cast(Any, wallet).rpc = SimpleNamespace( + GetPairInfo=mocker.AsyncMock( + return_value=SimpleNamespace( + fees=SimpleNamespace(percentage=0, miner_fees=0) + ) + ), + CreateSwap=mocker.AsyncMock(return_value=SimpleNamespace(id="swap-id")), + GetSwapInfoStream=mocker.Mock(return_value=swap_updates()), + ) + mocker.patch( + "lnbits.wallets.boltz.decode", + return_value=SimpleNamespace( + amount_msat=1_000, + payment_hash="payment-hash", + ), + ) + + response = await wallet.pay_invoice("bolt11", 1_000) + + assert response.ok is None + + +@pytest.mark.anyio +async def test_opennode_terminal_error_status_is_failed(mocker: MockerFixture): + wallet = object.__new__(OpenNodeWallet) + cast(Any, wallet).client = SimpleNamespace( + get=mocker.AsyncMock( + return_value=_response( + 200, + json={"data": {"status": "error", "fee": 1}}, + ) + ) + ) + + status = await wallet.get_payment_status("withdrawal-id") + + assert status.paid is False + + +@pytest.mark.anyio +async def test_opennode_terminal_status_does_not_require_provider_id( + mocker: MockerFixture, +): + wallet = object.__new__(OpenNodeWallet) + cast(Any, wallet).client = SimpleNamespace( + post=mocker.AsyncMock( + return_value=_response( + 200, + json={"data": {"status": "failed"}}, + ) + ) + ) + + response = await wallet.pay_invoice("bolt11", 1_000) + + assert response.ok is False + assert response.checking_id is None + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("provider_status", "expected"), + [("processing", None), ("completed", True), ("failed", False)], +) +async def test_zbd_preserves_provider_id_and_exact_status( + mocker: MockerFixture, + provider_status: str, + expected: bool | None, +): + wallet = object.__new__(ZBDWallet) + cast(Any, wallet).client = SimpleNamespace( + post=mocker.AsyncMock( + return_value=_response( + 200, + json={ + "data": { + "id": "zbd-payment-id", + "status": provider_status, + "fee": "10", + "preimage": "preimage", + } + }, + ) + ) + ) + + response = await wallet.pay_invoice("bolt11", 1_000) + + assert response.ok is expected + assert response.checking_id == "zbd-payment-id" + + +@pytest.mark.anyio +async def test_zbd_terminal_status_does_not_require_provider_id( + mocker: MockerFixture, +): + wallet = object.__new__(ZBDWallet) + cast(Any, wallet).client = SimpleNamespace( + post=mocker.AsyncMock( + return_value=_response( + 200, + json={"data": {"status": "failed"}}, + ) + ) + ) + + response = await wallet.pay_invoice("bolt11", 1_000) + + assert response.ok is False + assert response.checking_id is None + + +@pytest.mark.anyio +async def test_strike_invalid_fallback_identifier_is_pending(mocker: MockerFixture): + wallet = object.__new__(StrikeWallet) + wallet.pending_payments = {} + cast(Any, wallet)._get = mocker.AsyncMock( + return_value=_response( + 400, + json={ + "data": { + "code": "INVALID_DATA", + "validationErrors": { + "paymentId": [ + { + "code": "INVALID_DATA", + "message": "paymentId is not valid.", + } + ] + }, + } + }, + ) + ) + + status = await wallet._get_payment_status_by_checking_id("payment-hash") + + assert status.paid is None + + +@pytest.mark.anyio +async def test_strike_ambiguous_execution_uses_payment_hash_fallback( + mocker: MockerFixture, +): + wallet = object.__new__(StrikeWallet) + wallet.pending_payments = {} + mocker.patch( + "lnbits.wallets.strike.bolt11_decode", + return_value=SimpleNamespace(payment_hash="payment-hash"), + ) + mocker.patch.object( + wallet, + "_create_payment_quote", + return_value=("quote-id", None), + ) + mocker.patch.object( + wallet, + "_execute_payment_quote", + return_value=(None, "request timed out"), + ) + + response = await wallet.pay_invoice("bolt11", 1_000) + + assert response.ok is None + assert response.checking_id is None + + +@pytest.mark.anyio +async def test_strike_terminal_state_does_not_require_payment_id( + mocker: MockerFixture, +): + wallet = object.__new__(StrikeWallet) + wallet.pending_payments = {} + mocker.patch( + "lnbits.wallets.strike.bolt11_decode", + return_value=SimpleNamespace(payment_hash="payment-hash"), + ) + mocker.patch.object( + wallet, + "_create_payment_quote", + return_value=("quote-id", None), + ) + mocker.patch.object( + wallet, + "_execute_payment_quote", + return_value=({"state": "FAILED"}, None), + ) + + response = await wallet.pay_invoice("bolt11", 1_000) + + assert response.ok is False + assert response.checking_id == "payment-hash" + + +@pytest.mark.anyio +@pytest.mark.parametrize("state", ["CANCELED", "TIMED_OUT", "UNKNOWN"]) +async def test_strike_undocumented_payment_state_is_pending( + mocker: MockerFixture, state: str +): + wallet = object.__new__(StrikeWallet) + wallet.pending_payments = {} + mocker.patch( + "lnbits.wallets.strike.bolt11_decode", + return_value=SimpleNamespace(payment_hash="payment-hash"), + ) + mocker.patch.object( + wallet, + "_create_payment_quote", + return_value=("quote-id", None), + ) + mocker.patch.object( + wallet, + "_execute_payment_quote", + return_value=({"state": state, "paymentId": "payment-id"}, None), + ) + + response = await wallet.pay_invoice("bolt11", 1_000) + + assert response.ok is None + assert response.checking_id == "payment-id" + + +@pytest.mark.anyio +async def test_strike_persisted_payment_hash_not_found_stays_pending( + mocker: MockerFixture, +): + wallet = object.__new__(StrikeWallet) + wallet.pending_payments = {} + cast(Any, wallet)._get = mocker.AsyncMock( + return_value=_response(404, text="Not Found") + ) + + payment_hash = "ab" * 32 + status = await wallet.get_payment_status(payment_hash) + + assert status.paid is None + cast(Any, wallet)._get.assert_awaited_once_with(f"/payments/{payment_hash}") diff --git a/tests/wallets/fixtures/json/fixtures_rest.json b/tests/wallets/fixtures/json/fixtures_rest.json index bd49a4628..54a1e0f43 100644 --- a/tests/wallets/fixtures/json/fixtures_rest.json +++ b/tests/wallets/fixtures/json/fixtures_rest.json @@ -38,7 +38,9 @@ "wallet_class": "LNbitsWallet", "settings": { "lnbits_endpoint": "http://127.0.0.1:8555", + "lnbits_key": null, "lnbits_admin_key": "f171ba022a764e679eef950b21fb1c04", + "lnbits_invoice_key": null, "user_agent": "LNbits/Tests" } }, @@ -1831,6 +1833,26 @@ "fee_msat": null, "preimage": null }, + "expect_by_funding_source": { + "alby": { + "error_message": "Not Found", + "success": false, + "pending": false, + "failed": true, + "checking_id": null, + "fee_msat": null, + "preimage": null + }, + "eclair": { + "error_message": "Unable to connect to http://127.0.0.1:8555.", + "success": false, + "pending": false, + "failed": true, + "checking_id": null, + "fee_msat": null, + "preimage": null + } + }, "mocks": { "corelightningrest": { "pay_invoice_endpoint": [ diff --git a/tests/wallets/fixtures/models.py b/tests/wallets/fixtures/models.py index 7f5ecf5b1..023a2bb3c 100644 --- a/tests/wallets/fixtures/models.py +++ b/tests/wallets/fixtures/models.py @@ -46,6 +46,7 @@ class FunctionTest(BaseModel): description: str call_params: dict expect: dict + expect_by_funding_source: dict[str, dict] = {} mocks: dict[str, list[dict[str, TestMock]]] @@ -76,11 +77,20 @@ class WalletTest(BaseModel): fn, test, ) -> list["WalletTest"]: + test_data = { + key: value + for key, value in test.items() + if key != "expect_by_funding_source" + } + expect_by_funding_source = test.get("expect_by_funding_source", {}) + if fs.name in expect_by_funding_source: + test_data["expect"] = expect_by_funding_source[fs.name] + t = WalletTest( **{ "funding_source": fs, "function": fn_name, - **test, + **test_data, "mocks": [], "skip": fs.skip, } diff --git a/tests/wallets/test_bark.py b/tests/wallets/test_bark.py index 186514add..38a2d4f4c 100644 --- a/tests/wallets/test_bark.py +++ b/tests/wallets/test_bark.py @@ -246,8 +246,18 @@ async def test_send_payment_keeps_transport_errors_pending( @pytest.mark.anyio @pytest.mark.parametrize( ("status_code", "expected_ok"), - [(400, False), (500, None)], - ids=["client-error", "server-error"], + [ + (400, None), + (401, False), + (403, False), + (404, False), + (405, False), + (408, None), + (409, None), + (422, None), + (429, None), + (500, None), + ], ) async def test_send_payment_maps_http_errors( bark_wallet: BarkWallet,