feat: Add Strike Wallet Integration (#3150)

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
This commit is contained in:
Sat 2025-05-20 02:51:05 -06:00 committed by GitHub
parent 34b8490a2d
commit 00fccf513e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 571 additions and 2 deletions

View File

@ -40,7 +40,7 @@ PORT=5000
###################################### ######################################
# which fundingsources are allowed in the admin ui # which fundingsources are allowed in the admin ui
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet" # LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet"
LNBITS_BACKEND_WALLET_CLASS=VoidWallet LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities, # VoidWallet is just a fallback that works without any actual Lightning capabilities,
@ -105,6 +105,10 @@ BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
BOLTZ_CLIENT_WALLET="lnbits" BOLTZ_CLIENT_WALLET="lnbits"
# StrikeWallet
STRIKE_API_ENDPOINT=https://api.strike.me/v1
STRIKE_API_KEY=YOUR_STRIKE_API_KEY
# ZBDWallet # ZBDWallet
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/ ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
ZBD_API_KEY=ZBD_ACCESS_TOKEN ZBD_API_KEY=ZBD_ACCESS_TOKEN

View File

@ -556,6 +556,13 @@ class BoltzFundingSource(LNbitsSettings):
boltz_client_cert: str | None = Field(default=None) boltz_client_cert: str | None = Field(default=None)
class StrikeFundingSource(LNbitsSettings):
strike_api_endpoint: str | None = Field(
default="https://api.strike.me/v1", env="STRIKE_API_ENDPOINT"
)
strike_api_key: str | None = Field(default=None, env="STRIKE_API_KEY")
class LightningSettings(LNbitsSettings): class LightningSettings(LNbitsSettings):
lightning_invoice_expiry: int = Field(default=3600, gt=0) lightning_invoice_expiry: int = Field(default=3600, gt=0)
@ -580,6 +587,7 @@ class FundingSourcesSettings(
LnTipsFundingSource, LnTipsFundingSource,
NWCFundingSource, NWCFundingSource,
BreezSdkFundingSource, BreezSdkFundingSource,
StrikeFundingSource,
): ):
lnbits_backend_wallet_class: str = Field(default="VoidWallet") lnbits_backend_wallet_class: str = Field(default="VoidWallet")
# How long to wait for the payment to be confirmed before returning a pending status # How long to wait for the payment to be confirmed before returning a pending status
@ -863,6 +871,7 @@ class SuperUserSettings(LNbitsSettings):
"VoidWallet", "VoidWallet",
"ZBDWallet", "ZBDWallet",
"NWCWallet", "NWCWallet",
"StrikeWallet",
] ]
) )

File diff suppressed because one or more lines are too long

View File

@ -197,6 +197,14 @@ window.app.component('lnbits-funding-sources', {
breez_greenlight_device_cert: 'Greenlight Device Cert', breez_greenlight_device_cert: 'Greenlight Device Cert',
breez_greenlight_invite_code: 'Greenlight Invite Code' breez_greenlight_invite_code: 'Greenlight Invite Code'
} }
],
[
'StrikeWallet',
'Strike (alpha)',
{
strike_api_endpoint: 'API Endpoint',
strike_api_key: 'API Key'
}
] ]
] ]
} }

View File

@ -28,6 +28,7 @@ from .nwc import NWCWallet
from .opennode import OpenNodeWallet from .opennode import OpenNodeWallet
from .phoenixd import PhoenixdWallet from .phoenixd import PhoenixdWallet
from .spark import SparkWallet from .spark import SparkWallet
from .strike import StrikeWallet
from .void import VoidWallet from .void import VoidWallet
from .zbd import ZBDWallet from .zbd import ZBDWallet
@ -72,6 +73,7 @@ __all__ = [
"OpenNodeWallet", "OpenNodeWallet",
"PhoenixdWallet", "PhoenixdWallet",
"SparkWallet", "SparkWallet",
"StrikeWallet",
"VoidWallet", "VoidWallet",
"ZBDWallet", "ZBDWallet",
] ]

546
lnbits/wallets/strike.py Normal file
View File

