diff --git a/lnbits/app.py b/lnbits/app.py index fb750eb3..f612c32c 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -122,10 +122,10 @@ def check_funding_source(app: FastAPI) -> None: f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'", RuntimeWarning, ) - logger.info("Retrying connection to backend in 5 seconds...") - await asyncio.sleep(5) except: pass + logger.info("Retrying connection to backend in 5 seconds...") + await asyncio.sleep(5) signal.signal(signal.SIGINT, original_sigint_handler) logger.info( f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat." diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index f150270a..cba41f60 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -365,6 +365,11 @@ async def create_payment( webhook: Optional[str] = None, conn: Optional[Connection] = None, ) -> Payment: + + # todo: add this when tests are fixed + # previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn) + # assert previous_payment is None, "Payment already exists" + await (conn or db).execute( """ INSERT INTO apipayments @@ -404,6 +409,40 @@ async def update_payment_status( ) +async def update_payment_details( + checking_id: str, + pending: Optional[bool] = None, + fee: Optional[int] = None, + preimage: Optional[str] = None, + new_checking_id: Optional[str] = None, + conn: Optional[Connection] = None, +) -> None: + + set_clause: List[str] = [] + set_variables: List[Any] = [] + + if new_checking_id is not None: + set_clause.append("checking_id = ?") + set_variables.append(new_checking_id) + if pending is not None: + set_clause.append("pending = ?") + set_variables.append(pending) + if fee is not None: + set_clause.append("fee = ?") + set_variables.append(fee) + if preimage is not None: + set_clause.append("preimage = ?") + set_variables.append(preimage) + + set_variables.append(checking_id) + + await (conn or db).execute( + f"UPDATE apipayments SET {', '.join(set_clause)} WHERE checking_id = ?", + tuple(set_variables), + ) + return + + async def delete_payment(checking_id: str, conn: Optional[Connection] = None) -> None: await (conn or db).execute( "DELETE FROM apipayments WHERE checking_id = ?", (checking_id,) diff --git a/lnbits/core/models.py b/lnbits/core/models.py index c019d941..4dc15bbc 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -11,6 +11,7 @@ from pydantic import BaseModel from lnbits.helpers import url_for from lnbits.settings import WALLET +from lnbits.wallets.base import PaymentStatus class Wallet(BaseModel): @@ -128,8 +129,16 @@ class Payment(BaseModel): @property def is_uncheckable(self) -> bool: - return self.checking_id.startswith("temp_") or self.checking_id.startswith( - "internal_" + return self.checking_id.startswith("internal_") + + async def update_status(self, status: PaymentStatus) -> None: + from .crud import update_payment_details + + await update_payment_details( + checking_id=self.checking_id, + pending=status.pending, + fee=status.fee_msat, + preimage=status.preimage, ) async def set_pending(self, pending: bool) -> None: @@ -137,9 +146,9 @@ class Payment(BaseModel): await update_payment_status(self.checking_id, pending) - async def check_pending(self) -> None: + async def check_status(self) -> PaymentStatus: if self.is_uncheckable: - return + return PaymentStatus(None) logger.debug( f"Checking {'outgoing' if self.is_out else 'incoming'} pending payment {self.checking_id}" @@ -153,7 +162,7 @@ class Payment(BaseModel): logger.debug(f"Status: {status}") if self.is_out and status.failed: - logger.info( + logger.warning( f"Deleting outgoing failed payment {self.checking_id}: {status}" ) await self.delete() @@ -161,7 +170,8 @@ class Payment(BaseModel): logger.info( f"Marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}" ) - await self.set_pending(status.pending) + await self.update_status(status) + return status async def delete(self) -> None: from .crud import delete_payment diff --git a/lnbits/core/services.py b/lnbits/core/services.py index 90f62186..a6e0b43a 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -31,8 +31,10 @@ from .crud import ( delete_payment, get_wallet, get_wallet_payment, + update_payment_details, update_payment_status, ) +from .models import Payment try: from typing import TypedDict # type: ignore @@ -101,11 +103,20 @@ async def pay_invoice( description: str = "", conn: Optional[Connection] = None, ) -> str: + """ + Pay a Lightning invoice. + First, we create a temporary payment in the database with fees set to the reserve fee. + We then check whether the balance of the payer would go negative. + We then attempt to pay the invoice through the backend. + If the payment is successful, we update the payment in the database with the payment details. + If the payment is unsuccessful, we delete the temporary payment. + If the payment is still in flight, we hope that some other process will regularly check for the payment. + """ invoice = bolt11.decode(payment_request) fee_reserve_msat = fee_reserve(invoice.amount_msat) async with (db.reuse_conn(conn) if conn else db.connect()) as conn: - temp_id = f"temp_{urlsafe_short_hash()}" - internal_id = f"internal_{urlsafe_short_hash()}" + temp_id = invoice.payment_hash + internal_id = f"internal_{invoice.payment_hash}" if invoice.amount_msat == 0: raise ValueError("Amountless invoices not supported.") @@ -185,30 +196,41 @@ async def pay_invoice( payment: PaymentResponse = await WALLET.pay_invoice( payment_request, fee_reserve_msat ) + + if payment.checking_id and payment.checking_id != temp_id: + logger.warning( + f"backend sent unexpected checking_id (expected: {temp_id} got: {payment.checking_id})" + ) + logger.debug(f"backend: pay_invoice finished {temp_id}") - if payment.ok and payment.checking_id: - logger.debug(f"creating final payment {payment.checking_id}") + if payment.checking_id and payment.ok != False: + # payment.ok can be True (paid) or None (pending)! + logger.debug(f"updating payment {temp_id}") async with db.connect() as conn: - await create_payment( - checking_id=payment.checking_id, + await update_payment_details( + checking_id=temp_id, + pending=payment.ok != True, fee=payment.fee_msat, preimage=payment.preimage, - pending=payment.ok == None, + new_checking_id=payment.checking_id, conn=conn, - **payment_kwargs, ) - logger.debug(f"deleting temporary payment {temp_id}") - await delete_payment(temp_id, conn=conn) - else: - logger.debug(f"backend payment failed") + logger.debug(f"payment successful {payment.checking_id}") + elif payment.checking_id is None and payment.ok == False: + # payment failed + logger.warning(f"backend sent payment failure") async with db.connect() as conn: logger.debug(f"deleting temporary payment {temp_id}") await delete_payment(temp_id, conn=conn) raise PaymentFailure( - payment.error_message - or "Payment failed, but backend didn't give us an error message." + f"payment failed: {payment.error_message}" + or "payment failed, but backend didn't give us an error message" ) - logger.debug(f"payment successful {payment.checking_id}") + else: + logger.warning( + f"didn't receive checking_id from backend, payment may be stuck in database: {temp_id}" + ) + return invoice.payment_hash @@ -344,23 +366,16 @@ async def perform_lnurlauth( async def check_transaction_status( wallet_id: str, payment_hash: str, conn: Optional[Connection] = None ) -> PaymentStatus: - payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn) + payment: Optional[Payment] = await get_wallet_payment( + wallet_id, payment_hash, conn=conn + ) if not payment: return PaymentStatus(None) - if payment.is_out: - status = await WALLET.get_payment_status(payment.checking_id) - else: - status = await WALLET.get_invoice_status(payment.checking_id) if not payment.pending: - return status - if payment.is_out and status.failed: - logger.info(f"deleting outgoing failed payment {payment.checking_id}: {status}") - await payment.delete() - elif not status.pending: - logger.info( - f"marking '{'in' if payment.is_in else 'out'}' {payment.checking_id} as not pending anymore: {status}" - ) - await payment.set_pending(status.pending) + # note: before, we still checked the status of the payment again + return PaymentStatus(True) + + status: PaymentStatus = await payment.check_status() return status diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index d55941b2..830cc16a 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -402,6 +402,10 @@ async def subscribe(request: Request, wallet: Wallet): async def api_payments_sse( request: Request, wallet: WalletTypeInfo = Depends(get_key_type) ): + if wallet is None or wallet.wallet is None: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist." + ) return EventSourceResponse( subscribe(request, wallet.wallet), ping=20, media_type="text/event-stream" ) @@ -436,7 +440,7 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)): return {"paid": True, "preimage": payment.preimage} try: - await payment.check_pending() + await payment.check_status() except Exception: if wallet and wallet.id == payment.wallet_id: return {"paid": False, "details": payment} diff --git a/lnbits/extensions/lndhub/views_api.py b/lnbits/extensions/lndhub/views_api.py index a3160fa9..91371f9a 100644 --- a/lnbits/extensions/lndhub/views_api.py +++ b/lnbits/extensions/lndhub/views_api.py @@ -130,9 +130,8 @@ async def lndhub_gettxs( offset=offset, exclude_uncheckable=True, ): - await payment.set_pending( - (await WALLET.get_payment_status(payment.checking_id)).pending - ) + await payment.check_status() + await asyncio.sleep(0.1) return [ diff --git a/lnbits/tasks.py b/lnbits/tasks.py index f4d0a928..45e59c4c 100644 --- a/lnbits/tasks.py +++ b/lnbits/tasks.py @@ -86,6 +86,9 @@ async def check_pending_payments(): incoming = True while True: + logger.debug( + f"Task: checking all pending payments (incoming={incoming}, outgoing={outgoing}) of last 15 days" + ) for payment in await get_payments( since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago complete=False, @@ -94,11 +97,14 @@ async def check_pending_payments(): incoming=incoming, exclude_uncheckable=True, ): - await payment.check_pending() - + await payment.check_status() + logger.debug("Task: pending payments check finished") # we delete expired invoices once upon the first pending check if incoming: + logger.debug("Task: deleting all expired invoices") await delete_expired_invoices() + logger.debug("Task: expired invoice deletion finished") + # after the first check we will only check outgoing, not incoming # that will be handled by the global invoice listeners, hopefully incoming = False diff --git a/lnbits/wallets/base.py b/lnbits/wallets/base.py index f35eb370..e38b6d8f 100644 --- a/lnbits/wallets/base.py +++ b/lnbits/wallets/base.py @@ -18,13 +18,15 @@ class PaymentResponse(NamedTuple): # when ok is None it means we don't know if this succeeded ok: Optional[bool] = None checking_id: Optional[str] = None # payment_hash, rcp_id - fee_msat: int = 0 + fee_msat: Optional[int] = None preimage: Optional[str] = None error_message: Optional[str] = None class PaymentStatus(NamedTuple): paid: Optional[bool] = None + fee_msat: Optional[int] = None + preimage: Optional[str] = None @property def pending(self) -> bool: diff --git a/lnbits/wallets/cliche.py b/lnbits/wallets/cliche.py index 9dbe5a22..9b862794 100644 --- a/lnbits/wallets/cliche.py +++ b/lnbits/wallets/cliche.py @@ -81,31 +81,41 @@ class ClicheWallet(Wallet): data["result"]["invoice"], ) else: - return InvoiceResponse( - False, checking_id, payment_request, "Could not get payment hash" - ) + return InvoiceResponse(False, None, None, "Could not get payment hash") return InvoiceResponse(True, checking_id, payment_request, error_message) async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: ws = create_connection(self.endpoint) ws.send(f"pay-invoice --invoice {bolt11}") - r = ws.recv() - data = json.loads(r) - checking_id = None - error_message = None + for _ in range(2): + r = ws.recv() + data = json.loads(r) + checking_id, fee_msat, preimage, error_message, payment_ok = ( + None, + None, + None, + None, + None, + ) - if data.get("error") is not None and data["error"].get("message"): - logger.error(data["error"]["message"]) - error_message = data["error"]["message"] - return PaymentResponse(False, None, 0, error_message) + if data.get("error") is not None: + error_message = data["error"].get("message") + return PaymentResponse(False, None, None, None, error_message) - if data.get("result") is not None and data["result"].get("payment_hash"): - checking_id = data["result"]["payment_hash"] - else: - return PaymentResponse(False, checking_id, 0, "Could not get payment hash") + 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 - return PaymentResponse(True, checking_id, 0, error_message) + if data.get("result") is None: + return PaymentResponse(None) + + return PaymentResponse( + payment_ok, checking_id, fee_msat, preimage, error_message + ) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: ws = create_connection(self.endpoint) @@ -129,22 +139,30 @@ class ClicheWallet(Wallet): if data.get("error") is not None and data["error"].get("message"): logger.error(data["error"]["message"]) return PaymentStatus(None) - + payment = data["result"] statuses = {"pending": None, "complete": True, "failed": False} - return PaymentStatus(statuses[data["result"]["status"]]) + return PaymentStatus( + statuses[payment["status"]], + payment.get("fee_msatoshi"), + payment.get("preimage"), + ) async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: - try: - ws = await create_connection(self.endpoint) - while True: - r = await ws.recv() - data = json.loads(r) - try: - if data["result"]["status"]: - yield data["result"]["payment_hash"] - except: - continue - except: - pass - logger.error("lost connection to cliche's websocket, retrying in 5 seconds") - await asyncio.sleep(5) + while True: + try: + ws = await create_connection(self.endpoint) + while True: + r = await ws.recv() + data = json.loads(r) + print(data) + try: + if data["result"]["status"]: + yield data["result"]["payment_hash"] + except: + continue + except Exception as exc: + logger.error( + f"lost connection to cliche's invoices stream: '{exc}', retrying in 5 seconds" + ) + await asyncio.sleep(5) + continue diff --git a/lnbits/wallets/cln.py b/lnbits/wallets/cln.py index 4761a59b..48b96128 100644 --- a/lnbits/wallets/cln.py +++ b/lnbits/wallets/cln.py @@ -110,29 +110,38 @@ class CoreLightningWallet(Wallet): return InvoiceResponse(True, r["payment_hash"], r["bolt11"], "") except RpcError as exc: - error_message = f"lightningd '{exc.method}' failed with '{exc.error}'." - logger.error("RPC error:", error_message) + error_message = f"CLN method '{exc.method}' failed with '{exc.error.get('message') or exc.error}'." return InvoiceResponse(False, None, None, error_message) except Exception as e: - logger.error("error:", e) return InvoiceResponse(False, None, None, str(e)) async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: invoice = lnbits_bolt11.decode(bolt11) + + previous_payment = await self.get_payment_status(invoice.payment_hash) + if previous_payment.paid: + return PaymentResponse(False, None, None, None, "invoice already paid") + fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100 payload = { "bolt11": bolt11, "maxfeepercent": "{:.11}".format(fee_limit_percent), - "exemptfee": 0, # so fee_limit_percent is applied even on payments with fee under 5000 millisatoshi (which is default value of exemptfee) + "exemptfee": 0, # so fee_limit_percent is applied even on payments with fee < 5000 millisatoshi (which is default value of exemptfee) } try: wrapped = async_wrap(_pay_invoice) r = await wrapped(self.ln, payload) + except RpcError as exc: + try: + error_message = exc.error["attempts"][-1]["fail_reason"] + except: + error_message = f"CLN method '{exc.method}' failed with '{exc.error.get('message') or exc.error}'." + return PaymentResponse(False, None, None, None, error_message) except Exception as exc: - return PaymentResponse(False, None, 0, None, str(exc)) + return PaymentResponse(False, None, None, None, str(exc)) - fee_msat = r["msatoshi_sent"] - r["msatoshi"] + fee_msat = -int(r["msatoshi_sent"] - r["msatoshi"]) return PaymentResponse( True, r["payment_hash"], fee_msat, r["payment_preimage"], None ) @@ -144,9 +153,16 @@ class CoreLightningWallet(Wallet): return PaymentStatus(None) if not r["invoices"]: return PaymentStatus(None) - if r["invoices"][0]["payment_hash"] == checking_id: - return PaymentStatus(r["invoices"][0]["status"] == "paid") - raise KeyError("supplied an invalid checking_id") + + invoice_resp = r["invoices"][-1] + + if invoice_resp["payment_hash"] == checking_id: + if invoice_resp["status"] == "paid": + return PaymentStatus(True) + elif invoice_resp["status"] == "unpaid": + return PaymentStatus(None) + logger.warning(f"supplied an invalid checking_id: {checking_id}") + return PaymentStatus(None) async def get_payment_status(self, checking_id: str) -> PaymentStatus: try: @@ -155,14 +171,21 @@ class CoreLightningWallet(Wallet): return PaymentStatus(None) if not r["pays"]: return PaymentStatus(None) - if r["pays"][0]["payment_hash"] == checking_id: - status = r["pays"][0]["status"] + payment_resp = r["pays"][-1] + + if payment_resp["payment_hash"] == checking_id: + status = payment_resp["status"] if status == "complete": - return PaymentStatus(True) + fee_msat = -int( + payment_resp["amount_sent_msat"] - payment_resp["amount_msat"] + ) + + return PaymentStatus(True, fee_msat, payment_resp["preimage"]) elif status == "failed": return PaymentStatus(False) return PaymentStatus(None) - raise KeyError("supplied an invalid checking_id") + logger.warning(f"supplied an invalid checking_id: {checking_id}") + return PaymentStatus(None) async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: while True: diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py index 247b96e1..c03e3f53 100644 --- a/lnbits/wallets/eclair.py +++ b/lnbits/wallets/eclair.py @@ -50,7 +50,7 @@ class EclairWallet(Wallet): async def status(self) -> StatusResponse: async with httpx.AsyncClient() as client: r = await client.post( - f"{self.url}/usablebalances", headers=self.auth, timeout=40 + f"{self.url}/globalbalance", headers=self.auth, timeout=5 ) try: data = r.json() @@ -60,9 +60,11 @@ class EclairWallet(Wallet): ) if r.is_error: - return StatusResponse(data["error"], 0) + return StatusResponse(data.get("error") or "undefined error", 0) + if len(data) == 0: + return StatusResponse("no data", 0) - return StatusResponse(None, data[0]["canSend"] * 1000) + return StatusResponse(None, int(data.get("total") * 100_000_000_000)) async def create_invoice( self, @@ -114,13 +116,18 @@ class EclairWallet(Wallet): except: error_message = r.text pass - return PaymentResponse(False, None, 0, None, error_message) + return PaymentResponse(False, None, None, None, error_message) data = r.json() + if data["type"] == "payment-failed": + return PaymentResponse(False, None, None, None, "payment failed") + checking_id = data["paymentHash"] preimage = data["paymentPreimage"] + # We do all this again to get the fee: + async with httpx.AsyncClient() as client: r = await client.post( f"{self.url}/getsentinfo", @@ -136,15 +143,22 @@ class EclairWallet(Wallet): except: error_message = r.text pass - return PaymentResponse( - True, checking_id, 0, preimage, error_message - ) ## ?? is this ok ?? + return PaymentResponse(None, checking_id, None, preimage, error_message) - data = r.json() - fees = [i["status"] for i in data] - fee_msat = sum([i["feesPaid"] for i in fees]) + statuses = { + "sent": True, + "failed": False, + "pending": None, + } - return PaymentResponse(True, checking_id, fee_msat, preimage, None) + data = r.json()[-1] + if data["status"]["type"] == "sent": + fee_msat = -data["status"]["feesPaid"] + preimage = data["status"]["paymentPreimage"] + + return PaymentResponse( + statuses[data["status"]["type"]], checking_id, fee_msat, preimage, None + ) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: async with httpx.AsyncClient() as client: @@ -155,54 +169,61 @@ class EclairWallet(Wallet): ) data = r.json() - if r.is_error or "error" in data: + if r.is_error or "error" in data or data.get("status") is None: return PaymentStatus(None) - if data["status"]["type"] != "received": - return PaymentStatus(False) - - return PaymentStatus(True) + statuses = { + "received": True, + "expired": False, + "pending": None, + } + return PaymentStatus(statuses.get(data["status"]["type"])) async def get_payment_status(self, checking_id: str) -> PaymentStatus: async with httpx.AsyncClient() as client: r = await client.post( - url=f"{self.url}/getsentinfo", + f"{self.url}/getsentinfo", headers=self.auth, data={"paymentHash": checking_id}, + timeout=40, ) - data = r.json()[0] - if r.is_error: return PaymentStatus(None) - if data["status"]["type"] != "sent": - return PaymentStatus(False) + data = r.json()[-1] - return PaymentStatus(True) + if r.is_error or "error" in data or data.get("status") is None: + return PaymentStatus(None) + + fee_msat, preimage = None, None + if data["status"]["type"] == "sent": + fee_msat = -data["status"]["feesPaid"] + preimage = data["status"]["paymentPreimage"] + + statuses = { + "sent": True, + "failed": False, + "pending": None, + } + return PaymentStatus(statuses.get(data["status"]["type"]), fee_msat, preimage) async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + while True: + try: + async with connect( + self.ws_url, + extra_headers=[("Authorization", self.auth["Authorization"])], + ) as ws: + while True: + message = await ws.recv() + message = json.loads(message) - try: - async with connect( - self.ws_url, - extra_headers=[("Authorization", self.auth["Authorization"])], - ) as ws: - while True: - message = await ws.recv() - message = json.loads(message) + if message and message["type"] == "payment-received": + yield message["paymentHash"] - if message and message["type"] == "payment-received": - yield message["paymentHash"] - - except ( - OSError, - ConnectionClosedOK, - ConnectionClosedError, - ConnectionClosed, - ) as ose: - logger.error("OSE", ose) - pass - - logger.error("lost connection to eclair's websocket, retrying in 5 seconds") - await asyncio.sleep(5) + except Exception as exc: + logger.error( + f"lost connection to eclair invoices stream: '{exc}', retrying in 5 seconds" + ) + await asyncio.sleep(5) diff --git a/lnbits/wallets/lnbits.py b/lnbits/wallets/lnbits.py index 96b7dbb6..ddd80e77 100644 --- a/lnbits/wallets/lnbits.py +++ b/lnbits/wallets/lnbits.py @@ -62,10 +62,10 @@ class LNbitsWallet(Wallet): data: Dict = {"out": False, "amount": amount} if description_hash: data["description_hash"] = description_hash.hex() - elif unhashed_description: - data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest() - else: - data["memo"] = memo or "" + if unhashed_description: + data["unhashed_description"] = unhashed_description.hex() + + data["memo"] = memo or "" async with httpx.AsyncClient() as client: r = await client.post( @@ -94,15 +94,25 @@ class LNbitsWallet(Wallet): json={"out": True, "bolt11": bolt11}, timeout=None, ) - ok, checking_id, fee_msat, error_message = not r.is_error, None, 0, None + ok, checking_id, fee_msat, preimage, error_message = ( + not r.is_error, + None, + None, + None, + None, + ) if r.is_error: error_message = r.json()["detail"] + return PaymentResponse(None, None, None, None, error_message) else: data = r.json() - checking_id = data["checking_id"] + checking_id = data["payment_hash"] - return PaymentResponse(ok, checking_id, fee_msat, error_message) + # we do this to get the fee and preimage + payment: PaymentStatus = await self.get_payment_status(checking_id) + + return PaymentResponse(ok, checking_id, payment.fee_msat, payment.preimage) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: try: @@ -125,8 +135,11 @@ class LNbitsWallet(Wallet): if r.is_error: return PaymentStatus(None) + data = r.json() + if "paid" not in data and "details" not in data: + return PaymentStatus(None) - return PaymentStatus(r.json()["paid"]) + return PaymentStatus(data["paid"], data["details"]["fee"], data["preimage"]) async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: url = f"{self.endpoint}/api/v1/payments/sse" diff --git a/lnbits/wallets/lndgrpc.py b/lnbits/wallets/lndgrpc.py index 10bd27e7..a613ac9f 100644 --- a/lnbits/wallets/lndgrpc.py +++ b/lnbits/wallets/lndgrpc.py @@ -65,14 +65,32 @@ def get_ssl_context(cert_path: str): return context -def parse_checking_id(checking_id: str) -> bytes: +def b64_to_bytes(checking_id: str) -> bytes: return base64.b64decode(checking_id.replace("_", "/")) -def stringify_checking_id(r_hash: bytes) -> str: +def bytes_to_b64(r_hash: bytes) -> str: return base64.b64encode(r_hash).decode("utf-8").replace("/", "_") +def hex_to_b64(hex_str: str) -> str: + try: + return base64.b64encode(bytes.fromhex(hex_str)).decode() + except ValueError: + return "" + + +def hex_to_bytes(hex_str: str) -> bytes: + try: + return bytes.fromhex(hex_str) + except: + return b"" + + +def bytes_to_hex(b: bytes) -> str: + return b.hex() + + # Due to updated ECDSA generated tls.cert we need to let gprc know that # we need to use that cipher suite otherwise there will be a handhsake # error when we communicate with the lnd rpc server. @@ -153,7 +171,7 @@ class LndWallet(Wallet): error_message = str(exc) return InvoiceResponse(False, None, None, error_message) - checking_id = stringify_checking_id(resp.r_hash) + checking_id = bytes_to_hex(resp.r_hash) payment_request = str(resp.payment_request) return InvoiceResponse(True, checking_id, payment_request, None) @@ -168,9 +186,9 @@ class LndWallet(Wallet): try: resp = await self.routerpc.SendPaymentV2(req).read() except RpcError as exc: - return PaymentResponse(False, "", 0, None, exc._details) + return PaymentResponse(False, None, None, None, exc._details) except Exception as exc: - return PaymentResponse(False, "", 0, None, str(exc)) + return PaymentResponse(False, None, None, None, str(exc)) # PaymentStatus from https://github.com/lightningnetwork/lnd/blob/master/channeldb/payments.go#L178 statuses = { @@ -180,29 +198,31 @@ class LndWallet(Wallet): 3: False, # FAILED } - if resp.status in [0, 1, 3]: - fee_msat = 0 - preimage = "" - checking_id = "" - elif resp.status == 2: # SUCCEEDED - fee_msat = resp.htlcs[-1].route.total_fees_msat - preimage = resp.payment_preimage - checking_id = resp.payment_hash + fee_msat = None + preimage = None + checking_id = resp.payment_hash + + if resp.status: # SUCCEEDED + fee_msat = -resp.htlcs[-1].route.total_fees_msat + preimage = bytes_to_hex(resp.payment_preimage) + return PaymentResponse( statuses[resp.status], checking_id, fee_msat, preimage, None ) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: try: - r_hash = parse_checking_id(checking_id) + r_hash = hex_to_bytes(checking_id) if len(r_hash) != 32: raise binascii.Error except binascii.Error: # this may happen if we switch between backend wallets # that use different checking_id formats return PaymentStatus(None) - - resp = await self.rpc.LookupInvoice(ln.PaymentHash(r_hash=r_hash)) + try: + resp = await self.rpc.LookupInvoice(ln.PaymentHash(r_hash=r_hash)) + except RpcError as exc: + return PaymentStatus(None) if resp.settled: return PaymentStatus(True) @@ -213,7 +233,7 @@ class LndWallet(Wallet): This routine checks the payment status using routerpc.TrackPaymentV2. """ try: - r_hash = parse_checking_id(checking_id) + r_hash = hex_to_bytes(checking_id) if len(r_hash) != 32: raise binascii.Error except binascii.Error: @@ -221,11 +241,6 @@ class LndWallet(Wallet): # that use different checking_id formats return PaymentStatus(None) - # for some reason our checking_ids are in base64 but the payment hashes - # returned here are in hex, lnd is weird - checking_id = checking_id.replace("_", "/") - checking_id = base64.b64decode(checking_id).hex() - resp = self.routerpc.TrackPaymentV2( router.TrackPaymentRequest(payment_hash=r_hash) ) @@ -240,6 +255,12 @@ class LndWallet(Wallet): try: async for payment in resp: + if statuses[payment.htlcs[-1].status]: + return PaymentStatus( + True, + -payment.htlcs[-1].route.total_fees_msat, + bytes_to_hex(payment.htlcs[-1].preimage), + ) return PaymentStatus(statuses[payment.htlcs[-1].status]) except: # most likely the payment wasn't found return PaymentStatus(None) @@ -248,13 +269,13 @@ class LndWallet(Wallet): async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: while True: - request = ln.InvoiceSubscription() try: + request = ln.InvoiceSubscription() async for i in self.rpc.SubscribeInvoices(request): if not i.settled: continue - checking_id = stringify_checking_id(i.r_hash) + checking_id = bytes_to_hex(i.r_hash) yield checking_id except Exception as exc: logger.error( diff --git a/lnbits/wallets/lndrest.py b/lnbits/wallets/lndrest.py index 6bdbe5e0..1083e48a 100644 --- a/lnbits/wallets/lndrest.py +++ b/lnbits/wallets/lndrest.py @@ -123,18 +123,15 @@ class LndRestWallet(Wallet): if r.is_error or r.json().get("payment_error"): error_message = r.json().get("payment_error") or r.text - return PaymentResponse(False, None, 0, None, error_message) + return PaymentResponse(False, None, None, None, error_message) data = r.json() - payment_hash = data["payment_hash"] - checking_id = payment_hash + 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) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: - checking_id = checking_id.replace("_", "/") - async with httpx.AsyncClient(verify=self.cert) as client: r = await client.get( url=f"{self.endpoint}/v1/invoice/{checking_id}", headers=self.auth @@ -151,10 +148,18 @@ class LndRestWallet(Wallet): """ This routine checks the payment status using routerpc.TrackPaymentV2. """ + # convert checking_id from hex to base64 and some LND magic + try: + checking_id = base64.urlsafe_b64encode(bytes.fromhex(checking_id)).decode( + "ascii" + ) + except ValueError: + return PaymentStatus(None) + url = f"{self.endpoint}/v2/router/track/{checking_id}" # check payment.status: - # https://api.lightning.community/rest/index.html?python#peersynctype + # https://api.lightning.community/?python=#paymentpaymentstatus statuses = { "UNKNOWN": None, "IN_FLIGHT": None, @@ -178,7 +183,11 @@ class LndRestWallet(Wallet): return PaymentStatus(None) payment = line.get("result") if payment is not None and payment.get("status"): - return PaymentStatus(statuses[payment["status"]]) + return PaymentStatus( + paid=statuses[payment["status"]], + fee_msat=payment.get("fee_msat"), + preimage=payment.get("payment_preimage"), + ) else: return PaymentStatus(None) except: @@ -187,10 +196,9 @@ class LndRestWallet(Wallet): return PaymentStatus(None) async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: - url = self.endpoint + "/v1/invoices/subscribe" - while True: try: + url = self.endpoint + "/v1/invoices/subscribe" async with httpx.AsyncClient( timeout=None, headers=self.auth, verify=self.cert ) as client: diff --git a/lnbits/wallets/lnpay.py b/lnbits/wallets/lnpay.py index de0a60a8..5db68e1f 100644 --- a/lnbits/wallets/lnpay.py +++ b/lnbits/wallets/lnpay.py @@ -100,7 +100,7 @@ class LNPayWallet(Wallet): ) if r.is_error: - return PaymentResponse(False, None, 0, None, data["message"]) + return PaymentResponse(False, None, None, None, data["message"]) checking_id = data["lnTx"]["id"] fee_msat = 0 @@ -113,15 +113,18 @@ class LNPayWallet(Wallet): async def get_payment_status(self, checking_id: str) -> PaymentStatus: async with httpx.AsyncClient() as client: r = await client.get( - url=f"{self.endpoint}/lntx/{checking_id}?fields=settled", + url=f"{self.endpoint}/lntx/{checking_id}", headers=self.auth, ) if r.is_error: return PaymentStatus(None) + data = r.json() + preimage = data["payment_preimage"] + fee_msat = data["fee_msat"] statuses = {0: None, 1: True, -1: False} - return PaymentStatus(statuses[r.json()["settled"]]) + return PaymentStatus(statuses[data["settled"]], fee_msat, preimage) async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: self.queue: asyncio.Queue = asyncio.Queue(0) diff --git a/lnbits/wallets/lntxbot.py b/lnbits/wallets/lntxbot.py index ba310823..13046d26 100644 --- a/lnbits/wallets/lntxbot.py +++ b/lnbits/wallets/lntxbot.py @@ -97,10 +97,11 @@ class LntxbotWallet(Wallet): except: error_message = r.text pass - - return PaymentResponse(False, None, 0, None, error_message) + return PaymentResponse(False, None, None, None, error_message) data = r.json() + if data.get("type") != "paid_invoice": + return PaymentResponse(None) checking_id = data["payment_hash"] fee_msat = -data["fee_msat"] preimage = data["payment_preimage"] diff --git a/lnbits/wallets/opennode.py b/lnbits/wallets/opennode.py index 9fcf374a..f7dcba40 100644 --- a/lnbits/wallets/opennode.py +++ b/lnbits/wallets/opennode.py @@ -47,7 +47,7 @@ class OpenNodeWallet(Wallet): if r.is_error: return StatusResponse(data["message"], 0) - return StatusResponse(None, data["balance"]["BTC"] / 100_000_000_000) + return StatusResponse(None, data["balance"]["BTC"] * 1000) async def create_invoice( self, @@ -92,11 +92,15 @@ class OpenNodeWallet(Wallet): if r.is_error: error_message = r.json()["message"] - return PaymentResponse(False, None, 0, None, error_message) + return PaymentResponse(False, None, None, None, error_message) data = r.json()["data"] checking_id = data["id"] - fee_msat = data["fee"] * 1000 + fee_msat = -data["fee"] * 1000 + + if data["status"] != "paid": + return PaymentResponse(None, checking_id, fee_msat, None, "payment failed") + return PaymentResponse(True, checking_id, fee_msat, None, None) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: @@ -106,9 +110,9 @@ class OpenNodeWallet(Wallet): ) if r.is_error: return PaymentStatus(None) - - statuses = {"processing": None, "paid": True, "unpaid": False} - return PaymentStatus(statuses[r.json()["data"]["status"]]) + data = r.json()["data"] + statuses = {"processing": None, "paid": True, "unpaid": None} + return PaymentStatus(statuses[data.get("status")]) async def get_payment_status(self, checking_id: str) -> PaymentStatus: async with httpx.AsyncClient() as client: @@ -119,14 +123,16 @@ class OpenNodeWallet(Wallet): if r.is_error: return PaymentStatus(None) + data = r.json()["data"] statuses = { "initial": None, "pending": None, "confirmed": True, - "error": False, + "error": None, "failed": False, } - return PaymentStatus(statuses[r.json()["data"]["status"]]) + 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) diff --git a/lnbits/wallets/spark.py b/lnbits/wallets/spark.py index a26177db..414d4e47 100644 --- a/lnbits/wallets/spark.py +++ b/lnbits/wallets/spark.py @@ -137,7 +137,7 @@ class SparkWallet(Wallet): pays = listpays["pays"] if len(pays) == 0: - return PaymentResponse(False, None, 0, None, str(exc)) + return PaymentResponse(False, None, None, None, str(exc)) pay = pays[0] payment_hash = pay["payment_hash"] @@ -148,11 +148,9 @@ class SparkWallet(Wallet): ) if pay["status"] == "failed": - return PaymentResponse(False, None, 0, None, str(exc)) + return PaymentResponse(False, None, None, None, str(exc)) elif pay["status"] == "pending": - return PaymentResponse( - None, payment_hash, fee_limit_msat, None, None - ) + return PaymentResponse(None, payment_hash, None, None, None) elif pay["status"] == "complete": r = pay r["payment_preimage"] = pay["preimage"] @@ -163,7 +161,7 @@ class SparkWallet(Wallet): # this is good pass - fee_msat = r["msatoshi_sent"] - r["msatoshi"] + fee_msat = -int(r["msatoshi_sent"] - r["msatoshi"]) preimage = r["payment_preimage"] return PaymentResponse(True, r["payment_hash"], fee_msat, preimage, None) @@ -201,7 +199,10 @@ class SparkWallet(Wallet): if r["pays"][0]["payment_hash"] == checking_id: status = r["pays"][0]["status"] if status == "complete": - return PaymentStatus(True) + fee_msat = -int( + r["pays"][0]["amount_sent_msat"] - r["pays"][0]["amount_msat"] + ) + return PaymentStatus(True, fee_msat, r["pays"][0]["preimage"]) elif status == "failed": return PaymentStatus(False) return PaymentStatus(None)