diff --git a/lnbits/core/services/payments.py b/lnbits/core/services/payments.py index 8d8f8811c..2b1677f35 100644 --- a/lnbits/core/services/payments.py +++ b/lnbits/core/services/payments.py @@ -851,7 +851,7 @@ async def _pay_external_invoice( # IMPORTANT PAYMENT RULES! # True -> success # False-> failed - # None -> pending (any additional ambigous payments MUST be set as pending) + # None -> pending (any ambigous payment responses MUST be set as pending) # payment failed if payment_response.failed: diff --git a/lnbits/wallets/alby.py b/lnbits/wallets/alby.py index bc7822c0b..122ad4cd4 100644 --- a/lnbits/wallets/alby.py +++ b/lnbits/wallets/alby.py @@ -185,7 +185,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/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..35898255b 100644 --- a/lnbits/wallets/boltz.py +++ b/lnbits/wallets/boltz.py @@ -166,7 +166,7 @@ class BoltzWallet(Wallet): return PaymentResponse(ok=True, checking_id=invoice.payment_hash) except AioRpcError as exc: logger.warning(exc) - return PaymentResponse(ok=False, error_message=exc.details()) + return PaymentResponse(error_message=exc.details()) try: info_request = boltzrpc_pb2.GetSwapInfoRequest(id=response.id) @@ -188,12 +188,10 @@ class BoltzWallet(Wallet): ) elif info.swap.error != "": return PaymentResponse(ok=False, error_message=info.swap.error) - return PaymentResponse( - ok=False, error_message="stream stopped unexpectedly" - ) + 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 get_invoice_status(self, checking_id: str) -> PaymentStatus: try: @@ -217,10 +215,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 +233,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 +245,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: 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..c9aceeb34 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, @@ -382,6 +383,8 @@ class CLNRestWallet(Wallet): if pay["status"] == "complete": fee_msat = pay["amount_sent_msat"] - pay["amount_msat"] return PaymentSuccessStatus(fee_msat=fee_msat, preimage=pay["preimage"]) + if pay["status"] == "failed": + return PaymentFailedStatus() except Exception as exc: logger.warning(f"Error getting payment status: {exc}") diff --git a/lnbits/wallets/lndrest.py b/lnbits/wallets/lndrest.py index 0848a7ad9..19be9fe54 100644 --- a/lnbits/wallets/lndrest.py +++ b/lnbits/wallets/lndrest.py @@ -201,7 +201,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..769f6a4a4 100644 --- a/lnbits/wallets/lnpay.py +++ b/lnbits/wallets/lnpay.py @@ -105,44 +105,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 r.is_client_error 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..b06fe8822 100644 --- a/lnbits/wallets/lntips.py +++ b/lnbits/wallets/lntips.py @@ -103,26 +103,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 r.is_client_error 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..dec7b90a0 100644 --- a/lnbits/wallets/opennode.py +++ b/lnbits/wallets/opennode.py @@ -100,24 +100,29 @@ 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=None, error_message=error_message) - data = r.json()["data"] - checking_id = data["id"] - fee_msat = -data["fee"] * 1000 - # pending - if data["status"] != "paid": - return PaymentResponse(ok=None, checking_id=checking_id, fee_msat=fee_msat) - return PaymentResponse(ok=True, checking_id=checking_id, fee_msat=fee_msat) + data = r.json()["data"] + checking_id = data["id"] + fee_msat = -data["fee"] * 1000 + if data["status"] != "paid": + 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 +133,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": None, + "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..cfbed2e55 100644 --- a/lnbits/wallets/spark.py +++ b/lnbits/wallets/spark.py @@ -162,7 +162,10 @@ 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)) @@ -175,10 +178,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 +208,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: @@ -249,7 +254,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..ffeb5b63f 100644 --- a/lnbits/wallets/strike.py +++ b/lnbits/wallets/strike.py @@ -243,10 +243,14 @@ class StrikeWallet(Wallet): if error or not quote_id: return PaymentResponse(ok=False, error_message=error or "Unknown error") + # Keep the quote id before execution so an ambiguous execute response can + # still be reconciled while this process is running. + 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") @@ -276,9 +280,6 @@ class StrikeWallet(Wallet): 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) @@ -289,7 +290,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: @@ -625,7 +625,7 @@ class StrikeWallet(Wallet): if state in {"SUCCEEDED", "COMPLETED"}: self.pending_payments.pop(checking_id, None) return PaymentSuccessStatus(fee_msat=fee_msat, preimage=preimage) - if state == "FAILED": + if state in {"CANCELED", "FAILED", "TIMED_OUT"}: self.pending_payments.pop(checking_id, None) return PaymentFailedStatus() @@ -667,7 +667,7 @@ class StrikeWallet(Wallet): if state in {"SUCCEEDED", "COMPLETED"}: self.pending_payments.pop(checking_id, None) return PaymentSuccessStatus(fee_msat=fee_msat, preimage=preimage) - if state == "FAILED": + if state in {"CANCELED", "FAILED", "TIMED_OUT"}: self.pending_payments.pop(checking_id, None) return PaymentFailedStatus() @@ -693,10 +693,10 @@ 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) diff --git a/lnbits/wallets/zbd.py b/lnbits/wallets/zbd.py index 1377e0db8..a57df9d86 100644 --- a/lnbits/wallets/zbd.py +++ b/lnbits/wallets/zbd.py @@ -105,28 +105,41 @@ 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 r.is_client_error else None, + error_message=error_message, + ) - data = r.json() + try: + data = r.json() + fee_msat = -int(data["data"]["fee"]) + preimage = data["data"]["preimage"] + except Exception as exc: + logger.warning(exc) + return PaymentResponse(error_message="Invalid ZBD payment response.") checking_id = bolt11_decode(bolt11).payment_hash - fee_msat = -int(data["data"]["fee"]) - preimage = data["data"]["preimage"] - return PaymentResponse( ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage ) @@ -147,11 +160,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 +183,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 362f046e3..5fdcfdf73 100644 --- a/tests/unit/test_pay_invoice.py +++ b/tests/unit/test_pay_invoice.py @@ -379,18 +379,23 @@ 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 - backend_checking_id = f"backend_{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( @@ -416,9 +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 == backend_checking_id + assert _payment.checking_id == expected_checking_id assert _payment.payment_hash == external_invoice.checking_id - assert payment.checking_id == backend_checking_id + assert payment.checking_id == expected_checking_id assert _payment.amount == -2103_000 assert _payment.bolt11 == external_invoice.payment_request diff --git a/tests/unit/test_wallet_payment_ambiguity.py b/tests/unit/test_wallet_payment_ambiguity.py new file mode 100644 index 000000000..b8c89e933 --- /dev/null +++ b/tests/unit/test_wallet_payment_ambiguity.py @@ -0,0 +1,205 @@ +from types import SimpleNamespace +from typing import Any, cast + +import httpx +import pytest +from pytest_mock.plugin import MockerFixture + +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.lndrest import LndRestWallet +from lnbits.wallets.lnpay import LNPayWallet +from lnbits.wallets.nwc import NWCError, NWCWallet +from lnbits.wallets.phoenixd import PhoenixdWallet +from lnbits.wallets.sparkl2 import SparkL2Wallet +from lnbits.wallets.strike import StrikeWallet + + +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 +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 +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( + ("status_code", "expected"), + [(400, False), (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( + ("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( + ("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 +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