clean up funding source ambiguity

This commit is contained in:
Arc 2026-07-28 16:11:49 +01:00
parent 9c11b156b3
commit 290eba33fa
18 changed files with 468 additions and 176 deletions

View file

@ -851,7 +851,7 @@ async def _pay_external_invoice(
# IMPORTANT PAYMENT RULES!
# True -> success
# False-> failed
# None -> pending (any additional ambigous payments MUST be set as pending)
# None -> pending (any ambigous payment responses MUST be set as pending)
# payment failed
if payment_response.failed:

View file

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

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

@ -166,7 +166,7 @@ class BoltzWallet(Wallet):
return PaymentResponse(ok=True, checking_id=invoice.payment_hash)
except AioRpcError as exc:
logger.warning(exc)
return PaymentResponse(ok=False, error_message=exc.details())
return PaymentResponse(error_message=exc.details())
try:
info_request = boltzrpc_pb2.GetSwapInfoRequest(id=response.id)
@ -188,12 +188,10 @@ class BoltzWallet(Wallet):
)
elif info.swap.error != "":
return PaymentResponse(ok=False, error_message=info.swap.error)
return PaymentResponse(
ok=False, error_message="stream stopped unexpectedly"
)
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 get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
@ -217,10 +215,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 +233,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 +245,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:

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,
@ -382,6 +383,8 @@ class CLNRestWallet(Wallet):
if pay["status"] == "complete":
fee_msat = pay["amount_sent_msat"] - pay["amount_msat"]
return PaymentSuccessStatus(fee_msat=fee_msat, preimage=pay["preimage"])
if pay["status"] == "failed":
return PaymentFailedStatus()
except Exception as exc:
logger.warning(f"Error getting payment status: {exc}")

View file

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

@ -105,44 +105,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 r.is_client_error 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

@ -103,26 +103,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 r.is_client_error 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

@ -100,24 +100,29 @@ 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=None, error_message=error_message)
data = r.json()["data"]
checking_id = data["id"]
fee_msat = -data["fee"] * 1000
# pending
if data["status"] != "paid":
return PaymentResponse(ok=None, checking_id=checking_id, fee_msat=fee_msat)
return PaymentResponse(ok=True, checking_id=checking_id, fee_msat=fee_msat)
data = r.json()["data"]
checking_id = data["id"]
fee_msat = -data["fee"] * 1000
if data["status"] != "paid":
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 +133,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": None,
"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,7 +162,10 @@ 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))
@ -175,10 +178,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 +208,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:
@ -249,7 +254,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

@ -243,10 +243,14 @@ class StrikeWallet(Wallet):
if error or not quote_id:
return PaymentResponse(ok=False, error_message=error or "Unknown error")
# Keep the quote id before execution so an ambiguous execute response can
# still be reconciled while this process is running.
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")
@ -276,9 +280,6 @@ class StrikeWallet(Wallet):
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)
@ -289,7 +290,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:
@ -625,7 +625,7 @@ class StrikeWallet(Wallet):
if state in {"SUCCEEDED", "COMPLETED"}:
self.pending_payments.pop(checking_id, None)
return PaymentSuccessStatus(fee_msat=fee_msat, preimage=preimage)
if state == "FAILED":
if state in {"CANCELED", "FAILED", "TIMED_OUT"}:
self.pending_payments.pop(checking_id, None)
return PaymentFailedStatus()
@ -667,7 +667,7 @@ class StrikeWallet(Wallet):
if state in {"SUCCEEDED", "COMPLETED"}:
self.pending_payments.pop(checking_id, None)
return PaymentSuccessStatus(fee_msat=fee_msat, preimage=preimage)
if state == "FAILED":
if state in {"CANCELED", "FAILED", "TIMED_OUT"}:
self.pending_payments.pop(checking_id, None)
return PaymentFailedStatus()
@ -693,10 +693,10 @@ 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)

View file

@ -105,28 +105,41 @@ 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 r.is_client_error else None,
error_message=error_message,
)
data = r.json()
try:
data = r.json()
fee_msat = -int(data["data"]["fee"])
preimage = data["data"]["preimage"]
except Exception as exc:
logger.warning(exc)
return PaymentResponse(error_message="Invalid ZBD payment response.")
checking_id = bolt11_decode(bolt11).payment_hash
fee_msat = -int(data["data"]["fee"])
preimage = data["data"]["preimage"]
return PaymentResponse(
ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
)
@ -147,11 +160,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 +183,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

@ -379,18 +379,23 @@ 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
backend_checking_id = f"backend_{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(
@ -416,9 +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 == backend_checking_id
assert _payment.checking_id == expected_checking_id
assert _payment.payment_hash == external_invoice.checking_id
assert payment.checking_id == backend_checking_id
assert payment.checking_id == expected_checking_id
assert _payment.amount == -2103_000
assert _payment.bolt11 == external_invoice.payment_request

View file

@ -0,0 +1,205 @@
from types import SimpleNamespace
from typing import Any, cast
import httpx
import pytest
from pytest_mock.plugin import MockerFixture
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.lndrest import LndRestWallet
from lnbits.wallets.lnpay import LNPayWallet
from lnbits.wallets.nwc import NWCError, NWCWallet
from lnbits.wallets.phoenixd import PhoenixdWallet
from lnbits.wallets.sparkl2 import SparkL2Wallet
from lnbits.wallets.strike import StrikeWallet
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
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
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(
("status_code", "expected"),
[(400, False), (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(
("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(
("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
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