fix: update API response structure and fix checking_id mismatch (#3478)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
This commit is contained in:
parent
cf7726ddfd
commit
c84d1b66c6
1 changed files with 254 additions and 127 deletions
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue