Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06f9cf6a57 | ||
|
|
e990901548 | ||
|
|
3b040ee22c | ||
|
|
a738d8002f | ||
|
|
c6790feb14 | ||
|
|
e0d6f3f1c4 | ||
|
|
96fe807c5d | ||
|
|
ea75083663 | ||
|
|
87ae34d4b7 | ||
|
|
da0e7ba427 | ||
|
|
68229be4b4 | ||
|
|
7d9d975312 | ||
|
|
1f948bbe13 | ||
|
|
e8c8ae6537 | ||
|
|
a564627221 | ||
|
|
da57da3656 | ||
|
|
34f503626d | ||
|
|
e84c55f897 | ||
|
|
b688c918c1 | ||
|
|
e261fdd776 | ||
|
|
a59996d5d4 | ||
|
|
64e83e25dc | ||
|
|
d7f9caaaf6 | ||
|
|
3d653a056c | ||
|
|
f54824cc44 | ||
|
|
768d2cbe2a | ||
|
|
efd36d0221 | ||
|
|
4684939e13 | ||
|
|
193ee20da4 |
+3
-1
@@ -59,7 +59,7 @@ LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
|||||||
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
|
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
# 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, 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, LightsparkSparkWallet"
|
||||||
|
|
||||||
# uvicorn variable, allow https behind a proxy
|
# uvicorn variable, allow https behind a proxy
|
||||||
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
# 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
|
# HEXSTRING instead of path also possible
|
||||||
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert"
|
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert"
|
||||||
|
|
||||||
|
# TODO: add Spark
|
||||||
|
|
||||||
# StrikeWallet
|
# StrikeWallet
|
||||||
STRIKE_API_ENDPOINT=https://api.strike.me/v1
|
STRIKE_API_ENDPOINT=https://api.strike.me/v1
|
||||||
STRIKE_API_KEY=YOUR_STRIKE_API_KEY
|
STRIKE_API_KEY=YOUR_STRIKE_API_KEY
|
||||||
|
|||||||
@@ -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. |
|
| **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). |
|
| **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
|
### Notes for readers
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
### 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)
|
Should also work with the [Rust version of CLNRest](https://github.com/daywalker90/clnrest-rs)
|
||||||
|
|
||||||
**Environment variables**
|
**Environment variables**
|
||||||
@@ -338,6 +338,8 @@ Configure in the admin UI or via env vars:
|
|||||||
|
|
||||||
<a id="strike"></a>
|
<a id="strike"></a>
|
||||||
|
|
||||||
|
## TODO: add Spark
|
||||||
|
|
||||||
## Strike (alpha)
|
## 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 user’s behalf once scopes are granted. Requires a Strike business account, registered OAuth client, minimal scopes, and login/logout redirect URLs.
|
Custodial provider integrated via **Strike OAuth Connect** (OAuth 2.0 / OIDC). Authenticate a Strike user in your app, then call Strike APIs on the user’s behalf once scopes are granted. Requires a Strike business account, registered OAuth client, minimal scopes, and login/logout redirect URLs.
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ class RegisterUser(BaseModel):
|
|||||||
username: str = Query(default=..., min_length=2, max_length=20)
|
username: str = Query(default=..., min_length=2, max_length=20)
|
||||||
password: str = Query(default=..., min_length=8, max_length=50)
|
password: str = Query(default=..., min_length=8, max_length=50)
|
||||||
password_repeat: 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, min_length=1, max_length=256)
|
invitation_code: str | None = Query(default=None, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
class CreateUser(BaseModel):
|
class CreateUser(BaseModel):
|
||||||
|
|||||||
@@ -118,8 +118,8 @@ async def verify_paypal_webhook(headers, payload: bytes):
|
|||||||
"""
|
"""
|
||||||
webhook_id = settings.paypal_webhook_id
|
webhook_id = settings.paypal_webhook_id
|
||||||
if not webhook_id:
|
if not webhook_id:
|
||||||
logger.warning("PayPal webhook ID not set; skipping verification.")
|
logger.warning("PayPal webhook ID not set.")
|
||||||
return
|
raise ValueError("PayPal webhook cannot be verified. Missing webhook ID.")
|
||||||
|
|
||||||
required_headers = {
|
required_headers = {
|
||||||
"PAYPAL-TRANSMISSION-ID": headers.get("PAYPAL-TRANSMISSION-ID"),
|
"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"),
|
"PAYPAL-AUTH-ALGO": headers.get("PAYPAL-AUTH-ALGO"),
|
||||||
}
|
}
|
||||||
if not all(required_headers.values()):
|
if not all(required_headers.values()):
|
||||||
logger.warning("Missing PayPal webhook headers; skipping verification.")
|
logger.warning("Missing PayPal webhook headers.")
|
||||||
return
|
raise ValueError("PayPal webhook cannot be verified. Missing headers.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(base_url=settings.paypal_api_endpoint) as client:
|
async with httpx.AsyncClient(base_url=settings.paypal_api_endpoint) as client:
|
||||||
|
|||||||
@@ -511,15 +511,21 @@ async def update_ui_customization(
|
|||||||
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
||||||
if not settings.first_install:
|
if not settings.first_install:
|
||||||
raise HTTPException(HTTPStatus.FORBIDDEN, "This is not your first install")
|
raise HTTPException(HTTPStatus.FORBIDDEN, "This is not your first install")
|
||||||
|
|
||||||
if settings.first_install_token:
|
if settings.first_install_token:
|
||||||
if not data.first_install_token:
|
if not data.first_install_token:
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing first_install_token.")
|
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing first_install_token.")
|
||||||
if settings.first_install_token != data.first_install_token:
|
if settings.first_install_token != data.first_install_token:
|
||||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid 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)
|
account = await get_account(settings.super_user)
|
||||||
if not account:
|
if not account:
|
||||||
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.")
|
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.")
|
||||||
|
|
||||||
account.username = data.username
|
account.username = data.username
|
||||||
account.extra = account.extra or UserExtra()
|
account.extra = account.extra or UserExtra()
|
||||||
account.extra.provider = "lnbits"
|
account.extra.provider = "lnbits"
|
||||||
|
|||||||
@@ -602,6 +602,17 @@ class SparkFundingSource(LNbitsSettings):
|
|||||||
spark_token: str | None = Field(default=None)
|
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):
|
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 +718,7 @@ class FundingSourcesSettings(
|
|||||||
PhoenixdFundingSource,
|
PhoenixdFundingSource,
|
||||||
OpenNodeFundingSource,
|
OpenNodeFundingSource,
|
||||||
SparkFundingSource,
|
SparkFundingSource,
|
||||||
|
SparkL2FundingSource,
|
||||||
LnTipsFundingSource,
|
LnTipsFundingSource,
|
||||||
NWCFundingSource,
|
NWCFundingSource,
|
||||||
BreezSdkFundingSource,
|
BreezSdkFundingSource,
|
||||||
@@ -1035,6 +1047,7 @@ class SuperUserSettings(LNbitsSettings):
|
|||||||
"FakeWallet",
|
"FakeWallet",
|
||||||
"LNPayWallet",
|
"LNPayWallet",
|
||||||
"LNbitsWallet",
|
"LNbitsWallet",
|
||||||
|
"LightsparkSparkWallet",
|
||||||
"LnTipsWallet",
|
"LnTipsWallet",
|
||||||
"LndRestWallet",
|
"LndRestWallet",
|
||||||
"LndWallet",
|
"LndWallet",
|
||||||
|
|||||||
@@ -228,6 +228,51 @@ window.app.component('lnbits-admin-funding-sources', {
|
|||||||
spark_token: 'Token'
|
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',
|
'NWCWallet',
|
||||||
'Nostr Wallet Connect',
|
'Nostr Wallet Connect',
|
||||||
|
|||||||
@@ -310,7 +310,13 @@
|
|||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<q-select
|
<q-select
|
||||||
filled
|
filled
|
||||||
:options="[$t('second'), $t('minute'), $t('hour')]"
|
:options="[
|
||||||
|
{label: $t('second'), value: 'second'},
|
||||||
|
{label: $t('minute'), value: 'minute'},
|
||||||
|
{label: $t('hour'), value: 'hour'}
|
||||||
|
]"
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
v-model="formData.lnbits_rate_limit_unit"
|
v-model="formData.lnbits_rate_limit_unit"
|
||||||
:label="$t('time_unit')"
|
:label="$t('time_unit')"
|
||||||
></q-select>
|
></q-select>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from .corelightning import CoreLightningWallet as CLightningWallet
|
|||||||
from .corelightningrest import CoreLightningRestWallet
|
from .corelightningrest import CoreLightningRestWallet
|
||||||
from .eclair import EclairWallet
|
from .eclair import EclairWallet
|
||||||
from .fake import FakeWallet
|
from .fake import FakeWallet
|
||||||
|
from .lightspark import LightsparkSparkWallet
|
||||||
from .lnbits import LNbitsWallet
|
from .lnbits import LNbitsWallet
|
||||||
from .lndgrpc import LndWallet
|
from .lndgrpc import LndWallet
|
||||||
from .lndrest import LndRestWallet
|
from .lndrest import LndRestWallet
|
||||||
@@ -69,6 +70,7 @@ __all__ = [
|
|||||||
"FakeWallet",
|
"FakeWallet",
|
||||||
"LNPayWallet",
|
"LNPayWallet",
|
||||||
"LNbitsWallet",
|
"LNbitsWallet",
|
||||||
|
"LightsparkSparkWallet",
|
||||||
"LnTipsWallet",
|
"LnTipsWallet",
|
||||||
"LndRestWallet",
|
"LndRestWallet",
|
||||||
"LndWallet",
|
"LndWallet",
|
||||||
|
|||||||
@@ -0,0 +1,424 @@
|
|||||||
|
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
|
||||||
Reference in New Issue
Block a user