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
|
# 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, 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
|
# 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,8 +191,6 @@ 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,8 +338,6 @@ 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.
|
||||||
|
|||||||
+26
-31
@@ -98,17 +98,19 @@ async def get_accounts(
|
|||||||
|
|
||||||
|
|
||||||
async def get_account(
|
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:
|
) -> Account | None:
|
||||||
if len(user_id) == 0:
|
if len(user_id) == 0:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
activate_clause = "" if activated is None else "AND activated = :activated"
|
||||||
|
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"""
|
f"""
|
||||||
SELECT * FROM accounts
|
SELECT * FROM accounts
|
||||||
WHERE id = :id AND (activated = true OR activated = :activated)
|
WHERE id = :id {activate_clause}
|
||||||
""",
|
""", # noqa: S608
|
||||||
{"id": user_id, "activated": active_only},
|
{"id": user_id, "activated": activated},
|
||||||
Account,
|
Account,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -134,7 +136,7 @@ async def delete_accounts_no_wallets(
|
|||||||
|
|
||||||
|
|
||||||
async def get_account_by_username(
|
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:
|
) -> Account | None:
|
||||||
if len(username) == 0:
|
if len(username) == 0:
|
||||||
return None
|
return None
|
||||||
@@ -142,32 +144,28 @@ async def get_account_by_username(
|
|||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM accounts
|
SELECT * FROM accounts
|
||||||
WHERE
|
WHERE LOWER(username) = :username AND activated = :activated
|
||||||
LOWER(username) = :username
|
|
||||||
AND (activated = true OR activated = :activated)
|
|
||||||
""",
|
""",
|
||||||
{"username": username.lower(), "activated": active_only},
|
{"username": username.lower(), "activated": activated},
|
||||||
Account,
|
Account,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_account_by_pubkey(
|
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:
|
) -> Account | None:
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM accounts
|
SELECT * FROM accounts
|
||||||
WHERE
|
WHERE LOWER(pubkey) = :pubkey AND activated = :activated
|
||||||
LOWER(pubkey) = :pubkey
|
|
||||||
AND (activated = true OR activated = :activated)
|
|
||||||
""",
|
""",
|
||||||
{"pubkey": pubkey.lower(), "activated": active_only},
|
{"pubkey": pubkey.lower(), "activated": activated},
|
||||||
Account,
|
Account,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_account_by_email(
|
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:
|
) -> Account | None:
|
||||||
if len(email) == 0:
|
if len(email) == 0:
|
||||||
return None
|
return None
|
||||||
@@ -175,38 +173,35 @@ async def get_account_by_email(
|
|||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM accounts
|
SELECT * FROM accounts
|
||||||
WHERE
|
WHERE LOWER(email) = :email AND activated = :activated
|
||||||
LOWER(email) = :email
|
|
||||||
AND (activated = true OR activated = :activated)
|
|
||||||
""",
|
""",
|
||||||
{"email": email.lower(), "activated": active_only},
|
{"email": email.lower(), "activated": activated},
|
||||||
Account,
|
Account,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_account_by_username_or_email(
|
async def get_account_by_username_or_email(
|
||||||
username_or_email: str,
|
username_or_email: str,
|
||||||
active_only: bool = True,
|
activated: bool = True,
|
||||||
conn: Connection | None = None,
|
conn: Connection | None = None,
|
||||||
) -> Account | None:
|
) -> Account | None:
|
||||||
|
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM accounts
|
SELECT * FROM accounts
|
||||||
WHERE
|
WHERE (LOWER(email) = :value or LOWER(username) = :value)
|
||||||
(LOWER(email) = :value or LOWER(username) = :value)
|
AND activated = :activated
|
||||||
AND (activated = true OR activated = :activated)
|
|
||||||
""",
|
""",
|
||||||
{"value": username_or_email.lower(), "activated": active_only},
|
{"value": username_or_email.lower(), "activated": activated},
|
||||||
Account,
|
Account,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_user(
|
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:
|
) -> User | None:
|
||||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
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:
|
if not account:
|
||||||
return None
|
return None
|
||||||
return await get_user_from_account(account, conn=conn)
|
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(
|
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:
|
) -> UserAcls:
|
||||||
user_acls = await (conn or db).fetchone(
|
user_acls = await (conn or db).fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT id, access_control_list FROM accounts
|
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,
|
UserAcls,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -266,7 +261,7 @@ async def get_user_access_control_lists(
|
|||||||
|
|
||||||
|
|
||||||
async def clear_user_id_cache(user_id: str):
|
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:
|
if user:
|
||||||
clear_user_cache(user)
|
clear_user_cache(user)
|
||||||
|
|
||||||
|
|||||||
@@ -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, max_length=256)
|
invitation_code: str | None = Query(default=None, min_length=1, 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.")
|
logger.warning("PayPal webhook ID not set; skipping verification.")
|
||||||
raise ValueError("PayPal webhook cannot be verified. Missing webhook ID.")
|
return
|
||||||
|
|
||||||
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.")
|
logger.warning("Missing PayPal webhook headers; skipping verification.")
|
||||||
raise ValueError("PayPal webhook cannot be verified. Missing headers.")
|
return
|
||||||
|
|
||||||
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:
|
||||||
|
|||||||
@@ -776,7 +776,7 @@ async def _pay_internal_invoice(
|
|||||||
await update_payment(internal_payment, conn=conn)
|
await update_payment(internal_payment, conn=conn)
|
||||||
logger.success(f"internal payment successful {internal_payment.checking_id}")
|
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
|
# notify receiver asynchronously
|
||||||
from lnbits.tasks import internal_invoice_queue
|
from lnbits.tasks import internal_invoice_queue
|
||||||
@@ -849,8 +849,7 @@ async def _pay_external_invoice(
|
|||||||
payment = await update_payment_success_status(
|
payment = await update_payment_success_status(
|
||||||
payment, payment_response, conn=conn
|
payment, payment_response, conn=conn
|
||||||
)
|
)
|
||||||
|
send_payment_notification_in_background(wallet, payment)
|
||||||
await _send_payment_notification_in_background(wallet.id, payment, conn=conn)
|
|
||||||
logger.success(f"payment successful {payment_response.checking_id}")
|
logger.success(f"payment successful {payment_response.checking_id}")
|
||||||
|
|
||||||
payment.checking_id = 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)
|
await update_payment(payment)
|
||||||
|
|
||||||
return response
|
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):
|
async def check_register_activation_settings(data: RegisterUser):
|
||||||
if not settings.lnbits_require_user_activation:
|
if not settings.lnbits_require_user_activation:
|
||||||
return None
|
return None
|
||||||
if settings.lnbits_user_activation_by_invitation_code:
|
if settings.lnbits_user_activation_by_invitation_code and data.invitation_code:
|
||||||
code = data.invitation_code.strip() if data.invitation_code else ""
|
code = data.invitation_code.strip()
|
||||||
if len(code) == 0:
|
|
||||||
raise ValueError("Invitation code cannot be empty.")
|
|
||||||
|
|
||||||
if code == settings.lnbits_register_reusable_activation_code:
|
if code == settings.lnbits_register_reusable_activation_code:
|
||||||
return None
|
return None
|
||||||
if code in settings.lnbits_register_one_time_activation_codes:
|
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):
|
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
|
||||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Login with Nostr Auth not allowed.")
|
raise HTTPException(HTTPStatus.FORBIDDEN, "Login with Nostr Auth not allowed.")
|
||||||
event = _nostr_nip98_event(request)
|
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:
|
if not account:
|
||||||
account = Account(
|
account = Account(
|
||||||
id=uuid4().hex,
|
id=uuid4().hex,
|
||||||
@@ -106,8 +106,6 @@ async def nostr_login(request: Request) -> JSONResponse:
|
|||||||
extra=UserExtra(provider="nostr"),
|
extra=UserExtra(provider="nostr"),
|
||||||
)
|
)
|
||||||
await create_user_account(account)
|
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)
|
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):
|
if not is_valid_username(data.username):
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid 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.")
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
|
||||||
|
|
||||||
if data.email and not is_valid_email_address(data.email):
|
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:
|
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"
|
||||||
@@ -541,7 +533,7 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: str | None = Non
|
|||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
|
||||||
|
|
||||||
redirect_path = "/wallet"
|
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 verified_user_id:
|
||||||
if account:
|
if account:
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ async def api_get_users(
|
|||||||
summary="Get user by Id",
|
summary="Get user by Id",
|
||||||
)
|
)
|
||||||
async def api_get_user(user_id: str) -> User:
|
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:
|
if not user:
|
||||||
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
|
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
|
||||||
return user
|
return user
|
||||||
@@ -242,7 +242,7 @@ async def api_users_toggle_activated(
|
|||||||
if settings.is_admin_user(user_id):
|
if settings.is_admin_user(user_id):
|
||||||
settings.lnbits_admin_users.remove(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:
|
if not user_account:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
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 loguru import logger
|
||||||
from pydantic import BaseModel, ValidationError, root_validator
|
from pydantic import BaseModel, ValidationError, root_validator
|
||||||
|
from sqlalchemy import event
|
||||||
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
|
||||||
from sqlalchemy.sql import text
|
from sqlalchemy.sql import text
|
||||||
|
|
||||||
@@ -55,6 +56,14 @@ def compat_timestamp_placeholder(key: str):
|
|||||||
return f":{key}"
|
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:
|
class Compat:
|
||||||
type: str | None = "<inherited>"
|
type: str | None = "<inherited>"
|
||||||
schema: str | None = "<inherited>"
|
schema: str | None = "<inherited>"
|
||||||
@@ -317,7 +326,31 @@ class Database(Compat):
|
|||||||
self.engine: AsyncEngine = create_async_engine(
|
self.engine: AsyncEngine = create_async_engine(
|
||||||
database_uri, echo=settings.debug_database
|
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()
|
self.lock = asyncio.Lock()
|
||||||
|
|
||||||
logger.trace(f"database {self.type} added for {self.name}")
|
logger.trace(f"database {self.type} added for {self.name}")
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -630,7 +663,7 @@ def insert_query(table_name: str, model: BaseModel) -> str:
|
|||||||
placeholders = []
|
placeholders = []
|
||||||
keys = model_to_dict(model).keys()
|
keys = model_to_dict(model).keys()
|
||||||
for field in 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)
|
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
|
||||||
fields = ", ".join([f'"{key}"' for key in keys])
|
fields = ", ".join([f'"{key}"' for key in keys])
|
||||||
values = ", ".join(placeholders)
|
values = ", ".join(placeholders)
|
||||||
@@ -648,8 +681,9 @@ def update_query(
|
|||||||
"""
|
"""
|
||||||
fields = []
|
fields = []
|
||||||
for field in model_to_dict(model).keys():
|
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)
|
# 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)
|
query = ", ".join(fields)
|
||||||
return f"UPDATE {table_name} SET {query} {where}" # noqa: S608
|
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):
|
if model.__fields__[key].field_info.extra.get("no_database", False):
|
||||||
continue
|
continue
|
||||||
if isinstance(value, datetime):
|
if isinstance(value, datetime):
|
||||||
if DB_TYPE == SQLITE:
|
_dict[key] = value.timestamp()
|
||||||
_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)
|
|
||||||
continue
|
continue
|
||||||
if (
|
if (
|
||||||
type(type_) is type(BaseModel)
|
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:
|
if DB_TYPE == SQLITE:
|
||||||
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
|
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
|
||||||
else:
|
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
|
continue
|
||||||
if issubclass(type_, BaseModel):
|
if issubclass(type_, BaseModel):
|
||||||
_dict[key] = dict_to_submodel(type_, value)
|
_dict[key] = dict_to_submodel(type_, value)
|
||||||
|
|||||||
@@ -602,17 +602,6 @@ 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)
|
||||||
@@ -718,7 +707,6 @@ class FundingSourcesSettings(
|
|||||||
PhoenixdFundingSource,
|
PhoenixdFundingSource,
|
||||||
OpenNodeFundingSource,
|
OpenNodeFundingSource,
|
||||||
SparkFundingSource,
|
SparkFundingSource,
|
||||||
SparkL2FundingSource,
|
|
||||||
LnTipsFundingSource,
|
LnTipsFundingSource,
|
||||||
NWCFundingSource,
|
NWCFundingSource,
|
||||||
BreezSdkFundingSource,
|
BreezSdkFundingSource,
|
||||||
@@ -1047,7 +1035,6 @@ class SuperUserSettings(LNbitsSettings):
|
|||||||
"FakeWallet",
|
"FakeWallet",
|
||||||
"LNPayWallet",
|
"LNPayWallet",
|
||||||
"LNbitsWallet",
|
"LNbitsWallet",
|
||||||
"LightsparkSparkWallet",
|
|
||||||
"LnTipsWallet",
|
"LnTipsWallet",
|
||||||
"LndRestWallet",
|
"LndRestWallet",
|
||||||
"LndWallet",
|
"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.confirmationMethod !== 'code' ||
|
||||||
this.confirmationCode.length > 0
|
this.confirmationCode.length > 0
|
||||||
|
|
||||||
|
console.log('### disableRegister', {
|
||||||
|
usernameOK,
|
||||||
|
passwordOK,
|
||||||
|
passwordsMatch,
|
||||||
|
codeOk
|
||||||
|
})
|
||||||
return !usernameOK || !passwordOK || !passwordsMatch || !codeOk
|
return !usernameOK || !passwordOK || !passwordsMatch || !codeOk
|
||||||
},
|
},
|
||||||
confirmationMethodsCount() {
|
confirmationMethodsCount() {
|
||||||
|
|||||||
@@ -228,51 +228,6 @@ 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',
|
||||||
|
|||||||
@@ -71,10 +71,6 @@ window.app.component('lnbits-wallet-extra', {
|
|||||||
'lnbits.exchangeRate.' + this.g.wallet.currency,
|
'lnbits.exchangeRate.' + this.g.wallet.currency,
|
||||||
this.g.exchangeRate
|
this.g.exchangeRate
|
||||||
)
|
)
|
||||||
if (this.g.exchangeRate <= 0) {
|
|
||||||
this.g.fiatTracking = false
|
|
||||||
this.g.isFiatPriority = false
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(e => console.error(e))
|
.catch(e => console.error(e))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ window._lnbitsUtils = {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async notifyApiError(error) {
|
notifyApiError(error) {
|
||||||
if (!error.response) {
|
if (!error.response) {
|
||||||
return console.error(error)
|
return console.error(error)
|
||||||
}
|
}
|
||||||
@@ -154,10 +154,6 @@ window._lnbitsUtils = {
|
|||||||
500: 'negative'
|
500: 'negative'
|
||||||
}
|
}
|
||||||
let messages = error.response.data.detail
|
let messages = error.response.data.detail
|
||||||
if (!messages) {
|
|
||||||
const text = await error.response.data?.text()
|
|
||||||
messages = this.parseJSONSafe(text)?.detail
|
|
||||||
}
|
|
||||||
if (messages) {
|
if (messages) {
|
||||||
messages = Array.isArray(messages)
|
messages = Array.isArray(messages)
|
||||||
? messages.map(e => e.msg + ` (${e.loc?.join('/')})`)
|
? messages.map(e => e.msg + ` (${e.loc?.join('/')})`)
|
||||||
|
|||||||
@@ -310,13 +310,7 @@
|
|||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<q-select
|
<q-select
|
||||||
filled
|
filled
|
||||||
:options="[
|
:options="[$t('second'), $t('minute'), $t('hour')]"
|
||||||
{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>
|
||||||
|
|||||||
@@ -178,7 +178,7 @@
|
|||||||
"
|
"
|
||||||
:clickable="!!reviewsUrl"
|
:clickable="!!reviewsUrl"
|
||||||
@click="openReviews(extension)"
|
@click="openReviews(extension)"
|
||||||
></lnbits-extension-rating>
|
/>
|
||||||
<q-btn-group size="xs" style="margin: 5px 0">
|
<q-btn-group size="xs" style="margin: 5px 0">
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="extension.hasFreeRelease"
|
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:
|
async def btc_price(currency: str) -> float:
|
||||||
rates = await btc_rates(currency)
|
rates = await btc_rates(currency)
|
||||||
if not rates:
|
if not rates:
|
||||||
logger.warning("Could not fetch any Bitcoin price.")
|
raise ValueError("Could not fetch any Bitcoin price.")
|
||||||
return 0.0
|
|
||||||
elif len(rates) == 1:
|
elif len(rates) == 1:
|
||||||
logger.warning("Could only fetch one Bitcoin price.")
|
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}",
|
f"btc-price-{currency}",
|
||||||
settings.lnbits_exchange_rate_cache_seconds,
|
settings.lnbits_exchange_rate_cache_seconds,
|
||||||
)
|
)
|
||||||
rate = float(100_000_000 / price) if price > 0 else 0.0
|
return float(100_000_000 / price), price
|
||||||
return rate, price
|
|
||||||
|
|
||||||
|
|
||||||
async def get_fiat_rate_satoshis(currency: str) -> float:
|
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:
|
async def fiat_amount_as_satoshis(amount: float, currency: str) -> int:
|
||||||
rate = await get_fiat_rate_satoshis(currency)
|
rate = await get_fiat_rate_satoshis(currency)
|
||||||
if rate > 0:
|
return int(amount * (rate))
|
||||||
return int(amount * rate)
|
|
||||||
raise ValueError(f"Could not get exchange rate for {currency}.")
|
|
||||||
|
|
||||||
|
|
||||||
async def satoshis_amount_as_fiat(amount: float, currency: str) -> float:
|
async def satoshis_amount_as_fiat(amount: float, currency: str) -> float:
|
||||||
rate = await get_fiat_rate_satoshis(currency)
|
rate = await get_fiat_rate_satoshis(currency)
|
||||||
if rate > 0:
|
return float(amount / rate)
|
||||||
return float(amount / rate)
|
|
||||||
raise ValueError(f"Could not get exchange rate for {currency}.")
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ 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
|
||||||
@@ -70,7 +69,6 @@ __all__ = [
|
|||||||
"FakeWallet",
|
"FakeWallet",
|
||||||
"LNPayWallet",
|
"LNPayWallet",
|
||||||
"LNbitsWallet",
|
"LNbitsWallet",
|
||||||
"LightsparkSparkWallet",
|
|
||||||
"LnTipsWallet",
|
"LnTipsWallet",
|
||||||
"LndRestWallet",
|
"LndRestWallet",
|
||||||
"LndWallet",
|
"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)}."
|
), 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
|
@pytest.mark.anyio
|
||||||
async def test_register_email_twice(http_client: AsyncClient):
|
async def test_register_email_twice(http_client: AsyncClient):
|
||||||
tiny_id = shortuuid.uuid()[:8]
|
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_outgoing_payment_amount_sats = 10_000_000_100
|
||||||
settings.lnbits_max_incoming_payment_amount_sats = 10_000_000_200
|
settings.lnbits_max_incoming_payment_amount_sats = 10_000_000_200
|
||||||
settings.stripe_limits = FiatProviderLimits()
|
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