mirror of
https://github.com/lnbits/lnbits.git
synced 2026-08-13 12:42:47 +02:00
fix: harden ambiguous payments (#4110)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
This commit is contained in:
parent
04c130dca3
commit
d1a431afc8
30 changed files with 1678 additions and 269 deletions
30
Makefile
30
Makefile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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'"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -625,8 +625,6 @@ class NWCWallet(Wallet):
|
|||
"QUOTA_EXCEEDED",
|
||||
"RESTRICTED",
|
||||
"UNAUTHORIZED",
|
||||
"INTERNAL",
|
||||
"OTHER",
|
||||
"PAYMENT_FAILED",
|
||||
]
|
||||
failed = e.code in failure_codes
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
890
tests/unit/test_wallet_payment_ambiguity.py
Normal file
890
tests/unit/test_wallet_payment_ambiguity.py
Normal 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}")
|
||||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue