Compare commits

..
Author SHA1 Message Date
Vlad Stan c5388e23a9 fix: settings reset 2026-02-04 14:05:19 +02:00
Vlad Stan d01893f577 test: one time code 2026-02-04 14:04:25 +02:00
Vlad Stan 1fc15cde81 test: invalid code, reusable code 2026-02-04 13:57:46 +02:00
Vlad Stan c711be4d60 test: no code provided 2026-02-04 13:48:46 +02:00
Vlad Stan ccb9dda164 fix: handle empty strings better 2026-02-04 13:11:40 +02:00
Vlad Stan 100bc45185 chore: code clean-up 2026-02-03 18:11:04 +02:00
Vlad Stan 222d1c8990 fix: remove nostr message 2026-02-03 18:11:04 +02:00
Vlad Stan 69c414fabf feat: better wording 2026-02-03 18:11:04 +02:00
Vlad Stan b33aa41b9a feat: check invitation code 2026-02-03 18:11:04 +02:00
Vlad Stan 68a36dfd18 feat: update settings 2026-02-03 18:11:04 +02:00
Vlad Stan 84a958af6e feat: send invitation code to backend 2026-02-03 18:11:04 +02:00
Vlad Stan 7db645d388 feat: add confirmation options UI 2026-02-03 18:11:04 +02:00
Vlad Stan 30e61eacf3 feat: configure ui 2026-02-03 18:11:04 +02:00
Vlad Stan de496ed9e8 feat: configure activation codes 2026-02-03 18:11:04 +02:00
Vlad Stan 8cb35d9aac feat: add some info 2026-02-03 18:11:04 +02:00
Vlad Stan d662b03370 refactor: reorder fields 2026-02-03 18:11:04 +02:00
Vlad Stan ef78ca7db3 feat: add ui config 2026-02-03 18:11:04 +02:00
Vlad Stan c97c5842ad refactor: better query 2026-02-03 18:10:41 +02:00
Vlad Stan abce6196de refactor: code cleanup 2026-02-03 18:10:41 +02:00
Vlad Stan e7d3be9854 chore: clean-up 2026-02-03 18:10:41 +02:00
Vlad Stan e9dc8c775d chore: bundle 2026-02-03 18:10:41 +02:00
Vlad Stan 31a2d68285 refactor: simplify queries 2026-02-03 18:10:41 +02:00
Vlad Stan bf1ad185b9 refactor: use normal update 2026-02-03 18:10:41 +02:00
Vlad Stan 62de35541c test: add more check 2026-02-03 18:10:41 +02:00
Vlad Stan 1a61435868 test: login after user disabled does not work 2026-02-03 18:10:41 +02:00
Vlad Stan 03ba28d0cb fix: column selection 2026-02-03 18:10:41 +02:00
Vlad Stan 2b260bfdaf fix: better message 2026-02-03 18:10:41 +02:00
Vlad Stan 30ab42c0b2 feat: add back toggle admin 2026-02-03 18:10:41 +02:00
Vlad Stan f603026d5e fix: only fetch keys for activated users 2026-02-03 18:10:41 +02:00
Vlad Stan 84e6b3a9a8 fix: clear cache 2026-02-03 18:10:41 +02:00
Vlad Stan 3aafc0724f feat: clear cache on user deactivation 2026-02-03 18:10:41 +02:00
Vlad Stan b978ce11fc feat: basic account activate/deactivate 2026-02-03 18:10:41 +02:00
18 changed files with 59 additions and 540 deletions
+1 -3
View File
@@ -59,7 +59,7 @@ LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
# 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, StrikeWallet, CLNRestWallet, SparkWallet, LightsparkSparkWallet"
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet"
# uvicorn variable, allow https behind a proxy
# IMPORTANT: this also needs the webserver to be configured to forward the headers
@@ -191,8 +191,6 @@ BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroons/admin.macaroon"
# HEXSTRING instead of path also possible
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert"
# TODO: add Spark
# StrikeWallet
STRIKE_API_ENDPOINT=https://api.strike.me/v1
STRIKE_API_KEY=YOUR_STRIKE_API_KEY
+1 -1
View File
@@ -57,7 +57,7 @@ Below is a side-by-side comparison of Lightning funding sources you can use with
| **LN.tips** | Custodial/Self-Custodial | Depends on provider | Medium | ❌ | Low | Provider-managed | Moderate | Low | Transaction fees may apply | Medium | Simple hosted service; use LN.tips API as your backend. |
| **Fake Wallet** | Testing (simulated) | ❌ | Low | ❌ | N/A | N/A | Easy | Low | None (test only) | N/A | For testing only; mints accounting units in LNbits (no real sats, unit name configurable). |
## TODO: add Spark
---
### Notes for readers
+1 -3
View File
@@ -52,7 +52,7 @@ A backend wallet is selected and configured entirely through LNbits environment
### CLNRest (using [runes](https://docs.corelightning.org/reference/lightning-createrune))
[Core Lightning REST API docs](https://docs.corelightning.org/docs/rest)
[Core Lightning REST API docs](https://docs.corelightning.org/docs/rest)
Should also work with the [Rust version of CLNRest](https://github.com/daywalker90/clnrest-rs)
**Environment variables**
@@ -338,8 +338,6 @@ Configure in the admin UI or via env vars:
<a id="strike"></a>
## TODO: add Spark
## Strike (alpha)
Custodial provider integrated via **Strike OAuth Connect** (OAuth 2.0 / OIDC). Authenticate a Strike user in your app, then call Strike APIs on the users behalf once scopes are granted. Requires a Strike business account, registered OAuth client, minimal scopes, and login/logout redirect URLs.
+1 -1
View File
@@ -318,7 +318,7 @@ class RegisterUser(BaseModel):
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
invitation_code: str | None = Query(default=None, max_length=256)
invitation_code: str | None = Query(default=None, min_length=1, max_length=256)
class CreateUser(BaseModel):
+4 -4
View File
@@ -118,8 +118,8 @@ async def verify_paypal_webhook(headers, payload: bytes):
"""
webhook_id = settings.paypal_webhook_id
if not webhook_id:
logger.warning("PayPal webhook ID not set.")
raise ValueError("PayPal webhook cannot be verified. Missing webhook ID.")
logger.warning("PayPal webhook ID not set; skipping verification.")
return
required_headers = {
"PAYPAL-TRANSMISSION-ID": headers.get("PAYPAL-TRANSMISSION-ID"),
@@ -129,8 +129,8 @@ async def verify_paypal_webhook(headers, payload: bytes):
"PAYPAL-AUTH-ALGO": headers.get("PAYPAL-AUTH-ALGO"),
}
if not all(required_headers.values()):
logger.warning("Missing PayPal webhook headers.")
raise ValueError("PayPal webhook cannot be verified. Missing headers.")
logger.warning("Missing PayPal webhook headers; skipping verification.")
return
try:
async with httpx.AsyncClient(base_url=settings.paypal_api_endpoint) as client:
-6
View File
@@ -511,21 +511,15 @@ async def update_ui_customization(
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
if not settings.first_install:
raise HTTPException(HTTPStatus.FORBIDDEN, "This is not your first install")
if settings.first_install_token:
if not data.first_install_token:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing first_install_token.")
if settings.first_install_token != data.first_install_token:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid first_install_token.")
account = await get_account_by_username(data.username, False)
if account:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
account = await get_account(settings.super_user)
if not account:
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.")
account.username = data.username
account.extra = account.extra or UserExtra()
account.extra.provider = "lnbits"
+42 -9
View File
@@ -12,6 +12,7 @@ from typing import Any, Generic, Literal, TypeVar, get_origin
from loguru import logger
from pydantic import BaseModel, ValidationError, root_validator
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
from sqlalchemy.sql import text
@@ -55,6 +56,14 @@ def compat_timestamp_placeholder(key: str):
return f":{key}"
def get_placeholder(model: Any, field: str) -> str:
type_ = model.__fields__[field].type_
if type_ == datetime:
return compat_timestamp_placeholder(field)
else:
return f":{field}"
class Compat:
type: str | None = "<inherited>"
schema: str | None = "<inherited>"
@@ -317,7 +326,31 @@ class Database(Compat):
self.engine: AsyncEngine = create_async_engine(
database_uri, echo=settings.debug_database
)
if self.type in {POSTGRES, COCKROACH}:
@event.listens_for(self.engine.sync_engine, "connect")
def register_custom_types(dbapi_connection, *_):
def _parse_date(value) -> datetime:
if value is None:
value = "1970-01-01 00:00:00"
f = "%Y-%m-%d %H:%M:%S.%f"
if "." not in value:
f = "%Y-%m-%d %H:%M:%S"
# Parse and add UTC timezone info
return datetime.strptime(value, f).replace(tzinfo=timezone.utc)
dbapi_connection.run_async(
lambda connection: connection.set_type_codec(
"TIMESTAMP",
encoder=datetime,
decoder=_parse_date,
schema="pg_catalog",
)
)
self.lock = asyncio.Lock()
logger.trace(f"database {self.type} added for {self.name}")
@asynccontextmanager
@@ -630,7 +663,7 @@ def insert_query(table_name: str, model: BaseModel) -> str:
placeholders = []
keys = model_to_dict(model).keys()
for field in keys:
placeholders.append(f":{field}")
placeholders.append(get_placeholder(model, field))
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
fields = ", ".join([f'"{key}"' for key in keys])
values = ", ".join(placeholders)
@@ -648,8 +681,9 @@ def update_query(
"""
fields = []
for field in model_to_dict(model).keys():
placeholder = get_placeholder(model, field)
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
fields.append(f'"{field}" = :{field}')
fields.append(f'"{field}" = {placeholder}')
query = ", ".join(fields)
return f"UPDATE {table_name} SET {query} {where}" # noqa: S608
@@ -667,12 +701,7 @@ def model_to_dict(model: BaseModel) -> dict:
if model.__fields__[key].field_info.extra.get("no_database", False):
continue
if isinstance(value, datetime):
if DB_TYPE == SQLITE:
_dict[key] = value.timestamp()
else:
# remove tz. postgres and cockroach TIMESTAMP is not tz aware
# so it will throw if we dont remove the UTC.
_dict[key] = value.replace(tzinfo=None)
_dict[key] = value.timestamp()
continue
if (
type(type_) is type(BaseModel)
@@ -728,7 +757,11 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
if DB_TYPE == SQLITE:
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
else:
_dict[key] = value.replace(tzinfo=timezone.utc)
# Ensure PostgreSQL datetime values have timezone info
if isinstance(value, datetime) and value.tzinfo is None:
_dict[key] = value.replace(tzinfo=timezone.utc)
else:
_dict[key] = value
continue
if issubclass(type_, BaseModel):
_dict[key] = dict_to_submodel(type_, value)
-13
View File
@@ -602,17 +602,6 @@ class SparkFundingSource(LNbitsSettings):
spark_token: str | None = Field(default=None)
class SparkL2FundingSource(LNbitsSettings):
spark_l2_mnemonic: str | None = Field(default=None)
spark_l2_network: str = Field(default="MAINNET")
spark_l2_internal_sidecar_version: str | None = Field(default="0.1.1")
spark_l2_external_endpoint: str | None = Field(default=None)
spark_l2_external_api_key: str | None = Field(default=None)
spark_l2_pay_wait_ms: int = Field(default=4000, ge=0)
spark_l2_pay_poll_ms: int = Field(default=500, ge=0)
spark_l2_stream_keepalive_ms: int = Field(default=15000, ge=0)
class LnTipsFundingSource(LNbitsSettings):
lntips_api_endpoint: str | None = Field(default=None)
lntips_api_key: str | None = Field(default=None)
@@ -718,7 +707,6 @@ class FundingSourcesSettings(
PhoenixdFundingSource,
OpenNodeFundingSource,
SparkFundingSource,
SparkL2FundingSource,
LnTipsFundingSource,
NWCFundingSource,
BreezSdkFundingSource,
@@ -1047,7 +1035,6 @@ class SuperUserSettings(LNbitsSettings):
"FakeWallet",
"LNPayWallet",
"LNbitsWallet",
"LightsparkSparkWallet",
"LnTipsWallet",
"LndRestWallet",
"LndWallet",
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -228,51 +228,6 @@ window.app.component('lnbits-admin-funding-sources', {
spark_token: 'Token'
}
],
[
'LightsparkSparkWallet',
'Spark (L2)',
{
spark_l2_internal_sidecar_version: {
label: 'Internal Sidecar Version (eg: 0.1.1).',
hint: 'If specified then the sidecar will be downloaded. Alternatively you can specify an External Sidecar endpoint in the Advanced section.',
value: ''
},
spark_l2_mnemonic: {
label: 'Mnemonic',
hint: 'Only required if Interna Sidecar version is specified.'
},
spark_l2_network: {
label: 'Network',
value: 'MAINNET'
},
spark_l2_external_endpoint: {
label: 'External Sidecar Endpoint. ',
hint: 'If specified then this endpoint will be used instead of the internal sidecar. Make sure to also specify the API key if your sidecar requires authentication.',
value: '',
advanced: true
},
spark_l2_external_api_key: {
label: 'External Sidecar API Key. ',
hint: 'API Key for authenticating with the external sidecar if it requires authentication.',
value: '',
advanced: true
},
spark_l2_pay_wait_ms: {
label: 'Pay Wait Time (ms)',
advanced: true
},
spark_l2_pay_poll_ms: {
label: 'Pay Poll Time (ms)',
advanced: true
},
spark_l2_stream_keepalive_ms: {
label: 'Stream Keepalive Time (ms)',
advanced: true
}
}
],
[
'NWCWallet',
'Nostr Wallet Connect',
@@ -71,10 +71,6 @@ window.app.component('lnbits-wallet-extra', {
'lnbits.exchangeRate.' + this.g.wallet.currency,
this.g.exchangeRate
)
if (this.g.exchangeRate <= 0) {
this.g.fiatTracking = false
this.g.isFiatPriority = false
}
})
.catch(e => console.error(e))
}
+1 -5
View File
@@ -144,7 +144,7 @@ window._lnbitsUtils = {
return null
}
},
async notifyApiError(error) {
notifyApiError(error) {
if (!error.response) {
return console.error(error)
}
@@ -154,10 +154,6 @@ window._lnbitsUtils = {
500: 'negative'
}
let messages = error.response.data.detail
if (!messages) {
const text = await error.response.data?.text()
messages = this.parseJSONSafe(text)?.detail
}
if (messages) {
messages = Array.isArray(messages)
? messages.map(e => e.msg + ` (${e.loc?.join('/')})`)
@@ -310,13 +310,7 @@
<div class="col-12 col-md-6">
<q-select
filled
:options="[
{label: $t('second'), value: 'second'},
{label: $t('minute'), value: 'minute'},
{label: $t('hour'), value: 'hour'}
]"
emit-value
map-options
:options="[$t('second'), $t('minute'), $t('hour')]"
v-model="formData.lnbits_rate_limit_unit"
:label="$t('time_unit')"
></q-select>
+1 -1
View File
@@ -178,7 +178,7 @@
"
:clickable="!!reviewsUrl"
@click="openReviews(extension)"
></lnbits-extension-rating>
/>
<q-btn-group size="xs" style="margin: 5px 0">
<q-btn
v-if="extension.hasFreeRelease"
+4 -10
View File
@@ -292,8 +292,7 @@ async def btc_rates(currency: str) -> list[tuple[str, float]]:
async def btc_price(currency: str) -> float:
rates = await btc_rates(currency)
if not rates:
logger.warning("Could not fetch any Bitcoin price.")
return 0.0
raise ValueError("Could not fetch any Bitcoin price.")
elif len(rates) == 1:
logger.warning("Could only fetch one Bitcoin price.")
@@ -307,8 +306,7 @@ async def get_fiat_rate_and_price_satoshis(currency: str) -> tuple[float, float]
f"btc-price-{currency}",
settings.lnbits_exchange_rate_cache_seconds,
)
rate = float(100_000_000 / price) if price > 0 else 0.0
return rate, price
return float(100_000_000 / price), price
async def get_fiat_rate_satoshis(currency: str) -> float:
@@ -318,13 +316,9 @@ async def get_fiat_rate_satoshis(currency: str) -> float:
async def fiat_amount_as_satoshis(amount: float, currency: str) -> int:
rate = await get_fiat_rate_satoshis(currency)
if rate > 0:
return int(amount * rate)
raise ValueError(f"Could not get exchange rate for {currency}.")
return int(amount * (rate))
async def satoshis_amount_as_fiat(amount: float, currency: str) -> float:
rate = await get_fiat_rate_satoshis(currency)
if rate > 0:
return float(amount / rate)
raise ValueError(f"Could not get exchange rate for {currency}.")
return float(amount / rate)
-2
View File
@@ -20,7 +20,6 @@ from .corelightning import CoreLightningWallet as CLightningWallet
from .corelightningrest import CoreLightningRestWallet
from .eclair import EclairWallet
from .fake import FakeWallet
from .lightspark import LightsparkSparkWallet
from .lnbits import LNbitsWallet
from .lndgrpc import LndWallet
from .lndrest import LndRestWallet
@@ -70,7 +69,6 @@ __all__ = [
"FakeWallet",
"LNPayWallet",
"LNbitsWallet",
"LightsparkSparkWallet",
"LnTipsWallet",
"LndRestWallet",
"LndWallet",
-424
View File
@@ -1,424 +0,0 @@
import asyncio
import hashlib
import json
import os
import shutil
import subprocess
import uuid
from collections.abc import AsyncGenerator
from pathlib import Path
from typing import Any, cast
import httpx
from bolt11 import decode as bolt11_decode
from loguru import logger
from lnbits.helpers import download_url, normalize_endpoint
from lnbits.settings import settings
from .base import (
InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
PaymentSuccessStatus,
StatusResponse,
Wallet,
)
class SparkSidecarError(Exception):
pass
class LightsparkSparkWallet(Wallet):
"""
Spark L2 funding source via a local sidecar service.
Required settings/env:
- SPARK_L2_ENDPOINT (default http://127.0.0.1:8765)
Optional:
- SPARK_L2_API_KEY
"""
def __init__(self):
self._status = "Initializing"
self._sidecar_path = Path(settings.lnbits_data_folder, "light_spark")
self.pending_invoices: list[str] = []
self.endpoint = "http://127.0.0.1:8765"
self._api_key = uuid.uuid4().hex
if settings.spark_l2_internal_sidecar_version:
self._sidecar_version = settings.spark_l2_internal_sidecar_version
self.sidecar_task = asyncio.create_task(self._start_sidecar())
logger.info(f"Internal Spark sidecar ({self._sidecar_version}).")
elif settings.spark_l2_external_endpoint:
self.endpoint = normalize_endpoint(
cast(str, settings.spark_l2_external_endpoint)
)
self._api_key = settings.spark_l2_external_api_key
logger.info(f"Using external Spark sidecar endpoint: {self.endpoint}")
else:
logger.error(
"No Spark sidecar configuration found. Please set either "
"spark_l2_internal_sidecar_version or spark_l2_external_endpoint."
)
headers = {"User-Agent": settings.user_agent, "X-Api-Key": self._api_key}
self.client = httpx.AsyncClient(
base_url=self.endpoint,
headers=headers,
timeout=60,
)
async def cleanup(self):
try:
await self.client.aclose()
self.sidecar_task.cancel()
except RuntimeError as e:
logger.warning(f"Error closing wallet connection: {e}")
async def status(self) -> StatusResponse:
try:
res = await self._request("POST", "/v1/balance")
balance_msat = res.get("balance_msat")
if balance_msat is not None:
return StatusResponse(None, int(balance_msat))
balance_sats = res.get("balance_sats")
if balance_sats is None:
return StatusResponse("Spark sidecar: missing balance.", 0)
return StatusResponse(None, int(balance_sats) * 1000)
except Exception as e:
logger.warning(e)
return StatusResponse(f"Spark sidecar status error: {e}", 0)
async def create_invoice(
self,
amount: int,
memo: str | None = None,
description_hash: bytes | None = None,
unhashed_description: bytes | None = None,
**kwargs,
) -> InvoiceResponse:
expiry = kwargs.get("expiry")
expiry_secs = int(expiry) if expiry else None
description_hash_hex = None
if description_hash:
description_hash_hex = description_hash.hex()
elif unhashed_description:
description_hash_hex = hashlib.sha256(unhashed_description).hexdigest()
try:
payload = {
"amount_sats": int(amount),
"memo": (memo or "") if not description_hash_hex else None,
"description_hash": description_hash_hex,
"expiry_seconds": expiry_secs,
}
res = await self._request("POST", "/v1/invoices", payload)
bolt11 = res.get("payment_request")
checking_id = res.get("checking_id")
if not bolt11 or not checking_id:
return InvoiceResponse(
ok=False,
error_message="Spark sidecar invoice response missing fields.",
)
self.pending_invoices.append(checking_id)
return InvoiceResponse(
ok=True,
payment_request=bolt11,
checking_id=checking_id,
preimage=res.get("preimage", None),
)
except Exception as e:
return InvoiceResponse(ok=False, error_message=str(e))
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
try:
max_fee_sats = (int(fee_limit_msat) + 999) // 1000
payment_hash = None
try:
payment_hash = bolt11_decode(bolt11).payment_hash
except Exception as exc:
logger.warning(exc)
payment_hash = None
payload = {
"bolt11": bolt11,
"max_fee_sats": max_fee_sats,
"payment_hash": payment_hash,
}
res = await self._request("POST", "/v1/payments", payload)
checking_id = payment_hash or res.get("checking_id")
if not checking_id:
return PaymentResponse(
ok=False,
error_message="Spark sidecar payment response missing checking_id.",
)
status = res.get("status")
fee_msat = res.get("fee_msat")
ok = None
if status:
ok = self._map_payment_ok(status)
return PaymentResponse(
ok=ok,
checking_id=checking_id,
fee_msat=int(fee_msat) if fee_msat is not None else None,
preimage=res.get("preimage"),
)
except Exception as e:
return PaymentResponse(ok=False, error_message=str(e))
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
res = await self._request("GET", f"/v1/invoices/{checking_id}")
status = res.get("status")
if not status:
return PaymentPendingStatus()
return self._map_invoice_status(status)
except Exception as exc:
logger.warning(exc)
return PaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try:
res = await self._request("GET", f"/v1/payments/{checking_id}")
status = res.get("status")
fee_msat = res.get("fee_msat")
preimage = res.get("preimage")
if not status:
return PaymentPendingStatus()
mapped = self._map_payment_status(status)
if mapped.success:
return PaymentSuccessStatus(
fee_msat=int(fee_msat) if fee_msat is not None else None,
preimage=preimage,
)
if mapped.failed:
return PaymentFailedStatus()
return PaymentPendingStatus()
except Exception as exc:
logger.warning(exc)
return PaymentPendingStatus()
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
stream_path = "/v1/invoices/stream"
while settings.lnbits_running:
try:
async with self.client.stream("GET", stream_path, timeout=None) as r:
if r.status_code in {404, 405}:
logger.warning(
"Spark sidecar invoice stream not available, "
"falling back to polling."
)
async for checking_id in self._poll_pending_invoices():
yield checking_id
return
r.raise_for_status()
logger.info("connected to Spark sidecar invoice stream.")
async for line in r.aiter_lines():
if not line or not line.startswith("data:"):
continue
data = json.loads(line[5:].strip())
checking_id = data.get("checking_id")
if not checking_id:
continue
yield checking_id
except Exception as exc:
logger.error(
"lost connection to Spark sidecar invoice stream: "
f"'{exc}' retrying in 5 seconds"
)
await asyncio.sleep(5)
async def _request(
self, method: str, path: str, json_data: dict[str, Any] | None = None
) -> dict[str, Any]:
error_message = None
try:
r = await self.client.request(method, path, json=json_data)
r.raise_for_status()
j = r.json()
except (httpx.RequestError, httpx.HTTPStatusError, json.JSONDecodeError) as exc:
if isinstance(exc, httpx.HTTPStatusError) and exc.response is not None:
try:
error_json = exc.response.json()
if "error" in error_json:
error_message = error_json["error"]
except Exception as json_exc:
logger.error(
f"Failed to parse Spark error response as JSON: {json_exc}"
)
raise SparkSidecarError(
error_message or f"Spark sidecar request error: '{exc}'"
) from exc
if error_message or j.get("error"):
raise SparkSidecarError(
error_message or f"Spark sidecar error: {j['error']}"
)
return j
async def _poll_pending_invoices(self) -> AsyncGenerator[str, None]:
while settings.lnbits_running:
for invoice in list(self.pending_invoices):
try:
status = await self.get_invoice_status(invoice)
if status.paid:
yield invoice
self.pending_invoices.remove(invoice)
elif status.failed:
self.pending_invoices.remove(invoice)
except Exception as exc:
logger.error(f"could not get status of invoice {invoice}: '{exc}' ")
await asyncio.sleep(5)
async def _start_sidecar(self):
logger.info("Starting Spark sidecar")
node_path = shutil.which("node")
if not node_path:
logger.error("Node.js not found in PATH, cannot start Spark sidecar")
return
logger.info(f"Node.js found: {node_path}")
repo, version = "spark_sidecar", self._sidecar_version
node_modules_path = Path(self._sidecar_path, f"{repo}-{version}")
await self._prepare_sidecar(repo, version, node_modules_path)
await self._start_sidecar_process(node_path, node_modules_path)
async def _prepare_sidecar(self, repo: str, version: str, node_modules_path: Path):
if not Path(node_modules_path, "package.json").is_file():
await self._download_sidecar(repo, version)
else:
logger.info("Spark sidecar already downloaded.")
if not Path(node_modules_path, "node_modules").is_dir():
self._install_sidecar_packages(node_modules_path)
else:
logger.info("Spark sidecar npm dependencies already installed.")
def _install_sidecar_packages(self, node_modules_path: Path):
logger.info(f"Installing Spark sidecar npm dependencies {node_modules_path}")
npm_path = shutil.which("npm")
if not npm_path:
logger.error("npm not found in PATH, cannot start Spark sidecar")
return
logger.info(f"npm found: {npm_path}")
result = subprocess.run( # noqa: S603
[npm_path, "install"],
cwd=str(node_modules_path),
capture_output=True,
text=True,
shell=False,
check=True, # raises an exception if npm fails
)
logger.info("Spark sidecar npm dependencies installed.")
logger.info("npm install output:")
logger.info(result.stdout)
logger.error(result.stderr)
async def _start_sidecar_process(self, node_path: str, node_modules_path: Path):
logger.info("Starting Spark sidecar node process.")
env = {
"SPARK_NETWORK": settings.spark_l2_network,
"SPARK_SIDECAR_API_KEY": self._api_key or "",
"SPARK_PAY_WAIT_MS": str(settings.spark_l2_pay_wait_ms),
"SPARK_MNEMONIC": str(settings.spark_l2_mnemonic),
}
process = subprocess.Popen( # noqa: S603
[node_path, "server.mjs"],
env=env,
cwd=str(node_modules_path),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
shell=False,
text=True,
)
logger.info("Started Spark sidecar node process.")
await asyncio.to_thread(self._log_process_output, process)
async def _download_sidecar(self, repo: str, version: str):
zip_path = Path(self._sidecar_path, f"{repo}.zip")
logger.info(f"⏳ Downloading Spark sidecar to {zip_path}")
Path(zip_path).parent.mkdir(parents=True, exist_ok=True)
if zip_path.is_file():
os.remove(zip_path)
await asyncio.to_thread(
download_url,
f"https://github.com/lnbits/{repo}/archive/refs/tags/v{version}.zip",
zip_path,
)
logger.info("✅ Downloaded Spark sidecar.")
logger.info("⏳ Extracting Spark sidecar.")
shutil.unpack_archive(
zip_path,
self._sidecar_path,
)
logger.info("✅ Extracted Spark sidecar.")
def _log_process_output(self, process: subprocess.Popen):
if process.stdout:
for line in process.stdout:
logger.warning(f"[Lightspark]: {line}", end="")
else:
logger.error(" No output captured for Spark sidecar.")
def _map_invoice_status(self, status: str) -> PaymentStatus:
success = {
"LIGHTNING_PAYMENT_RECEIVED",
"TRANSFER_COMPLETED",
"PAYMENT_PREIMAGE_RECOVERED",
}
failed = {
"TRANSFER_FAILED",
"PAYMENT_PREIMAGE_RECOVERING_FAILED",
"REFUND_SIGNING_FAILED",
"REFUND_SIGNING_COMMITMENTS_QUERYING_FAILED",
"TRANSFER_CREATION_FAILED",
}
if status in success:
return PaymentSuccessStatus()
if status in failed:
return PaymentFailedStatus()
return PaymentPendingStatus()
def _map_payment_status(self, status: str) -> PaymentStatus:
success = {
"LIGHTNING_PAYMENT_SUCCEEDED",
"TRANSFER_COMPLETED",
"PREIMAGE_PROVIDED",
}
failed = {
"LIGHTNING_PAYMENT_FAILED",
"TRANSFER_FAILED",
"PREIMAGE_PROVIDING_FAILED",
"USER_TRANSFER_VALIDATION_FAILED",
"USER_SWAP_RETURN_FAILED",
}
if status in success:
return PaymentSuccessStatus()
if status in failed:
return PaymentFailedStatus()
return PaymentPendingStatus()
def _map_payment_ok(self, status: str) -> bool | None:
mapped = self._map_payment_status(status)
if mapped.success:
return True
if mapped.failed:
return False
return None