From c84d1b66c62924b1d78b1ba658c3123b62e0ab46 Mon Sep 17 00:00:00 2001 From: Sat <792024+santyr@users.noreply.github.com> Date: Mon, 10 Nov 2025 07:05:50 -0700 Subject: [PATCH] fix: update API response structure and fix checking_id mismatch (#3478) Co-authored-by: Vlad Stan --- lnbits/wallets/strike.py | 381 ++++++++++++++++++++++++++------------- 1 file changed, 254 insertions(+), 127 deletions(-) diff --git a/lnbits/wallets/strike.py b/lnbits/wallets/strike.py index 77f982e7..8fbbb7e1 100644 --- a/lnbits/wallets/strike.py +++ b/lnbits/wallets/strike.py @@ -7,6 +7,7 @@ from decimal import Decimal from typing import Any import httpx +from bolt11 import decode as bolt11_decode from loguru import logger from lnbits.helpers import normalize_endpoint @@ -131,53 +132,12 @@ class StrikeWallet(Wallet): self._cached_balance_ts: float = 0.0 self._cache_ttl = 30 # seconds - def _persist_pending(self): - try: - with open(self.state_path, "w") as f: - json.dump(self.pending_invoices, f) - except Exception as e: - logger.warning(f"Could not persist pending invoices: {e}") - async def cleanup(self) -> None: try: await self.client.aclose() except Exception: logger.warning("Error closing Strike client") - # --------------------------------------------------------------------- # - # low-level request helpers # - # --------------------------------------------------------------------- # - - async def _req(self, method: str, path: str, /, **kw) -> httpx.Response: - """Make a Strike HTTP call with: - One Strike HTTP call with - • rate limiting based on endpoint type - • concurrency throttle - • exponential back-off + jitter - • explicit retry on 429/5xx - • latency logging - """ - # Apply the appropriate rate limiter based on the endpoint path. - if path.startswith("/invoices") or path.startswith("/receive-requests"): - await self._invoice_limiter.consume() - elif path.startswith("/payment-quotes"): - await self._payment_limiter.consume() - else: - await self._general_limiter.consume() - - async with self._sem: - return await self.client.request(method, path, **kw) - - # Typed wrappers - so call-sites stay tidy. - async def _get(self, path: str, **kw) -> httpx.Response: # GET request. - return await self._req("GET", path, **kw) - - async def _post(self, path: str, **kw) -> httpx.Response: - return await self._req("POST", path, **kw) - - async def _patch(self, path: str, **kw) -> httpx.Response: - return await self._req("PATCH", path, **kw) - # --------------------------------------------------------------------- # # LNbits wallet API implementation # # --------------------------------------------------------------------- # @@ -266,63 +226,75 @@ class StrikeWallet(Wallet): return InvoiceResponse(ok=False, error_message="Connection error") async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + # Extract payment hash from invoice for checking_id + try: - # 1) Create a payment quote. - q = await self._post( - "/payment-quotes/lightning", - json={"lnInvoice": bolt11}, + invoice = bolt11_decode(bolt11) + payment_hash = invoice.payment_hash + except Exception as decode_exc: + logger.warning(f"Strike: Failed to decode invoice: {decode_exc}") + return PaymentResponse( + ok=False, error_message=f"Invalid invoice: {decode_exc!s}" ) - q.raise_for_status() - quote_id = q.json().get("paymentQuoteId") - if not quote_id: - return PaymentResponse( - ok=False, error_message="Strike: missing payment quote Id" - ) - # 2) Execute the payment quote. - e = await self._patch(f"/payment-quotes/{quote_id}/execute") - e.raise_for_status() + try: + # 1) Create a payment quote + quote_id, error = await self._create_payment_quote(bolt11) + if error or not quote_id: + return PaymentResponse(ok=False, error_message=error or "Unknown error") + + # 2) Execute the payment quote + data, error = await self._execute_payment_quote(quote_id) + if error or not data: + return PaymentResponse(ok=False, error_message=error or "Unknown error") - data = e.json() if e.content else {} - payment_id = data.get("paymentId") state = data.get("state", "").upper() + payment_id = data.get("paymentId") - # Network fee → msat. - fee_obj = data.get("lightningNetworkFee") or data.get("totalFee") or {} - fee_btc = Decimal(fee_obj.get("amount", "0")) - fee_msat = int(fee_btc * Decimal("1e11")) # millisatoshis. + # Parse fee + fee_msat = self._parse_payment_fee(data, payment_id or "") + # Handle successful payment if state in {"SUCCEEDED", "COMPLETED"}: - preimage = data.get("preimage") or data.get("preImage") + preimage = self._extract_preimage(data) return PaymentResponse( ok=True, - checking_id=payment_id, + checking_id=payment_hash, fee_msat=fee_msat, preimage=preimage, ) - failed_states = { - "CANCELED", - "FAILED", - "TIMED_OUT", - } + # Handle failed payment + failed_states = {"CANCELED", "FAILED", "TIMED_OUT"} if state in failed_states: + logger.warning( + f"Strike payment {payment_id} failed with state: {state}" + ) return PaymentResponse( - ok=False, checking_id=payment_id, error_message=f"State: {state}" + ok=False, + checking_id=payment_hash, + error_message=f"Payment {state.lower()}", ) - # Store mapping for later polling. - if payment_id: - # todo: this will be lost on server restart - self.pending_payments[payment_id] = quote_id + # Store mapping for later polling + self.pending_payments[payment_hash] = quote_id - # Treat all other states as pending (including unknown states). - return PaymentResponse(ok=None, checking_id=payment_id) + # Treat all other states as pending + return PaymentResponse(ok=None, checking_id=payment_hash) + except httpx.HTTPStatusError as http_exc: + logger.warning(f"Strike HTTP error during payment: {http_exc}") + logger.warning( + f"Response status: {http_exc.response.status_code}, " + f"body: {http_exc.response.text}" + ) + return PaymentResponse( + ok=False, + error_message=f"Strike API error: {http_exc.response.status_code}", + ) except Exception as e: - logger.warning(e) - # Keep pending. Not sure if the payment went trough or not. - return PaymentResponse(ok=None, error_message="Connection error") + logger.warning(f"Strike payment exception: {e}", exc_info=True) + return PaymentResponse(ok=None, error_message=f"Error: {e!s}") async def get_invoice_status(self, checking_id: str) -> PaymentStatus: try: @@ -390,26 +362,31 @@ class StrikeWallet(Wallet): logger.debug(f"Error while fetching payment {checking_id}.") return PaymentPendingStatus() - async def _get_invoices_status_batch( - self, invoice_ids: list[str] - ) -> dict[str, PaymentStatus]: - out: dict[str, PaymentStatus] = {} - if not invoice_ids: - return out - ids_list = ",".join(f"'{i}'" for i in invoice_ids) - filter_expr = f"receiveRequestId in ({ids_list})" - params = {"$filter": filter_expr, "$top": len(invoice_ids)} - r = await self._get("/receive-requests/receives", params=params) - r.raise_for_status() - items = r.json().get("items") or r.json().get("value") or [] - completed = {item.get("receiveRequestId") for item in items} - for inv in invoice_ids: - out[inv] = ( - PaymentSuccessStatus(fee_msat=0) - if inv in completed - else PaymentPendingStatus() - ) - return out + async def get_invoices( + self, + filters: str | None = None, + orderby: str | None = None, + skip: int | None = None, + top: int | None = None, + ) -> dict[str, Any]: + try: + params: dict[str, Any] = {} + if filters: + params["$filter"] = filters + if orderby: + params["$orderby"] = orderby + if skip is not None: + params["$skip"] = skip + if top is not None: + params["$top"] = top + r = await self._get( + "/invoices", params=params + ) # Get invoices from Strike API. + r.raise_for_status() + return r.json() + except Exception: + logger.warning("Error in get_invoices()") + return {"error": "unable to fetch invoices"} async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: """ @@ -457,36 +434,155 @@ class StrikeWallet(Wallet): min_sleep_for_rate = processed * 60 / rate_limit - elapsed await asyncio.sleep(max(sleep_s, min_sleep_for_rate, 0)) + # --------------------------------------------------------------------- # + # low-level request helpers # + # --------------------------------------------------------------------- # + + def _persist_pending(self): + try: + with open(self.state_path, "w") as f: + json.dump(self.pending_invoices, f) + except Exception as e: + logger.warning(f"Could not persist pending invoices: {e}") + + async def _req(self, method: str, path: str, /, **kw) -> httpx.Response: + """Make a Strike HTTP call with: + One Strike HTTP call with + • rate limiting based on endpoint type + • concurrency throttle + • exponential back-off + jitter + • explicit retry on 429/5xx + • latency logging + """ + # Apply the appropriate rate limiter based on the endpoint path. + if path.startswith("/invoices") or path.startswith("/receive-requests"): + await self._invoice_limiter.consume() + elif path.startswith("/payment-quotes"): + await self._payment_limiter.consume() + else: + await self._general_limiter.consume() + + async with self._sem: + return await self.client.request(method, path, **kw) + + # Typed wrappers - so call-sites stay tidy. + async def _get(self, path: str, **kw) -> httpx.Response: # GET request. + return await self._req("GET", path, **kw) + + async def _post(self, path: str, **kw) -> httpx.Response: + return await self._req("POST", path, **kw) + + async def _patch(self, path: str, **kw) -> httpx.Response: + return await self._req("PATCH", path, **kw) + + async def _create_payment_quote(self, bolt11: str) -> tuple[str | None, str | None]: + """Create a payment quote and return (quote_id, error_message).""" + try: + q = await self._post( + "/payment-quotes/lightning", + json={"lnInvoice": bolt11}, + ) + q.raise_for_status() + except httpx.HTTPStatusError as quote_exc: + logger.warning(f"Strike: Failed to create payment quote: {quote_exc}") + logger.warning( + f"Response: {quote_exc.response.status_code} - " + f"{quote_exc.response.text}" + ) + error_msg = ( + f"Strike: Failed to create quote " + f"(HTTP {quote_exc.response.status_code})" + ) + return None, error_msg + + quote_data = q.json() + quote_id = quote_data.get("paymentQuoteId") + if not quote_id: + logger.warning( + f"Strike: missing paymentQuoteId in quote response: {quote_data}" + ) + return None, "Strike: missing payment quote Id" + + return quote_id, None + + async def _execute_payment_quote( + self, quote_id: str + ) -> tuple[dict | None, str | None]: + """Execute a payment quote and return (response_data, error_message).""" + try: + e = await self._patch(f"/payment-quotes/{quote_id}/execute") + e.raise_for_status() + except httpx.HTTPStatusError as exec_exc: + logger.warning( + f"Strike: Failed to execute payment quote {quote_id}: {exec_exc}" + ) + logger.warning( + f"Response: {exec_exc.response.status_code} - " + f"{exec_exc.response.text}" + ) + error_msg = ( + f"Strike: Failed to execute quote " + f"(HTTP {exec_exc.response.status_code})" + ) + return None, error_msg + + data = e.json() if e.content else {} + payment_id = data.get("paymentId") + if not payment_id: + logger.warning(f"Strike: missing paymentId in response: {data}") + return None, "Strike: missing paymentId in response" + + return data, None + + def _parse_payment_fee(self, data: dict, payment_id: str) -> int: + """Parse payment fee from response data and return fee in millisatoshis.""" + lightning_data = data.get("lightning", {}) + fee_obj = lightning_data.get("networkFee") or data.get("totalFee") or {} + fee_msat = 0 + try: + fee_amount = fee_obj.get("amount") + if fee_amount is not None: + fee_btc = Decimal(str(fee_amount)) + fee_msat = int(fee_btc * Decimal("1e11")) + except Exception as fee_exc: + logger.warning(f"Error parsing fee for payment {payment_id}: {fee_exc}") + return fee_msat + + def _extract_preimage(self, data: dict) -> str | None: + """Extract preimage from payment response data.""" + lightning_data = data.get("lightning", {}) + return ( + lightning_data.get("preimage") + or lightning_data.get("preImage") + or data.get("preimage") + or data.get("preImage") + ) + + async def _get_invoices_status_batch( + self, invoice_ids: list[str] + ) -> dict[str, PaymentStatus]: + out: dict[str, PaymentStatus] = {} + if not invoice_ids: + return out + ids_list = ",".join(f"'{i}'" for i in invoice_ids) + filter_expr = f"receiveRequestId in ({ids_list})" + params = {"$filter": filter_expr, "$top": len(invoice_ids)} + r = await self._get("/receive-requests/receives", params=params) + r.raise_for_status() + items = r.json().get("items") or r.json().get("value") or [] + completed = {item.get("receiveRequestId") for item in items} + for inv in invoice_ids: + out[inv] = ( + PaymentSuccessStatus(fee_msat=0) + if inv in completed + else PaymentPendingStatus() + ) + return out + # ------------------------------------------------------------------ # # misc Strike helpers # # ------------------------------------------------------------------ # - async def get_invoices( - self, - filters: str | None = None, - orderby: str | None = None, - skip: int | None = None, - top: int | None = None, - ) -> dict[str, Any]: - try: - params: dict[str, Any] = {} - if filters: - params["$filter"] = filters - if orderby: - params["$orderby"] = orderby - if skip is not None: - params["$skip"] = skip - if top is not None: - params["$top"] = top - r = await self._get( - "/invoices", params=params - ) # Get invoices from Strike API. - r.raise_for_status() - return r.json() - except Exception: - logger.warning("Error in get_invoices()") - return {"error": "unable to fetch invoices"} - async def _get_payment_status_by_quote_id( self, checking_id: str, quote_id: str ) -> PaymentStatus | None: @@ -495,10 +591,19 @@ class StrikeWallet(Wallet): data = resp.json() state = data.get("state", "").upper() - preimage = data.get("preimage") or data.get("preImage") + + # Extract preimage from lightning object (new API structure) + lightning_data = data.get("lightning", {}) + preimage = ( + lightning_data.get("preimage") + or lightning_data.get("preImage") + or data.get("preimage") + or data.get("preImage") + ) fee_msat = 0 - fee_obj = data.get("lightningNetworkFee") or data.get("totalFee") + # Updated API structure (Aug 26, 2024): fee is now in lightning.networkFee + fee_obj = lightning_data.get("networkFee") or data.get("totalFee") if fee_obj and fee_obj.get("amount") and fee_obj.get("currency"): amount_str = fee_obj.get("amount") currency_str = fee_obj.get("currency").upper() @@ -534,8 +639,30 @@ class StrikeWallet(Wallet): if r_payment.status_code == 200: data = r_payment.json() state = data.get("state", "").upper() - preimage = None + + # Extract data from lightning object (new API structure) + lightning_data = data.get("lightning", {}) + preimage = ( + lightning_data.get("preimage") + or lightning_data.get("preImage") + or data.get("preimage") + or data.get("preImage") + ) + + # Extract fee from new API structure + fee_obj = lightning_data.get("networkFee") or data.get("totalFee") fee_msat = 0 + if fee_obj and fee_obj.get("amount"): + try: + fee_btc = Decimal(fee_obj.get("amount", "0")) + currency = fee_obj.get("currency", "BTC").upper() + if currency == "BTC": + fee_msat = int(fee_btc * Decimal("1e11")) + elif currency == "SAT": + fee_msat = int(fee_btc * 1000) + except Exception as e: + logger.warning(f"Error parsing fee for payment {checking_id}: {e}") + fee_msat = 0 if state in {"SUCCEEDED", "COMPLETED"}: self.pending_payments.pop(checking_id, None) @@ -564,7 +691,7 @@ class StrikeWallet(Wallet): ) if not is_invalid: continue - logger.error( + logger.warning( f"Payment '{checking_id}' not a valid Strike payment. " f"Marked as failed. Response: {r_payment.text}" )