Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0cf512d3e | ||
|
|
b86720f59e | ||
|
|
13c370fb08 | ||
|
|
3b4e8ace6a | ||
|
|
390c616998 | ||
|
|
e5e6ee3628 | ||
|
|
f962502610 | ||
|
|
f40cb536ba | ||
|
|
db9a9eb8c2 | ||
|
|
8ba662ff99 | ||
|
|
e5acd6188e | ||
|
|
46ebb6ce27 | ||
|
|
c7398f6314 | ||
|
|
efa6398c51 | ||
|
|
7179dbfeef | ||
|
|
4021648082 | ||
|
|
d5236aa88f | ||
|
|
ff4132ef95 | ||
|
|
7b2805ef46 | ||
|
|
3bbfc2a58c | ||
|
|
00795a186d | ||
|
|
fa0c57ce3d | ||
|
|
ff6959095f | ||
|
|
e951d02c3d | ||
|
|
fadad38c99 |
+1
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 user’s behalf once scopes are granted. Requires a Strike business account, registered OAuth client, minimal scopes, and login/logout redirect URLs.
|
||||
|
||||
+26
-31
@@ -98,17 +98,19 @@ async def get_accounts(
|
||||
|
||||
|
||||
async def get_account(
|
||||
user_id: str, active_only: bool = True, conn: Connection | None = None
|
||||
user_id: str, activated: bool | None = True, conn: Connection | None = None
|
||||
) -> Account | None:
|
||||
if len(user_id) == 0:
|
||||
return None
|
||||
|
||||
activate_clause = "" if activated is None else "AND activated = :activated"
|
||||
|
||||
return await (conn or db).fetchone(
|
||||
"""
|
||||
f"""
|
||||
SELECT * FROM accounts
|
||||
WHERE id = :id AND (activated = true OR activated = :activated)
|
||||
""",
|
||||
{"id": user_id, "activated": active_only},
|
||||
WHERE id = :id {activate_clause}
|
||||
""", # noqa: S608
|
||||
{"id": user_id, "activated": activated},
|
||||
Account,
|
||||
)
|
||||
|
||||
@@ -134,7 +136,7 @@ async def delete_accounts_no_wallets(
|
||||
|
||||
|
||||
async def get_account_by_username(
|
||||
username: str, active_only: bool = True, conn: Connection | None = None
|
||||
username: str, activated: bool = True, conn: Connection | None = None
|
||||
) -> Account | None:
|
||||
if len(username) == 0:
|
||||
return None
|
||||
@@ -142,32 +144,28 @@ async def get_account_by_username(
|
||||
return await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT * FROM accounts
|
||||
WHERE
|
||||
LOWER(username) = :username
|
||||
AND (activated = true OR activated = :activated)
|
||||
WHERE LOWER(username) = :username AND activated = :activated
|
||||
""",
|
||||
{"username": username.lower(), "activated": active_only},
|
||||
{"username": username.lower(), "activated": activated},
|
||||
Account,
|
||||
)
|
||||
|
||||
|
||||
async def get_account_by_pubkey(
|
||||
pubkey: str, active_only: bool = True, conn: Connection | None = None
|
||||
pubkey: str, activated: bool | None = True, conn: Connection | None = None
|
||||
) -> Account | None:
|
||||
return await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT * FROM accounts
|
||||
WHERE
|
||||
LOWER(pubkey) = :pubkey
|
||||
AND (activated = true OR activated = :activated)
|
||||
WHERE LOWER(pubkey) = :pubkey AND activated = :activated
|
||||
""",
|
||||
{"pubkey": pubkey.lower(), "activated": active_only},
|
||||
{"pubkey": pubkey.lower(), "activated": activated},
|
||||
Account,
|
||||
)
|
||||
|
||||
|
||||
async def get_account_by_email(
|
||||
email: str, active_only: bool = True, conn: Connection | None = None
|
||||
email: str, activated: bool = True, conn: Connection | None = None
|
||||
) -> Account | None:
|
||||
if len(email) == 0:
|
||||
return None
|
||||
@@ -175,38 +173,35 @@ async def get_account_by_email(
|
||||
return await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT * FROM accounts
|
||||
WHERE
|
||||
LOWER(email) = :email
|
||||
AND (activated = true OR activated = :activated)
|
||||
WHERE LOWER(email) = :email AND activated = :activated
|
||||
""",
|
||||
{"email": email.lower(), "activated": active_only},
|
||||
{"email": email.lower(), "activated": activated},
|
||||
Account,
|
||||
)
|
||||
|
||||
|
||||
async def get_account_by_username_or_email(
|
||||
username_or_email: str,
|
||||
active_only: bool = True,
|
||||
activated: bool = True,
|
||||
conn: Connection | None = None,
|
||||
) -> Account | None:
|
||||
|
||||
return await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT * FROM accounts
|
||||
WHERE
|
||||
(LOWER(email) = :value or LOWER(username) = :value)
|
||||
AND (activated = true OR activated = :activated)
|
||||
WHERE (LOWER(email) = :value or LOWER(username) = :value)
|
||||
AND activated = :activated
|
||||
""",
|
||||
{"value": username_or_email.lower(), "activated": active_only},
|
||||
{"value": username_or_email.lower(), "activated": activated},
|
||||
Account,
|
||||
)
|
||||
|
||||
|
||||
async def get_user(
|
||||
user_id: str, active_only: bool = True, conn: Connection | None = None
|
||||
user_id: str, activated: bool | None = True, conn: Connection | None = None
|
||||
) -> User | None:
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||
account = await get_account(user_id, active_only, conn=conn)
|
||||
account = await get_account(user_id, activated=activated, conn=conn)
|
||||
if not account:
|
||||
return None
|
||||
return await get_user_from_account(account, conn=conn)
|
||||
@@ -251,14 +246,14 @@ async def update_user_access_control_list(
|
||||
|
||||
|
||||
async def get_user_access_control_lists(
|
||||
user_id: str, active_only: bool = True, conn: Connection | None = None
|
||||
user_id: str, activated: bool = True, conn: Connection | None = None
|
||||
) -> UserAcls:
|
||||
user_acls = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT id, access_control_list FROM accounts
|
||||
WHERE id = :user_id AND (activated = true OR activated = :activated)
|
||||
WHERE id = :user_id AND activated = :activated
|
||||
""",
|
||||
{"user_id": user_id, "activated": active_only},
|
||||
{"user_id": user_id, "activated": activated},
|
||||
UserAcls,
|
||||
)
|
||||
|
||||
@@ -266,7 +261,7 @@ async def get_user_access_control_lists(
|
||||
|
||||
|
||||
async def clear_user_id_cache(user_id: str):
|
||||
user = await get_user(user_id, active_only=True)
|
||||
user = await get_user(user_id, activated=None)
|
||||
if user:
|
||||
clear_user_cache(user)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -776,7 +776,7 @@ async def _pay_internal_invoice(
|
||||
await update_payment(internal_payment, conn=conn)
|
||||
logger.success(f"internal payment successful {internal_payment.checking_id}")
|
||||
|
||||
await _send_payment_notification_in_background(wallet.id, payment, conn=conn)
|
||||
send_payment_notification_in_background(wallet, payment)
|
||||
|
||||
# notify receiver asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
@@ -849,8 +849,7 @@ async def _pay_external_invoice(
|
||||
payment = await update_payment_success_status(
|
||||
payment, payment_response, conn=conn
|
||||
)
|
||||
|
||||
await _send_payment_notification_in_background(wallet.id, payment, conn=conn)
|
||||
send_payment_notification_in_background(wallet, payment)
|
||||
logger.success(f"payment successful {payment_response.checking_id}")
|
||||
|
||||
payment.checking_id = payment_response.checking_id
|
||||
@@ -1058,13 +1057,3 @@ async def cancel_hold_invoice(payment: Payment) -> InvoiceResponse:
|
||||
await update_payment(payment)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
async def _send_payment_notification_in_background(
|
||||
wallet_id: str, payment: Payment, conn: Connection | None = None
|
||||
):
|
||||
# fetch balance again
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
if not wallet:
|
||||
raise PaymentError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
|
||||
send_payment_notification_in_background(wallet, payment)
|
||||
|
||||
@@ -199,11 +199,8 @@ async def init_admin_settings(super_user: str | None = None) -> SuperSettings:
|
||||
async def check_register_activation_settings(data: RegisterUser):
|
||||
if not settings.lnbits_require_user_activation:
|
||||
return None
|
||||
if settings.lnbits_user_activation_by_invitation_code:
|
||||
code = data.invitation_code.strip() if data.invitation_code else ""
|
||||
if len(code) == 0:
|
||||
raise ValueError("Invitation code cannot be empty.")
|
||||
|
||||
if settings.lnbits_user_activation_by_invitation_code and data.invitation_code:
|
||||
code = data.invitation_code.strip()
|
||||
if code == settings.lnbits_register_reusable_activation_code:
|
||||
return None
|
||||
if code in settings.lnbits_register_one_time_activation_codes:
|
||||
|
||||
@@ -98,7 +98,7 @@ async def nostr_login(request: Request) -> JSONResponse:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Login with Nostr Auth not allowed.")
|
||||
event = _nostr_nip98_event(request)
|
||||
account = await get_account_by_pubkey(event["pubkey"], active_only=False)
|
||||
account = await get_account_by_pubkey(event["pubkey"])
|
||||
if not account:
|
||||
account = Account(
|
||||
id=uuid4().hex,
|
||||
@@ -106,8 +106,6 @@ async def nostr_login(request: Request) -> JSONResponse:
|
||||
extra=UserExtra(provider="nostr"),
|
||||
)
|
||||
await create_user_account(account)
|
||||
if not account.activated:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User is not activated.")
|
||||
return _auth_success_response(account.username or "", account.id, account.email)
|
||||
|
||||
|
||||
@@ -363,7 +361,7 @@ async def register(data: RegisterUser) -> JSONResponse:
|
||||
if not is_valid_username(data.username):
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
|
||||
|
||||
if await get_account_by_username(data.username, active_only=False):
|
||||
if await get_account_by_username(data.username):
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
|
||||
|
||||
if data.email and not is_valid_email_address(data.email):
|
||||
@@ -511,21 +509,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"
|
||||
@@ -541,7 +533,7 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: str | None = Non
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
|
||||
|
||||
redirect_path = "/wallet"
|
||||
account = await get_account_by_email(email, active_only=False)
|
||||
account = await get_account_by_email(email)
|
||||
|
||||
if verified_user_id:
|
||||
if account:
|
||||
|
||||
@@ -74,7 +74,7 @@ async def api_get_users(
|
||||
summary="Get user by Id",
|
||||
)
|
||||
async def api_get_user(user_id: str) -> User:
|
||||
user = await get_user(user_id, active_only=False)
|
||||
user = await get_user(user_id, activated=None)
|
||||
if not user:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
|
||||
return user
|
||||
@@ -242,7 +242,7 @@ async def api_users_toggle_activated(
|
||||
if settings.is_admin_user(user_id):
|
||||
settings.lnbits_admin_users.remove(user_id)
|
||||
|
||||
user_account = await get_account(user_id, active_only=False)
|
||||
user_account = await get_account(user_id, activated=None)
|
||||
if not user_account:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
|
||||
+42
-9
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -566,6 +566,12 @@ window.app.component('username-password', {
|
||||
this.confirmationMethod !== 'code' ||
|
||||
this.confirmationCode.length > 0
|
||||
|
||||
console.log('### disableRegister', {
|
||||
usernameOK,
|
||||
passwordOK,
|
||||
passwordsMatch,
|
||||
codeOk
|
||||
})
|
||||
return !usernameOK || !passwordOK || !passwordsMatch || !codeOk
|
||||
},
|
||||
confirmationMethodsCount() {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -297,148 +297,6 @@ async def test_register_ok(http_client: AsyncClient):
|
||||
), f"Expected 1 default wallet, not {len(user.wallets)}."
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_register_no_activation_code(
|
||||
http_client: AsyncClient, settings: Settings
|
||||
):
|
||||
settings.lnbits_require_user_activation = True
|
||||
|
||||
tiny_id = shortuuid.uuid()[:8]
|
||||
response = await http_client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"username": f"u21.{tiny_id}",
|
||||
"password": "secret1234",
|
||||
"password_repeat": "secret1234",
|
||||
"email": f"u21.{tiny_id}@lnbits.com",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json().get("detail") == "No activation method provided."
|
||||
|
||||
settings.lnbits_user_activation_by_invitation_code = True
|
||||
|
||||
response = await http_client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"username": f"u21.{tiny_id}",
|
||||
"password": "secret1234",
|
||||
"password_repeat": "secret1234",
|
||||
"email": f"u21.{tiny_id}@lnbits.com",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400, "User creation blocked without activation code."
|
||||
assert response.json().get("detail") == "Invitation code cannot be empty."
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_register_invalid_activation_code(
|
||||
http_client: AsyncClient, settings: Settings
|
||||
):
|
||||
settings.lnbits_require_user_activation = True
|
||||
settings.lnbits_user_activation_by_invitation_code = True
|
||||
settings.lnbits_register_reusable_activation_code = "foo"
|
||||
settings.lnbits_register_one_time_activation_codes = ["baz", "qux"]
|
||||
|
||||
tiny_id = shortuuid.uuid()[:8]
|
||||
response = await http_client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"username": f"u21.{tiny_id}",
|
||||
"password": "secret1234",
|
||||
"password_repeat": "secret1234",
|
||||
"email": f"u21.{tiny_id}@lnbits.com",
|
||||
"invitation_code": "bar",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json().get("detail") == "Invalid invitation code."
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_register_reusable_activation_code(
|
||||
http_client: AsyncClient, settings: Settings
|
||||
):
|
||||
settings.lnbits_require_user_activation = True
|
||||
settings.lnbits_user_activation_by_invitation_code = True
|
||||
settings.lnbits_register_reusable_activation_code = "foo"
|
||||
|
||||
tiny_id = shortuuid.uuid()[:8]
|
||||
response = await http_client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"username": f"u21.{tiny_id}",
|
||||
"password": "secret1234",
|
||||
"password_repeat": "secret1234",
|
||||
"email": f"u21.{tiny_id}@lnbits.com",
|
||||
"invitation_code": "foo",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, "User created with reusable code."
|
||||
assert response.json().get("access_token") is not None
|
||||
|
||||
# Register again with the same code
|
||||
tiny_id = shortuuid.uuid()[:8]
|
||||
response = await http_client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"username": f"u21.{tiny_id}",
|
||||
"password": "secret1234",
|
||||
"password_repeat": "secret1234",
|
||||
"email": f"u21.{tiny_id}@lnbits.com",
|
||||
"invitation_code": "foo",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, "User created with reusable code."
|
||||
assert response.json().get("access_token") is not None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_register_one_time_activation_code(
|
||||
http_client: AsyncClient, settings: Settings
|
||||
):
|
||||
settings.lnbits_require_user_activation = True
|
||||
settings.lnbits_user_activation_by_invitation_code = True
|
||||
settings.lnbits_register_reusable_activation_code = "foo"
|
||||
settings.lnbits_register_one_time_activation_codes = ["baz", "qux"]
|
||||
|
||||
tiny_id = shortuuid.uuid()[:8]
|
||||
response = await http_client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"username": f"u21.{tiny_id}",
|
||||
"password": "secret1234",
|
||||
"password_repeat": "secret1234",
|
||||
"email": f"u21.{tiny_id}@lnbits.com",
|
||||
"invitation_code": "baz",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, "User created with one-time code."
|
||||
assert response.json().get("access_token") is not None
|
||||
|
||||
# Register again with the same code
|
||||
tiny_id = shortuuid.uuid()[:8]
|
||||
response = await http_client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"username": f"u21.{tiny_id}",
|
||||
"password": "secret1234",
|
||||
"password_repeat": "secret1234",
|
||||
"email": f"u21.{tiny_id}@lnbits.com",
|
||||
"invitation_code": "baz",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400, "Invalid invitation code."
|
||||
assert response.json().get("detail") == "Invalid invitation code."
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_register_email_twice(http_client: AsyncClient):
|
||||
tiny_id = shortuuid.uuid()[:8]
|
||||
|
||||
@@ -341,7 +341,3 @@ def _settings_cleanup(settings: Settings):
|
||||
settings.lnbits_max_outgoing_payment_amount_sats = 10_000_000_100
|
||||
settings.lnbits_max_incoming_payment_amount_sats = 10_000_000_200
|
||||
settings.stripe_limits = FiatProviderLimits()
|
||||
settings.lnbits_require_user_activation = False
|
||||
settings.lnbits_user_activation_by_invitation_code = False
|
||||
settings.lnbits_register_reusable_activation_code = ""
|
||||
settings.lnbits_register_one_time_activation_codes = []
|
||||
|
||||
Reference in New Issue
Block a user