From 13feee8ea435a5facdc0600f7bcb226ee1a3d6dc Mon Sep 17 00:00:00 2001 From: Arc Date: Wed, 29 Jul 2026 18:10:21 +0100 Subject: [PATCH] better false states --- lnbits/wallets/boltz.py | 21 +++ lnbits/wallets/corelightning.py | 19 ++- lnbits/wallets/lndrest.py | 38 ++++++ tests/unit/test_wallet_payment_ambiguity.py | 143 ++++++++++++++++++++ 4 files changed, 220 insertions(+), 1 deletion(-) diff --git a/lnbits/wallets/boltz.py b/lnbits/wallets/boltz.py index 41fd36ee2..f8b04f78b 100644 --- a/lnbits/wallets/boltz.py +++ b/lnbits/wallets/boltz.py @@ -3,6 +3,7 @@ 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 @@ -23,6 +24,25 @@ from .base import ( Wallet, ) +_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 + ) + class BoltzWallet(Wallet): """ @@ -154,6 +174,7 @@ class BoltzWallet(Wallet): except AioRpcError as exc: logger.warning(exc) return PaymentResponse( + ok=False if _is_pre_dispatch_create_swap_error(exc) else None, checking_id=invoice.payment_hash, error_message=exc.details(), ) 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/lndrest.py b/lnbits/wallets/lndrest.py index 19be9fe54..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'" diff --git a/tests/unit/test_wallet_payment_ambiguity.py b/tests/unit/test_wallet_payment_ambiguity.py index 205fa1750..4dc43cad6 100644 --- a/tests/unit/test_wallet_payment_ambiguity.py +++ b/tests/unit/test_wallet_payment_ambiguity.py @@ -4,6 +4,7 @@ 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 @@ -13,6 +14,7 @@ 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 @@ -150,6 +152,44 @@ async def test_lndrest_unknown_payment_state_is_pending( 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"), @@ -214,6 +254,58 @@ async def test_lndgrpc_in_flight_payment_is_pending(mocker: MockerFixture, setti 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"), @@ -436,6 +528,57 @@ async def test_boltz_only_known_terminal_swap_state_is_failed( 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 async def test_boltz_error_text_without_terminal_state_is_pending( mocker: MockerFixture,