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:
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user