From 6b233cc067ce6cc91d5f1f95822b5f16873f772b Mon Sep 17 00:00:00 2001 From: Arc Date: Sat, 10 Jan 2026 14:22:17 +0000 Subject: [PATCH] working --- lnbits/settings.py | 7 + .../admin/lnbits-admin-funding-sources.js | 14 ++ lnbits/wallets/__init__.py | 2 + lnbits/wallets/lightspark.py | 226 ++++++++++++++++++ lnbits/wallets/sidecars/spark/README.md | 35 +++ lnbits/wallets/sidecars/spark/package.json | 8 + lnbits/wallets/sidecars/spark/server.mjs | 170 +++++++++++++ 7 files changed, 462 insertions(+) create mode 100644 lnbits/wallets/lightspark.py create mode 100644 lnbits/wallets/sidecars/spark/README.md create mode 100644 lnbits/wallets/sidecars/spark/package.json create mode 100644 lnbits/wallets/sidecars/spark/server.mjs diff --git a/lnbits/settings.py b/lnbits/settings.py index 2f4692236..d6c40b401 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -578,6 +578,11 @@ class SparkFundingSource(LNbitsSettings): 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): lntips_api_endpoint: str | None = Field(default=None) lntips_api_key: str | None = Field(default=None) @@ -683,6 +688,7 @@ class FundingSourcesSettings( PhoenixdFundingSource, OpenNodeFundingSource, SparkFundingSource, + SparkL2FundingSource, LnTipsFundingSource, NWCFundingSource, BreezSdkFundingSource, @@ -1010,6 +1016,7 @@ class SuperUserSettings(LNbitsSettings): "FakeWallet", "LNPayWallet", "LNbitsWallet", + "LightsparkSparkWallet", "LnTipsWallet", "LndRestWallet", "LndWallet", diff --git a/lnbits/static/js/components/admin/lnbits-admin-funding-sources.js b/lnbits/static/js/components/admin/lnbits-admin-funding-sources.js index df65ea76e..f86140145 100644 --- a/lnbits/static/js/components/admin/lnbits-admin-funding-sources.js +++ b/lnbits/static/js/components/admin/lnbits-admin-funding-sources.js @@ -229,6 +229,20 @@ window.app.component('lnbits-admin-funding-sources', { 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', 'Nostr Wallet Connect', diff --git a/lnbits/wallets/__init__.py b/lnbits/wallets/__init__.py index 4833345f9..1a3f08128 100644 --- a/lnbits/wallets/__init__.py +++ b/lnbits/wallets/__init__.py @@ -32,6 +32,7 @@ from .spark import SparkWallet from .strike import StrikeWallet from .void import VoidWallet from .zbd import ZBDWallet +from .lightspark import LightsparkSparkWallet def set_funding_source(class_name: str | None = None) -> None: @@ -76,6 +77,7 @@ __all__ = [ "OpenNodeWallet", "PhoenixdWallet", "SparkWallet", + "LightsparkSparkWallet", "StrikeWallet", "VoidWallet", "ZBDWallet", diff --git a/lnbits/wallets/lightspark.py b/lnbits/wallets/lightspark.py new file mode 100644 index 000000000..5895890b9 --- /dev/null +++ b/lnbits/wallets/lightspark.py @@ -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 diff --git a/lnbits/wallets/sidecars/spark/README.md b/lnbits/wallets/sidecars/spark/README.md new file mode 100644 index 000000000..6bd1627e2 --- /dev/null +++ b/lnbits/wallets/sidecars/spark/README.md @@ -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}` diff --git a/lnbits/wallets/sidecars/spark/package.json b/lnbits/wallets/sidecars/spark/package.json new file mode 100644 index 000000000..9d9ea931a --- /dev/null +++ b/lnbits/wallets/sidecars/spark/package.json @@ -0,0 +1,8 @@ +{ + "name": "lnbits-spark-sidecar", + "private": true, + "type": "module", + "dependencies": { + "@buildonspark/spark-sdk": "^0.5.5" + } +} diff --git a/lnbits/wallets/sidecars/spark/server.mjs b/lnbits/wallets/sidecars/spark/server.mjs new file mode 100644 index 000000000..ccd7633a3 --- /dev/null +++ b/lnbits/wallets/sidecars/spark/server.mjs @@ -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}`); +});