fix: pay invoice status (#2481)

* fix: rest `pay_invoice` pending instead of failed
* fix: rpc `pay_invoice` pending instead of failed
* fix: return "failed" value for payment
* fix: handle failed status for LNbits funding source
* chore: `phoenixd` todo
* test: fix condition
* fix: wait for payment status to be updated
* fix: fail payment when explicit status provided

---------

Co-authored-by: dni  <office@dnilabs.com>
This commit is contained in:
Vlad Stan
2024-05-10 11:49:50 +02:00
committed by GitHub
co-authored by dni ⚡
parent b9e62bfceb
commit eae5002b69
14 changed files with 335 additions and 220 deletions
+5 -4
View File
@@ -129,7 +129,7 @@ class AlbyWallet(Wallet):
if r.is_error:
error_message = data["message"] if "message" in data else r.text
return PaymentResponse(False, None, None, None, error_message)
return PaymentResponse(None, None, None, None, error_message)
checking_id = data["payment_hash"]
# todo: confirm with bitkarrot that having the minus is fine
@@ -141,18 +141,18 @@ class AlbyWallet(Wallet):
except KeyError as exc:
logger.warning(exc)
return PaymentResponse(
False, None, None, None, "Server error: 'missing required fields'"
None, None, None, None, "Server error: 'missing required fields'"
)
except json.JSONDecodeError as exc:
logger.warning(exc)
return PaymentResponse(
False, None, None, None, "Server error: 'invalid json response'"
None, None, None, None, "Server error: 'invalid json response'"
)
except Exception as exc:
logger.info(f"Failed to pay invoice {bolt11}")
logger.warning(exc)
return PaymentResponse(
False, None, None, None, f"Unable to connect to {self.endpoint}."
None, None, None, None, f"Unable to connect to {self.endpoint}."
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
@@ -167,6 +167,7 @@ class AlbyWallet(Wallet):
data = r.json()
# TODO: how can we detect a failed payment?
statuses = {
"CREATED": None,
"SETTLED": True,
+4 -7
View File
@@ -70,14 +70,11 @@ class PaymentStatus(NamedTuple):
return self.paid is False
def __str__(self) -> str:
if self.paid is True:
return "settled"
elif self.paid is False:
if self.success:
return "success"
if self.failed:
return "failed"
elif self.paid is None:
return "still pending"
else:
return "unknown (should never happen)"
return "pending"
class PaymentSuccessStatus(PaymentStatus):
+21 -4
View File
@@ -46,6 +46,15 @@ class CoreLightningWallet(Wallet):
command = self.ln.help("invoice")["help"][0]["command"] # type: ignore
self.supports_description_hash = "deschashonly" in command
# https://docs.corelightning.org/reference/lightning-pay
# 201: Already paid
# 203: Permanent failure at destination.
# 205: Unable to find a route.
# 206: Route too expensive.
# 207: Invoice expired.
# 210: Payment timed out without a payment in progress.
self.pay_failure_error_codes = [201, 203, 205, 206, 207, 210]
# check last payindex so we can listen from that point on
self.last_pay_index = 0
invoices: dict = self.ln.listinvoices() # type: ignore
@@ -155,19 +164,27 @@ class CoreLightningWallet(Wallet):
except RpcError as exc:
logger.warning(exc)
try:
error_message = exc.error["attempts"][-1]["fail_reason"] # type: ignore
error_code = exc.error.get("code")
if error_code in self.pay_failure_error_codes: # type: ignore
error_message = exc.error.get("message", error_code) # type: ignore
return PaymentResponse(
False, None, None, None, f"Payment failed: {error_message}"
)
else:
error_message = f"Payment failed: {exc.error}"
return PaymentResponse(None, None, None, None, error_message)
except Exception:
error_message = f"RPC '{exc.method}' failed with '{exc.error}'."
return PaymentResponse(False, None, None, None, error_message)
return PaymentResponse(None, None, None, None, error_message)
except KeyError as exc:
logger.warning(exc)
return PaymentResponse(
False, None, None, None, "Server error: 'missing required fields'"
None, None, None, None, "Server error: 'missing required fields'"
)
except Exception as exc:
logger.info(f"Failed to pay invoice {bolt11}")
logger.warning(exc)
return PaymentResponse(False, None, None, None, f"Payment failed: '{exc}'.")
return PaymentResponse(None, None, None, None, f"Payment failed: '{exc}'.")
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
+37 -17
View File
@@ -49,6 +49,15 @@ class CoreLightningRestWallet(Wallet):
"User-Agent": settings.user_agent,
}
# https://docs.corelightning.org/reference/lightning-pay
# 201: Already paid
# 203: Permanent failure at destination.
# 205: Unable to find a route.
# 206: Route too expensive.
# 207: Invoice expired.
# 210: Payment timed out without a payment in progress.
self.pay_failure_error_codes = [201, 203, 205, 206, 207, 210]
self.cert = settings.corelightning_rest_cert or False
self.client = httpx.AsyncClient(verify=self.cert, headers=headers)
self.last_pay_index = 0
@@ -176,37 +185,48 @@ class CoreLightningRestWallet(Wallet):
r.raise_for_status()
data = r.json()
if "error" in data:
return PaymentResponse(False, None, None, None, data["error"])
if r.is_error:
return PaymentResponse(False, None, None, None, r.text)
if (
"payment_hash" not in data
or "payment_preimage" not in data
or "msatoshi_sent" not in data
or "msatoshi" not in data
or "status" not in data
):
status = self.statuses.get(data["status"])
if "payment_preimage" not in data:
return PaymentResponse(
False, None, None, None, "Server error: 'missing required fields'"
status,
None,
None,
None,
data.get("error"),
)
checking_id = data["payment_hash"]
preimage = data["payment_preimage"]
fee_msat = data["msatoshi_sent"] - data["msatoshi"]
return PaymentResponse(
self.statuses.get(data["status"]), checking_id, fee_msat, preimage, None
)
return PaymentResponse(status, checking_id, fee_msat, preimage, None)
except httpx.HTTPStatusError as exc:
try:
logger.debug(exc)
data = exc.response.json()
if data["error"]["code"] in self.pay_failure_error_codes: # type: ignore
error_message = f"Payment failed: {data['error']['message']}"
return PaymentResponse(False, None, None, None, error_message)
error_message = f"REST failed with {data['error']['message']}."
return PaymentResponse(None, None, None, None, error_message)
except Exception as exc:
error_message = f"Unable to connect to {self.url}."
return PaymentResponse(None, None, None, None, error_message)
except json.JSONDecodeError:
return PaymentResponse(
False, None, None, None, "Server error: 'invalid json response'"
None, None, None, None, "Server error: 'invalid json response'"
)
except KeyError as exc:
logger.warning(exc)
return PaymentResponse(
None, None, None, None, "Server error: 'missing required fields'"
)
except Exception as exc:
logger.info(f"Failed to pay invoice {bolt11}")
logger.warning(exc)
return PaymentResponse(
False, None, None, None, f"Unable to connect to {self.url}."
None, None, None, None, f"Unable to connect to {self.url}."
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
+5 -5
View File
@@ -142,9 +142,9 @@ class EclairWallet(Wallet):
data = r.json()
if "error" in data:
return PaymentResponse(False, None, None, None, data["error"])
return PaymentResponse(None, None, None, None, data["error"])
if r.is_error:
return PaymentResponse(False, None, None, None, r.text)
return PaymentResponse(None, None, None, None, r.text)
if data["type"] == "payment-failed":
return PaymentResponse(False, None, None, None, "payment failed")
@@ -154,17 +154,17 @@ class EclairWallet(Wallet):
except json.JSONDecodeError:
return PaymentResponse(
False, None, None, None, "Server error: 'invalid json response'"
None, None, None, None, "Server error: 'invalid json response'"
)
except KeyError:
return PaymentResponse(
False, None, None, None, "Server error: 'missing required fields'"
None, None, None, None, "Server error: 'missing required fields'"
)
except Exception as exc:
logger.info(f"Failed to pay invoice {bolt11}")
logger.warning(exc)
return PaymentResponse(
False, None, None, None, f"Unable to connect to {self.url}."
None, None, None, None, f"Unable to connect to {self.url}."
)
payment_status: PaymentStatus = await self.get_payment_status(checking_id)
+21 -7
View File
@@ -9,6 +9,7 @@ from lnbits.settings import settings
from .base import (
InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
@@ -115,13 +116,10 @@ class LNbitsWallet(Wallet):
json={"out": True, "bolt11": bolt11},
timeout=None,
)
r.raise_for_status()
data = r.json()
if r.is_error or "payment_hash" not in data:
error_message = data["detail"] if "detail" in data else r.text
return PaymentResponse(False, None, None, None, error_message)
checking_id = data["payment_hash"]
# we do this to get the fee and preimage
@@ -131,19 +129,32 @@ class LNbitsWallet(Wallet):
return PaymentResponse(
success, checking_id, payment.fee_msat, payment.preimage
)
except httpx.HTTPStatusError as exc:
try:
logger.debug(exc)
data = exc.response.json()
error_message = f"Payment {data['status']}: {data['detail']}."
if data["status"] == "failed":
return PaymentResponse(False, None, None, None, error_message)
return PaymentResponse(None, None, None, None, error_message)
except Exception as exc:
error_message = f"Unable to connect to {self.endpoint}."
return PaymentResponse(None, None, None, None, error_message)
except json.JSONDecodeError:
return PaymentResponse(
False, None, None, None, "Server error: 'invalid json response'"
None, None, None, None, "Server error: 'invalid json response'"
)
except KeyError:
return PaymentResponse(
False, None, None, None, "Server error: 'missing required fields'"
None, None, None, None, "Server error: 'missing required fields'"
)
except Exception as exc:
logger.info(f"Failed to pay invoice {bolt11}")
logger.warning(exc)
return PaymentResponse(
False, None, None, None, f"Unable to connect to {self.endpoint}."
None, None, None, None, f"Unable to connect to {self.endpoint}."
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
@@ -169,6 +180,9 @@ class LNbitsWallet(Wallet):
return PaymentPendingStatus()
data = r.json()
if data.get("status") == "failed":
return PaymentFailedStatus()
if "paid" not in data or not data["paid"]:
return PaymentPendingStatus()
+1 -1
View File
@@ -167,7 +167,7 @@ class LndWallet(Wallet):
resp = await self.routerpc.SendPaymentV2(req).read()
except Exception as exc:
logger.warning(exc)
return PaymentResponse(False, None, None, None, str(exc))
return PaymentResponse(None, None, None, None, str(exc))
# PaymentStatus from https://github.com/lightningnetwork/lnd/blob/master/channeldb/payments.go#L178
statuses = {
+15 -24
View File
@@ -174,39 +174,30 @@ class LndRestWallet(Wallet):
timeout=None,
)
r.raise_for_status()
except Exception as exc:
logger.warning(f"LndRestWallet pay_invoice POST error: {exc}.")
return PaymentResponse(
False, None, None, None, f"Unable to connect to {self.endpoint}."
)
try:
data = r.json()
if data.get("payment_error"):
error_message = r.json().get("payment_error") or r.text
logger.warning(
f"LndRestWallet pay_invoice payment_error: {error_message}."
)
return PaymentResponse(False, None, None, None, error_message)
if (
"payment_hash" not in data
or "payment_route" not in data
or "total_fees_msat" not in data["payment_route"]
or "payment_preimage" not in data
):
return PaymentResponse(
False, None, None, None, "Server error: 'missing required fields'"
)
payment_error = data.get("payment_error")
if payment_error:
logger.warning(f"LndRestWallet payment_error: {payment_error}.")
return PaymentResponse(False, None, None, None, payment_error)
checking_id = base64.b64decode(data["payment_hash"]).hex()
fee_msat = int(data["payment_route"]["total_fees_msat"])
preimage = base64.b64decode(data["payment_preimage"]).hex()
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
except KeyError as exc:
logger.warning(exc)
return PaymentResponse(
None, None, None, None, "Server error: 'missing required fields'"
)
except json.JSONDecodeError:
return PaymentResponse(
False, None, None, None, "Server error: 'invalid json response'"
None, None, None, None, "Server error: 'invalid json response'"
)
except Exception as exc:
logger.warning(f"LndRestWallet pay_invoice POST error: {exc}.")
return PaymentResponse(
None, None, None, None, f"Unable to connect to {self.endpoint}."
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
+6 -5
View File
@@ -144,11 +144,11 @@ class PhoenixdWallet(Wallet):
data = r.json()
if "routingFeeSat" not in data and "reason" in data:
return PaymentResponse(False, None, None, None, data["reason"])
return PaymentResponse(None, None, None, None, data["reason"])
if r.is_error or "paymentHash" not in data:
error_message = data["message"] if "message" in data else r.text
return PaymentResponse(False, None, None, None, error_message)
return PaymentResponse(None, None, None, None, error_message)
checking_id = data["paymentHash"]
fee_msat = -int(data["routingFeeSat"])
@@ -158,17 +158,17 @@ class PhoenixdWallet(Wallet):
except json.JSONDecodeError:
return PaymentResponse(
False, None, None, None, "Server error: 'invalid json response'"
None, None, None, None, "Server error: 'invalid json response'"
)
except KeyError:
return PaymentResponse(
False, None, None, None, "Server error: 'missing required fields'"
None, None, None, None, "Server error: 'missing required fields'"
)
except Exception as exc:
logger.info(f"Failed to pay invoice {bolt11}")
logger.warning(exc)
return PaymentResponse(
False, None, None, None, f"Unable to connect to {self.endpoint}."
None, None, None, None, f"Unable to connect to {self.endpoint}."
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
@@ -189,6 +189,7 @@ class PhoenixdWallet(Wallet):
return PaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
# TODO: how can we detect a failed payment?
try:
r = await self.client.get(f"/payments/outgoing/{checking_id}")
if r.is_error: