This commit is contained in:
Arc
2026-02-13 16:58:52 +02:00
committed by Vlad Stan
parent f54824cc44
commit 3d653a056c
7 changed files with 462 additions and 0 deletions
+7
View File
@@ -602,6 +602,11 @@ class SparkFundingSource(LNbitsSettings):
spark_token: str | None = Field(default=None) spark_token: str | None = Field(default=None)
class SparkL2FundingSource(LNbitsSettings):
spark_l2_endpoint: str | None = Field(default="http://127.0.0.1:8765")
spark_l2_api_key: str | None = Field(default=None)
class LnTipsFundingSource(LNbitsSettings): class LnTipsFundingSource(LNbitsSettings):
lntips_api_endpoint: str | None = Field(default=None) lntips_api_endpoint: str | None = Field(default=None)
lntips_api_key: str | None = Field(default=None) lntips_api_key: str | None = Field(default=None)
@@ -707,6 +712,7 @@ class FundingSourcesSettings(
PhoenixdFundingSource, PhoenixdFundingSource,
OpenNodeFundingSource, OpenNodeFundingSource,
SparkFundingSource, SparkFundingSource,
SparkL2FundingSource,
LnTipsFundingSource, LnTipsFundingSource,
NWCFundingSource, NWCFundingSource,
BreezSdkFundingSource, BreezSdkFundingSource,
@@ -1035,6 +1041,7 @@ class SuperUserSettings(LNbitsSettings):
"FakeWallet", "FakeWallet",
"LNPayWallet", "LNPayWallet",
"LNbitsWallet", "LNbitsWallet",
"LightsparkSparkWallet",
"LnTipsWallet", "LnTipsWallet",
"LndRestWallet", "LndRestWallet",
"LndWallet", "LndWallet",
@@ -228,6 +228,20 @@ window.app.component('lnbits-admin-funding-sources', {
spark_token: 'Token' spark_token: 'Token'
} }
], ],
[
'LightsparkSparkWallet',
'Spark (L2)',
{
spark_l2_endpoint: {
label: 'Sidecar Endpoint',
value: 'http://127.0.0.1:8765'
},
spark_l2_api_key: {
label: 'Sidecar API Key',
advanced: true
}
}
],
[ [
'NWCWallet', 'NWCWallet',
'Nostr Wallet Connect', 'Nostr Wallet Connect',
+2
View File
@@ -32,6 +32,7 @@ from .spark import SparkWallet
from .strike import StrikeWallet from .strike import StrikeWallet
from .void import VoidWallet from .void import VoidWallet
from .zbd import ZBDWallet from .zbd import ZBDWallet
from .lightspark import LightsparkSparkWallet
def set_funding_source(class_name: str | None = None) -> None: def set_funding_source(class_name: str | None = None) -> None:
@@ -76,6 +77,7 @@ __all__ = [
"OpenNodeWallet", "OpenNodeWallet",
"PhoenixdWallet", "PhoenixdWallet",
"SparkWallet", "SparkWallet",
"LightsparkSparkWallet",
"StrikeWallet", "StrikeWallet",
"VoidWallet", "VoidWallet",
"ZBDWallet", "ZBDWallet",
+226
View File
@@ -0,0 +1,226 @@
import hashlib
import json
from typing import Any
import httpx
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 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):
super().__init__()
self.endpoint = normalize_endpoint(
getattr(settings, "spark_l2_endpoint", None)
or getattr(settings, "SPARK_L2_ENDPOINT", None)
or "http://127.0.0.1:8765"
)
api_key = getattr(settings, "spark_l2_api_key", None) or getattr(
settings, "SPARK_L2_API_KEY", None
)
headers = {"User-Agent": settings.user_agent}
if api_key:
headers["X-Api-Key"] = 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 _request(
self, method: str, path: str, json_data: dict[str, Any] | None = None
) -> dict[str, Any]:
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:
raise SparkSidecarError(f"Spark sidecar request failed: {exc}") from exc
if "error" in j and j["error"]:
raise SparkSidecarError(str(j["error"]))
return j
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:
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:
raise SparkSidecarError("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"),
)
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
payload = {"bolt11": bolt11, "max_fee_sats": max_fee_sats}
res = await self._request("POST", "/v1/payments", payload)
checking_id = res.get("checking_id")
if not checking_id:
raise SparkSidecarError("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:
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:
return PaymentPendingStatus()
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
+35
View File
@@ -0,0 +1,35 @@
# Spark L2 sidecar
This sidecar exposes a small HTTP API for LNbits to talk to the Spark L2 SDK.
## Install
```
cd lnbits/wallets/sidecars/spark
npm install
```
## Run
```
SPARK_MNEMONIC="your seed phrase" \
SPARK_NETWORK=MAINNET \
SPARK_SIDECAR_PORT=8765 \
node server.mjs
```
Optional auth:
```
SPARK_SIDECAR_API_KEY="mykey"
```
Set the same key in LNbits as `SPARK_L2_API_KEY`.
## Endpoints
- `POST /v1/balance`
- `POST /v1/invoices`
- `POST /v1/payments`
- `GET /v1/invoices/{id}`
- `GET /v1/payments/{id}`
@@ -0,0 +1,8 @@
{
"name": "lnbits-spark-sidecar",
"private": true,
"type": "module",
"dependencies": {
"@buildonspark/spark-sdk": "^0.5.5"
}
}
+170
View File
@@ -0,0 +1,170 @@
import http from "node:http";
import { SparkWallet } from "@buildonspark/spark-sdk";
const PORT = parseInt(process.env.SPARK_SIDECAR_PORT || "8765", 10);
const API_KEY = process.env.SPARK_SIDECAR_API_KEY || "";
const MNEMONIC = process.env.SPARK_MNEMONIC || "";
const NETWORK = process.env.SPARK_NETWORK || "MAINNET";
const ACCOUNT_NUMBER = process.env.SPARK_ACCOUNT_NUMBER
? parseInt(process.env.SPARK_ACCOUNT_NUMBER, 10)
: undefined;
if (!MNEMONIC) {
console.error("Missing SPARK_MNEMONIC for Spark sidecar.");
process.exit(1);
}
let walletPromise;
async function getWallet() {
if (!walletPromise) {
walletPromise = SparkWallet.initialize({
mnemonicOrSeed: MNEMONIC,
accountNumber: ACCOUNT_NUMBER,
options: { network: NETWORK },
}).then(({ wallet }) => wallet);
}
return walletPromise;
}
function sendJson(res, statusCode, payload) {
res.writeHead(statusCode, { "content-type": "application/json" });
res.end(JSON.stringify(payload));
}
async function readJson(req) {
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
if (chunks.length === 0) {
return {};
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
function feeToMsat(fee) {
if (!fee || fee.originalValue === undefined || !fee.originalUnit) {
return null;
}
const value = Number(fee.originalValue);
if (!Number.isFinite(value)) {
return null;
}
switch (fee.originalUnit) {
case "MILLISATOSHI":
return BigInt(Math.round(value)).toString();
case "SATOSHI":
return BigInt(Math.round(value * 1000)).toString();
case "BITCOIN":
return BigInt(Math.round(value * 100_000_000_000)).toString();
default:
return BigInt(Math.round(value * 1000)).toString();
}
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
if (API_KEY && req.headers["x-api-key"] !== API_KEY) {
return sendJson(res, 401, { error: "Unauthorized" });
}
try {
if (req.method === "GET" && url.pathname === "/health") {
return sendJson(res, 200, { status: "ok" });
}
if (req.method === "POST" && url.pathname === "/v1/balance") {
const wallet = await getWallet();
const balance = await wallet.getBalance();
const sats = BigInt(balance.balance);
return sendJson(res, 200, {
balance_sats: sats.toString(),
balance_msat: (sats * 1000n).toString(),
});
}
if (req.method === "POST" && url.pathname === "/v1/invoices") {
const wallet = await getWallet();
const body = await readJson(req);
const amountSats = Number(body.amount_sats);
if (!Number.isFinite(amountSats) || amountSats < 0) {
return sendJson(res, 400, { error: "Invalid amount_sats" });
}
const invoice = await wallet.createLightningInvoice({
amountSats,
memo: body.memo || undefined,
descriptionHash: body.description_hash || undefined,
expirySeconds: body.expiry_seconds || undefined,
});
return sendJson(res, 200, {
checking_id: invoice.id,
payment_request: invoice.invoice.encodedInvoice,
payment_hash: invoice.invoice.paymentHash,
status: invoice.status,
preimage: invoice.paymentPreimage || null,
});
}
if (req.method === "POST" && url.pathname === "/v1/payments") {
const wallet = await getWallet();
const body = await readJson(req);
const bolt11 = body.bolt11;
if (!bolt11) {
return sendJson(res, 400, { error: "Missing bolt11" });
}
const maxFeeSats = Number(body.max_fee_sats || 0);
const amountSatsToSend = body.amount_sats ? Number(body.amount_sats) : undefined;
const payment = await wallet.payLightningInvoice({
invoice: bolt11,
maxFeeSats,
amountSatsToSend,
});
return sendJson(res, 200, {
checking_id: payment.id,
status: payment.status,
fee_msat: feeToMsat(payment.fee),
preimage: payment.paymentPreimage || null,
});
}
const parts = url.pathname.split("/").filter(Boolean);
if (parts.length === 3 && parts[0] === "v1" && parts[1] === "invoices") {
const wallet = await getWallet();
const invoice = await wallet.getLightningReceiveRequest(parts[2]);
if (!invoice) {
return sendJson(res, 404, { error: "Not found" });
}
return sendJson(res, 200, {
checking_id: invoice.id,
status: invoice.status,
payment_hash: invoice.invoice.paymentHash,
preimage: invoice.paymentPreimage || null,
});
}
if (parts.length === 3 && parts[0] === "v1" && parts[1] === "payments") {
const wallet = await getWallet();
const payment = await wallet.getLightningSendRequest(parts[2]);
if (!payment) {
return sendJson(res, 404, { error: "Not found" });
}
return sendJson(res, 200, {
checking_id: payment.id,
status: payment.status,
fee_msat: feeToMsat(payment.fee),
preimage: payment.paymentPreimage || null,
});
}
return sendJson(res, 404, { error: "Not found" });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return sendJson(res, 500, { error: message });
}
});
server.listen(PORT, () => {
console.log(`Spark sidecar listening on :${PORT}`);
});