fix: harden ambiguous payments (#4110)

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
This commit is contained in:
Arc 2026-07-31 14:29:23 +01:00 committed by GitHub
parent 04c130dca3
commit d1a431afc8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1678 additions and 269 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

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

View file

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

View file

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

View file

@ -625,8 +625,6 @@ class NWCWallet(Wallet):
"QUOTA_EXCEEDED",
"RESTRICTED",
"UNAUTHORIZED",
"INTERNAL",
"OTHER",
"PAYMENT_FAILED",
]
failed = e.code in failure_codes

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": [

View file

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

View file

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