better false states

This commit is contained in:
Arc 2026-07-29 18:10:21 +01:00
parent 0a398425cb
commit 13feee8ea4
4 changed files with 220 additions and 1 deletions

View file

@ -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(),
)

View file

@ -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}"

View file

@ -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'"

View file

@ -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,