mirror of
https://github.com/lnbits/lnbits.git
synced 2026-08-18 13:18:00 +02:00
better false states
This commit is contained in:
parent
0a398425cb
commit
13feee8ea4
4 changed files with 220 additions and 1 deletions
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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'"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue