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 12:49:50 +03:00 committed by GitHub
parent b9e62bfceb
commit eae5002b69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 335 additions and 220 deletions

View File

@ -27,19 +27,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Docker Buildx - name: docker build
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }} if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
uses: docker/setup-buildx-action@v3 run: |
docker build -t lnbits/lnbits .
- name: Build and push
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
uses: docker/build-push-action@v5
with:
context: .
push: false
tags: lnbits/lnbits:latest
cache-from: type=registry,ref=lnbits/lnbits:latest
cache-to: type=inline
- name: Setup Regtest - name: Setup Regtest
run: | run: |
@ -89,3 +80,8 @@ jobs:
file: ./coverage.xml file: ./coverage.xml
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
verbose: true verbose: true
- name: docker lnbits logs
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
run: |
docker logs lnbits-lnbits-1

View File

@ -63,11 +63,15 @@ from .models import Payment, UserConfig, Wallet
class PaymentError(Exception): class PaymentError(Exception):
pass def __init__(self, message: str, status: str = "pending"):
self.message = message
self.status = status
class InvoiceError(Exception): class InvoiceError(Exception):
pass def __init__(self, message: str, status: str = "pending"):
self.message = message
self.status = status
async def calculate_fiat_amounts( async def calculate_fiat_amounts(
@ -123,11 +127,11 @@ async def create_invoice(
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Tuple[str, str]: ) -> Tuple[str, str]:
if not amount > 0: if not amount > 0:
raise InvoiceError("Amountless invoices not supported.") raise InvoiceError("Amountless invoices not supported.", status="failed")
user_wallet = await get_wallet(wallet_id, conn=conn) user_wallet = await get_wallet(wallet_id, conn=conn)
if not user_wallet: if not user_wallet:
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.") raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
invoice_memo = None if description_hash else memo invoice_memo = None if description_hash else memo
@ -143,7 +147,8 @@ async def create_invoice(
): ):
raise InvoiceError( raise InvoiceError(
f"Wallet balance cannot exceed " f"Wallet balance cannot exceed "
f"{settings.lnbits_wallet_limit_max_balance} sats." f"{settings.lnbits_wallet_limit_max_balance} sats.",
status="failed",
) )
( (
@ -159,7 +164,9 @@ async def create_invoice(
expiry=expiry or settings.lightning_invoice_expiry, expiry=expiry or settings.lightning_invoice_expiry,
) )
if not ok or not payment_request or not checking_id: if not ok or not payment_request or not checking_id:
raise InvoiceError(error_message or "unexpected backend error.") raise InvoiceError(
error_message or "unexpected backend error.", status="pending"
)
invoice = bolt11_decode(payment_request) invoice = bolt11_decode(payment_request)
@ -202,12 +209,12 @@ async def pay_invoice(
try: try:
invoice = bolt11_decode(payment_request) invoice = bolt11_decode(payment_request)
except Exception as exc: except Exception as exc:
raise InvoiceError("Bolt11 decoding failed.") from exc raise PaymentError("Bolt11 decoding failed.", status="failed") from exc
if not invoice.amount_msat or not invoice.amount_msat > 0: if not invoice.amount_msat or not invoice.amount_msat > 0:
raise InvoiceError("Amountless invoices not supported.") raise PaymentError("Amountless invoices not supported.", status="failed")
if max_sat and invoice.amount_msat > max_sat * 1000: if max_sat and invoice.amount_msat > max_sat * 1000:
raise InvoiceError("Amount in invoice is too high.") raise PaymentError("Amount in invoice is too high.", status="failed")
await check_wallet_limits(wallet_id, conn, invoice.amount_msat) await check_wallet_limits(wallet_id, conn, invoice.amount_msat)
@ -242,7 +249,7 @@ async def pay_invoice(
# we check if an internal invoice exists that has already been paid # we check if an internal invoice exists that has already been paid
# (not pending anymore) # (not pending anymore)
if not await check_internal_pending(invoice.payment_hash, conn=conn): if not await check_internal_pending(invoice.payment_hash, conn=conn):
raise PaymentError("Internal invoice already paid.") raise PaymentError("Internal invoice already paid.", status="failed")
# check_internal() returns the checking_id of the invoice we're waiting for # check_internal() returns the checking_id of the invoice we're waiting for
# (pending only) # (pending only)
@ -261,7 +268,7 @@ async def pay_invoice(
internal_invoice.amount != invoice.amount_msat internal_invoice.amount != invoice.amount_msat
or internal_invoice.bolt11 != payment_request.lower() or internal_invoice.bolt11 != payment_request.lower()
): ):
raise PaymentError("Invalid invoice.") raise PaymentError("Invalid invoice.", status="failed")
logger.debug(f"creating temporary internal payment with id {internal_id}") logger.debug(f"creating temporary internal payment with id {internal_id}")
# create a new payment from this wallet # create a new payment from this wallet
@ -289,7 +296,7 @@ async def pay_invoice(
except Exception as exc: except Exception as exc:
logger.error(f"could not create temporary payment: {exc}") logger.error(f"could not create temporary payment: {exc}")
# happens if the same wallet tries to pay an invoice twice # happens if the same wallet tries to pay an invoice twice
raise PaymentError("Could not make payment.") from exc raise PaymentError("Could not make payment.", status="failed") from exc
# do the balance check # do the balance check
wallet = await get_wallet(wallet_id, conn=conn) wallet = await get_wallet(wallet_id, conn=conn)
@ -302,9 +309,10 @@ async def pay_invoice(
): ):
raise PaymentError( raise PaymentError(
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}" f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
" sat) to cover potential routing fees." " sat) to cover potential routing fees.",
status="failed",
) )
raise PermissionError("Insufficient balance.") raise PaymentError("Insufficient balance.", status="failed")
if internal_checking_id: if internal_checking_id:
service_fee_msat = service_fee(invoice.amount_msat, internal=True) service_fee_msat = service_fee(invoice.amount_msat, internal=True)
@ -340,6 +348,7 @@ async def pay_invoice(
) )
logger.debug(f"backend: pay_invoice finished {temp_id}") logger.debug(f"backend: pay_invoice finished {temp_id}")
logger.debug(f"backend: pay_invoice response {payment}")
if payment.checking_id and payment.ok is not False: if payment.checking_id and payment.ok is not False:
# payment.ok can be True (paid) or None (pending)! # payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {temp_id}") logger.debug(f"updating payment {temp_id}")
@ -370,7 +379,8 @@ async def pay_invoice(
await delete_wallet_payment(temp_id, wallet_id, conn=conn) await delete_wallet_payment(temp_id, wallet_id, conn=conn)
raise PaymentError( raise PaymentError(
f"Payment failed: {payment.error_message}" f"Payment failed: {payment.error_message}"
or "Payment failed, but backend didn't give us an error message." or "Payment failed, but backend didn't give us an error message.",
status="failed",
) )
else: else:
logger.warning( logger.warning(
@ -413,8 +423,9 @@ async def check_time_limit_between_transactions(conn, wallet_id):
if len(payments) == 0: if len(payments) == 0:
return return
raise ValueError( raise PaymentError(
f"The time limit of {limit} seconds between payments has been reached." status="failed",
message=f"The time limit of {limit} seconds between payments has been reached.",
) )

View File

@ -171,7 +171,10 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
assert payment_db is not None, "payment not found" assert payment_db is not None, "payment not found"
checking_id = payment_db.checking_id checking_id = payment_db.checking_id
except InvoiceError as exc: except InvoiceError as exc:
raise HTTPException(status_code=520, detail=str(exc)) from exc return JSONResponse(
status_code=520,
content={"detail": exc.message, "status": exc.status},
)
except Exception as exc: except Exception as exc:
raise exc raise exc
@ -217,14 +220,11 @@ async def api_payments_pay_invoice(
payment_hash = await pay_invoice( payment_hash = await pay_invoice(
wallet_id=wallet.id, payment_request=bolt11, extra=extra wallet_id=wallet.id, payment_request=bolt11, extra=extra
) )
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
except PermissionError as exc:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(exc)) from exc
except PaymentError as exc: except PaymentError as exc:
raise HTTPException(status_code=520, detail=str(exc)) from exc return JSONResponse(
status_code=520,
content={"detail": exc.message, "status": exc.status},
)
except Exception as exc: except Exception as exc:
raise exc raise exc
@ -434,7 +434,7 @@ async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
return {"paid": True, "preimage": payment.preimage} return {"paid": True, "preimage": payment.preimage}
try: try:
await payment.check_status() status = await payment.check_status()
except Exception: except Exception:
if wallet and wallet.id == payment.wallet_id: if wallet and wallet.id == payment.wallet_id:
return {"paid": False, "details": payment} return {"paid": False, "details": payment}
@ -443,6 +443,7 @@ async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
if wallet and wallet.id == payment.wallet_id: if wallet and wallet.id == payment.wallet_id:
return { return {
"paid": not payment.pending, "paid": not payment.pending,
"status": f"{status!s}",
"preimage": payment.preimage, "preimage": payment.preimage,
"details": payment, "details": payment,
} }

View File

@ -129,7 +129,7 @@ class AlbyWallet(Wallet):
if r.is_error: if r.is_error:
error_message = data["message"] if "message" in data else r.text 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"] checking_id = data["payment_hash"]
# todo: confirm with bitkarrot that having the minus is fine # todo: confirm with bitkarrot that having the minus is fine
@ -141,18 +141,18 @@ class AlbyWallet(Wallet):
except KeyError as exc: except KeyError as exc:
logger.warning(exc) logger.warning(exc)
return PaymentResponse( 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: except json.JSONDecodeError as exc:
logger.warning(exc) logger.warning(exc)
return PaymentResponse( return PaymentResponse(
False, None, None, None, "Server error: 'invalid json response'" None, None, None, None, "Server error: 'invalid json response'"
) )
except Exception as exc: except Exception as exc:
logger.info(f"Failed to pay invoice {bolt11}") logger.info(f"Failed to pay invoice {bolt11}")
logger.warning(exc) logger.warning(exc)
return PaymentResponse( 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: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
@ -167,6 +167,7 @@ class AlbyWallet(Wallet):
data = r.json() data = r.json()
# TODO: how can we detect a failed payment?
statuses = { statuses = {
"CREATED": None, "CREATED": None,
"SETTLED": True, "SETTLED": True,

View File

@ -70,14 +70,11 @@ class PaymentStatus(NamedTuple):
return self.paid is False return self.paid is False
def __str__(self) -> str: def __str__(self) -> str:
if self.paid is True: if self.success:
return "settled" return "success"
elif self.paid is False: if self.failed:
return "failed" return "failed"
elif self.paid is None: return "pending"
return "still pending"
else:
return "unknown (should never happen)"
class PaymentSuccessStatus(PaymentStatus): class PaymentSuccessStatus(PaymentStatus):

View File

@ -46,6 +46,15 @@ class CoreLightningWallet(Wallet):
command = self.ln.help("invoice")["help"][0]["command"] # type: ignore command = self.ln.help("invoice")["help"][0]["command"] # type: ignore
self.supports_description_hash = "deschashonly" in command 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 # check last payindex so we can listen from that point on
self.last_pay_index = 0 self.last_pay_index = 0
invoices: dict = self.ln.listinvoices() # type: ignore invoices: dict = self.ln.listinvoices() # type: ignore
@ -155,19 +164,27 @@ class CoreLightningWallet(Wallet):
except RpcError as exc: except RpcError as exc:
logger.warning(exc) logger.warning(exc)
try: 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: except Exception:
error_message = f"RPC '{exc.method}' failed with '{exc.error}'." 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: except KeyError as exc:
logger.warning(exc) logger.warning(exc)
return PaymentResponse( return PaymentResponse(
False, None, None, None, "Server error: 'missing required fields'" None, None, None, None, "Server error: 'missing required fields'"
) )
except Exception as exc: except Exception as exc:
logger.info(f"Failed to pay invoice {bolt11}") logger.info(f"Failed to pay invoice {bolt11}")
logger.warning(exc) 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: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try: try:

View File

@ -49,6 +49,15 @@ class CoreLightningRestWallet(Wallet):
"User-Agent": settings.user_agent, "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.cert = settings.corelightning_rest_cert or False
self.client = httpx.AsyncClient(verify=self.cert, headers=headers) self.client = httpx.AsyncClient(verify=self.cert, headers=headers)
self.last_pay_index = 0 self.last_pay_index = 0
@ -176,37 +185,48 @@ class CoreLightningRestWallet(Wallet):
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
if "error" in data: status = self.statuses.get(data["status"])
return PaymentResponse(False, None, None, None, data["error"]) if "payment_preimage" not in data:
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
):
return PaymentResponse( return PaymentResponse(
False, None, None, None, "Server error: 'missing required fields'" status,
None,
None,
None,
data.get("error"),
) )
checking_id = data["payment_hash"] checking_id = data["payment_hash"]
preimage = data["payment_preimage"] preimage = data["payment_preimage"]
fee_msat = data["msatoshi_sent"] - data["msatoshi"] fee_msat = data["msatoshi_sent"] - data["msatoshi"]
return PaymentResponse( return PaymentResponse(status, checking_id, fee_msat, preimage, None)
self.statuses.get(data["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: except json.JSONDecodeError:
return PaymentResponse( 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: except Exception as exc:
logger.info(f"Failed to pay invoice {bolt11}") logger.info(f"Failed to pay invoice {bolt11}")
logger.warning(exc) logger.warning(exc)
return PaymentResponse( 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: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:

View File

@ -142,9 +142,9 @@ class EclairWallet(Wallet):
data = r.json() data = r.json()
if "error" in data: if "error" in data:
return PaymentResponse(False, None, None, None, data["error"]) return PaymentResponse(None, None, None, None, data["error"])
if r.is_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": if data["type"] == "payment-failed":
return PaymentResponse(False, None, None, None, "payment failed") return PaymentResponse(False, None, None, None, "payment failed")
@ -154,17 +154,17 @@ class EclairWallet(Wallet):
except json.JSONDecodeError: except json.JSONDecodeError:
return PaymentResponse( return PaymentResponse(
False, None, None, None, "Server error: 'invalid json response'" None, None, None, None, "Server error: 'invalid json response'"
) )
except KeyError: except KeyError:
return PaymentResponse( return PaymentResponse(
False, None, None, None, "Server error: 'missing required fields'" None, None, None, None, "Server error: 'missing required fields'"
) )
except Exception as exc: except Exception as exc:
logger.info(f"Failed to pay invoice {bolt11}") logger.info(f"Failed to pay invoice {bolt11}")
logger.warning(exc) logger.warning(exc)
return PaymentResponse( 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) payment_status: PaymentStatus = await self.get_payment_status(checking_id)

View File

@ -9,6 +9,7 @@ from lnbits.settings import settings
from .base import ( from .base import (
InvoiceResponse, InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus, PaymentPendingStatus,
PaymentResponse, PaymentResponse,
PaymentStatus, PaymentStatus,
@ -115,13 +116,10 @@ class LNbitsWallet(Wallet):
json={"out": True, "bolt11": bolt11}, json={"out": True, "bolt11": bolt11},
timeout=None, timeout=None,
) )
r.raise_for_status() r.raise_for_status()
data = r.json() 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"] checking_id = data["payment_hash"]
# we do this to get the fee and preimage # we do this to get the fee and preimage
@ -131,19 +129,32 @@ class LNbitsWallet(Wallet):
return PaymentResponse( return PaymentResponse(
success, checking_id, payment.fee_msat, payment.preimage 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: except json.JSONDecodeError:
return PaymentResponse( return PaymentResponse(
False, None, None, None, "Server error: 'invalid json response'" None, None, None, None, "Server error: 'invalid json response'"
) )
except KeyError: except KeyError:
return PaymentResponse( return PaymentResponse(
False, None, None, None, "Server error: 'missing required fields'" None, None, None, None, "Server error: 'missing required fields'"
) )
except Exception as exc: except Exception as exc:
logger.info(f"Failed to pay invoice {bolt11}") logger.info(f"Failed to pay invoice {bolt11}")
logger.warning(exc) logger.warning(exc)
return PaymentResponse( 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: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
@ -169,6 +180,9 @@ class LNbitsWallet(Wallet):
return PaymentPendingStatus() return PaymentPendingStatus()
data = r.json() data = r.json()
if data.get("status") == "failed":
return PaymentFailedStatus()
if "paid" not in data or not data["paid"]: if "paid" not in data or not data["paid"]:
return PaymentPendingStatus() return PaymentPendingStatus()

View File

@ -167,7 +167,7 @@ class LndWallet(Wallet):
resp = await self.routerpc.SendPaymentV2(req).read() resp = await self.routerpc.SendPaymentV2(req).read()
except Exception as exc: except Exception as exc:
logger.warning(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 # PaymentStatus from https://github.com/lightningnetwork/lnd/blob/master/channeldb/payments.go#L178
statuses = { statuses = {

View File

@ -174,39 +174,30 @@ class LndRestWallet(Wallet):
timeout=None, timeout=None,
) )
r.raise_for_status() 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() data = r.json()
if data.get("payment_error"): payment_error = data.get("payment_error")
error_message = r.json().get("payment_error") or r.text if payment_error:
logger.warning( logger.warning(f"LndRestWallet payment_error: {payment_error}.")
f"LndRestWallet pay_invoice payment_error: {error_message}." return PaymentResponse(False, None, None, None, payment_error)
)
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'"
)
checking_id = base64.b64decode(data["payment_hash"]).hex() checking_id = base64.b64decode(data["payment_hash"]).hex()
fee_msat = int(data["payment_route"]["total_fees_msat"]) fee_msat = int(data["payment_route"]["total_fees_msat"])
preimage = base64.b64decode(data["payment_preimage"]).hex() preimage = base64.b64decode(data["payment_preimage"]).hex()
return PaymentResponse(True, checking_id, fee_msat, preimage, None) 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: except json.JSONDecodeError:
return PaymentResponse( 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: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:

View File

@ -144,11 +144,11 @@ class PhoenixdWallet(Wallet):
data = r.json() data = r.json()
if "routingFeeSat" not in data and "reason" in data: 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: if r.is_error or "paymentHash" not in data:
error_message = data["message"] if "message" in data else r.text 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"] checking_id = data["paymentHash"]
fee_msat = -int(data["routingFeeSat"]) fee_msat = -int(data["routingFeeSat"])
@ -158,17 +158,17 @@ class PhoenixdWallet(Wallet):
except json.JSONDecodeError: except json.JSONDecodeError:
return PaymentResponse( return PaymentResponse(
False, None, None, None, "Server error: 'invalid json response'" None, None, None, None, "Server error: 'invalid json response'"
) )
except KeyError: except KeyError:
return PaymentResponse( return PaymentResponse(
False, None, None, None, "Server error: 'missing required fields'" None, None, None, None, "Server error: 'missing required fields'"
) )
except Exception as exc: except Exception as exc:
logger.info(f"Failed to pay invoice {bolt11}") logger.info(f"Failed to pay invoice {bolt11}")
logger.warning(exc) logger.warning(exc)
return PaymentResponse( 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: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
@ -189,6 +189,7 @@ class PhoenixdWallet(Wallet):
return PaymentPendingStatus() return PaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> PaymentStatus: async def get_payment_status(self, checking_id: str) -> PaymentStatus:
# TODO: how can we detect a failed payment?
try: try:
r = await self.client.get(f"/payments/outgoing/{checking_id}") r = await self.client.get(f"/payments/outgoing/{checking_id}")
if r.is_error: if r.is_error:

View File

@ -1310,6 +1310,58 @@
} }
} }
}, },
{
"description": "failed",
"call_params": {
"bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
"fee_limit_msat": 25000
},
"expect": {
"success": false,
"pending": false,
"failed": true,
"checking_id": null,
"fee_msat": null,
"preimage": null
},
"mocks": {
"corelightningrest": {
"pay_invoice_endpoint": [
{
"request_type": "data",
"request_body": {
"invoice": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
"maxfeepercent": "119.04761905",
"exemptfee": 0
},
"response_type": "json",
"response": {
"status": "failed"
}
}
]
},
"lndrest": {
"pay_invoice_endpoint": [
{
"request_type": "json",
"request_body": {
"payment_request": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
"fee_limit": 25000
},
"response_type": "json",
"response": {
"payment_error": "Test Error"
}
}
]
},
"alby": {},
"eclair": [],
"lnbits": [],
"phoenixd": []
}
},
{ {
"description": "pending, no fee", "description": "pending, no fee",
"call_params": { "call_params": {
@ -1462,8 +1514,8 @@
}, },
"expect": { "expect": {
"success": false, "success": false,
"pending": false, "pending": true,
"failed": true, "failed": false,
"checking_id": null, "checking_id": null,
"fee_msat": null, "fee_msat": null,
"preimage": null, "preimage": null,
@ -1481,25 +1533,14 @@
}, },
"response_type": "json", "response_type": "json",
"response": { "response": {
"status": "pending",
"error": "Test Error" "error": "Test Error"
} }
} }
] ]
}, },
"lndrest": { "lndrest": {
"pay_invoice_endpoint": [ "pay_invoice_endpoint": []
{
"request_type": "json",
"request_body": {
"payment_request": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
"fee_limit": 25000
},
"response_type": "json",
"response": {
"payment_error": "Test Error"
}
}
]
}, },
"alby": { "alby": {
"pay_invoice_endpoint": [] "pay_invoice_endpoint": []
@ -1530,31 +1571,7 @@
] ]
}, },
"lnbits": { "lnbits": {
"pay_invoice_endpoint": [ "pay_invoice_endpoint": []
{
"request_type": "json",
"request_body": {
"out": true,
"blt11": "lnbc5550n1pnq9jg3sp52rvwstvjcypjsaenzdh0h30jazvzsf8aaye0julprtth9kysxtuspp5e5s3z7felv4t9zrcc6wpn7ehvjl5yzewanzl5crljdl3jgeffyhqdq2f38xy6t5wvxqzjccqpjrzjq0yzeq76ney45hmjlnlpvu0nakzy2g35hqh0dujq8ujdpr2e42pf2rrs6vqpgcsqqqqqqqqqqqqqqeqqyg9qxpqysgqwftcx89k5pp28435pgxfl2vx3ksemzxccppw2j9yjn0ngr6ed7wj8ztc0d5kmt2mvzdlcgrludhz7jncd5l5l9w820hc4clpwhtqj3gq62g66n"
},
"response_type": "json",
"response": {
"detail": "Test Error"
}
}
],
"get_payment_status_endpoint": [
{
"response_type": "json",
"response": {
"paid": true,
"preimage": "0000000000000000000000000000000000000000000000000000000000000000",
"details": {
"fee": 50
}
}
}
]
}, },
"phoenixd": { "phoenixd": {
"pay_invoice_endpoint": [ "pay_invoice_endpoint": [
@ -1591,8 +1608,8 @@
"expect": { "expect": {
"error_message": "Server error: 'missing required fields'", "error_message": "Server error: 'missing required fields'",
"success": false, "success": false,
"pending": false, "pending": true,
"failed": true, "failed": false,
"checking_id": null, "checking_id": null,
"fee_msat": null, "fee_msat": null,
"preimage": null "preimage": null
@ -1688,8 +1705,8 @@
"expect": { "expect": {
"error_message": "Server error: 'invalid json response'", "error_message": "Server error: 'invalid json response'",
"success": false, "success": false,
"pending": false, "pending": true,
"failed": true, "failed": false,
"checking_id": null, "checking_id": null,
"fee_msat": null, "fee_msat": null,
"preimage": null "preimage": null
@ -1806,8 +1823,8 @@
"expect": { "expect": {
"error_message": "Unable to connect to http://127.0.0.1:8555.", "error_message": "Unable to connect to http://127.0.0.1:8555.",
"success": false, "success": false,
"pending": false, "pending": true,
"failed": true, "failed": false,
"checking_id": null, "checking_id": null,
"fee_msat": null, "fee_msat": null,
"preimage": null "preimage": null
@ -1936,8 +1953,8 @@
"expect": { "expect": {
"error_message": "Unable to connect to http://127.0.0.1:8555.", "error_message": "Unable to connect to http://127.0.0.1:8555.",
"success": false, "success": false,
"pending": false, "pending": true,
"failed": true, "failed": false,
"checking_id": null, "checking_id": null,
"fee_msat": null, "fee_msat": null,
"preimage": null "preimage": null
@ -2643,7 +2660,17 @@
] ]
}, },
"lnbits": { "lnbits": {
"get_payment_status_endpoint": [] "get_payment_status_endpoint": [
{
"response_type": "json",
"response": {
"paid": false,
"status": "failed",
"preimage": "0000000000000000000000000000000000000000000000000000000000000000",
"details": {}
}
}
]
}, },
"phoenixd": { "phoenixd": {
"description": "phoenixd.py doesn't handle the 'failed' status for `get_invoice_status`", "description": "phoenixd.py doesn't handle the 'failed' status for `get_invoice_status`",

View File

@ -818,7 +818,7 @@
} }
}, },
{ {
"description": "error", "description": "failed",
"call_params": { "call_params": {
"bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", "bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
"fee_limit_msat": 25000 "fee_limit_msat": 25000
@ -826,31 +826,17 @@
"expect": { "expect": {
"__eval__:error_message": "\"Payment failed: \" in \"{error_message}\"", "__eval__:error_message": "\"Payment failed: \" in \"{error_message}\"",
"success": false, "success": false,
"pending": false,
"failed": true,
"checking_id": null, "checking_id": null,
"fee_msat": null, "fee_msat": null,
"preimage": null "preimage": null
}, },
"mocks": { "mocks": {
"breez": { "breez": {},
"sdk_services": [
{
"response_type": "data",
"response": {
"send_payment": {
"request_type": "function",
"response_type": "exception",
"response": {
"data": "test-error"
}
}
}
}
]
},
"corelightning": { "corelightning": {
"ln": [ "ln": [
{ {
"description": "test-error",
"response": { "response": {
"call": { "call": {
"description": "indirect call to `pay` (via `call`)", "description": "indirect call to `pay` (via `call`)",
@ -867,7 +853,20 @@
}, },
"response_type": "exception", "response_type": "exception",
"response": { "response": {
"data": "test-error" "module": "pyln.client.lightning",
"class": "RpcError",
"data": {
"method": "test_method",
"payload": "y",
"error": {
"code": 205,
"attempts": [
{
"fail_reason": "some reason"
}
]
}
}
} }
} }
} }
@ -994,7 +993,77 @@
} }
} }
} }
}
]
}
}
}, },
{
"description": "error",
"call_params": {
"bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
"fee_limit_msat": 25000
},
"expect": {
"__eval__:error_message": "\"Payment failed: \" in \"{error_message}\"",
"success": false,
"pending": true,
"failed": false,
"checking_id": null,
"fee_msat": null,
"preimage": null
},
"mocks": {
"breez": {
"sdk_services": [
{
"response_type": "data",
"response": {
"send_payment": {
"request_type": "function",
"response_type": "exception",
"response": {
"data": "test-error"
}
}
}
}
]
},
"corelightning": {
"ln": [
{
"description": "test-error",
"response": {
"call": {
"description": "indirect call to `pay` (via `call`)",
"request_type": "function",
"request_data": {
"args": [
"pay",
{
"bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
"description": "Unit Test Invoice",
"maxfee": 25000
}
]
},
"response_type": "exception",
"response": {
"data": "test-error"
}
}
}
}
]
},
"lndrpc": {
"rpc": [
{
"response": {}
}
],
"routerpc": [
{ {
"description": "RPC error.", "description": "RPC error.",
"response": { "response": {
@ -1024,11 +1093,13 @@
"fee_limit_msat": 25000 "fee_limit_msat": 25000
}, },
"expect": { "expect": {
"error_message": "Server error: 'missing required fields'",
"success": false, "success": false,
"pending": true,
"failed": false,
"checking_id": null, "checking_id": null,
"fee_msat": null, "fee_msat": null,
"preimage": null, "preimage": null
"error_message": "Server error: 'missing required fields'"
}, },
"mocks": { "mocks": {
"breez": { "breez": {
@ -1071,11 +1142,13 @@
"fee_limit_msat": 25000 "fee_limit_msat": 25000
}, },
"expect": { "expect": {
"error_message": "RPC 'test_method' failed with 'test-error'.",
"success": false, "success": false,
"pending": true,
"failed": false,
"checking_id": null, "checking_id": null,
"fee_msat": null, "fee_msat": null,
"preimage": null, "preimage": null
"error_message": "RPC 'test_method' failed with 'test-error'."
}, },
"mocks": { "mocks": {
"breez": { "breez": {
@ -1083,40 +1156,6 @@
}, },
"corelightning": { "corelightning": {
"ln": [ "ln": [
{
"response": {
"call": {
"description": "indirect call to `pay` (via `call`)",
"request_type": "function",
"request_data": {
"args": [
"pay",
{
"bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu",
"description": "Unit Test Invoice",
"maxfee": 25000
}
]
},
"response_type": "exception",
"response": {
"module": "pyln.client.lightning",
"class": "RpcError",
"data": {
"method": "test_method",
"payload": "y",
"error": {
"attempts": [
{
"fail_reason": "RPC 'test_method' failed with 'test-error'."
}
]
}
}
}
}
}
},
{ {
"response": { "response": {
"call": { "call": {