feat: Spark L2 (#3715)

Co-authored-by: blackcoffeexbt <87530449+blackcoffeexbt@users.noreply.github.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
This commit is contained in:
Arc
2026-02-25 07:54:18 +01:00
committed by dni ⚡
co-authored by blackcoffeexbt Vlad Stan
parent 7c28b58aaa
commit c102f10fab
8 changed files with 422 additions and 4 deletions
+3 -1
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"
# 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, SparkL2Wallet"
# uvicorn variable, allow https behind a proxy
# IMPORTANT: this also needs the webserver to be configured to forward the headers
@@ -191,6 +191,8 @@ 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
+4 -1
View File
@@ -50,6 +50,7 @@ Below is a side-by-side comparison of Lightning funding sources you can use with
| **Blink** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; focuses on mobile integrations. |
| **ZBD** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Gaming-focused payment platform. |
| **Spark (CLN)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Web interface for CLN; requires Spark server setup. |
| **Spark (L2)** | Self-custodial | ❌ | Medium | ❌ | Medium | Automatic | Easy | Low | Minimal fees | Medium | Runs via Spark sidecar; seed-based self-custody. |
| **Cliche Wallet** | Self-custodial | ❌ | Medium | ❌ | Medium | Manual | Moderate | Moderate | Minimal fees | Medium | Lightweight wallet; suitable for embedded systems. |
| **Strike** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
| **LNPay** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
@@ -57,7 +58,9 @@ 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). |
---
## Spark (L2)
Spark L2 uses a local Node.js sidecar to expose an HTTP API that LNbits can use as a funding source. It is self-custodial and secured by a standard mnemonic seed. You can let LNbits run the sidecar internally or run your own instance (see `https://github.com/lnbits/spark_sidecar`).
### Notes for readers
+43 -1
View File
@@ -41,6 +41,7 @@ A backend wallet is selected and configured entirely through LNbits environment
| [CoreLightning](#corelightning) | [LND (gRPC)](#lnd-grpc) | [Blink](#blink) |
| [CoreLightning REST](#corelightning-rest) | [LNbits](#lnbits) | [Alby](#alby) |
| [Spark (Core Lightning)](#spark-core-lightning) | [LNPay](#lnpay) | [Boltz](#boltz) |
| [Spark (L2)](#spark-l2) | | |
| [Cliche Wallet](#cliche-wallet) | [ZBD](#zbd) | [Phoenixd](#phoenixd) |
| [Breez SDK](#breez-sdk) | [Breez Liquid SDK](#breez-liquid-sdk) | [Nostr Wallet Connect](#nostr-wallet-connect-nwc) |
| [Strike](#strike) | [Eclair (ACINQ)](#eclair-acinq) | [LN.tips](#lntips) |
@@ -52,7 +53,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**
@@ -127,6 +128,47 @@ Old REST interface using [RTL c-lightning-REST](https://github.com/Ride-The-Ligh
- `SPARK_URL`: `http://10.147.17.230:9737/rpc`
- `SPARK_TOKEN`: `secret_access_key`
## Spark (L2)
Spark L2 is a self-custodial funding source that uses a small Node.js sidecar to talk to the Spark network and expose an HTTP API. You can let LNbits run the sidecar internally, or run it yourself and point LNbits at it.
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `SparkL2Wallet`
**Internal sidecar (LNbits-managed)**
- `SPARK_L2_INTERNAL_SIDECAR_VERSION`: `0.1.1`
- `SPARK_L2_MNEMONIC`: `...` (optional; auto-generated if blank)
- `SPARK_L2_NETWORK`: `MAINNET`
Optional tuning:
- `SPARK_L2_PAY_WAIT_MS`: `4000`
- `SPARK_L2_PAY_POLL_MS`: `500`
- `SPARK_L2_STREAM_KEEPALIVE_MS`: `15000`
**External sidecar**
- `SPARK_L2_EXTERNAL_ENDPOINT`: `http://127.0.0.1:8765`
- `SPARK_L2_EXTERNAL_API_KEY`: `...` (if your sidecar requires auth)
Example: run the sidecar manually (external mode)
```bash
git clone https://github.com/lnbits/spark_sidecar.git
cd spark_sidecar
npm install
SPARK_MNEMONIC="bottom bottom bottom bottom bottom bottom bottom bottom bottom bottom bottom bottom" \
SPARK_NETWORK=MAINNET \
SPARK_SIDECAR_PORT=8765 \
SPARK_PAY_WAIT_MS=20000 \
node server.mjs
```
For testing, you can generate a 12-word mnemonic at `https://iancoleman.io/bip39/`. Store it securely — it controls your funds. Then select **Spark (L2)** as the funding source in LNbits.
## LND (REST)
**Required env vars**
+11
View File
@@ -606,6 +606,15 @@ class SparkFundingSource(LNbitsSettings):
spark_token: str | None = Field(default=None)
class SparkL2FundingSource(LNbitsSettings):
spark_l2_network: str = Field(default="MAINNET")
spark_l2_external_endpoint: str | None = Field(default="http://localhost:8765")
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)
@@ -711,6 +720,7 @@ class FundingSourcesSettings(
PhoenixdFundingSource,
OpenNodeFundingSource,
SparkFundingSource,
SparkL2FundingSource,
LnTipsFundingSource,
NWCFundingSource,
BreezSdkFundingSource,
@@ -1039,6 +1049,7 @@ class SuperUserSettings(LNbitsSettings):
"FakeWallet",
"LNPayWallet",
"LNbitsWallet",
"SparkL2Wallet",
"LnTipsWallet",
"LndRestWallet",
"LndWallet",
File diff suppressed because one or more lines are too long
@@ -228,6 +228,43 @@ window.app.component('lnbits-admin-funding-sources', {
spark_token: 'Token'
}
],
[
'SparkL2Wallet',
'Spark (L2)',
{
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: ''
},
spark_l2_external_api_key: {
label: 'External Sidecar API Key',
hint: 'API key for authenticating with the external sidecar if it requires authentication.',
value: ''
},
spark_l2_network: {
label: 'Network',
value: 'MAINNET',
hint: 'The network to use for the Spark wallet.',
advanced: true
},
spark_l2_pay_wait_ms: {
label: 'Payment Wait Time (ms)',
hint: 'The time to wait for a payment to be processed before considering it failed.',
advanced: true
},
spark_l2_pay_poll_ms: {
label: 'Payment Poll Time (ms)',
hint: 'The time to wait between polling for payment status updates.',
advanced: true
},
spark_l2_stream_keepalive_ms: {
label: 'Stream Keepalive Time (ms)',
hint: 'The time to wait between sending keepalive messages to the Spark sidecar to keep the connection open.',
advanced: true
}
}
],
[
'NWCWallet',
'Nostr Wallet Connect',
+2
View File
@@ -29,6 +29,7 @@ from .nwc import NWCWallet
from .opennode import OpenNodeWallet
from .phoenixd import PhoenixdWallet
from .spark import SparkWallet
from .sparkl2 import SparkL2Wallet
from .strike import StrikeWallet
from .void import VoidWallet
from .zbd import ZBDWallet
@@ -75,6 +76,7 @@ __all__ = [
"NWCWallet",
"OpenNodeWallet",
"PhoenixdWallet",
"SparkL2Wallet",
"SparkWallet",
"StrikeWallet",
"VoidWallet",
+321
View File
@@ -0,0 +1,321 @@
import asyncio
import hashlib
import json
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 normalize_endpoint
from lnbits.settings import settings
from .base import (
InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
PaymentSuccessStatus,
StatusResponse,
Wallet,
)
class SparkSidecarError(Exception):
pass
class SparkL2Wallet(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_external_endpoint:
self.endpoint = normalize_endpoint(
cast(str, settings.spark_l2_external_endpoint)
)
logger.info(f"Using external Spark sidecar endpoint: {self.endpoint}")
else:
logger.error("No Spark sidecar endpoint configuration found.")
if settings.spark_l2_external_api_key:
self._api_key = cast(str, settings.spark_l2_external_api_key)
logger.info("Using external Spark sidecar API.")
else:
logger.warning("No Spark sidecar API key configured.")
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()
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)
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