@ -0,0 +1,546 @@
import asyncio
import time
from decimal import Decimal
from typing import Any, AsyncGenerator, Dict, Optional
import httpx
from loguru import logger
from lnbits.settings import settings
from .base import (
InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
PaymentSuccessStatus,
StatusResponse,
Wallet,
)
class TokenBucket:
"""
Token bucket rate limiter for Strike API endpoints.
"""
def __init__(self, rate: int, period_seconds: int):
"""
Initialize a token bucket.
Args:
rate: Max requests allowed in the period
period_seconds: Time period in seconds.
"""
self.rate = rate
self.period = period_seconds
self.tokens = rate
self.last_refill = time.monotonic()
self.lock = asyncio.Lock()
async def consume(self) -> None:
"""Wait until a token is available and consume it."""
async with self.lock:
# Refill tokens based on elapsed time
now = time.monotonic()
elapsed = now - self.last_refill
if elapsed > 0:
new_tokens = int((elapsed / self.period) * self.rate)
self.tokens = min(self.rate, self.tokens + new_tokens)
self.last_refill = now
# If no tokens available, calculate wait time and wait for a token
if self.tokens < 1:
# Calculate time needed for one token
wait_time = (self.period / self.rate) * (1 - self.tokens)
await asyncio.sleep(wait_time)
# After waiting, update time and add one token
self.last_refill = time.monotonic()
self.tokens = 1
# Consume a token (will be 0 or more after consumption)
self.tokens -= 1
class StrikeWallet(Wallet):
"""
https://developer.strike.me/api
A minimal LNbits wallet backend for Strike.
"""
# --------------------------------------------------------------------- #
# construction / teardown #
# --------------------------------------------------------------------- #
def __init__(self):
if not settings.strike_api_endpoint:
raise ValueError("Missing strike_api_endpoint")
if not settings.strike_api_key:
raise ValueError("Missing strike_api_key")
super().__init__()
# throttle
self._sem = asyncio.Semaphore(value=20)
# Rate limiters for different API endpoints
# Invoice/payment operations: 250 requests / 1 minute
self._invoice_limiter = TokenBucket(250, 60)
self._payment_limiter = TokenBucket(250, 60)
# All other operations: 1,000 requests / 10 minutes
self._general_limiter = TokenBucket(1000, 600)
self.client = httpx.AsyncClient(
base_url=self.normalize_endpoint(settings.strike_api_endpoint),
headers={
"Authorization": f"Bearer {settings.strike_api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": settings.user_agent,
},
timeout=httpx.Timeout(connect=5.0, read=40.0, write=10.0, pool=None),
transport=httpx.AsyncHTTPTransport(
limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
retries=0, # we handle retries ourselves
),
)
# runtime state
self.pending_invoices: list[str] = [] # Keep it as a list
self.pending_payments: Dict[str, str] = {}
self.failed_payments: Dict[str, str] = {}
# balance cache
self._cached_balance: Optional[int] = None
self._cached_balance_ts: float = 0.0
self._cache_ttl = 30 # seconds
async def cleanup(self):
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 #
# --------------------------------------------------------------------- #
async def status(self) -> StatusResponse:
"""
Return wallet balance (millisatoshis) with a 30-second cache.
"""
now = time.time()
if (
self._cached_balance is not None
and now - self._cached_balance_ts < self._cache_ttl
):
return StatusResponse(None, self._cached_balance)
try:
r = await self._get("/balances")
r.raise_for_status()
data = r.json()
balances = data.get("data", []) if isinstance(data, dict) else data
btc = next((b for b in balances if b.get("currency") == "BTC"), None)
if btc and "available" in btc:
available_btc = Decimal(btc["available"]) # Get available BTC amount.
msats = int(
available_btc * Decimal(1e11)
) # Convert BTC to millisatoshis.
self._cached_balance = msats
self._cached_balance_ts = now
return StatusResponse(None, msats)
return StatusResponse(None, 0)
except Exception as e:
logger.warning(e)
return StatusResponse("Connection error", 0)
async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: bytes | None = None, # Add this parameter
**kwargs,
) -> InvoiceResponse:
try:
btc_amt = (Decimal(amount) / Decimal(1e8)).quantize(
Decimal("0.00000001")
) # Convert amount from millisatoshis to BTC.
payload: Dict[str, Any] = {
"bolt11": {
"amount": {
"currency": "BTC",
"amount": str(btc_amt),
},
"description": memo or "",
},
"targetCurrency": "BTC",
}
if description_hash:
payload["bolt11"]["descriptionHash"] = description_hash.hex()
r = await self._post(
"/receive-requests",
json=payload,
)
r.raise_for_status()
resp = r.json()
invoice_id = resp.get("receiveRequestId")
bolt11 = resp.get("bolt11", {}).get("invoice")
if not invoice_id or not bolt11:
return InvoiceResponse(
ok=False, error_message="Invalid invoice response"
)
self.pending_invoices.append(invoice_id)
return InvoiceResponse(
ok=True, checking_id=invoice_id, payment_request=bolt11
)
except httpx.HTTPStatusError as e:
logger.warning(e)
msg = e.response.json().get("message", e.response.text)
return InvoiceResponse(ok=False, error_message=f"Strike API error: {msg}")
except Exception as e:
logger.warning(e)
return InvoiceResponse(ok=False, error_message="Connection error")
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
try:
# 1) Create a payment quote.
q = await self._post(
"/payment-quotes/lightning",
json={"lnInvoice": bolt11},
)
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()
data = e.json() if e.content else {}
payment_id = data.get("paymentId")
state = data.get("state", "").upper()
# 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.
if state in {"SUCCEEDED", "COMPLETED"}:
preimage = data.get("preimage") or data.get("preImage")
return PaymentResponse(
ok=True,
checking_id=payment_id,
fee_msat=fee_msat,
preimage=preimage,
)
failed_states = {
"CANCELED",
"FAILED",
"TIMED_OUT",
}
if state in failed_states:
return PaymentResponse(
ok=False, checking_id=payment_id, error_message=f"State: {state}"
)
# Store mapping for later polling.
if payment_id:
# todo: this will be lost on server restart
self.pending_payments[payment_id] = quote_id
# Treat all other states as pending (including unknown states).
return PaymentResponse(ok=None, checking_id=payment_id)
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")
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
r = await self._get(f"/receive-requests/{checking_id}/receives")
if r.status_code == 200:
data = r.json()
items = data.get("items", [])
if not items:
# Still pending.
return PaymentPendingStatus()
for item in items:
if item.get("state") == "COMPLETED":
preimage = None
lightning_data = item.get("lightning")
if lightning_data:
preimage = lightning_data.get(
"preimage"
) or lightning_data.get("preImage")
return PaymentSuccessStatus(fee_msat=0, preimage=preimage)
return PaymentPendingStatus()
if r.status_code == 404:
logger.warning(f"Payment '{checking_id}' not found. Marking as failed.")
return PaymentFailedStatus(False)
r.raise_for_status()
return PaymentPendingStatus()
except httpx.HTTPStatusError as e:
logger.warning(
f"HTTPStatusError in get_invoice_status for checking_id {checking_id} "
f"on URL {e.request.url}: {e.response.status_code} - {e.response.text}"
)
# Default to Pending to allow retries by paid_invoices_stream.
return PaymentPendingStatus()
except Exception as e:
logger.warning(e)
return PaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
quote_id = self.pending_payments.get(checking_id)
try:
# Attempt 1: Use quote_id if available (from in-memory store)
if quote_id:
status = await self._get_payment_status_by_quote_id(
checking_id, quote_id
)
if status:
return status
except Exception as e:
logger.warning(e)
logger.debug(f"Error while fetching payment by quote id {checking_id}.")
try:
# Attempt 2: Fallback - Use paymentId (checking_id) directly.
return await self._get_payment_status_by_checking_id(checking_id)
except Exception as e:
logger.warning(e)
logger.debug(f"Error while fetching payment {checking_id}.")
return PaymentPendingStatus()
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
"""
Poll Strike for invoice settlement while respecting the documented API limits.
Uses dynamic adjustment of polling frequency based on activity.
"""
min_poll, max_poll = 1, 15
# 1,000 requests / 10 minutes = ~100 requests/minute.
rate_limit = 100
sleep_s = min_poll
# Main loop for polling invoices.
self._running = True
while self._running and settings.lnbits_running:
loop_start = time.time()
had_activity = False
req_budget = max(
1, rate_limit * sleep_s // 60
) # Calculate request budget based on sleep time.
processed = 0
for inv in list(self.pending_invoices):
if processed >= req_budget: # If request budget is exhausted.
break
status = await self.get_invoice_status(inv)
processed += 1
if status.success or status.failed:
self.pending_invoices.remove(inv)
if status.success:
had_activity = True
yield inv
# Dynamic adjustment of polling frequency based on activity.
sleep_s = (
max(min_poll, sleep_s // 2)
if had_activity
else min(max_poll, sleep_s * 2)
)
# Sleep to respect rate limits.
elapsed = time.time() - loop_start
# Ensure we respect the rate limit, even with dynamic adjustment.
min_sleep_for_rate = processed * 60 / rate_limit - elapsed
await asyncio.sleep(max(sleep_s, min_sleep_for_rate, 0))
# ------------------------------------------------------------------ #
# misc Strike helpers #
# ------------------------------------------------------------------ #
async def get_invoices(
self,
filters: Optional[str] = None,
orderby: Optional[str] = None,
skip: Optional[int] = None,
top: Optional[int] = 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
) -> Optional[PaymentStatus]:
resp = await self._get(f"/payment-quotes/{quote_id}")
resp.raise_for_status()
data = resp.json()
state = data.get("state", "").upper()
preimage = data.get("preimage") or data.get("preImage")
fee_msat = 0
fee_obj = data.get("lightningNetworkFee") 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()
try:
if currency_str == "BTC":
fee_btc_decimal = Decimal(amount_str)
fee_msat = int(fee_btc_decimal * Decimal(1e11))
elif currency_str == "SAT":
fee_sat_decimal = Decimal(amount_str)
fee_msat = int(fee_sat_decimal * 1000)
except Exception as e:
logger.warning(e)
logger.warning(
f"Fee parse error. Quote: '{quote_id}'. "
f"Payment '{checking_id}'."
)
fee_msat = 0
if state in {"SUCCEEDED", "COMPLETED"}:
self.pending_payments.pop(checking_id, None)
return PaymentSuccessStatus(fee_msat=fee_msat, preimage=preimage)
if state == "FAILED":
self.pending_payments.pop(checking_id, None)
return PaymentFailedStatus()
return None
async def _get_payment_status_by_checking_id(
self, checking_id: str
) -> PaymentStatus:
r_payment = await self._get(f"/payments/{checking_id}")
if r_payment.status_code == 200:
data = r_payment.json()
state = data.get("state", "").upper()
preimage = None
fee_msat = 0
if state in {"SUCCEEDED", "COMPLETED"}:
self.pending_payments.pop(checking_id, None)
return PaymentSuccessStatus(fee_msat=fee_msat, preimage=preimage)
if state == "FAILED":
self.pending_payments.pop(checking_id, None)
return PaymentFailedStatus()
return PaymentPendingStatus()
if r_payment.status_code == 400:
try:
error_data = r_payment.json()
# Check for Strike's specific validation
# error structure for paymentId format
if error_data.get("data", {}).get("code") == "INVALID_DATA":
validation_errors = error_data.get("data", {}).get(
"validationErrors", {}
)
if "paymentId" in validation_errors:
for err_detail in validation_errors["paymentId"]:
is_invalid = err_detail.get(
"code"
) == "INVALID_DATA" and "is not valid." in err_detail.get(
"message", ""
)
if not is_invalid:
continue
logger.error(
f"Payment '{checking_id}' not a valid Strike payment. "
f"Marked as failed. Response: {r_payment.text}"
)
self.pending_payments.pop(checking_id, None)
return PaymentFailedStatus()
except Exception as e:
logger.warning(e)
return PaymentPendingStatus()
if r_payment.status_code == 404:
logger.warning(f"Payment {checking_id} not found. Marking as failed.")
self.pending_payments.pop(checking_id, None)
return PaymentFailedStatus()
logger.debug(
f"Error fetching payment {checking_id} directly: "
f"{r_payment.status_code} - {r_payment.text}"
)
return PaymentPendingStatus()