Compare commits
12
Commits
v1.2.0-rc2
...
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4aa0b598c | ||
|
|
aab76e691d | ||
|
|
3ed30be93e | ||
|
|
37f99e7f96 | ||
|
|
a16078a6ba | ||
|
|
c4c03d96a3 | ||
|
|
ab3248da15 | ||
|
|
b4b37cd733 | ||
|
|
26eb7a449c | ||
|
|
695e9b6471 | ||
|
|
5c9511ccfe | ||
|
|
c7b7832a88 |
@@ -159,6 +159,14 @@ mkdir data
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
||||
```
|
||||
|
||||
The LNbits Docker image comes with no extensions installed. User-installed extensions will be stored by default in a container directory.
|
||||
It is recommended to point the `LNBITS_EXTENSIONS_PATH` environment variable to a directory that is mapped to a Docker volume. This way, the extensions will not be reinstalled when the container is destroyed.
|
||||
Example:
|
||||
|
||||
```sh
|
||||
docker run ... -e "LNBITS_EXTENSIONS_PATH='/app/data/extensions'" --volume ${PWD}/data/:/app/data ...
|
||||
```
|
||||
|
||||
Build the image yourself.
|
||||
|
||||
```sh
|
||||
|
||||
+5
-8
@@ -9,16 +9,13 @@ from .decorators import (
|
||||
from .exceptions import InvoiceError, PaymentError
|
||||
|
||||
__all__ = [
|
||||
# decorators
|
||||
"require_admin_key",
|
||||
"require_invoice_key",
|
||||
"InvoiceError",
|
||||
"PaymentError",
|
||||
"check_admin",
|
||||
"check_super_user",
|
||||
"check_user_exists",
|
||||
# services
|
||||
"pay_invoice",
|
||||
"create_invoice",
|
||||
# exceptions
|
||||
"PaymentError",
|
||||
"InvoiceError",
|
||||
"pay_invoice",
|
||||
"require_admin_key",
|
||||
"require_invoice_key",
|
||||
]
|
||||
|
||||
@@ -65,7 +65,6 @@ from .middleware import (
|
||||
add_ip_block_middleware,
|
||||
add_ratelimit_middleware,
|
||||
)
|
||||
from .requestvars import g
|
||||
from .tasks import (
|
||||
check_pending_payments,
|
||||
internal_invoice_listener,
|
||||
@@ -171,8 +170,6 @@ def create_app() -> FastAPI:
|
||||
name="library",
|
||||
)
|
||||
|
||||
g().base_url = f"http://{settings.host}:{settings.port}"
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
|
||||
)
|
||||
|
||||
@@ -5,7 +5,9 @@ from .views.admin_api import admin_router
|
||||
from .views.api import api_router
|
||||
from .views.audit_api import audit_router
|
||||
from .views.auth_api import auth_router
|
||||
from .views.callback_api import callback_router
|
||||
from .views.extension_api import extension_router
|
||||
from .views.fiat_api import fiat_router
|
||||
|
||||
# this compat is needed for usermanager extension
|
||||
from .views.generic import generic_router
|
||||
@@ -34,10 +36,12 @@ def init_core_routers(app: FastAPI):
|
||||
app.include_router(wallet_router)
|
||||
app.include_router(api_router)
|
||||
app.include_router(websocket_router)
|
||||
app.include_router(callback_router)
|
||||
app.include_router(tinyurl_router)
|
||||
app.include_router(webpush_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(audit_router)
|
||||
app.include_router(fiat_router)
|
||||
|
||||
|
||||
__all__ = ["core_app", "core_app_extra", "db"]
|
||||
|
||||
@@ -85,87 +85,78 @@ from .webpush import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# audit
|
||||
"create_audit_entry",
|
||||
# db_versions
|
||||
"get_db_version",
|
||||
"get_db_versions",
|
||||
"update_migration_version",
|
||||
"delete_dbversion",
|
||||
# extensions
|
||||
"create_installed_extension",
|
||||
"create_user_extension",
|
||||
"delete_installed_extension",
|
||||
"drop_extension_db",
|
||||
"get_installed_extension",
|
||||
"get_installed_extensions",
|
||||
"get_user_active_extensions_ids",
|
||||
"get_user_extension",
|
||||
"update_installed_extension",
|
||||
"update_installed_extension_state",
|
||||
"update_user_extension",
|
||||
"get_user_extensions",
|
||||
# payments
|
||||
"DateTrunc",
|
||||
"check_internal",
|
||||
"create_payment",
|
||||
"delete_expired_invoices",
|
||||
"delete_wallet_payment",
|
||||
"get_latest_payments_by_extension",
|
||||
"get_payment",
|
||||
"get_payments",
|
||||
"get_payments_history",
|
||||
"get_payments_paginated",
|
||||
"get_standalone_payment",
|
||||
"get_wallet_payment",
|
||||
"is_internal_status_success",
|
||||
"mark_webhook_sent",
|
||||
"update_payment",
|
||||
"update_payment_checking_id",
|
||||
"update_payment_extra",
|
||||
# settings
|
||||
"create_admin_settings",
|
||||
"delete_admin_settings",
|
||||
"get_admin_settings",
|
||||
"get_super_settings",
|
||||
"update_admin_settings",
|
||||
"update_super_user",
|
||||
"reset_core_settings",
|
||||
# tinyurl
|
||||
"create_tinyurl",
|
||||
"delete_tinyurl",
|
||||
"get_tinyurl",
|
||||
"get_tinyurl_by_url",
|
||||
# users
|
||||
"create_account",
|
||||
"create_admin_settings",
|
||||
"create_audit_entry",
|
||||
"create_installed_extension",
|
||||
"create_payment",
|
||||
"create_tinyurl",
|
||||
"create_user_extension",
|
||||
"create_wallet",
|
||||
"create_webpush_subscription",
|
||||
"delete_account",
|
||||
"delete_accounts_no_wallets",
|
||||
"delete_admin_settings",
|
||||
"delete_dbversion",
|
||||
"delete_expired_invoices",
|
||||
"delete_installed_extension",
|
||||
"delete_tinyurl",
|
||||
"delete_unused_wallets",
|
||||
"delete_wallet",
|
||||
"delete_wallet_by_id",
|
||||
"delete_wallet_payment",
|
||||
"delete_webpush_subscription",
|
||||
"delete_webpush_subscriptions",
|
||||
"drop_extension_db",
|
||||
"force_delete_wallet",
|
||||
"get_account",
|
||||
"get_account_by_email",
|
||||
"get_account_by_pubkey",
|
||||
"get_account_by_username",
|
||||
"get_account_by_username_or_email",
|
||||
"get_accounts",
|
||||
"get_user",
|
||||
"get_user_from_account",
|
||||
"get_user_access_control_lists",
|
||||
"update_account",
|
||||
# wallets
|
||||
"create_wallet",
|
||||
"delete_unused_wallets",
|
||||
"delete_wallet",
|
||||
"delete_wallet_by_id",
|
||||
"force_delete_wallet",
|
||||
"get_admin_settings",
|
||||
"get_db_version",
|
||||
"get_db_versions",
|
||||
"get_installed_extension",
|
||||
"get_installed_extensions",
|
||||
"get_latest_payments_by_extension",
|
||||
"get_payment",
|
||||
"get_payments",
|
||||
"get_payments_history",
|
||||
"get_payments_paginated",
|
||||
"get_standalone_payment",
|
||||
"get_super_settings",
|
||||
"get_tinyurl",
|
||||
"get_tinyurl_by_url",
|
||||
"get_total_balance",
|
||||
"get_user",
|
||||
"get_user_access_control_lists",
|
||||
"get_user_active_extensions_ids",
|
||||
"get_user_extension",
|
||||
"get_user_extensions",
|
||||
"get_user_from_account",
|
||||
"get_wallet",
|
||||
"get_wallet_for_key",
|
||||
"get_wallet_payment",
|
||||
"get_wallets",
|
||||
"remove_deleted_wallets",
|
||||
"update_wallet",
|
||||
# webpush
|
||||
"create_webpush_subscription",
|
||||
"delete_webpush_subscription",
|
||||
"delete_webpush_subscriptions",
|
||||
"get_webpush_subscription",
|
||||
"get_webpush_subscriptions_for_user",
|
||||
"is_internal_status_success",
|
||||
"mark_webhook_sent",
|
||||
"remove_deleted_wallets",
|
||||
"reset_core_settings",
|
||||
"update_account",
|
||||
"update_admin_settings",
|
||||
"update_installed_extension",
|
||||
"update_installed_extension_state",
|
||||
"update_migration_version",
|
||||
"update_payment",
|
||||
"update_payment_checking_id",
|
||||
"update_payment_extra",
|
||||
"update_super_user",
|
||||
"update_user_extension",
|
||||
"update_wallet",
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from time import time
|
||||
from typing import Any, Optional, Tuple
|
||||
from typing import Any, Optional
|
||||
|
||||
from lnbits.core.crud.wallets import get_total_balance, get_wallet, get_wallets_ids
|
||||
from lnbits.core.db import db
|
||||
@@ -394,7 +394,7 @@ async def get_daily_stats(
|
||||
filters: Optional[Filters[PaymentFilters]] = None,
|
||||
user_id: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
|
||||
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
@@ -68,6 +68,7 @@ async def get_accounts(
|
||||
accounts.username,
|
||||
accounts.email,
|
||||
accounts.pubkey,
|
||||
accounts.external_id,
|
||||
SUM(COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0)) as balance_msat,
|
||||
@@ -128,8 +129,8 @@ async def get_account_by_username(
|
||||
if len(username) == 0:
|
||||
return None
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM accounts WHERE username = :username",
|
||||
{"username": username},
|
||||
"SELECT * FROM accounts WHERE LOWER(username) = :username",
|
||||
{"username": username.lower()},
|
||||
Account,
|
||||
)
|
||||
|
||||
@@ -138,8 +139,8 @@ async def get_account_by_pubkey(
|
||||
pubkey: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Account]:
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM accounts WHERE pubkey = :pubkey",
|
||||
{"pubkey": pubkey},
|
||||
"SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey",
|
||||
{"pubkey": pubkey.lower()},
|
||||
Account,
|
||||
)
|
||||
|
||||
@@ -150,8 +151,8 @@ async def get_account_by_email(
|
||||
if len(email) == 0:
|
||||
return None
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM accounts WHERE email = :email",
|
||||
{"email": email},
|
||||
"SELECT * FROM accounts WHERE LOWER(email) = :email",
|
||||
{"email": email.lower()},
|
||||
Account,
|
||||
)
|
||||
|
||||
@@ -160,8 +161,11 @@ async def get_account_by_username_or_email(
|
||||
username_or_email: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Account]:
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM accounts WHERE email = :value or username = :value",
|
||||
{"value": username_or_email},
|
||||
"""
|
||||
SELECT * FROM accounts
|
||||
WHERE LOWER(email) = :value or LOWER(username) = :value
|
||||
""",
|
||||
{"value": username_or_email.lower()},
|
||||
Account,
|
||||
)
|
||||
|
||||
@@ -183,6 +187,7 @@ async def get_user_from_account(
|
||||
email=account.email,
|
||||
username=account.username,
|
||||
pubkey=account.pubkey,
|
||||
external_id=account.external_id,
|
||||
extra=account.extra,
|
||||
created_at=account.created_at,
|
||||
updated_at=account.updated_at,
|
||||
@@ -190,6 +195,7 @@ async def get_user_from_account(
|
||||
wallets=wallets,
|
||||
admin=account.is_admin,
|
||||
super_user=account.is_super_user,
|
||||
fiat_providers=account.fiat_providers,
|
||||
has_password=account.password_hash is not None,
|
||||
)
|
||||
|
||||
|
||||
@@ -711,3 +711,15 @@ async def m031_add_color_and_icon_to_wallets(db: Connection):
|
||||
Adds icon and color columns to wallets.
|
||||
"""
|
||||
await db.execute("ALTER TABLE wallets ADD COLUMN extra TEXT")
|
||||
|
||||
|
||||
async def m032_add_external_id_to_accounts(db: Connection):
|
||||
"""
|
||||
Adds external_id column to accounts.
|
||||
Used for external account linking.
|
||||
"""
|
||||
await db.execute("ALTER TABLE accounts ADD COLUMN external_id TEXT")
|
||||
|
||||
|
||||
async def m033_update_payment_table(db: Connection):
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN fiat_provider TEXT")
|
||||
|
||||
@@ -48,62 +48,54 @@ from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
|
||||
from .webpush import CreateWebPushSubscription, WebPushSubscription
|
||||
|
||||
__all__ = [
|
||||
# audit
|
||||
"AuditEntry",
|
||||
"AuditFilters",
|
||||
# lnurl
|
||||
"CreateLnurl",
|
||||
"CreateLnurlAuth",
|
||||
"PayLnurlWData",
|
||||
# misc
|
||||
"BalanceDelta",
|
||||
"Callback",
|
||||
"ConversionData",
|
||||
"CoreAppExtra",
|
||||
"DbVersion",
|
||||
"SimpleStatus",
|
||||
# payments
|
||||
"CreateInvoice",
|
||||
"CreatePayment",
|
||||
"DecodePayment",
|
||||
"PayInvoice",
|
||||
"Payment",
|
||||
"PaymentCountField",
|
||||
"PaymentCountStat",
|
||||
"PaymentDailyStats",
|
||||
"PaymentsStatusCount",
|
||||
"PaymentWalletStats",
|
||||
"PaymentExtra",
|
||||
"PaymentFilters",
|
||||
"PaymentHistoryPoint",
|
||||
"PaymentState",
|
||||
# tinyurl
|
||||
"TinyURL",
|
||||
# users
|
||||
"AccessTokenPayload",
|
||||
"Account",
|
||||
"AccountFilters",
|
||||
"AccountOverview",
|
||||
"UserAcls",
|
||||
"AuditEntry",
|
||||
"AuditFilters",
|
||||
"BalanceDelta",
|
||||
"BaseWallet",
|
||||
"Callback",
|
||||
"ConversionData",
|
||||
"CoreAppExtra",
|
||||
"CreateInvoice",
|
||||
"CreateLnurl",
|
||||
"CreateLnurlAuth",
|
||||
"CreatePayment",
|
||||
"CreateUser",
|
||||
"RegisterUser",
|
||||
"CreateWallet",
|
||||
"CreateWebPushSubscription",
|
||||
"DbVersion",
|
||||
"DecodePayment",
|
||||
"KeyType",
|
||||
"LoginUsernamePassword",
|
||||
"LoginUsr",
|
||||
"PayInvoice",
|
||||
"PayLnurlWData",
|
||||
"Payment",
|
||||
"PaymentCountField",
|
||||
"PaymentCountStat",
|
||||
"PaymentDailyStats",
|
||||
"PaymentExtra",
|
||||
"PaymentFilters",
|
||||
"PaymentHistoryPoint",
|
||||
"PaymentState",
|
||||
"PaymentWalletStats",
|
||||
"PaymentsStatusCount",
|
||||
"RegisterUser",
|
||||
"ResetUserPassword",
|
||||
"SimpleStatus",
|
||||
"TinyURL",
|
||||
"UpdateBalance",
|
||||
"UpdateSuperuserPassword",
|
||||
"UpdateUser",
|
||||
"UpdateUserPassword",
|
||||
"UpdateUserPubkey",
|
||||
"User",
|
||||
"UserAcls",
|
||||
"UserExtra",
|
||||
# wallets
|
||||
"BaseWallet",
|
||||
"CreateWallet",
|
||||
"KeyType",
|
||||
"Wallet",
|
||||
"WalletTypeInfo",
|
||||
# webpush
|
||||
"CreateWebPushSubscription",
|
||||
"WebPushSubscription",
|
||||
]
|
||||
|
||||
@@ -8,6 +8,13 @@ from fastapi import Query
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
from lnbits.db import FilterModel
|
||||
from lnbits.fiat import get_fiat_provider
|
||||
from lnbits.fiat.base import (
|
||||
FiatPaymentFailedStatus,
|
||||
FiatPaymentPendingStatus,
|
||||
FiatPaymentStatus,
|
||||
FiatPaymentSuccessStatus,
|
||||
)
|
||||
from lnbits.utils.exchange_rates import allowed_currencies
|
||||
from lnbits.wallets import get_funding_source
|
||||
from lnbits.wallets.base import (
|
||||
@@ -60,6 +67,8 @@ class Payment(BaseModel):
|
||||
amount: int
|
||||
fee: int
|
||||
bolt11: str
|
||||
# payment_request: str | None
|
||||
fiat_provider: str | None = None
|
||||
status: str = PaymentState.PENDING
|
||||
memo: str | None = None
|
||||
expiry: datetime | None = None
|
||||
@@ -107,14 +116,23 @@ class Payment(BaseModel):
|
||||
|
||||
@property
|
||||
def is_internal(self) -> bool:
|
||||
return self.checking_id.startswith("internal_")
|
||||
return self.checking_id.startswith("internal_") or self.checking_id.startswith(
|
||||
"fiat_"
|
||||
)
|
||||
|
||||
async def check_status(self) -> PaymentStatus:
|
||||
async def check_status(
|
||||
self, skip_internal_payment_notifications: bool | None = False
|
||||
) -> PaymentStatus:
|
||||
if self.is_internal:
|
||||
if self.success:
|
||||
return PaymentSuccessStatus()
|
||||
if self.failed:
|
||||
return PaymentFailedStatus()
|
||||
if self.is_in and self.fiat_provider:
|
||||
fiat_status = await self.check_fiat_status(
|
||||
skip_internal_payment_notifications
|
||||
)
|
||||
return PaymentStatus(paid=fiat_status.paid)
|
||||
return PaymentPendingStatus()
|
||||
funding_source = get_funding_source()
|
||||
if self.is_out:
|
||||
@@ -123,6 +141,39 @@ class Payment(BaseModel):
|
||||
status = await funding_source.get_invoice_status(self.checking_id)
|
||||
return status
|
||||
|
||||
async def check_fiat_status(
|
||||
self, skip_internal_payment_notifications: bool | None = False
|
||||
) -> FiatPaymentStatus:
|
||||
if not self.is_internal:
|
||||
return FiatPaymentPendingStatus()
|
||||
if self.success:
|
||||
return FiatPaymentSuccessStatus()
|
||||
if self.failed:
|
||||
return FiatPaymentFailedStatus()
|
||||
|
||||
if not self.fiat_provider:
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
checking_id = self.extra.get("fiat_checking_id")
|
||||
if not checking_id:
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
fiat_provider = await get_fiat_provider(self.fiat_provider)
|
||||
if not fiat_provider:
|
||||
return FiatPaymentPendingStatus()
|
||||
fiat_status = await fiat_provider.get_invoice_status(checking_id)
|
||||
|
||||
if skip_internal_payment_notifications:
|
||||
return fiat_status
|
||||
|
||||
if fiat_status.success:
|
||||
# notify receivers asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
|
||||
await internal_invoice_queue.put(self.checking_id)
|
||||
|
||||
return fiat_status
|
||||
|
||||
|
||||
class PaymentFilters(FilterModel):
|
||||
__search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"]
|
||||
@@ -206,6 +257,7 @@ class CreateInvoice(BaseModel):
|
||||
webhook: str | None = None
|
||||
bolt11: str | None = None
|
||||
lnurl_callback: str | None = None
|
||||
fiat_provider: str | None = None
|
||||
|
||||
@validator("unit")
|
||||
@classmethod
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Keycloak SSO Login Helper
|
||||
"""
|
||||
"""Keycloak SSO Login Helper"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@@ -9,7 +9,12 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from lnbits.core.models.misc import SimpleItem
|
||||
from lnbits.db import FilterModel
|
||||
from lnbits.helpers import is_valid_email_address, is_valid_pubkey, is_valid_username
|
||||
from lnbits.helpers import (
|
||||
is_valid_email_address,
|
||||
is_valid_external_id,
|
||||
is_valid_pubkey,
|
||||
is_valid_username,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .wallets import Wallet
|
||||
@@ -93,6 +98,7 @@ class UserAcls(BaseModel):
|
||||
|
||||
class Account(BaseModel):
|
||||
id: str
|
||||
external_id: str | None = None # for external account linking
|
||||
username: str | None = None
|
||||
password_hash: str | None = None
|
||||
pubkey: str | None = None
|
||||
@@ -104,11 +110,13 @@ class Account(BaseModel):
|
||||
|
||||
is_super_user: bool = Field(default=False, no_database=True)
|
||||
is_admin: bool = Field(default=False, no_database=True)
|
||||
fiat_providers: list[str] = Field(default=[], no_database=True)
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
self.is_super_user = settings.is_super_user(self.id)
|
||||
self.is_admin = settings.is_admin_user(self.id)
|
||||
self.fiat_providers = settings.get_fiat_providers_for_user(self.id)
|
||||
|
||||
def hash_password(self, password: str) -> str:
|
||||
"""sets and returns the hashed password"""
|
||||
@@ -130,6 +138,11 @@ class Account(BaseModel):
|
||||
raise ValueError("Invalid email.")
|
||||
if self.pubkey and not is_valid_pubkey(self.pubkey):
|
||||
raise ValueError("Invalid pubkey.")
|
||||
if self.external_id and not is_valid_external_id(self.external_id):
|
||||
raise ValueError(
|
||||
"Invalid external id. Max length is 256 characters. "
|
||||
"Space and newlines are not allowed."
|
||||
)
|
||||
user_uuid4 = UUID(hex=self.id, version=4)
|
||||
if user_uuid4.hex != self.id:
|
||||
raise ValueError("User ID is not valid UUID4 hex string.")
|
||||
@@ -143,7 +156,14 @@ class AccountOverview(Account):
|
||||
|
||||
|
||||
class AccountFilters(FilterModel):
|
||||
__search_fields__ = ["user", "email", "username", "pubkey", "wallet_id"]
|
||||
__search_fields__ = [
|
||||
"user",
|
||||
"email",
|
||||
"username",
|
||||
"pubkey",
|
||||
"external_id",
|
||||
"wallet_id",
|
||||
]
|
||||
__sort_fields__ = [
|
||||
"balance_msat",
|
||||
"email",
|
||||
@@ -157,6 +177,7 @@ class AccountFilters(FilterModel):
|
||||
user: str | None = None
|
||||
username: str | None = None
|
||||
pubkey: str | None = None
|
||||
external_id: str | None = None
|
||||
wallet_id: str | None = None
|
||||
|
||||
|
||||
@@ -167,10 +188,12 @@ class User(BaseModel):
|
||||
email: str | None = None
|
||||
username: str | None = None
|
||||
pubkey: str | None = None
|
||||
external_id: str | None = None # for external account linking
|
||||
extensions: list[str] = []
|
||||
wallets: list[Wallet] = []
|
||||
admin: bool = False
|
||||
super_user: bool = False
|
||||
fiat_providers: list[str] = []
|
||||
has_password: bool = False
|
||||
extra: UserExtra = UserExtra()
|
||||
|
||||
@@ -207,13 +230,13 @@ class CreateUser(BaseModel):
|
||||
password: str | None = Query(default=None, min_length=8, max_length=50)
|
||||
password_repeat: str | None = Query(default=None, min_length=8, max_length=50)
|
||||
pubkey: str = Query(default=None, max_length=64)
|
||||
external_id: str = Query(default=None, max_length=256)
|
||||
extensions: list[str] | None = None
|
||||
extra: UserExtra | None = None
|
||||
|
||||
|
||||
class UpdateUser(BaseModel):
|
||||
user_id: str
|
||||
email: str | None = Query(default=None)
|
||||
username: str | None = Query(default=..., min_length=2, max_length=20)
|
||||
extra: UserExtra | None = None
|
||||
|
||||
|
||||
@@ -8,11 +8,15 @@ from .payments import (
|
||||
calculate_fiat_amounts,
|
||||
check_transaction_status,
|
||||
check_wallet_limits,
|
||||
create_fiat_invoice,
|
||||
create_invoice,
|
||||
create_wallet_invoice,
|
||||
fee_reserve,
|
||||
fee_reserve_total,
|
||||
get_payments_daily_stats,
|
||||
pay_invoice,
|
||||
service_fee,
|
||||
update_pending_payment,
|
||||
update_pending_payments,
|
||||
update_wallet_balance,
|
||||
)
|
||||
@@ -30,36 +34,33 @@ from .users import (
|
||||
from .websockets import websocket_manager, websocket_updater
|
||||
|
||||
__all__ = [
|
||||
# funding source
|
||||
"get_balance_delta",
|
||||
"switch_to_voidwallet",
|
||||
# lnurl
|
||||
"redeem_lnurl_withdraw",
|
||||
"perform_lnurlauth",
|
||||
# notifications
|
||||
"enqueue_notification",
|
||||
"send_payment_notification",
|
||||
# payments
|
||||
"calculate_fiat_amounts",
|
||||
"check_admin_settings",
|
||||
"check_transaction_status",
|
||||
"check_wallet_limits",
|
||||
"create_invoice",
|
||||
"fee_reserve",
|
||||
"fee_reserve_total",
|
||||
"pay_invoice",
|
||||
"service_fee",
|
||||
"update_pending_payments",
|
||||
"update_wallet_balance",
|
||||
# settings
|
||||
"check_webpush_settings",
|
||||
"update_cached_settings",
|
||||
# users
|
||||
"check_admin_settings",
|
||||
"create_fiat_invoice",
|
||||
"create_invoice",
|
||||
"create_user_account",
|
||||
"create_user_account_no_ckeck",
|
||||
"create_wallet_invoice",
|
||||
"enqueue_notification",
|
||||
"fee_reserve",
|
||||
"fee_reserve_total",
|
||||
"get_balance_delta",
|
||||
"get_payments_daily_stats",
|
||||
"pay_invoice",
|
||||
"perform_lnurlauth",
|
||||
"redeem_lnurl_withdraw",
|
||||
"send_payment_notification",
|
||||
"service_fee",
|
||||
"switch_to_voidwallet",
|
||||
"update_cached_settings",
|
||||
"update_pending_payment",
|
||||
"update_pending_payments",
|
||||
"update_user_account",
|
||||
"update_user_extensions",
|
||||
# websockets
|
||||
"update_wallet_balance",
|
||||
"websocket_manager",
|
||||
"websocket_updater",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.core.crud.payments import create_payment, get_standalone_payment
|
||||
from lnbits.core.models import CreatePayment, Payment, PaymentState
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.db import Connection
|
||||
from lnbits.fiat import get_fiat_provider
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
async def handle_fiat_payment_confirmation(
|
||||
payment: Payment, conn: Optional[Connection] = None
|
||||
):
|
||||
try:
|
||||
await _credit_fiat_service_fee_wallet(payment, conn=conn)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
|
||||
try:
|
||||
await _debit_fiat_service_faucet_wallet(payment, conn=conn)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
|
||||
|
||||
async def _credit_fiat_service_fee_wallet(
|
||||
payment: Payment, conn: Optional[Connection] = None
|
||||
):
|
||||
fiat_provider_name = payment.fiat_provider
|
||||
if not fiat_provider_name:
|
||||
return
|
||||
if payment.fee == 0:
|
||||
return
|
||||
|
||||
limits = settings.get_fiat_provider_limits(fiat_provider_name)
|
||||
if not limits:
|
||||
return
|
||||
|
||||
if not limits.service_fee_wallet_id:
|
||||
return
|
||||
|
||||
memo = (
|
||||
f"Service fee for fiat payment of "
|
||||
f"{abs(payment.sat)} sats. "
|
||||
f"Provider: {fiat_provider_name}. "
|
||||
f"Wallet: '{payment.wallet_id}'."
|
||||
)
|
||||
create_payment_model = CreatePayment(
|
||||
wallet_id=limits.service_fee_wallet_id,
|
||||
bolt11=payment.bolt11,
|
||||
payment_hash=payment.payment_hash,
|
||||
amount_msat=abs(payment.fee),
|
||||
memo=memo,
|
||||
)
|
||||
await create_payment(
|
||||
checking_id=f"service_fee_{payment.payment_hash}",
|
||||
data=create_payment_model,
|
||||
status=PaymentState.SUCCESS,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
|
||||
async def _debit_fiat_service_faucet_wallet(
|
||||
payment: Payment, conn: Optional[Connection] = None
|
||||
):
|
||||
fiat_provider_name = payment.fiat_provider
|
||||
if not fiat_provider_name:
|
||||
return
|
||||
|
||||
limits = settings.get_fiat_provider_limits(fiat_provider_name)
|
||||
if not limits:
|
||||
return
|
||||
|
||||
if not limits.service_faucet_wallet_id:
|
||||
return
|
||||
|
||||
faucet_wallet = await get_wallet(limits.service_faucet_wallet_id, conn=conn)
|
||||
if not faucet_wallet:
|
||||
raise ValueError(
|
||||
f"Fiat provider '{fiat_provider_name}' faucet wallet not found."
|
||||
)
|
||||
|
||||
memo = (
|
||||
f"Faucet payment of {abs(payment.sat)} sats. "
|
||||
f"Provider: {fiat_provider_name}. "
|
||||
f"Wallet: '{payment.wallet_id}'."
|
||||
)
|
||||
create_payment_model = CreatePayment(
|
||||
wallet_id=limits.service_faucet_wallet_id,
|
||||
bolt11=payment.bolt11,
|
||||
payment_hash=payment.payment_hash,
|
||||
amount_msat=-abs(payment.amount),
|
||||
memo=memo,
|
||||
extra=payment.extra,
|
||||
)
|
||||
await create_payment(
|
||||
checking_id=f"internal_fiat_{fiat_provider_name}_"
|
||||
f"faucet_{payment.payment_hash}",
|
||||
data=create_payment_model,
|
||||
status=PaymentState.SUCCESS,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
|
||||
async def handle_stripe_event(event: dict):
|
||||
event_id = event.get("id")
|
||||
event_object = event.get("data", {}).get("object", {})
|
||||
object_type = event_object.get("object")
|
||||
payment_hash = event_object.get("metadata", {}).get("payment_hash")
|
||||
logger.debug(
|
||||
f"Handling Stripe event: '{event_id}'. Type: '{object_type}'."
|
||||
f" Payment hash: '{payment_hash}'."
|
||||
)
|
||||
if not payment_hash:
|
||||
logger.warning("Stripe event does not contain a payment hash.")
|
||||
return
|
||||
|
||||
payment = await get_standalone_payment(payment_hash)
|
||||
if not payment:
|
||||
logger.warning(f"No payment found for hash: '{payment_hash}'.")
|
||||
return
|
||||
await payment.check_fiat_status()
|
||||
|
||||
|
||||
def check_stripe_signature(
|
||||
payload: bytes,
|
||||
sig_header: Optional[str],
|
||||
secret: Optional[str],
|
||||
tolerance_seconds=300,
|
||||
):
|
||||
if not sig_header:
|
||||
logger.warning("Stripe-Signature header is missing.")
|
||||
raise ValueError("Stripe-Signature header is missing.")
|
||||
|
||||
if not secret:
|
||||
logger.warning("Stripe webhook signing secret is not set.")
|
||||
raise ValueError("Stripe webhook cannot be verified.")
|
||||
|
||||
# Split the Stripe-Signature header
|
||||
items = dict(i.split("=") for i in sig_header.split(","))
|
||||
timestamp = int(items["t"])
|
||||
signature = items["v1"]
|
||||
|
||||
# Check timestamp tolerance
|
||||
if abs(time.time() - timestamp) > tolerance_seconds:
|
||||
logger.warning("Timestamp outside tolerance.")
|
||||
logger.debug(
|
||||
f"Current time: {time.time()}, "
|
||||
f"Timestamp: {timestamp}, "
|
||||
f"Tolerance: {tolerance_seconds} seconds"
|
||||
)
|
||||
|
||||
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
|
||||
|
||||
signed_payload = f"{timestamp}.{payload.decode()}"
|
||||
|
||||
# Compute HMAC SHA256 using the webhook secret
|
||||
computed_signature = hmac.new(
|
||||
key=secret.encode(), msg=signed_payload.encode(), digestmod=hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
# Compare signatures using constant time comparison
|
||||
if hmac.compare_digest(computed_signature, signature) is not True:
|
||||
logger.warning("Stripe signature verification failed.")
|
||||
raise ValueError("Stripe signature verification failed.")
|
||||
|
||||
|
||||
async def test_connection(provider: str) -> SimpleStatus:
|
||||
"""
|
||||
Test the connection to Stripe by checking if the API key is valid.
|
||||
This function should be called when setting up or testing the Stripe integration.
|
||||
"""
|
||||
fiat_provider = await get_fiat_provider(provider)
|
||||
status = await fiat_provider.status()
|
||||
if status.error_message:
|
||||
return SimpleStatus(
|
||||
success=False,
|
||||
message=f"Cconnection test failed: {status.error_message}",
|
||||
)
|
||||
|
||||
return SimpleStatus(
|
||||
success=True,
|
||||
message="Connection test successful." f" Balance: {status.balance}.",
|
||||
)
|
||||
@@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
from typing import Tuple
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
@@ -48,7 +47,7 @@ async def send_nostr_dm(
|
||||
return dm_event.to_dict()
|
||||
|
||||
|
||||
async def fetch_nip5_details(identifier: str) -> Tuple[str, list[str]]:
|
||||
async def fetch_nip5_details(identifier: str) -> tuple[str, list[str]]:
|
||||
identifier, domain = identifier.split("@")
|
||||
if not identifier or not domain:
|
||||
raise ValueError("Invalid NIP5 identifier")
|
||||
|
||||
@@ -4,7 +4,7 @@ import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from http import HTTPStatus
|
||||
from typing import Optional, Tuple
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
@@ -186,7 +186,7 @@ def is_message_type_enabled(message_type: NotificationType) -> bool:
|
||||
|
||||
def _notification_message_to_text(
|
||||
notification_message: NotificationMessage,
|
||||
) -> Tuple[str, str]:
|
||||
) -> tuple[str, str]:
|
||||
message_type = notification_message.message_type.value
|
||||
meesage_value = NOTIFICATION_TEMPLATES.get(message_type, message_type)
|
||||
try:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from bolt11 import Bolt11, MilliSatoshi, Tags
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from bolt11 import encode as bolt11_encode
|
||||
@@ -11,11 +13,14 @@ from loguru import logger
|
||||
from lnbits.core.crud.payments import get_daily_stats
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import PaymentDailyStats, PaymentFilters
|
||||
from lnbits.core.models.payments import CreateInvoice
|
||||
from lnbits.db import Connection, Filters
|
||||
from lnbits.decorators import check_user_extension_access
|
||||
from lnbits.exceptions import InvoiceError, PaymentError
|
||||
from lnbits.fiat import get_fiat_provider
|
||||
from lnbits.helpers import check_callback_url
|
||||
from lnbits.settings import settings
|
||||
from lnbits.tasks import create_task
|
||||
from lnbits.tasks import create_task, internal_invoice_queue_put
|
||||
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
|
||||
from lnbits.wallets import fake_wallet, get_funding_source
|
||||
@@ -94,6 +99,122 @@ async def pay_invoice(
|
||||
return payment
|
||||
|
||||
|
||||
async def create_fiat_invoice(
|
||||
wallet_id: str, invoice_data: CreateInvoice, conn: Optional[Connection] = None
|
||||
):
|
||||
fiat_provider_name = invoice_data.fiat_provider
|
||||
if not fiat_provider_name:
|
||||
raise ValueError("Fiat provider is required for fiat invoices.")
|
||||
if not settings.is_fiat_provider_enabled(fiat_provider_name):
|
||||
raise ValueError(
|
||||
f"Fiat provider '{fiat_provider_name}' is not enabled.",
|
||||
)
|
||||
|
||||
if invoice_data.unit == "sat":
|
||||
raise ValueError("Fiat provider cannot be used with satoshis.")
|
||||
amount_sat = await fiat_amount_as_satoshis(invoice_data.amount, invoice_data.unit)
|
||||
await _check_fiat_invoice_limits(amount_sat, fiat_provider_name, conn)
|
||||
|
||||
invoice_data.internal = True # use FakeWallet for fiat invoices
|
||||
if not invoice_data.memo:
|
||||
invoice_data.memo = settings.lnbits_site_title + f" ({fiat_provider_name})"
|
||||
|
||||
internal_payment = await create_wallet_invoice(wallet_id, invoice_data)
|
||||
|
||||
fiat_provider = await get_fiat_provider(fiat_provider_name)
|
||||
fiat_invoice = await fiat_provider.create_invoice(
|
||||
amount=invoice_data.amount,
|
||||
payment_hash=internal_payment.payment_hash,
|
||||
currency=invoice_data.unit,
|
||||
memo=invoice_data.memo,
|
||||
)
|
||||
if fiat_invoice.failed:
|
||||
logger.warning(fiat_invoice.error_message)
|
||||
internal_payment.status = PaymentState.FAILED
|
||||
await update_payment(internal_payment, conn=conn)
|
||||
raise ValueError(
|
||||
f"Cannot create payment request for '{fiat_provider_name}'.",
|
||||
)
|
||||
|
||||
internal_payment.fee = -abs(
|
||||
service_fee_fiat(internal_payment.msat, fiat_provider_name)
|
||||
)
|
||||
|
||||
internal_payment.fiat_provider = fiat_provider_name
|
||||
internal_payment.extra["fiat_checking_id"] = fiat_invoice.checking_id
|
||||
# todo: move to payent
|
||||
internal_payment.extra["fiat_payment_request"] = fiat_invoice.payment_request
|
||||
new_checking_id = (
|
||||
f"fiat_{fiat_provider_name}_"
|
||||
f"{fiat_invoice.checking_id or internal_payment.checking_id}"
|
||||
)
|
||||
await update_payment(internal_payment, new_checking_id, conn=conn)
|
||||
internal_payment.checking_id = new_checking_id
|
||||
|
||||
return internal_payment
|
||||
|
||||
|
||||
async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
|
||||
description_hash = b""
|
||||
unhashed_description = b""
|
||||
memo = data.memo or settings.lnbits_site_title
|
||||
if data.description_hash or data.unhashed_description:
|
||||
if data.description_hash:
|
||||
try:
|
||||
description_hash = bytes.fromhex(data.description_hash)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
"'description_hash' must be a valid hex string"
|
||||
) from exc
|
||||
if data.unhashed_description:
|
||||
try:
|
||||
unhashed_description = bytes.fromhex(data.unhashed_description)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
"'unhashed_description' must be a valid hex string",
|
||||
) from exc
|
||||
# do not save memo if description_hash or unhashed_description is set
|
||||
memo = ""
|
||||
|
||||
payment = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=data.amount,
|
||||
memo=memo,
|
||||
currency=data.unit,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=data.expiry,
|
||||
extra=data.extra,
|
||||
webhook=data.webhook,
|
||||
internal=data.internal,
|
||||
)
|
||||
|
||||
# lnurl_response is not saved in the database
|
||||
if data.lnurl_callback:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
try:
|
||||
check_callback_url(data.lnurl_callback)
|
||||
r = await client.get(
|
||||
data.lnurl_callback,
|
||||
params={"pr": payment.bolt11},
|
||||
timeout=10,
|
||||
)
|
||||
if r.is_error:
|
||||
payment.extra["lnurl_response"] = r.text
|
||||
else:
|
||||
resp = json.loads(r.text)
|
||||
if resp["status"] != "OK":
|
||||
payment.extra["lnurl_response"] = resp["reason"]
|
||||
else:
|
||||
payment.extra["lnurl_response"] = True
|
||||
except (httpx.ConnectError, httpx.RequestError) as ex:
|
||||
logger.error(ex)
|
||||
payment.extra["lnurl_response"] = False
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def create_invoice(
|
||||
*,
|
||||
wallet_id: str,
|
||||
@@ -226,6 +347,26 @@ def service_fee(amount_msat: int, internal: bool = False) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def service_fee_fiat(amount_msat: int, fiat_provider_name: str) -> int:
|
||||
"""
|
||||
Calculate the service fee for a fiat provider based on the amount in msat.
|
||||
Return the fee in msat.
|
||||
"""
|
||||
limits = settings.get_fiat_provider_limits(fiat_provider_name)
|
||||
if not limits:
|
||||
return 0
|
||||
amount_msat = abs(amount_msat)
|
||||
fee_max = limits.service_max_fee_sats * 1000
|
||||
if not limits.service_fee_wallet_id:
|
||||
return 0
|
||||
|
||||
fee_percentage = int(amount_msat / 100 * limits.service_fee_percent)
|
||||
if fee_max > 0 and fee_percentage > fee_max:
|
||||
return fee_max
|
||||
else:
|
||||
return fee_percentage
|
||||
|
||||
|
||||
async def update_wallet_balance(
|
||||
wallet: Wallet,
|
||||
amount: int,
|
||||
@@ -284,10 +425,7 @@ async def update_wallet_balance(
|
||||
)
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment, conn=conn)
|
||||
# notify receiver asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
|
||||
await internal_invoice_queue.put(payment.checking_id)
|
||||
await internal_invoice_queue_put(payment.checking_id)
|
||||
|
||||
|
||||
async def check_wallet_limits(
|
||||
@@ -586,41 +724,28 @@ async def _pay_external_invoice(
|
||||
logger.debug(f"payment timeout, {checking_id} is still pending")
|
||||
return payment
|
||||
|
||||
if payment_response.checking_id and payment_response.checking_id != checking_id:
|
||||
logger.warning(
|
||||
f"backend sent unexpected checking_id (expected: {checking_id} got:"
|
||||
f" {payment_response.checking_id})"
|
||||
)
|
||||
if payment_response.checking_id and payment_response.ok is not False:
|
||||
# payment.ok can be True (paid) or None (pending)!
|
||||
logger.debug(f"updating payment {checking_id}")
|
||||
payment.status = (
|
||||
PaymentState.SUCCESS
|
||||
if payment_response.ok is True
|
||||
else PaymentState.PENDING
|
||||
)
|
||||
payment.fee = -(abs(payment_response.fee_msat or 0) + abs(service_fee_msat))
|
||||
payment.preimage = payment_response.preimage
|
||||
await update_payment(payment, payment_response.checking_id, conn=conn)
|
||||
payment.checking_id = payment_response.checking_id
|
||||
if payment.success:
|
||||
await send_payment_notification(wallet, payment)
|
||||
logger.success(f"payment successful {payment_response.checking_id}")
|
||||
elif payment_response.checking_id is None and payment_response.ok is False:
|
||||
# payment failed
|
||||
logger.debug(f"payment failed {checking_id}, {payment_response.error_message}")
|
||||
# payment failed
|
||||
if (
|
||||
payment_response.checking_id is None
|
||||
or payment_response.ok is False
|
||||
or payment_response.checking_id != checking_id
|
||||
):
|
||||
payment.status = PaymentState.FAILED
|
||||
await update_payment(payment, conn=conn)
|
||||
raise PaymentError(
|
||||
f"Payment failed: {payment_response.error_message}"
|
||||
or "Payment failed, but backend didn't give us an error message.",
|
||||
status="failed",
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"didn't receive checking_id from backend, payment may be stuck in"
|
||||
f" database: {checking_id}"
|
||||
)
|
||||
message = payment_response.error_message or "without an error message."
|
||||
raise PaymentError(f"Payment failed: {message}", status="failed")
|
||||
|
||||
# payment.ok can be True (paid) or None (pending)!
|
||||
payment.status = (
|
||||
PaymentState.SUCCESS if payment_response.ok is True else PaymentState.PENDING
|
||||
)
|
||||
payment.fee = -(abs(payment_response.fee_msat or 0) + abs(service_fee_msat))
|
||||
payment.preimage = payment_response.preimage
|
||||
await update_payment(payment, payment_response.checking_id, conn=conn)
|
||||
payment.checking_id = payment_response.checking_id
|
||||
if payment.success:
|
||||
await send_payment_notification(wallet, payment)
|
||||
logger.success(f"payment successful {payment_response.checking_id}")
|
||||
|
||||
return payment
|
||||
|
||||
@@ -728,3 +853,47 @@ async def _credit_service_fee_wallet(
|
||||
status=PaymentState.SUCCESS,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
|
||||
async def _check_fiat_invoice_limits(
|
||||
amount_sat: int, fiat_provider_name: str, conn: Optional[Connection] = None
|
||||
):
|
||||
limits = settings.get_fiat_provider_limits(fiat_provider_name)
|
||||
if not limits:
|
||||
raise ValueError(
|
||||
f"Fiat provider '{fiat_provider_name}' does not have limits configured.",
|
||||
)
|
||||
|
||||
min_amount_sat = limits.service_min_amount_sats
|
||||
if min_amount_sat and (amount_sat < min_amount_sat):
|
||||
raise ValueError(
|
||||
f"Minimum amount is {min_amount_sat} " f"sats for '{fiat_provider_name}'.",
|
||||
)
|
||||
max_amount_sats = limits.service_max_amount_sats
|
||||
if max_amount_sats and (amount_sat > max_amount_sats):
|
||||
raise ValueError(
|
||||
f"Maximum amount is {max_amount_sats} " f"sats for '{fiat_provider_name}'.",
|
||||
)
|
||||
|
||||
if limits.service_max_fee_sats > 0 or limits.service_fee_percent > 0:
|
||||
if not limits.service_fee_wallet_id:
|
||||
raise ValueError(
|
||||
f"Fiat provider '{fiat_provider_name}' service fee wallet missing.",
|
||||
)
|
||||
fees_wallet = await get_wallet(limits.service_fee_wallet_id, conn=conn)
|
||||
if not fees_wallet:
|
||||
raise ValueError(
|
||||
f"Fiat provider '{fiat_provider_name}' service fee wallet not found.",
|
||||
)
|
||||
|
||||
if limits.service_faucet_wallet_id:
|
||||
faucet_wallet = await get_wallet(limits.service_faucet_wallet_id, conn=conn)
|
||||
if not faucet_wallet:
|
||||
raise ValueError(
|
||||
f"Fiat provider '{fiat_provider_name}' faucet wallet not found.",
|
||||
)
|
||||
if faucet_wallet.balance < amount_sat:
|
||||
raise ValueError(
|
||||
f"The amount exceeds the '{fiat_provider_name}'"
|
||||
"faucet wallet balance.",
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import traceback
|
||||
from typing import Callable, Coroutine
|
||||
from collections.abc import Coroutine
|
||||
from typing import Callable
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
<q-tab-panel name="fiat_providers">
|
||||
<h6 class="q-my-none q-mb-sm">
|
||||
<span v-text="$t('fiat_providers')"></span>
|
||||
<q-btn
|
||||
round
|
||||
flat
|
||||
@click="hideInputsToggle()"
|
||||
:icon="hideInputToggle ? 'visibility_off' : 'visibility'"
|
||||
></q-btn>
|
||||
</h6>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-list bordered class="rounded-borders">
|
||||
<q-expansion-item header-class="text-primary text-bold">
|
||||
<template v-slot:header>
|
||||
<q-item-section avatar>
|
||||
<q-avatar>
|
||||
<img
|
||||
:src="'{{ static_url_for('static', 'images/stripe_logo.ico') }}'"
|
||||
/>
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section> Stripe </q-item-section>
|
||||
|
||||
<q-item-section side>
|
||||
<div class="row items-center">
|
||||
<q-toggle
|
||||
size="md"
|
||||
:label="$t('enabled')"
|
||||
v-model="formData.stripe_enabled"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</template>
|
||||
|
||||
<q-card class="q-pb-xl">
|
||||
<q-expansion-item :label="$t('api')" default-opened>
|
||||
<q-card-section class="q-pa-md">
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.stripe_api_endpoint"
|
||||
:label="$t('endpoint')"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
class="q-mt-md"
|
||||
:type="hideInputToggle ? 'password' : 'text'"
|
||||
v-model="formData.stripe_api_secret_key"
|
||||
:label="$t('secret_key')"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
class="q-mt-md"
|
||||
type="text"
|
||||
v-model="formData.stripe_payment_success_url"
|
||||
:label="$t('callback_success_url')"
|
||||
:hint="$t('callback_success_url_hint')"
|
||||
></q-input>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-md">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
class="float-right"
|
||||
:label="$t('check_connection')"
|
||||
@click="checkFiatProvider('stripe')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item :label="$t('webhook')" default-opened>
|
||||
<q-card-section>
|
||||
<span v-text="$t('webhook_stripe_description')"></span>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-input
|
||||
filled
|
||||
class="q-mt-md"
|
||||
type="text"
|
||||
disable
|
||||
v-model="formData.stripe_payment_webhook_url"
|
||||
:label="$t('webhook_url')"
|
||||
:hint="$t('webhook_url_hint')"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
class="q-mt-md"
|
||||
:type="hideInputToggle ? 'password' : 'text'"
|
||||
v-model="formData.stripe_webhook_signing_secret"
|
||||
:label="$t('signing_secret')"
|
||||
:hint="$t('signing_secret_hint')"
|
||||
></q-input>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<span v-text="$t('webhook_events_list')"></span>
|
||||
<ul>
|
||||
<li><code>checkout.session.async_payment_failed</code></li>
|
||||
<li><code>checkout.session.async_payment_succeeded</code></li>
|
||||
<li><code>checkout.session.completed</code></li>
|
||||
<li><code>checkout.session.expired</code></li>
|
||||
</ul>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item :label="$t('service_fee')">
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
type="number"
|
||||
min="0"
|
||||
v-model="formData.stripe_limits.service_fee_percent"
|
||||
@update:model-value="touchSettings()"
|
||||
:label="$t('service_fee_label')"
|
||||
:hint="$t('service_fee_hint')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
type="number"
|
||||
min="0"
|
||||
v-model="formData.stripe_limits.service_max_fee_sats"
|
||||
@update:model-value="touchSettings()"
|
||||
:label="$t('service_fee_max')"
|
||||
:hint="$t('service_fee_max_hint')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
type="text"
|
||||
v-model="formData.stripe_limits.service_fee_wallet_id"
|
||||
@update:model-value="touchSettings()"
|
||||
:label="$t('fee_wallet_label')"
|
||||
:hint="$t('fee_wallet_hint')"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item :label="$t('amount_limits')">
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
type="number"
|
||||
min="0"
|
||||
v-model="formData.stripe_limits.service_min_amount_sats"
|
||||
@update:model-value="touchSettings()"
|
||||
:label="$t('min_incoming_payment_amount')"
|
||||
:hint="$t('min_incoming_payment_amount_desc')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
type="number"
|
||||
min="0"
|
||||
v-model="formData.stripe_limits.service_max_amount_sats"
|
||||
@update:model-value="touchSettings()"
|
||||
:label="$t('max_incoming_payment_amount')"
|
||||
:hint="$t('max_incoming_payment_amount_desc')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
class="q-ma-sm"
|
||||
v-model="formData.stripe_limits.service_faucet_wallet_id"
|
||||
@update:model-value="touchSettings()"
|
||||
:label="$t('faucest_wallet_id')"
|
||||
:hint="$t('faucest_wallet_id_hint')"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('faucest_wallet')"></q-item-label>
|
||||
<q-item-label caption>
|
||||
<ul>
|
||||
<li>
|
||||
<span
|
||||
v-text="$t('faucest_wallet_desc_1', {provider: 'stripe'})"
|
||||
></span>
|
||||
</li>
|
||||
<li>
|
||||
<span
|
||||
v-text="$t('faucest_wallet_desc_2', {provider: 'stripe'})"
|
||||
></span>
|
||||
</li>
|
||||
<li>
|
||||
<span v-text="$t('faucest_wallet_desc_3')"></span>
|
||||
</li>
|
||||
<li>
|
||||
<span
|
||||
v-text="$t('faucest_wallet_desc_4', {provider: 'stripe'})"
|
||||
></span>
|
||||
</li>
|
||||
<li>
|
||||
<span v-text="$t('faucest_wallet_desc_5')"></span>
|
||||
</li>
|
||||
</ul>
|
||||
<br />
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item :label="$t('allowed_users')">
|
||||
<q-card-section>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddStripeUser"
|
||||
@keydown.enter="addAllowedUser"
|
||||
type="text"
|
||||
:label="$t('allowed_users_label')"
|
||||
:hint="$t('allowed_users_hint_feature', {feature: 'Stripe'})"
|
||||
>
|
||||
<q-btn
|
||||
@click="addStripeAllowedUser"
|
||||
dense
|
||||
flat
|
||||
icon="add"
|
||||
></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
<q-chip
|
||||
v-for="user in formData.stripe_limits.allowed_users"
|
||||
@update:model-value="touchSettings()"
|
||||
:key="user"
|
||||
removable
|
||||
@remove="removeStripeAllowedUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
:label="user"
|
||||
class="ellipsis"
|
||||
>
|
||||
</q-chip>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-separator />
|
||||
|
||||
<q-expansion-item header-class="text-primary text-bold">
|
||||
<template v-slot:header>
|
||||
<q-item-section avatar>
|
||||
<q-avatar>
|
||||
<img
|
||||
:src="'{{ static_url_for('static', 'images/square_logo.png') }}'"
|
||||
/>
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section> Square </q-item-section>
|
||||
|
||||
<q-item-section side>
|
||||
<div class="row items-center">Disabled</div>
|
||||
</q-item-section>
|
||||
</template>
|
||||
|
||||
<q-card>
|
||||
<q-card-section> Coming Soon </q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-list>
|
||||
</div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
@@ -115,6 +115,13 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('exchanges')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="fiat_providers"
|
||||
icon="credit_score"
|
||||
:label="$q.screen.gt.sm ? $t('fiat_providers') : null"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('fiat_providers')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="users"
|
||||
icon="group"
|
||||
@@ -183,7 +190,8 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
|
||||
>
|
||||
{% include "admin/_tab_funding.html" %} {% include
|
||||
"admin/_tab_users.html" %} {% include "admin/_tab_server.html"
|
||||
%} {% include "admin/_tab_exchange_providers.html" %} {% include
|
||||
%} {% include "admin/_tab_exchange_providers.html" %}{% include
|
||||
"admin/_tab_fiat_providers.html" %} {% include
|
||||
"admin/_tab_extensions.html" %} {% include
|
||||
"admin/_tab_notifications.html" %} {% include
|
||||
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
|
||||
|
||||
@@ -272,10 +272,21 @@
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="user.external_id"
|
||||
:label="$t('external_id')"
|
||||
filled
|
||||
dense
|
||||
readonly
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
|
||||
<q-input
|
||||
v-model="user.extra.picture"
|
||||
:label="$t('picture')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
|
||||
@@ -166,11 +166,12 @@
|
||||
v-if="authAction === 'login' && allowedRegister"
|
||||
class="q-mb-none"
|
||||
>
|
||||
Not registered? Create an
|
||||
<span
|
||||
Not registered?
|
||||
<a
|
||||
href="#"
|
||||
class="text-secondary cursor-pointer"
|
||||
@click="showRegister('username-password')"
|
||||
>Account</span
|
||||
@click.prevent="showRegister('username-password')"
|
||||
>Create an Account</a
|
||||
>
|
||||
</p>
|
||||
<p
|
||||
@@ -182,11 +183,12 @@
|
||||
<p v-else-if="authAction === 'register'" class="q-mb-none">
|
||||
<span v-text="$t('existing_account_question')"></span>
|
||||
|
||||
<span
|
||||
<a
|
||||
href="#"
|
||||
class="text-secondary cursor-pointer q-ml-sm"
|
||||
@click="showLogin('username-password')"
|
||||
@click.prevent="showLogin('username-password')"
|
||||
v-text="$t('login')"
|
||||
></span>
|
||||
></a>
|
||||
</p>
|
||||
</div>
|
||||
</username-password>
|
||||
|
||||
@@ -211,7 +211,12 @@
|
||||
</q-card>
|
||||
<div id="hiddenQrCodeContainer" style="display: none">
|
||||
<lnbits-qrcode
|
||||
:value="'lightning:' + this.receive.paymentReq"
|
||||
v-if="receive.fiatPaymentReq"
|
||||
:value="receive.fiatPaymentReq"
|
||||
></lnbits-qrcode>
|
||||
<lnbits-qrcode
|
||||
v-else
|
||||
:value="'lightning:' + (this.receive.paymentReq || '').toUpperCase()"
|
||||
></lnbits-qrcode>
|
||||
</div>
|
||||
</div>
|
||||
@@ -619,14 +624,30 @@
|
||||
:readonly="receive.lnurl && receive.lnurl.fixed"
|
||||
></q-input>
|
||||
{% else %}
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="receive.unit"
|
||||
type="text"
|
||||
:label="$t('unit')"
|
||||
:options="receive.units"
|
||||
></q-select>
|
||||
<div class="row">
|
||||
<div class="col-10">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="receive.unit"
|
||||
type="text"
|
||||
:label="$t('unit')"
|
||||
:options="receive.units"
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<q-btn
|
||||
v-if="g.fiatTracking"
|
||||
@click="swapBalancePriority"
|
||||
class="float-right"
|
||||
color="primary"
|
||||
flat
|
||||
dense
|
||||
icon="swap_vert"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-input
|
||||
ref="setAmount"
|
||||
filled
|
||||
@@ -644,9 +665,59 @@
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
type="textarea"
|
||||
rows="2"
|
||||
v-model.trim="receive.data.memo"
|
||||
:label="$t('memo')"
|
||||
></q-input>
|
||||
<div v-if="g.user.fiat_providers?.length" class="q-mt-md">
|
||||
<q-list bordered dense class="rounded-borders">
|
||||
<q-item-label dense header>
|
||||
<span v-text="$t('select_payment_provider')"></span>
|
||||
</q-item-label>
|
||||
<q-separator></q-separator>
|
||||
<q-item
|
||||
:active="!receive.fiatProvider"
|
||||
@click="receive.fiatProvider = ''"
|
||||
active-class="bg-teal-1 text-grey-8 text-weight-bold"
|
||||
clickable
|
||||
v-ripple
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-avatar square>
|
||||
<img
|
||||
:src="'{{ static_url_for('static', 'images/logos/lnbits.png') }}'"
|
||||
/>
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<span
|
||||
v-text="$t('pay_with', {provider: 'Lightning Network'})"
|
||||
></span>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-separator></q-separator>
|
||||
<q-item
|
||||
:active="receive.fiatProvider === 'stripe'"
|
||||
@click="receive.fiatProvider = 'stripe'"
|
||||
active-class="bg-teal-1 text-grey-8 text-weight-bold"
|
||||
clickable
|
||||
v-ripple
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-avatar>
|
||||
<img
|
||||
:src="'{{ static_url_for('static', 'images/stripe_logo.ico') }}'"
|
||||
/>
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<span v-text="$t('pay_with', {provider: 'Stripe'})"></span>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
|
||||
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
@@ -680,7 +751,14 @@
|
||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||
>
|
||||
<div class="text-center q-mb-lg">
|
||||
<a :href="'lightning:' + receive.paymentReq">
|
||||
<a
|
||||
v-if="receive.fiatPaymentReq"
|
||||
:href="receive.fiatPaymentReq"
|
||||
target="_blank"
|
||||
>
|
||||
<div v-html="invoiceQrCode"></div>
|
||||
</a>
|
||||
<a v-else :href="'lightning:' + receive.paymentReq">
|
||||
<div v-html="invoiceQrCode"></div>
|
||||
</a>
|
||||
</div>
|
||||
@@ -691,25 +769,27 @@
|
||||
<h5 v-if="receive.unit != 'sat'" class="q-mt-none q-mb-sm">
|
||||
<span v-text="formattedSatAmount"></span>
|
||||
</h5>
|
||||
<q-chip v-if="hasNfc" outline square color="positive">
|
||||
<q-avatar
|
||||
icon="nfc"
|
||||
color="positive"
|
||||
text-color="white"
|
||||
></q-avatar>
|
||||
<span v-text="$t('nfc_supported')"></span>
|
||||
</q-chip>
|
||||
<span
|
||||
v-else
|
||||
class="text-caption text-grey"
|
||||
v-text="$t('nfc_not_supported')"
|
||||
></span>
|
||||
<div v-if="!receive.fiatPaymentReq">
|
||||
<q-chip v-if="hasNfc" outline square color="positive">
|
||||
<q-avatar
|
||||
icon="nfc"
|
||||
color="positive"
|
||||
text-color="white"
|
||||
></q-avatar>
|
||||
<span v-text="$t('nfc_supported')"></span>
|
||||
</q-chip>
|
||||
<span
|
||||
v-else
|
||||
class="text-caption text-grey"
|
||||
v-text="$t('nfc_not_supported')"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(receive.paymentReq)"
|
||||
@click="copyText(receive.fiatPaymentReq || receive.paymentReq)"
|
||||
:label="$t('copy_invoice')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
|
||||
@@ -147,6 +147,14 @@
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="activeUser.data.external_id"
|
||||
:label="$t('external_id')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="activeUser.data.extra.picture"
|
||||
:label="$t('picture')"
|
||||
|
||||
@@ -411,23 +411,13 @@ async def update(
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
|
||||
if data.username and not is_valid_username(data.username):
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
|
||||
if data.email != user.email:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Email mismatch.",
|
||||
)
|
||||
|
||||
if (
|
||||
data.username
|
||||
and user.username != data.username
|
||||
and await get_account_by_username(data.username)
|
||||
):
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
|
||||
if (
|
||||
data.email
|
||||
and data.email != user.email
|
||||
and await get_account_by_email(data.email)
|
||||
):
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Email already exists.")
|
||||
|
||||
account = await get_account(user.id)
|
||||
if not account:
|
||||
@@ -435,8 +425,6 @@ async def update(
|
||||
|
||||
if data.username:
|
||||
account.username = data.username
|
||||
if data.email:
|
||||
account.email = data.email
|
||||
if data.extra:
|
||||
account.extra = data.extra
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.core.services.fiat_providers import (
|
||||
check_stripe_signature,
|
||||
handle_stripe_event,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
|
||||
callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"])
|
||||
|
||||
|
||||
@callback_router.post("/{provider_name}")
|
||||
async def api_generic_webhook_handler(
|
||||
provider_name: str, request: Request
|
||||
) -> SimpleStatus:
|
||||
|
||||
if provider_name.lower() == "stripe":
|
||||
payload = await request.body()
|
||||
sig_header = request.headers.get("Stripe-Signature")
|
||||
check_stripe_signature(
|
||||
payload, sig_header, settings.stripe_webhook_signing_secret
|
||||
)
|
||||
event = await request.json()
|
||||
await handle_stripe_event(event)
|
||||
|
||||
return SimpleStatus(
|
||||
success=True,
|
||||
message=f"Callback received successfully from '{provider_name}'.",
|
||||
)
|
||||
|
||||
return SimpleStatus(
|
||||
success=False,
|
||||
message=f"Unknown fiat provider '{provider_name}'.",
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.core.services.fiat_providers import test_connection
|
||||
from lnbits.decorators import check_admin
|
||||
|
||||
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
|
||||
|
||||
|
||||
@fiat_router.put(
|
||||
"/check/{provider}",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_test_fiat_provider(provider: str) -> SimpleStatus:
|
||||
return await test_connection(provider)
|
||||
@@ -1,5 +1,5 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated, List, Optional, Union
|
||||
from typing import Annotated, Optional, Union
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import httpx
|
||||
@@ -70,7 +70,7 @@ async def robots():
|
||||
|
||||
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
|
||||
async def extensions(request: Request, user: User = Depends(check_user_exists)):
|
||||
installed_exts: List[InstallableExtension] = await get_installed_extensions()
|
||||
installed_exts: list[InstallableExtension] = await get_installed_extensions()
|
||||
installed_exts_ids = [e.id for e in installed_exts]
|
||||
|
||||
installable_exts = await InstallableExtension.get_installable_extensions()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from http import HTTPStatus
|
||||
from typing import List, Optional
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
@@ -91,7 +91,7 @@ async def api_get_info(
|
||||
@node_router.get("/channels")
|
||||
async def api_get_channels(
|
||||
node: Node = Depends(require_node),
|
||||
) -> Optional[List[NodeChannel]]:
|
||||
) -> Optional[list[NodeChannel]]:
|
||||
return await node.get_channels()
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ async def api_delete_channel(
|
||||
output_index: Optional[int],
|
||||
force: bool = False,
|
||||
node: Node = Depends(require_node),
|
||||
) -> Optional[List[NodeChannel]]:
|
||||
) -> Optional[list[NodeChannel]]:
|
||||
return await node.close_channel(
|
||||
short_id,
|
||||
(
|
||||
@@ -170,7 +170,7 @@ async def api_get_invoices(
|
||||
|
||||
|
||||
@node_router.get("/peers")
|
||||
async def api_get_peers(node: Node = Depends(require_node)) -> List[NodePeerInfo]:
|
||||
async def api_get_peers(node: Node = Depends(require_node)) -> list[NodePeerInfo]:
|
||||
return await node.get_peers()
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
import ssl
|
||||
from http import HTTPStatus
|
||||
from math import ceil
|
||||
from typing import List, Optional
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
@@ -34,13 +34,8 @@ from lnbits.core.models import (
|
||||
PaymentFilters,
|
||||
PaymentHistoryPoint,
|
||||
PaymentWalletStats,
|
||||
Wallet,
|
||||
)
|
||||
from lnbits.core.models.users import User
|
||||
from lnbits.core.services.payments import (
|
||||
get_payments_daily_stats,
|
||||
update_pending_payment,
|
||||
)
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
@@ -67,9 +62,12 @@ from ..crud import (
|
||||
get_wallet_for_key,
|
||||
)
|
||||
from ..services import (
|
||||
create_invoice,
|
||||
create_fiat_invoice,
|
||||
create_wallet_invoice,
|
||||
fee_reserve_total,
|
||||
get_payments_daily_stats,
|
||||
pay_invoice,
|
||||
update_pending_payment,
|
||||
update_pending_payments,
|
||||
)
|
||||
|
||||
@@ -81,7 +79,7 @@ payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
|
||||
name="Payment List",
|
||||
summary="get list of payments",
|
||||
response_description="list of payments",
|
||||
response_model=List[Payment],
|
||||
response_model=list[Payment],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments(
|
||||
@@ -100,7 +98,7 @@ async def api_payments(
|
||||
@payment_router.get(
|
||||
"/history",
|
||||
name="Get payments history",
|
||||
response_model=List[PaymentHistoryPoint],
|
||||
response_model=list[PaymentHistoryPoint],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_history(
|
||||
@@ -115,7 +113,7 @@ async def api_payments_history(
|
||||
@payment_router.get(
|
||||
"/stats/count",
|
||||
name="Get payments history for all users",
|
||||
response_model=List[PaymentCountStat],
|
||||
response_model=list[PaymentCountStat],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_counting_stats(
|
||||
@@ -137,7 +135,7 @@ async def api_payments_counting_stats(
|
||||
@payment_router.get(
|
||||
"/stats/wallets",
|
||||
name="Get payments history for all users",
|
||||
response_model=List[PaymentWalletStats],
|
||||
response_model=list[PaymentWalletStats],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_wallets_stats(
|
||||
@@ -158,7 +156,7 @@ async def api_payments_wallets_stats(
|
||||
@payment_router.get(
|
||||
"/stats/daily",
|
||||
name="Get payments history per day",
|
||||
response_model=List[PaymentDailyStats],
|
||||
response_model=list[PaymentDailyStats],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_daily_stats(
|
||||
@@ -198,69 +196,6 @@ async def api_payments_paginated(
|
||||
return page
|
||||
|
||||
|
||||
async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
||||
description_hash = b""
|
||||
unhashed_description = b""
|
||||
memo = data.memo or settings.lnbits_site_title
|
||||
if data.description_hash or data.unhashed_description:
|
||||
if data.description_hash:
|
||||
try:
|
||||
description_hash = bytes.fromhex(data.description_hash)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'description_hash' must be a valid hex string",
|
||||
) from exc
|
||||
if data.unhashed_description:
|
||||
try:
|
||||
unhashed_description = bytes.fromhex(data.unhashed_description)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'unhashed_description' must be a valid hex string",
|
||||
) from exc
|
||||
# do not save memo if description_hash or unhashed_description is set
|
||||
memo = ""
|
||||
|
||||
payment = await create_invoice(
|
||||
wallet_id=wallet.id,
|
||||
amount=data.amount,
|
||||
memo=memo,
|
||||
currency=data.unit,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=data.expiry,
|
||||
extra=data.extra,
|
||||
webhook=data.webhook,
|
||||
internal=data.internal,
|
||||
)
|
||||
|
||||
# lnurl_response is not saved in the database
|
||||
if data.lnurl_callback:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
try:
|
||||
check_callback_url(data.lnurl_callback)
|
||||
r = await client.get(
|
||||
data.lnurl_callback,
|
||||
params={"pr": payment.bolt11},
|
||||
timeout=10,
|
||||
)
|
||||
if r.is_error:
|
||||
payment.extra["lnurl_response"] = r.text
|
||||
else:
|
||||
resp = json.loads(r.text)
|
||||
if resp["status"] != "OK":
|
||||
payment.extra["lnurl_response"] = resp["reason"]
|
||||
else:
|
||||
payment.extra["lnurl_response"] = True
|
||||
except (httpx.ConnectError, httpx.RequestError) as ex:
|
||||
logger.error(ex)
|
||||
payment.extra["lnurl_response"] = False
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
"/all/paginated",
|
||||
name="Payment List",
|
||||
@@ -308,6 +243,7 @@ async def api_payments_create(
|
||||
invoice_data: CreateInvoice,
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
) -> Payment:
|
||||
wallet_id = wallet.wallet.id
|
||||
if invoice_data.out is True and wallet.key_type == KeyType.admin:
|
||||
if not invoice_data.bolt11:
|
||||
raise HTTPException(
|
||||
@@ -315,21 +251,24 @@ async def api_payments_create(
|
||||
detail="Missing BOLT11 invoice",
|
||||
)
|
||||
payment = await pay_invoice(
|
||||
wallet_id=wallet.wallet.id,
|
||||
wallet_id=wallet_id,
|
||||
payment_request=invoice_data.bolt11,
|
||||
extra=invoice_data.extra,
|
||||
)
|
||||
return payment
|
||||
|
||||
elif not invoice_data.out:
|
||||
# invoice key
|
||||
return await _api_payments_create_invoice(invoice_data, wallet.wallet)
|
||||
else:
|
||||
if invoice_data.out:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
|
||||
# If the payment is not outgoing, we can create a new invoice.
|
||||
if invoice_data.fiat_provider:
|
||||
return await create_fiat_invoice(wallet_id, invoice_data)
|
||||
|
||||
return await create_wallet_invoice(wallet_id, invoice_data)
|
||||
|
||||
|
||||
@payment_router.get("/fee-reserve")
|
||||
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
|
||||
@@ -439,6 +378,9 @@ async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
|
||||
return {"paid": True, "preimage": payment.preimage, "details": payment}
|
||||
return {"paid": True, "preimage": payment.preimage}
|
||||
|
||||
if payment.failed:
|
||||
return {"paid": False, "status": "failed", "details": payment}
|
||||
|
||||
try:
|
||||
status = await payment.check_status()
|
||||
except Exception:
|
||||
|
||||
@@ -2,7 +2,7 @@ import base64
|
||||
import json
|
||||
import time
|
||||
from http import HTTPStatus
|
||||
from typing import List, Optional
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import shortuuid
|
||||
@@ -100,6 +100,7 @@ async def api_create_user(data: CreateUser) -> CreateUser:
|
||||
username=data.username,
|
||||
email=data.email,
|
||||
pubkey=data.pubkey,
|
||||
external_id=data.external_id,
|
||||
extra=data.extra,
|
||||
)
|
||||
account.validate_fields()
|
||||
@@ -132,6 +133,7 @@ async def api_update_user(
|
||||
username=data.username,
|
||||
email=data.email,
|
||||
pubkey=data.pubkey,
|
||||
external_id=data.external_id,
|
||||
extra=data.extra or UserExtra(),
|
||||
)
|
||||
await update_user_account(account)
|
||||
@@ -214,7 +216,7 @@ async def api_users_toggle_admin(user_id: str) -> SimpleStatus:
|
||||
|
||||
|
||||
@users_router.get("/user/{user_id}/wallet", name="Get wallets for user")
|
||||
async def api_users_get_user_wallet(user_id: str) -> List[Wallet]:
|
||||
async def api_users_get_user_wallet(user_id: str) -> list[Wallet]:
|
||||
return await get_wallets(user_id)
|
||||
|
||||
|
||||
|
||||
+16
-3
@@ -1,5 +1,5 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated, Literal, Optional, Type, Union
|
||||
from typing import Annotated, Literal, Optional, Union
|
||||
|
||||
import jwt
|
||||
from fastapi import Cookie, Depends, Query, Request, Security
|
||||
@@ -28,7 +28,7 @@ from lnbits.core.models import (
|
||||
WalletTypeInfo,
|
||||
)
|
||||
from lnbits.db import Connection, Filter, Filters, TFilterModel
|
||||
from lnbits.helpers import path_segments
|
||||
from lnbits.helpers import normalize_path, path_segments
|
||||
from lnbits.settings import AuthMethods, settings
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(
|
||||
@@ -223,7 +223,7 @@ async def check_super_user(user: Annotated[User, Depends(check_user_exists)]) ->
|
||||
return user
|
||||
|
||||
|
||||
def parse_filters(model: Type[TFilterModel]):
|
||||
def parse_filters(model: type[TFilterModel]):
|
||||
"""
|
||||
Parses the query params as filters.
|
||||
:param model: model used for validation of filter values
|
||||
@@ -346,3 +346,16 @@ async def _check_account_api_access(
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Path not allowed.")
|
||||
if not endpoint.supports_method(method):
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Method not allowed.")
|
||||
|
||||
|
||||
def url_for_interceptor(original_method):
|
||||
def normalize_url(self, *args, **kwargs):
|
||||
url = original_method(self, *args, **kwargs)
|
||||
return url.replace(path=normalize_path(url.path))
|
||||
|
||||
return normalize_url
|
||||
|
||||
|
||||
# Upgraded extensions modify the path.
|
||||
# This interceptor ensures that the path is normalized.
|
||||
Request.url_for = url_for_interceptor(Request.url_for) # type: ignore[method-assign]
|
||||
|
||||
@@ -26,6 +26,10 @@ class InvoiceError(Exception):
|
||||
self.status = status
|
||||
|
||||
|
||||
class UnsupportedError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
|
||||
# Only the browser sends "text/html" request
|
||||
# not fail proof, but everything else get's a JSON response
|
||||
@@ -147,7 +151,7 @@ def register_exception_handlers(app: FastAPI):
|
||||
status_code = HTTPStatus.NOT_FOUND
|
||||
message: str = "Page not found."
|
||||
|
||||
if path in settings.lnbits_all_extensions_ids:
|
||||
if settings.is_ready_to_install_extension_id(path):
|
||||
status_code = HTTPStatus.FORBIDDEN
|
||||
message = f"Extension '{path}' not installed. Ask the admin to install it."
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from enum import Enum
|
||||
|
||||
from lnbits.fiat.base import FiatProvider
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .stripe import StripeWallet
|
||||
|
||||
fiat_module = importlib.import_module("lnbits.fiat")
|
||||
|
||||
|
||||
class FiatProviderType(Enum):
|
||||
stripe = "StripeWallet"
|
||||
|
||||
|
||||
async def get_fiat_provider(name: str) -> FiatProvider:
|
||||
if name not in FiatProviderType.__members__:
|
||||
raise ValueError(f"Fiat provider '{name}' is not supported.")
|
||||
|
||||
fiat_provider = fiat_providers.get(name)
|
||||
if fiat_provider:
|
||||
status = await fiat_provider.status(only_check_settings=True)
|
||||
if status.error_message:
|
||||
await fiat_provider.cleanup()
|
||||
del fiat_providers[name]
|
||||
else:
|
||||
return fiat_provider
|
||||
fiat_providers[name] = _init_fiat_provider(FiatProviderType[name])
|
||||
return fiat_providers[name]
|
||||
|
||||
|
||||
def _init_fiat_provider(fiat_provider: FiatProviderType) -> FiatProvider:
|
||||
if not settings.is_fiat_provider_enabled(fiat_provider.name):
|
||||
raise ValueError(f"Fiat provider '{fiat_provider.name}' not enabled.")
|
||||
provider_constructor = getattr(fiat_module, fiat_provider.value)
|
||||
return provider_constructor()
|
||||
|
||||
|
||||
fiat_providers: dict[str, FiatProvider] = {}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"StripeWallet",
|
||||
]
|
||||
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncGenerator, Coroutine
|
||||
from typing import TYPE_CHECKING, NamedTuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class FiatStatusResponse(NamedTuple):
|
||||
error_message: str | None = None
|
||||
balance: float = 0
|
||||
|
||||
|
||||
class FiatInvoiceResponse(NamedTuple):
|
||||
ok: bool
|
||||
checking_id: str | None = None # payment_hash, rpc_id
|
||||
payment_request: str | None = None
|
||||
error_message: str | None = None
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self.ok is True
|
||||
|
||||
@property
|
||||
def pending(self) -> bool:
|
||||
return self.ok is None
|
||||
|
||||
@property
|
||||
def failed(self) -> bool:
|
||||
return self.ok is False
|
||||
|
||||
|
||||
class FiatPaymentResponse(NamedTuple):
|
||||
# when ok is None it means we don't know if this succeeded
|
||||
ok: bool | None = None
|
||||
checking_id: str | None = None # payment_hash, rcp_id
|
||||
fee: float | None = None
|
||||
error_message: str | None = None
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self.ok is True
|
||||
|
||||
@property
|
||||
def pending(self) -> bool:
|
||||
return self.ok is None
|
||||
|
||||
@property
|
||||
def failed(self) -> bool:
|
||||
return self.ok is False
|
||||
|
||||
|
||||
class FiatPaymentStatus(NamedTuple):
|
||||
paid: bool | None = None
|
||||
fee: float | None = None # todo: what fee is this?
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self.paid is True
|
||||
|
||||
@property
|
||||
def pending(self) -> bool:
|
||||
return self.paid is not True
|
||||
|
||||
@property
|
||||
def failed(self) -> bool:
|
||||
return self.paid is False
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.success:
|
||||
return "success"
|
||||
if self.failed:
|
||||
return "failed"
|
||||
return "pending"
|
||||
|
||||
|
||||
class FiatPaymentSuccessStatus(FiatPaymentStatus):
|
||||
paid = True
|
||||
|
||||
|
||||
class FiatPaymentFailedStatus(FiatPaymentStatus):
|
||||
paid = False
|
||||
|
||||
|
||||
class FiatPaymentPendingStatus(FiatPaymentStatus):
|
||||
paid = None
|
||||
|
||||
|
||||
class FiatProvider(ABC):
|
||||
@abstractmethod
|
||||
async def cleanup(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def status(
|
||||
self, only_check_settings: bool | None = False
|
||||
) -> Coroutine[None, None, FiatStatusResponse]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create_invoice(
|
||||
self,
|
||||
amount: float,
|
||||
payment_hash: str,
|
||||
currency: str,
|
||||
memo: str | None = None,
|
||||
**kwargs,
|
||||
) -> Coroutine[None, None, FiatInvoiceResponse]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def pay_invoice(
|
||||
self,
|
||||
payment_request: str,
|
||||
) -> Coroutine[None, None, FiatPaymentResponse]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_invoice_status(
|
||||
self, checking_id: str
|
||||
) -> Coroutine[None, None, FiatPaymentStatus]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_payment_status(
|
||||
self, checking_id: str
|
||||
) -> Coroutine[None, None, FiatPaymentStatus]:
|
||||
pass
|
||||
|
||||
async def paid_invoices_stream(
|
||||
self,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
yield ""
|
||||
@@ -0,0 +1,174 @@
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.helpers import normalize_endpoint
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
FiatInvoiceResponse,
|
||||
FiatPaymentFailedStatus,
|
||||
FiatPaymentPendingStatus,
|
||||
FiatPaymentResponse,
|
||||
FiatPaymentStatus,
|
||||
FiatPaymentSuccessStatus,
|
||||
FiatProvider,
|
||||
FiatStatusResponse,
|
||||
)
|
||||
|
||||
|
||||
class StripeWallet(FiatProvider):
|
||||
"""https://docs.stripe.com/api"""
|
||||
|
||||
def __init__(self):
|
||||
logger.debug("Initializing StripeWallet")
|
||||
self._settings_fields = self._settings_connection_fields()
|
||||
if not settings.stripe_api_endpoint:
|
||||
raise ValueError("Cannot initialize StripeWallet: missing endpoint.")
|
||||
|
||||
if not settings.stripe_api_secret_key:
|
||||
raise ValueError("Cannot initialize StripeWallet: missing API secret key.")
|
||||
self.endpoint = normalize_endpoint(settings.stripe_api_endpoint)
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {settings.stripe_api_secret_key}",
|
||||
"User-Agent": settings.user_agent,
|
||||
}
|
||||
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
|
||||
logger.info("StripeWallet initialized.")
|
||||
|
||||
async def cleanup(self):
|
||||
try:
|
||||
await self.client.aclose()
|
||||
except RuntimeError as e:
|
||||
logger.warning(f"Error closing stripe wallet connection: {e}")
|
||||
|
||||
async def status(
|
||||
self, only_check_settings: Optional[bool] = False
|
||||
) -> FiatStatusResponse:
|
||||
if only_check_settings:
|
||||
if self._settings_fields != self._settings_connection_fields():
|
||||
return FiatStatusResponse("Connection settings have changed.", 0)
|
||||
return FiatStatusResponse(balance=0)
|
||||
|
||||
try:
|
||||
r = await self.client.get(url="/v1/balance", timeout=15)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
available_balance = data.get("available", [{}])[0].get("amount", 0)
|
||||
# pending_balance = data.get("pending", {}).get("amount", 0)
|
||||
|
||||
return FiatStatusResponse(balance=available_balance)
|
||||
except json.JSONDecodeError:
|
||||
return FiatStatusResponse("Server error: 'invalid json response'", 0)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return FiatStatusResponse(f"Unable to connect to {self.endpoint}.", 0)
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
amount: float,
|
||||
payment_hash: str,
|
||||
currency: str,
|
||||
memo: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> FiatInvoiceResponse:
|
||||
amount_cents = int(amount * 100)
|
||||
form_data = [
|
||||
("mode", "payment"),
|
||||
(
|
||||
"success_url",
|
||||
settings.stripe_payment_success_url or "https://lnbits.com",
|
||||
),
|
||||
("metadata[payment_hash]", payment_hash),
|
||||
("line_items[0][price_data][currency]", currency.lower()),
|
||||
("line_items[0][price_data][product_data][name]", memo or "LNbits Invoice"),
|
||||
("line_items[0][price_data][unit_amount]", amount_cents),
|
||||
("line_items[0][quantity]", "1"),
|
||||
]
|
||||
encoded_data = urlencode(form_data)
|
||||
|
||||
try:
|
||||
headers = self.headers.copy()
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
r = await self.client.post(
|
||||
url="/v1/checkout/sessions", headers=headers, content=encoded_data
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
session_id = data.get("id")
|
||||
if not session_id:
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message="Server error: 'missing session id'"
|
||||
)
|
||||
payment_request = data.get("url")
|
||||
if not payment_request:
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message="Server error: 'missing payment URL'"
|
||||
)
|
||||
|
||||
return FiatInvoiceResponse(
|
||||
ok=True, checking_id=session_id, payment_request=payment_request
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message="Server error: 'invalid json response'"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
|
||||
raise NotImplementedError("Stripe does not support paying invoices directly.")
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
|
||||
try:
|
||||
r = await self.client.get(
|
||||
url=f"/v1/checkout/sessions/{checking_id}",
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
data = r.json()
|
||||
payment_status = data.get("payment_status")
|
||||
if not payment_status:
|
||||
return FiatPaymentPendingStatus()
|
||||
if payment_status == "paid":
|
||||
# todo: handle fee
|
||||
return FiatPaymentSuccessStatus()
|
||||
|
||||
expires_at = data.get("expires_at")
|
||||
_24_hours_ago = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
if expires_at and expires_at < _24_hours_ago.timestamp():
|
||||
# be defensive: add a 24 hour buffer
|
||||
return FiatPaymentFailedStatus()
|
||||
|
||||
return FiatPaymentPendingStatus()
|
||||
except Exception as exc:
|
||||
logger.debug(f"Error getting invoice status: {exc}")
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
|
||||
raise NotImplementedError("Stripe does not support outgoing payments.")
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
logger.warning(
|
||||
"Stripe does not support paid invoices stream. Use webhooks instead."
|
||||
)
|
||||
mock_queue: asyncio.Queue[str] = asyncio.Queue(0)
|
||||
while settings.lnbits_running:
|
||||
value = await mock_queue.get()
|
||||
yield value
|
||||
|
||||
def _settings_connection_fields(self) -> str:
|
||||
return "-".join(
|
||||
[str(settings.stripe_api_endpoint), str(settings.stripe_api_secret_key)]
|
||||
)
|
||||
+22
-4
@@ -3,7 +3,7 @@ import json
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Type
|
||||
from typing import Any, Optional
|
||||
from urllib import request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -17,7 +17,6 @@ from pydantic.schema import field_schema
|
||||
|
||||
from lnbits.jinja2_templating import Jinja2Templates
|
||||
from lnbits.nodes import get_node_class
|
||||
from lnbits.requestvars import g
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import AESCipher
|
||||
|
||||
@@ -42,7 +41,7 @@ def urlsafe_short_hash() -> str:
|
||||
|
||||
|
||||
def url_for(endpoint: str, external: Optional[bool] = False, **params: Any) -> str:
|
||||
base = g().base_url if external else ""
|
||||
base = f"http://{settings.host}:{settings.port}" if external else ""
|
||||
url_params = "?"
|
||||
for key, value in params.items():
|
||||
url_params += f"{key}={value}&"
|
||||
@@ -154,7 +153,7 @@ def get_current_extension_name() -> str:
|
||||
return ext_name
|
||||
|
||||
|
||||
def generate_filter_params_openapi(model: Type[FilterModel], keep_optional=False):
|
||||
def generate_filter_params_openapi(model: type[FilterModel], keep_optional=False):
|
||||
"""
|
||||
Generate openapi documentation for Filters. This is intended to be used along
|
||||
parse_filters (see example)
|
||||
@@ -198,6 +197,14 @@ def is_valid_username(username: str) -> bool:
|
||||
return re.fullmatch(username_regex, username) is not None
|
||||
|
||||
|
||||
def is_valid_external_id(external_id: str) -> bool:
|
||||
if len(external_id) > 256:
|
||||
return False
|
||||
if " " in external_id or "\n" in external_id:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_valid_pubkey(pubkey: str) -> bool:
|
||||
if len(pubkey) != 64:
|
||||
return False
|
||||
@@ -355,3 +362,14 @@ def safe_upload_file_path(filename: str, directory: str = "images") -> Path:
|
||||
# Prevent filename with subdirectories
|
||||
file_path = image_folder / filename.split("/")[-1]
|
||||
return file_path.resolve()
|
||||
|
||||
|
||||
def normalize_endpoint(endpoint: str, add_proto=True) -> str:
|
||||
endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
|
||||
if add_proto:
|
||||
if endpoint.startswith("ws://") or endpoint.startswith("wss://"):
|
||||
return endpoint
|
||||
endpoint = (
|
||||
f"https://{endpoint}" if not endpoint.startswith("http") else endpoint
|
||||
)
|
||||
return endpoint
|
||||
|
||||
+2
-2
@@ -58,9 +58,9 @@ class LnurlErrorResponseHandler(APIRoute):
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LnurlErrorResponse",
|
||||
"LnurlErrorResponseHandler",
|
||||
"decode",
|
||||
"encode",
|
||||
"handle",
|
||||
"LnurlErrorResponse",
|
||||
"LnurlErrorResponseHandler",
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@ import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from http import HTTPStatus
|
||||
from typing import Any, List, Optional, Union
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
@@ -61,7 +61,7 @@ class InstalledExtensionMiddleware:
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
def _response_by_accepted_type(
|
||||
self, scope: Scope, headers: List[Any], msg: str, status_code: HTTPStatus
|
||||
self, scope: Scope, headers: list[Any], msg: str, status_code: HTTPStatus
|
||||
) -> Union[HTMLResponse, JSONResponse]:
|
||||
"""
|
||||
Build an HTTP response containing the `msg` as HTTP body and the `status_code`
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import contextvars
|
||||
import types
|
||||
|
||||
request_global = contextvars.ContextVar(
|
||||
"request_global", default=types.SimpleNamespace()
|
||||
)
|
||||
|
||||
|
||||
def g() -> types.SimpleNamespace:
|
||||
return request_global.get()
|
||||
@@ -565,6 +565,34 @@ class StrikeFundingSource(LNbitsSettings):
|
||||
strike_api_key: str | None = Field(default=None, env="STRIKE_API_KEY")
|
||||
|
||||
|
||||
class FiatProviderLimits(BaseModel):
|
||||
# empty list means all users are allowed to receive payments via Stripe
|
||||
allowed_users: list[str] = Field(default=[])
|
||||
|
||||
service_max_fee_sats: int = Field(default=0)
|
||||
service_fee_percent: float = Field(default=0)
|
||||
service_fee_wallet_id: str | None = Field(default=None)
|
||||
|
||||
service_min_amount_sats: int = Field(default=0)
|
||||
service_max_amount_sats: int = Field(default=0)
|
||||
service_faucet_wallet_id: str | None = Field(default="")
|
||||
|
||||
|
||||
class StripeFiatProvider(LNbitsSettings):
|
||||
stripe_enabled: bool = Field(default=False)
|
||||
stripe_api_endpoint: str = Field(default="https://api.stripe.com")
|
||||
stripe_api_secret_key: str | None = Field(default=None)
|
||||
stripe_payment_success_url: str = Field(default="https://lnbits.com")
|
||||
|
||||
stripe_payment_webhook_url: str = Field(
|
||||
default="https://your-lnbits-domain-here.com/api/v1/callback/stripe"
|
||||
)
|
||||
# Use this secret to verify that events come from Stripe.
|
||||
stripe_webhook_signing_secret: str | None = Field(default=None)
|
||||
|
||||
stripe_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
|
||||
|
||||
|
||||
class LightningSettings(LNbitsSettings):
|
||||
lightning_invoice_expiry: int = Field(default=3600, gt=0)
|
||||
|
||||
@@ -597,6 +625,40 @@ class FundingSourcesSettings(
|
||||
lnbits_funding_source_pay_invoice_wait_seconds: int = Field(default=5, ge=0)
|
||||
|
||||
|
||||
class FiatProvidersSettings(StripeFiatProvider):
|
||||
|
||||
def is_fiat_provider_enabled(self, provider: str | None) -> bool:
|
||||
"""
|
||||
Checks if a specific fiat provider is enabled.
|
||||
"""
|
||||
if not provider:
|
||||
return False
|
||||
if provider == "stripe":
|
||||
return self.stripe_enabled
|
||||
# Add checks for other fiat providers here as needed
|
||||
return False
|
||||
|
||||
def get_fiat_providers_for_user(self, user_id: str) -> list[str]:
|
||||
"""
|
||||
Returns a list of fiat payment methods allowed for the user.
|
||||
"""
|
||||
allowed_providers = []
|
||||
if self.stripe_enabled and (
|
||||
not self.stripe_limits.allowed_users
|
||||
or user_id in self.stripe_limits.allowed_users
|
||||
):
|
||||
allowed_providers.append("stripe")
|
||||
|
||||
# Add other fiat providers here as needed
|
||||
return allowed_providers
|
||||
|
||||
def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None:
|
||||
"""
|
||||
Returns the limits for a specific fiat provider.
|
||||
"""
|
||||
return getattr(self, provider_name + "_limits", None)
|
||||
|
||||
|
||||
class WebPushSettings(LNbitsSettings):
|
||||
lnbits_webpush_pubkey: str | None = Field(default=None)
|
||||
lnbits_webpush_privkey: str | None = Field(default=None)
|
||||
@@ -769,6 +831,7 @@ class EditableSettings(
|
||||
SecuritySettings,
|
||||
NotificationsSettings,
|
||||
FundingSourcesSettings,
|
||||
FiatProvidersSettings,
|
||||
LightningSettings,
|
||||
WebPushSettings,
|
||||
NodeUISettings,
|
||||
@@ -963,6 +1026,12 @@ class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettin
|
||||
def is_installed_extension_id(self, ext_id: str) -> bool:
|
||||
return ext_id in self.lnbits_installed_extensions_ids
|
||||
|
||||
def is_ready_to_install_extension_id(self, ext_id: str) -> bool:
|
||||
return (
|
||||
ext_id not in self.lnbits_installed_extensions_ids
|
||||
and ext_id in self.lnbits_all_extensions_ids
|
||||
)
|
||||
|
||||
|
||||
class SuperSettings(EditableSettings):
|
||||
super_user: str
|
||||
|
||||
Vendored
+10
-10
File diff suppressed because one or more lines are too long
@@ -338,7 +338,7 @@ window.localisation.br = {
|
||||
node_balance: 'Saldo do Nó: {balance} sats',
|
||||
lnbits_balance: 'Saldo do LNbits: {balance} sats',
|
||||
funding_reserve_percent: 'Reserve Percentual: {percent} %',
|
||||
node_managment: 'Gerenciamento de Nós',
|
||||
node_management: 'Gerenciamento de Nós',
|
||||
node_management_not_supported:
|
||||
'Gerenciamento de nó não suportado pela fonte de financiamento ativa',
|
||||
toggle_node_ui: 'Interface do Nó',
|
||||
|
||||
@@ -320,7 +320,7 @@ window.localisation.cn = {
|
||||
node_balance: '节点余额:{balance} sats',
|
||||
lnbits_balance: 'LNbits 余额:{balance} sats',
|
||||
funding_reserve_percent: '保留百分比: {percent} %',
|
||||
node_managment: '节点管理',
|
||||
node_management: '节点管理',
|
||||
node_management_not_supported: '活动资金来源不支持节点管理',
|
||||
toggle_node_ui: '节点用户界面',
|
||||
toggle_public_node_ui: '公共节点用户界面',
|
||||
|
||||
@@ -332,7 +332,7 @@ window.localisation.cs = {
|
||||
node_balance: 'Stav uzlu: {balance} sats',
|
||||
lnbits_balance: 'Zůstatek LNbits: {balance} sats',
|
||||
funding_reserve_percent: 'Rezervovat procento: {percent} %',
|
||||
node_managment: 'Správa uzlů',
|
||||
node_management: 'Správa uzlů',
|
||||
node_management_not_supported:
|
||||
'Správa uzlů není podporována aktivním zdrojem financování',
|
||||
toggle_node_ui: 'Uživatelské rozhraní uzlu',
|
||||
|
||||
@@ -342,7 +342,7 @@ window.localisation.de = {
|
||||
node_balance: 'Kontostand: {balance} Sats',
|
||||
lnbits_balance: 'LNbits-Guthaben: {balance} Sats',
|
||||
funding_reserve_percent: 'Reservieren Prozent: {percent} %',
|
||||
node_managment: 'Knotenverwaltung',
|
||||
node_management: 'Knotenverwaltung',
|
||||
node_management_not_supported:
|
||||
'Knotenverwaltung wird von der aktiven Finanzierungsquelle nicht unterstützt',
|
||||
toggle_node_ui: 'Node-Benutzeroberfläche',
|
||||
|
||||
@@ -22,6 +22,7 @@ window.localisation.en = {
|
||||
active_channels: 'Active Channels',
|
||||
connect_peer: 'Connect Peer',
|
||||
connect: 'Connect',
|
||||
reconnect: 'Reconnect',
|
||||
open_channel: 'Open Channel',
|
||||
open: 'Open',
|
||||
close_channel: 'Close Channel',
|
||||
@@ -60,6 +61,7 @@ window.localisation.en = {
|
||||
rename_wallet: 'Rename wallet',
|
||||
update_name: 'Update name',
|
||||
fiat_tracking: 'Fiat tracking',
|
||||
fiat_providers: 'Fiat providers',
|
||||
currency: 'Currency',
|
||||
update_currency: 'Update currency',
|
||||
press_to_claim: 'Press to claim bitcoin',
|
||||
@@ -141,6 +143,7 @@ window.localisation.en = {
|
||||
uninstall: 'Uninstall',
|
||||
drop_db: 'Remove Data',
|
||||
enable: 'Enable',
|
||||
enabled: 'Enabled',
|
||||
pay_to_enable: 'Pay To Enable',
|
||||
enable_extension_details: 'Enable extension for current user',
|
||||
disable: 'Disable',
|
||||
@@ -171,12 +174,33 @@ window.localisation.en = {
|
||||
payment_hash: 'Payment Hash',
|
||||
fee: 'Fee',
|
||||
amount: 'Amount',
|
||||
amount_limits: 'Amount Limits',
|
||||
amount_sats: 'Amount (sats)',
|
||||
faucest_wallet: 'Faucet Wallet',
|
||||
faucest_wallet_desc_1:
|
||||
'Each time a payment is confirmed by the {provider} provider funds will be subtracted from this wallet.',
|
||||
faucest_wallet_desc_2:
|
||||
'This helps monitor all {provider} payments and their status.',
|
||||
faucest_wallet_desc_3:
|
||||
'This wallet must be topped up with the amount of sats that the admin is willing to offer in exchange for the fiat currency.',
|
||||
faucest_wallet_desc_4:
|
||||
'If this wallet is configured, but is empty, the {provider} payments will not be processed.',
|
||||
faucest_wallet_desc_5:
|
||||
'This wallet can eventually get to a negative balance if parallel fiat payments are made.',
|
||||
faucest_wallet_id: 'Faucet Wallet ID (optional)',
|
||||
faucest_wallet_id_hint:
|
||||
'Wallet ID to use for the faucet. It will be used to send the funds to the user.',
|
||||
tag: 'Tag',
|
||||
unit: 'Unit',
|
||||
description: 'Description',
|
||||
expiry: 'Expiry',
|
||||
webhook: 'Webhook',
|
||||
webhook_url: 'Webhook URL',
|
||||
webhook_url_hint:
|
||||
'Webhook URL to send the payment details to. It will be called when the payment is completed.',
|
||||
webhook_events_list: 'The following events must be supported by the webhook:',
|
||||
webhook_stripe_description:
|
||||
'One the stripe side you must configure a webhook with a URL that points to your LNbits server.',
|
||||
payment_proof: 'Payment Proof',
|
||||
update: 'Update',
|
||||
update_available: 'Update {version} available!',
|
||||
@@ -350,12 +374,15 @@ window.localisation.en = {
|
||||
update_account: 'Update Account',
|
||||
invalid_username: 'Invalid Username',
|
||||
auth_provider: 'Auth Provider',
|
||||
external_id: 'External ID',
|
||||
my_account: 'My Account',
|
||||
existing_account_question: 'Already have an account?',
|
||||
background_image: 'Background Image',
|
||||
back: 'Back',
|
||||
logout: 'Logout',
|
||||
look_and_feel: 'Look and Feel',
|
||||
endpoint: 'Endpoint',
|
||||
api: 'API',
|
||||
api_token: 'API Token',
|
||||
api_tokens: 'API Tokens',
|
||||
access_control_list: 'Access Control List',
|
||||
@@ -373,6 +400,8 @@ window.localisation.en = {
|
||||
extension_paid_sats: 'You have already paid {paid_sats} sats.',
|
||||
release_details_error: 'Cannot get the release details.',
|
||||
pay_from_wallet: 'Pay from Wallet',
|
||||
pay_with: 'Pay with {provider}',
|
||||
select_payment_provider: 'Select payment provider',
|
||||
wallet_required: 'Wallet *',
|
||||
show_qr: 'Show QR',
|
||||
retry_install: 'Retry Install',
|
||||
@@ -385,6 +414,9 @@ window.localisation.en = {
|
||||
'The {name} extension requires a payment of minimum {amount} sats to enable.',
|
||||
hide_empty_wallets: 'Hide empty wallets',
|
||||
recheck: 'Recheck',
|
||||
check: 'Check',
|
||||
check_connection: 'Check Connection',
|
||||
check_webhook: 'Check Webhook',
|
||||
contributors: 'Contributors',
|
||||
license: 'License',
|
||||
reset_key: 'Reset Key',
|
||||
@@ -446,7 +478,7 @@ window.localisation.en = {
|
||||
node_balance: 'Node Balance: {balance} sats',
|
||||
lnbits_balance: 'LNbits Balance: {balance} sats',
|
||||
funding_reserve_percent: 'Reserve Percent: {percent} %',
|
||||
node_managment: 'Node Management',
|
||||
node_management: 'Node Management',
|
||||
node_management_not_supported:
|
||||
'Node Management not supported by active funding source',
|
||||
toggle_node_ui: 'Node UI',
|
||||
@@ -491,7 +523,9 @@ window.localisation.en = {
|
||||
allowed_currencies_hint: 'Limit the number of available fiat currencies',
|
||||
default_account_currency: 'Default Account Currency',
|
||||
default_account_currency_hint: 'Default currency for accounting',
|
||||
|
||||
min_incoming_payment_amount: 'Min Incoming Payment Amount',
|
||||
min_incoming_payment_amount_desc:
|
||||
'Minimum amount allowed for generating an invoice',
|
||||
max_incoming_payment_amount: 'Max Incoming Payment Amount',
|
||||
max_incoming_payment_amount_desc:
|
||||
'Maximum amount allowed for generating an invoice',
|
||||
@@ -553,6 +587,7 @@ window.localisation.en = {
|
||||
admin_users_label: 'User ID',
|
||||
allowed_users: 'Allowed Users',
|
||||
allowed_users_hint: 'Only these users can use LNbits',
|
||||
allowed_users_hint_feature: 'Only these users can use {feature}',
|
||||
allowed_users_label: 'User ID',
|
||||
allow_creation_user: 'Allow creation of new users',
|
||||
allow_creation_user_desc: 'Allow creation of new users on the index page',
|
||||
@@ -587,5 +622,12 @@ window.localisation.en = {
|
||||
view_column: 'View wallets as rows',
|
||||
filter_payments: 'Filter payments',
|
||||
filter_date: 'Filter by date',
|
||||
websocket_example: 'Websocket example'
|
||||
websocket_example: 'Websocket example',
|
||||
secret_key: 'Secret Key',
|
||||
signing_secret: 'Signing Secret',
|
||||
signing_secret_hint:
|
||||
'Signing secret for the webhook. Messages will be signed with this secret.',
|
||||
callback_success_url: 'Callback Success URL',
|
||||
callback_success_url_hint:
|
||||
'The user will be redirected to this URL after the payment is successful'
|
||||
}
|
||||
|
||||
@@ -343,7 +343,7 @@ window.localisation.es = {
|
||||
node_balance: 'Balance de Nodo: {balance} sats',
|
||||
lnbits_balance: 'Saldo de LNbits: {balance} sats',
|
||||
funding_reserve_percent: 'Reserve Porcentaje: {percent} %',
|
||||
node_managment: 'Gestión de nodos',
|
||||
node_management: 'Gestión de nodos',
|
||||
node_management_not_supported:
|
||||
'La gestión de nodos no es compatible con la fuente de financiación activa',
|
||||
toggle_node_ui: 'Interfaz de usuario de nodo',
|
||||
|
||||
@@ -434,7 +434,7 @@ window.localisation.fi = {
|
||||
node_balance: 'Solmun saldo: {balance} sats',
|
||||
lnbits_balance: 'LNbits-saldo: {balance} sat',
|
||||
funding_reserve_percent: 'Omavaraisuusaste: {percent} %',
|
||||
node_managment: 'Solmun hallinta',
|
||||
node_management: 'Solmun hallinta',
|
||||
node_management_not_supported:
|
||||
'Solmun hallinta ei ole mahdollista valitun rahoituslähteen kanssa.',
|
||||
toggle_node_ui: 'Solmun käyttöliittymä',
|
||||
|
||||
@@ -346,7 +346,7 @@ window.localisation.fr = {
|
||||
node_balance: 'Solde du nœud : {balance} sats',
|
||||
lnbits_balance: 'Solde LNbits : {balance} sats',
|
||||
funding_reserve_percent: 'Pourcentage de Réserve : {percent} %',
|
||||
node_managment: 'Gestion des nœuds',
|
||||
node_management: 'Gestion des nœuds',
|
||||
node_management_not_supported:
|
||||
"La gestion des nœuds n'est pas prise en charge par la source de financement active",
|
||||
toggle_node_ui: 'Interface utilisateur de nœud',
|
||||
|
||||
@@ -343,7 +343,7 @@ window.localisation.it = {
|
||||
node_balance: 'Saldo Nodo: {balance} sats',
|
||||
lnbits_balance: 'Saldo LNbits: {balance} sats',
|
||||
funding_reserve_percent: 'Riserva Percentuale: {percent} %',
|
||||
node_managment: 'Gestione dei nodi',
|
||||
node_management: 'Gestione dei nodi',
|
||||
node_management_not_supported:
|
||||
'La gestione dei nodi non è supportata dalla fonte di finanziamento attiva.',
|
||||
toggle_node_ui: 'Interfaccia utente del nodo',
|
||||
|
||||
@@ -334,7 +334,7 @@ window.localisation.jp = {
|
||||
node_balance: 'ノード残高: {balance} サッツ',
|
||||
lnbits_balance: 'LNbits残高: {balance} sats',
|
||||
funding_reserve_percent: '予約パーセント: {percent} %',
|
||||
node_managment: 'ノード管理',
|
||||
node_management: 'ノード管理',
|
||||
node_management_not_supported:
|
||||
'アクティブな資金源ではノード管理がサポートされていません',
|
||||
toggle_node_ui: 'ノードUI',
|
||||
|
||||
@@ -331,7 +331,7 @@ window.localisation.kr = {
|
||||
node_balance: '노드 잔액: {balance} 사토시',
|
||||
lnbits_balance: 'LNbits 잔액: {balance} sats',
|
||||
funding_reserve_percent: '예약 비율: {percent} %',
|
||||
node_managment: '노드 관리',
|
||||
node_management: '노드 관리',
|
||||
node_management_not_supported:
|
||||
'활성화된 자금 출처에 의해 노드 관리는 지원되지 않습니다.',
|
||||
toggle_node_ui: '노드 UI',
|
||||
|
||||
@@ -341,7 +341,7 @@ window.localisation.nl = {
|
||||
node_balance: 'Node Balans: {balance} sats',
|
||||
lnbits_balance: 'LNbits Saldo: {balance} sats',
|
||||
funding_reserve_percent: 'Reservepercentage: {percent} %',
|
||||
node_managment: 'Nodebeheer',
|
||||
node_management: 'Nodebeheer',
|
||||
node_management_not_supported:
|
||||
'Nodebeheer wordt niet ondersteund door de actieve financieringsbron',
|
||||
toggle_node_ui: 'Node UI',
|
||||
|
||||
@@ -337,7 +337,7 @@ window.localisation.pi = {
|
||||
node_balance: 'Node Balance: {balance} doubloons',
|
||||
lnbits_balance: "LNbits Balance: {balance} pieces o' eight",
|
||||
funding_reserve_percent: 'Reserve Percent: {percent} %',
|
||||
node_managment: 'Node Management',
|
||||
node_management: 'Node Management',
|
||||
node_management_not_supported:
|
||||
'Node Management not be supported by active funding source',
|
||||
toggle_node_ui: 'Node Main Deck',
|
||||
|
||||
@@ -337,7 +337,7 @@ window.localisation.pl = {
|
||||
node_balance: 'Saldo węzła: {balance} sats',
|
||||
lnbits_balance: 'Saldo LNbits: {balance} sats',
|
||||
funding_reserve_percent: 'Rezerwa procentowa: {percent} %',
|
||||
node_managment: 'Zarządzanie węzłami',
|
||||
node_management: 'Zarządzanie węzłami',
|
||||
node_management_not_supported:
|
||||
'Zarządzanie węzłami nie jest obsługiwane przez aktywne źródło finansowania.',
|
||||
toggle_node_ui: 'Interfejs użytkownika węzła',
|
||||
|
||||
@@ -340,7 +340,7 @@ window.localisation.pt = {
|
||||
node_balance: 'Saldo do Nó: {balance} sats',
|
||||
lnbits_balance: 'Saldo do LNbits: {balance} sats',
|
||||
funding_reserve_percent: 'Reserve Percentagem: {percent} %',
|
||||
node_managment: 'Gerenciamento de Nós',
|
||||
node_management: 'Gerenciamento de Nós',
|
||||
node_management_not_supported:
|
||||
'Gerenciamento de nós não suportado pela fonte de financiamento ativa',
|
||||
toggle_node_ui: 'Interface do Usuário de Nó',
|
||||
|
||||
@@ -335,7 +335,7 @@ window.localisation.sk = {
|
||||
node_balance: 'Stav uzla: {balance} sats',
|
||||
lnbits_balance: 'Zostatok LNbits: {balance} sats',
|
||||
funding_reserve_percent: 'Rezervovať percento: {percent} %',
|
||||
node_managment: 'Správa uzlov',
|
||||
node_management: 'Správa uzlov',
|
||||
node_management_not_supported:
|
||||
'Správa uzlov nie je podporovaná aktívnym zdrojom financovania',
|
||||
toggle_node_ui: 'Používateľské rozhranie uzla',
|
||||
|
||||
@@ -335,7 +335,7 @@ window.localisation.we = {
|
||||
node_balance: 'Cydbwysedd Nôd: {balance} sats',
|
||||
lnbits_balance: 'Cydbwysedd LNbits: {balance} sats',
|
||||
funding_reserve_percent: 'Cadw Canran: {percent} %',
|
||||
node_managment: 'Rheoli Nodau',
|
||||
node_management: 'Rheoli Nodau',
|
||||
node_management_not_supported:
|
||||
'Nid yw Rheoli Nodau yn cael ei gefnogi gan ffynhonnell ariannu weithredol',
|
||||
toggle_node_ui: 'Node UI',
|
||||
|
||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -54,6 +54,7 @@ window.AdminPageLogic = {
|
||||
chartReady: false,
|
||||
formAddAdmin: '',
|
||||
formAddUser: '',
|
||||
formAddStripeUser: '',
|
||||
hideInputToggle: true,
|
||||
formAddExtensionsManifest: '',
|
||||
nostrNotificationIdentifier: '',
|
||||
@@ -187,6 +188,23 @@ window.AdminPageLogic = {
|
||||
let allowed_users = this.formData.lnbits_allowed_users
|
||||
this.formData.lnbits_allowed_users = allowed_users.filter(u => u !== user)
|
||||
},
|
||||
addStripeAllowedUser() {
|
||||
const addUser = this.formAddStripeUser || ''
|
||||
if (
|
||||
addUser.length &&
|
||||
!this.formData.stripe_limits.allowed_users.includes(addUser)
|
||||
) {
|
||||
this.formData.stripe_limits.allowed_users = [
|
||||
...this.formData.stripe_limits.allowed_users,
|
||||
addUser
|
||||
]
|
||||
this.formAddStripeUser = ''
|
||||
}
|
||||
},
|
||||
removeStripeAllowedUser(user) {
|
||||
this.formData.stripe_limits.allowed_users =
|
||||
this.formData.stripe_limits.allowed_users.filter(u => u !== user)
|
||||
},
|
||||
addIncludePath() {
|
||||
if (!this.formAddIncludePath) {
|
||||
return
|
||||
@@ -613,6 +631,20 @@ window.AdminPageLogic = {
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
})
|
||||
},
|
||||
checkFiatProvider(providerName) {
|
||||
LNbits.api
|
||||
.request('PUT', `/api/v1/fiat/check/${providerName}`)
|
||||
.then(response => {
|
||||
response
|
||||
const data = response.data
|
||||
Quasar.Notify.create({
|
||||
type: data.success ? 'positive' : 'warning',
|
||||
message: data.message,
|
||||
icon: null
|
||||
})
|
||||
})
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
},
|
||||
downloadBackup() {
|
||||
window.open('/admin/api/v1/backup', '_blank')
|
||||
},
|
||||
|
||||
@@ -19,14 +19,16 @@ window.LNbits = {
|
||||
amount,
|
||||
memo,
|
||||
unit = 'sat',
|
||||
lnurlCallback = null
|
||||
lnurlCallback = null,
|
||||
fiatProvider = null
|
||||
) {
|
||||
return this.request('post', '/api/v1/payments', wallet.inkey, {
|
||||
out: false,
|
||||
amount: amount,
|
||||
memo: memo,
|
||||
lnurl_callback: lnurlCallback,
|
||||
unit: unit
|
||||
unit: unit,
|
||||
fiat_provider: fiatProvider
|
||||
})
|
||||
},
|
||||
payInvoice(wallet, bolt11) {
|
||||
@@ -197,6 +199,7 @@ window.LNbits = {
|
||||
email: data.email,
|
||||
extensions: data.extensions,
|
||||
wallets: data.wallets,
|
||||
fiat_providers: data.fiat_providers || [],
|
||||
super_user: data.super_user,
|
||||
extra: data.extra ?? {}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ window.WalletPageLogic = {
|
||||
lnurl: null,
|
||||
units: ['sat'],
|
||||
unit: 'sat',
|
||||
fiatProvider: '',
|
||||
data: {
|
||||
amount: null,
|
||||
memo: ''
|
||||
@@ -253,12 +254,15 @@ window.WalletPageLogic = {
|
||||
this.receive.data.amount,
|
||||
this.receive.data.memo,
|
||||
this.receive.unit,
|
||||
this.receive.lnurl && this.receive.lnurl.callback
|
||||
this.receive.lnurl && this.receive.lnurl.callback,
|
||||
this.receive.fiatProvider
|
||||
)
|
||||
.then(response => {
|
||||
this.g.updatePayments = !this.g.updatePayments
|
||||
this.receive.status = 'success'
|
||||
this.receive.paymentReq = response.data.bolt11
|
||||
this.receive.fiatPaymentReq =
|
||||
response.data.extra?.fiat_payment_request
|
||||
this.receive.amountMsat = response.data.amount
|
||||
this.receive.paymentHash = response.data.payment_hash
|
||||
if (!this.receive.lnurl) {
|
||||
@@ -820,6 +824,9 @@ window.WalletPageLogic = {
|
||||
},
|
||||
swapBalancePriority() {
|
||||
this.isFiatPriority = !this.isFiatPriority
|
||||
this.receive.unit = this.isFiatPriority
|
||||
? this.g.wallet.currency || 'sat'
|
||||
: 'sat'
|
||||
this.$q.localStorage.setItem('lnbits.isFiatPriority', this.isFiatPriority)
|
||||
},
|
||||
handleFiatTracking() {
|
||||
|
||||
+32
-18
@@ -2,11 +2,9 @@ import asyncio
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from collections.abc import Coroutine
|
||||
from typing import (
|
||||
Callable,
|
||||
Coroutine,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
)
|
||||
|
||||
@@ -18,11 +16,12 @@ from lnbits.core.crud import (
|
||||
update_payment,
|
||||
)
|
||||
from lnbits.core.models import Payment, PaymentState
|
||||
from lnbits.core.services.fiat_providers import handle_fiat_payment_confirmation
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets import get_funding_source
|
||||
|
||||
tasks: List[asyncio.Task] = []
|
||||
unique_tasks: Dict[str, asyncio.Task] = {}
|
||||
tasks: list[asyncio.Task] = []
|
||||
unique_tasks: dict[str, asyncio.Task] = {}
|
||||
|
||||
|
||||
def create_task(coro: Coroutine) -> asyncio.Task:
|
||||
@@ -82,7 +81,7 @@ async def catch_everything_and_restart(
|
||||
return await catch_everything_and_restart(func, name)
|
||||
|
||||
|
||||
invoice_listeners: Dict[str, asyncio.Queue] = {}
|
||||
invoice_listeners: dict[str, asyncio.Queue] = {}
|
||||
|
||||
|
||||
# TODO: name should not be optional
|
||||
@@ -106,6 +105,13 @@ def register_invoice_listener(send_chan: asyncio.Queue, name: Optional[str] = No
|
||||
internal_invoice_queue: asyncio.Queue = asyncio.Queue(0)
|
||||
|
||||
|
||||
async def internal_invoice_queue_put(checking_id: str) -> None:
|
||||
"""
|
||||
A method to call when it wants to notify about an internal invoice payment.
|
||||
"""
|
||||
await internal_invoice_queue.put(checking_id)
|
||||
|
||||
|
||||
async def internal_invoice_listener() -> None:
|
||||
"""
|
||||
internal_invoice_queue will be filled directly in core/services.py
|
||||
@@ -200,15 +206,23 @@ async def invoice_callback_dispatcher(checking_id: str, is_internal: bool = Fals
|
||||
invoice_listeners from core and extensions.
|
||||
"""
|
||||
payment = await get_standalone_payment(checking_id, incoming=True)
|
||||
if payment and payment.is_in:
|
||||
status = await payment.check_status()
|
||||
payment.fee = status.fee_msat or 0
|
||||
# only overwrite preimage if status.preimage provides it
|
||||
payment.preimage = status.preimage or payment.preimage
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment)
|
||||
internal = "internal" if is_internal else ""
|
||||
logger.success(f"{internal} invoice {checking_id} settled")
|
||||
for name, send_chan in invoice_listeners.items():
|
||||
logger.trace(f"invoice listeners: sending to `{name}`")
|
||||
await send_chan.put(payment)
|
||||
if not payment:
|
||||
logger.warning(f"No payment found for '{checking_id}'.")
|
||||
return
|
||||
if not payment.is_in:
|
||||
logger.warning(f"Payment '{checking_id}' is not incoming, skipping.")
|
||||
return
|
||||
|
||||
status = await payment.check_status(skip_internal_payment_notifications=True)
|
||||
payment.fee = status.fee_msat or payment.fee
|
||||
# only overwrite preimage if status.preimage provides it
|
||||
payment.preimage = status.preimage or payment.preimage
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment)
|
||||
if payment.fiat_provider:
|
||||
await handle_fiat_payment_confirmation(payment)
|
||||
internal = "internal" if is_internal else ""
|
||||
logger.success(f"{internal} invoice {checking_id} settled")
|
||||
for name, send_chan in invoice_listeners.items():
|
||||
logger.trace(f"invoice listeners: sending to `{name}`")
|
||||
await send_chan.put(payment)
|
||||
|
||||
@@ -998,11 +998,23 @@
|
||||
v-if="props.row.isIn && props.row.isPending && props.row.bolt11"
|
||||
class="text-center q-my-lg"
|
||||
>
|
||||
<a :href="'lightning:' + props.row.bolt11">
|
||||
<lnbits-qrcode
|
||||
:value="'lightning:' + props.row.bolt11.toUpperCase()"
|
||||
></lnbits-qrcode>
|
||||
</a>
|
||||
<div v-if="props.row.extra.fiat_payment_request">
|
||||
<a
|
||||
:href="props.row.extra.fiat_payment_request"
|
||||
target="_blank"
|
||||
>
|
||||
<lnbits-qrcode
|
||||
:value="props.row.extra.fiat_payment_request"
|
||||
></lnbits-qrcode>
|
||||
</a>
|
||||
</div>
|
||||
<div v-else>
|
||||
<a :href="'lightning:' + props.row.bolt11">
|
||||
<lnbits-qrcode
|
||||
:value="'lightning:' + props.row.bolt11.toUpperCase()"
|
||||
></lnbits-qrcode>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
@@ -1013,7 +1025,11 @@
|
||||
"
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(props.row.bolt11)"
|
||||
@click="
|
||||
copyText(
|
||||
props.row.extra.fiat_payment_request || props.row.bolt11
|
||||
)
|
||||
"
|
||||
:label="$t('copy_invoice')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
|
||||
@@ -2,7 +2,7 @@ import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from typing import Dict, Tuple, Union
|
||||
from typing import Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import secp256k1
|
||||
@@ -13,7 +13,7 @@ from Cryptodome.Util.Padding import pad, unpad
|
||||
from pynostr.key import PrivateKey
|
||||
|
||||
|
||||
def generate_keypair() -> Tuple[str, str]:
|
||||
def generate_keypair() -> tuple[str, str]:
|
||||
private_key = PrivateKey()
|
||||
public_key = private_key.public_key
|
||||
return private_key.hex(), public_key.hex()
|
||||
@@ -82,7 +82,7 @@ def decrypt_content(
|
||||
return decrypted
|
||||
|
||||
|
||||
def verify_event(event: Dict) -> bool:
|
||||
def verify_event(event: dict) -> bool:
|
||||
"""
|
||||
Verify the event signature
|
||||
|
||||
@@ -115,8 +115,8 @@ def verify_event(event: Dict) -> bool:
|
||||
|
||||
|
||||
def sign_event(
|
||||
event: Dict, account_public_key_hex: str, account_private_key: secp256k1.PrivateKey
|
||||
) -> Dict:
|
||||
event: dict, account_public_key_hex: str, account_private_key: secp256k1.PrivateKey
|
||||
) -> dict:
|
||||
"""
|
||||
Signs the event (in place) with the service secret
|
||||
|
||||
@@ -149,7 +149,7 @@ def sign_event(
|
||||
return event
|
||||
|
||||
|
||||
def json_dumps(data: Union[Dict, list]) -> str:
|
||||
def json_dumps(data: Union[dict, list]) -> str:
|
||||
"""
|
||||
Converts a Python dictionary to a JSON string with compact encoding.
|
||||
|
||||
@@ -159,7 +159,7 @@ def json_dumps(data: Union[Dict, list]) -> str:
|
||||
Returns:
|
||||
str: The compact JSON string.
|
||||
"""
|
||||
if isinstance(data, Dict):
|
||||
if isinstance(data, dict):
|
||||
data = {k: v for k, v in data.items() if v is not None}
|
||||
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
|
||||
@@ -58,17 +58,17 @@ __all__ = [
|
||||
"BlinkWallet",
|
||||
"BoltzWallet",
|
||||
"BreezSdkWallet",
|
||||
"ClicheWallet",
|
||||
"CoreLightningWallet",
|
||||
"CLightningWallet",
|
||||
"ClicheWallet",
|
||||
"CoreLightningRestWallet",
|
||||
"CoreLightningWallet",
|
||||
"EclairWallet",
|
||||
"FakeWallet",
|
||||
"LNbitsWallet",
|
||||
"LndWallet",
|
||||
"LndRestWallet",
|
||||
"LNPayWallet",
|
||||
"LNbitsWallet",
|
||||
"LnTipsWallet",
|
||||
"LndRestWallet",
|
||||
"LndWallet",
|
||||
"NWCWallet",
|
||||
"OpenNodeWallet",
|
||||
"PhoenixdWallet",
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from typing import AsyncGenerator, Optional
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.helpers import normalize_endpoint
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
@@ -27,7 +29,7 @@ class AlbyWallet(Wallet):
|
||||
if not settings.alby_access_token:
|
||||
raise ValueError("cannot initialize AlbyWallet: missing alby_access_token")
|
||||
|
||||
self.endpoint = self.normalize_endpoint(settings.alby_api_endpoint)
|
||||
self.endpoint = normalize_endpoint(settings.alby_api_endpoint)
|
||||
self.auth = {
|
||||
"Authorization": "Bearer " + settings.alby_access_token,
|
||||
"User-Agent": settings.user_agent,
|
||||
|
||||
+2
-15
@@ -2,7 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING, AsyncGenerator, Coroutine, NamedTuple
|
||||
from collections.abc import AsyncGenerator, Coroutine
|
||||
from typing import TYPE_CHECKING, NamedTuple
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -150,17 +151,3 @@ class Wallet(ABC):
|
||||
except Exception as exc:
|
||||
logger.error(f"could not get status of invoice {invoice}: '{exc}' ")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
def normalize_endpoint(self, endpoint: str, add_proto=True) -> str:
|
||||
endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
|
||||
if add_proto:
|
||||
if endpoint.startswith("ws://") or endpoint.startswith("wss://"):
|
||||
return endpoint
|
||||
endpoint = (
|
||||
f"https://{endpoint}" if not endpoint.startswith("http") else endpoint
|
||||
)
|
||||
return endpoint
|
||||
|
||||
|
||||
class UnsupportedError(Exception):
|
||||
pass
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from typing import AsyncGenerator, Optional
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from websockets.client import WebSocketClientProtocol, connect
|
||||
from websockets.legacy.client import WebSocketClientProtocol, connect
|
||||
from websockets.typing import Subprotocol
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.helpers import normalize_endpoint
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
@@ -34,13 +36,13 @@ class BlinkWallet(Wallet):
|
||||
if not settings.blink_token:
|
||||
raise ValueError("cannot initialize BlinkWallet: missing blink_token")
|
||||
|
||||
self.endpoint = self.normalize_endpoint(settings.blink_api_endpoint)
|
||||
self.endpoint = normalize_endpoint(settings.blink_api_endpoint)
|
||||
|
||||
self.auth = {
|
||||
"X-API-KEY": settings.blink_token,
|
||||
"User-Agent": settings.user_agent,
|
||||
}
|
||||
self.ws_endpoint = self.normalize_endpoint(settings.blink_ws_endpoint)
|
||||
self.ws_endpoint = normalize_endpoint(settings.blink_ws_endpoint)
|
||||
self.ws_auth = {
|
||||
"type": "connection_init",
|
||||
"payload": {"X-API-KEY": settings.blink_token},
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import asyncio
|
||||
from typing import AsyncGenerator, Optional
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Optional
|
||||
|
||||
from bolt11.decode import decode
|
||||
from grpc.aio import AioRpcError
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.helpers import normalize_endpoint
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets.boltz_grpc_files import boltzrpc_pb2, boltzrpc_pb2_grpc
|
||||
from lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc import grpc
|
||||
@@ -42,7 +44,7 @@ class BoltzWallet(Wallet):
|
||||
"cannot initialize BoltzWallet: missing boltz_client_wallet"
|
||||
)
|
||||
|
||||
self.endpoint = self.normalize_endpoint(
|
||||
self.endpoint = normalize_endpoint(
|
||||
settings.boltz_client_endpoint, add_proto=True
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import base64
|
||||
|
||||
from lnbits.exceptions import UnsupportedError
|
||||
|
||||
try:
|
||||
import breez_sdk # type: ignore
|
||||
|
||||
@@ -18,8 +20,9 @@ if not BREEZ_SDK_INSTALLED:
|
||||
|
||||
else:
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator, Optional
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -34,7 +37,6 @@ else:
|
||||
PaymentStatus,
|
||||
PaymentSuccessStatus,
|
||||
StatusResponse,
|
||||
UnsupportedError,
|
||||
Wallet,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from typing import AsyncGenerator, Optional
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
from websocket import create_connection
|
||||
|
||||
from lnbits.helpers import normalize_endpoint
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
@@ -25,7 +27,7 @@ class ClicheWallet(Wallet):
|
||||
if not settings.cliche_endpoint:
|
||||
raise ValueError("cannot initialize ClicheWallet: missing cliche_endpoint")
|
||||
|
||||
self.endpoint = self.normalize_endpoint(settings.cliche_endpoint)
|
||||
self.endpoint = normalize_endpoint(settings.cliche_endpoint)
|
||||
|
||||
async def cleanup(self):
|
||||
pass
|
||||
|
||||
@@ -8,6 +8,7 @@ from bolt11.exceptions import Bolt11Exception
|
||||
from loguru import logger
|
||||
from pyln.client import LightningRpc, RpcError
|
||||
|
||||
from lnbits.exceptions import UnsupportedError
|
||||
from lnbits.nodes.cln import CoreLightningNode
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import random_secret_and_hash
|
||||
@@ -20,7 +21,6 @@ from .base import (
|
||||
PaymentStatus,
|
||||
PaymentSuccessStatus,
|
||||
StatusResponse,
|
||||
UnsupportedError,
|
||||
Wallet,
|
||||
)
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ from bolt11 import Bolt11Exception
|
||||
from bolt11.decode import decode
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.exceptions import UnsupportedError
|
||||
from lnbits.helpers import normalize_endpoint
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import random_secret_and_hash
|
||||
|
||||
@@ -18,7 +20,6 @@ from .base import (
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
StatusResponse,
|
||||
UnsupportedError,
|
||||
Wallet,
|
||||
)
|
||||
from .macaroon import load_macaroon
|
||||
@@ -43,7 +44,7 @@ class CoreLightningRestWallet(Wallet):
|
||||
"invalid corelightning_rest_macaroon provided"
|
||||
)
|
||||
|
||||
self.url = self.normalize_endpoint(settings.corelightning_rest_url)
|
||||
self.url = normalize_endpoint(settings.corelightning_rest_url)
|
||||
headers = {
|
||||
"macaroon": macaroon,
|
||||
"encodingtype": "hex",
|
||||
@@ -217,7 +218,7 @@ class CoreLightningRestWallet(Wallet):
|
||||
data = exc.response.json()
|
||||
error_code = int(data["error"]["code"])
|
||||
if error_code in self.pay_failure_error_codes:
|
||||
error_message = f"Payment failed: {data['error']['message']}"
|
||||
error_message = data["error"]["message"]
|
||||
return PaymentResponse(ok=False, error_message=error_message)
|
||||
error_message = f"REST failed with {data['error']['message']}."
|
||||
return PaymentResponse(error_message=error_message)
|
||||
|
||||
@@ -9,8 +9,9 @@ from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from websockets.client import connect
|
||||
from websockets.legacy.client import connect
|
||||
|
||||
from lnbits.helpers import normalize_endpoint
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import random_secret_and_hash
|
||||
|
||||
@@ -39,7 +40,7 @@ class EclairWallet(Wallet):
|
||||
if not settings.eclair_pass:
|
||||
raise ValueError("cannot initialize EclairWallet: missing eclair_pass")
|
||||
|
||||
self.url = self.normalize_endpoint(settings.eclair_url)
|
||||
self.url = normalize_endpoint(settings.eclair_url)
|
||||
self.ws_url = f"ws://{urllib.parse.urlsplit(self.url).netloc}/ws"
|
||||
|
||||
password = settings.eclair_pass
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import datetime
|
||||
from hashlib import sha256
|
||||
from os import urandom
|
||||
from typing import AsyncGenerator, Optional
|
||||
from typing import Optional
|
||||
|
||||
from bolt11 import (
|
||||
Bolt11,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import asyncio
|
||||
import json
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from websockets.client import connect
|
||||
from websockets.legacy.client import connect
|
||||
|
||||
from lnbits.helpers import normalize_endpoint
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
@@ -36,7 +38,7 @@ class LNbitsWallet(Wallet):
|
||||
"cannot initialize LNbitsWallet: "
|
||||
"missing lnbits_key or lnbits_admin_key or lnbits_invoice_key"
|
||||
)
|
||||
self.endpoint = self.normalize_endpoint(settings.lnbits_endpoint)
|
||||
self.endpoint = normalize_endpoint(settings.lnbits_endpoint)
|
||||
self.ws_url = f"{self.endpoint.replace('http', 'ws', 1)}/api/v1/ws/{key}"
|
||||
self.headers = {"X-Api-Key": key, "User-Agent": settings.user_agent}
|
||||
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
|
||||
@@ -74,7 +76,7 @@ class LNbitsWallet(Wallet):
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
data: Dict = {"out": False, "amount": amount, "memo": memo or ""}
|
||||
data: dict = {"out": False, "amount": amount, "memo": memo or ""}
|
||||
if kwargs.get("expiry"):
|
||||
data["expiry"] = kwargs["expiry"]
|
||||
if description_hash:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import asyncio
|
||||
import base64
|
||||
from collections.abc import AsyncGenerator
|
||||
from hashlib import sha256
|
||||
from os import environ
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
from typing import Optional
|
||||
|
||||
import grpc
|
||||
from loguru import logger
|
||||
@@ -11,6 +12,7 @@ import lnbits.wallets.lnd_grpc_files.lightning_pb2 as ln
|
||||
import lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc as lnrpc
|
||||
import lnbits.wallets.lnd_grpc_files.router_pb2 as router
|
||||
import lnbits.wallets.lnd_grpc_files.router_pb2_grpc as routerrpc
|
||||
from lnbits.helpers import normalize_endpoint
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import random_secret_and_hash
|
||||
|
||||
@@ -72,9 +74,7 @@ class LndWallet(Wallet):
|
||||
"cannot initialize LndWallet: missing lnd_grpc_cert or lnd_cert"
|
||||
)
|
||||
|
||||
self.endpoint = self.normalize_endpoint(
|
||||
settings.lnd_grpc_endpoint, add_proto=False
|
||||
)
|
||||
self.endpoint = normalize_endpoint(settings.lnd_grpc_endpoint, add_proto=False)
|
||||
self.port = int(settings.lnd_grpc_port)
|
||||
|
||||
macaroon = (
|
||||
@@ -122,7 +122,7 @@ class LndWallet(Wallet):
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
data: Dict = {
|
||||
data: dict = {
|
||||
"description_hash": b"",
|
||||
"value": amount,
|
||||
"private": True,
|
||||
|
||||
@@ -2,11 +2,13 @@ import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, AsyncGenerator, Dict, Optional
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.helpers import normalize_endpoint
|
||||
from lnbits.nodes.lndrest import LndRestNode
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import random_secret_and_hash
|
||||
@@ -41,7 +43,7 @@ class LndRestWallet(Wallet):
|
||||
"This only works if you have a publicly issued certificate."
|
||||
)
|
||||
|
||||
self.endpoint = self.normalize_endpoint(settings.lnd_rest_endpoint)
|
||||
self.endpoint = normalize_endpoint(settings.lnd_rest_endpoint)
|
||||
|
||||
# if no cert provided it should be public so we set verify to True
|
||||
# and it will still check for validity of certificate and fail if its not valid
|
||||
@@ -104,7 +106,7 @@ class LndRestWallet(Wallet):
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
_data: Dict = {
|
||||
_data: dict = {
|
||||
"value": amount,
|
||||
"private": settings.lnd_rest_route_hints,
|
||||
"memo": memo or "",
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.helpers import normalize_endpoint
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
@@ -36,7 +38,7 @@ class LNPayWallet(Wallet):
|
||||
"missing lnpay_wallet_key or lnpay_admin_key"
|
||||
)
|
||||
self.wallet_key = wallet_key
|
||||
self.endpoint = self.normalize_endpoint(settings.lnpay_api_endpoint)
|
||||
self.endpoint = normalize_endpoint(settings.lnpay_api_endpoint)
|
||||
|
||||
headers = {
|
||||
"X-Api-Key": settings.lnpay_api_key,
|
||||
@@ -78,7 +80,7 @@ class LNPayWallet(Wallet):
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**_,
|
||||
) -> InvoiceResponse:
|
||||
data: Dict = {"num_satoshis": f"{amount}"}
|
||||
data: dict = {"num_satoshis": f"{amount}"}
|
||||
if description_hash:
|
||||
data["description_hash"] = description_hash.hex()
|
||||
elif unhashed_description:
|
||||
|
||||
@@ -2,11 +2,13 @@ import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.helpers import normalize_endpoint
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
@@ -36,7 +38,7 @@ class LnTipsWallet(Wallet):
|
||||
"missing lntips_api_key or lntips_admin_key or lntips_invoice_key"
|
||||
)
|
||||
|
||||
self.endpoint = self.normalize_endpoint(settings.lntips_api_endpoint)
|
||||
self.endpoint = normalize_endpoint(settings.lntips_api_endpoint)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Basic {key}",
|
||||
@@ -72,7 +74,7 @@ class LnTipsWallet(Wallet):
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**_,
|
||||
) -> InvoiceResponse:
|
||||
data: Dict = {"amount": amount, "description_hash": "", "memo": memo or ""}
|
||||
data: dict = {"amount": amount, "description_hash": "", "memo": memo or ""}
|
||||
if description_hash:
|
||||
data["description_hash"] = description_hash.hex()
|
||||
elif unhashed_description:
|
||||
|
||||
+13
-12
@@ -3,13 +3,14 @@ import hashlib
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
from typing import AsyncGenerator, Dict, List, Optional, Union, cast
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Optional, Union, cast
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
import secp256k1
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from loguru import logger
|
||||
from websockets.client import connect as ws_connect
|
||||
from websockets.legacy.client import connect as ws_connect
|
||||
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.nostr import (
|
||||
@@ -358,7 +359,7 @@ class NWCConnection:
|
||||
"""
|
||||
return self.shutdown or not settings.lnbits_running
|
||||
|
||||
async def _send(self, data: List[Union[str, Dict]]):
|
||||
async def _send(self, data: list[Union[str, dict]]):
|
||||
"""
|
||||
Sends data to the NWC relay.
|
||||
|
||||
@@ -394,7 +395,7 @@ class NWCConnection:
|
||||
|
||||
async def _close_subscription_by_subid(
|
||||
self, sub_id: str, send_event: bool = True
|
||||
) -> Optional[Dict]:
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Closes a subscription by its sub_id.
|
||||
|
||||
@@ -425,7 +426,7 @@ class NWCConnection:
|
||||
|
||||
async def _close_subscription_by_eventid(
|
||||
self, event_id, send_event=True
|
||||
) -> Optional[Dict]:
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Closes a subscription associated to an event_id.
|
||||
|
||||
@@ -498,7 +499,7 @@ class NWCConnection:
|
||||
except Exception as e:
|
||||
logger.error("Error handling subscription timeout: " + str(e))
|
||||
|
||||
async def _on_ok_message(self, msg: List[str]):
|
||||
async def _on_ok_message(self, msg: list[str]):
|
||||
"""
|
||||
Handles OK messages from the relay.
|
||||
"""
|
||||
@@ -512,12 +513,12 @@ class NWCConnection:
|
||||
if subscription: # Check if the subscription exists first
|
||||
subscription["future"].set_exception(Exception(info))
|
||||
|
||||
async def _on_event_message(self, msg: List[Union[str, Dict]]):
|
||||
async def _on_event_message(self, msg: list[Union[str, dict]]):
|
||||
"""
|
||||
Handles EVENT messages from the relay.
|
||||
"""
|
||||
sub_id = cast(str, msg[1])
|
||||
event = cast(Dict, msg[2])
|
||||
event = cast(dict, msg[2])
|
||||
if not verify_event(event): # Ensure the event is valid (do not trust relays)
|
||||
raise Exception("Invalid event signature")
|
||||
tags = event["tags"]
|
||||
@@ -571,7 +572,7 @@ class NWCConnection:
|
||||
else:
|
||||
subscription["future"].set_result(result)
|
||||
|
||||
async def _on_closed_message(self, msg: List[str]):
|
||||
async def _on_closed_message(self, msg: list[str]):
|
||||
"""
|
||||
Handles CLOSED messages from the relay.
|
||||
"""
|
||||
@@ -646,7 +647,7 @@ class NWCConnection:
|
||||
logger.debug("Reconnecting to NWC relay in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def call(self, method: str, params: Dict) -> Dict:
|
||||
async def call(self, method: str, params: dict) -> dict:
|
||||
"""
|
||||
Call a NWC method.
|
||||
|
||||
@@ -708,7 +709,7 @@ class NWCConnection:
|
||||
# Wait for the response
|
||||
return await future
|
||||
|
||||
async def get_info(self) -> Dict:
|
||||
async def get_info(self) -> dict:
|
||||
"""
|
||||
Get the info about the service provider and cache it.
|
||||
|
||||
@@ -793,7 +794,7 @@ class NWCConnection:
|
||||
logger.warning("Error closing connection: " + str(e))
|
||||
|
||||
|
||||
def parse_nwc(nwc) -> Dict:
|
||||
def parse_nwc(nwc) -> dict:
|
||||
"""
|
||||
Parses a NWC URL (nostr+walletconnect://...) and extracts relevant information.
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import asyncio
|
||||
from typing import AsyncGenerator, Optional
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.exceptions import UnsupportedError
|
||||
from lnbits.helpers import normalize_endpoint
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
@@ -12,7 +15,6 @@ from .base import (
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
StatusResponse,
|
||||
UnsupportedError,
|
||||
Wallet,
|
||||
)
|
||||
|
||||
@@ -38,7 +40,7 @@ class OpenNodeWallet(Wallet):
|
||||
)
|
||||
self.key = key
|
||||
|
||||
self.endpoint = self.normalize_endpoint(settings.opennode_api_endpoint)
|
||||
self.endpoint = normalize_endpoint(settings.opennode_api_endpoint)
|
||||
|
||||
headers = {
|
||||
"Authorization": self.key,
|
||||
|
||||
@@ -3,12 +3,14 @@ import base64
|
||||
import hashlib
|
||||
import json
|
||||
import urllib.parse
|
||||
from typing import Any, AsyncGenerator, Dict, Optional
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from websockets.client import connect
|
||||
from websockets.legacy.client import connect
|
||||
|
||||
from lnbits.helpers import normalize_endpoint
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
@@ -35,7 +37,7 @@ class PhoenixdWallet(Wallet):
|
||||
"cannot initialize PhoenixdWallet: missing phoenixd_api_password"
|
||||
)
|
||||
|
||||
self.endpoint = self.normalize_endpoint(settings.phoenixd_api_endpoint)
|
||||
self.endpoint = normalize_endpoint(settings.phoenixd_api_endpoint)
|
||||
parsed_url = urllib.parse.urlparse(settings.phoenixd_api_endpoint)
|
||||
|
||||
if parsed_url.scheme == "http":
|
||||
@@ -100,7 +102,7 @@ class PhoenixdWallet(Wallet):
|
||||
|
||||
try:
|
||||
msats_amount = amount
|
||||
data: Dict[str, Any] = {
|
||||
data: dict[str, Any] = {
|
||||
"amountSat": f"{msats_amount}",
|
||||
"externalId": "",
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
from typing import AsyncGenerator, Optional
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.helpers import normalize_endpoint
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
@@ -36,7 +38,7 @@ class SparkWallet(Wallet):
|
||||
if not settings.spark_token:
|
||||
raise ValueError("cannot initialize SparkWallet: missing spark_token")
|
||||
|
||||
url = self.normalize_endpoint(settings.spark_url)
|
||||
url = normalize_endpoint(settings.spark_url)
|
||||
url = url.replace("/rpc", "")
|
||||
self.token = settings.spark_token
|
||||
|
||||
|
||||
+13
-11
@@ -1,11 +1,13 @@
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import AsyncGenerator
|
||||
from decimal import Decimal
|
||||
from typing import Any, AsyncGenerator, Dict, Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.helpers import normalize_endpoint
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
@@ -94,7 +96,7 @@ class StrikeWallet(Wallet):
|
||||
self._general_limiter = TokenBucket(1000, 600)
|
||||
|
||||
self.client = httpx.AsyncClient(
|
||||
base_url=self.normalize_endpoint(settings.strike_api_endpoint),
|
||||
base_url=normalize_endpoint(settings.strike_api_endpoint),
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.strike_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
@@ -110,8 +112,8 @@ class StrikeWallet(Wallet):
|
||||
|
||||
# runtime state
|
||||
self.pending_invoices: list[str] = [] # Keep it as a list
|
||||
self.pending_payments: Dict[str, str] = {}
|
||||
self.failed_payments: Dict[str, str] = {}
|
||||
self.pending_payments: dict[str, str] = {}
|
||||
self.failed_payments: dict[str, str] = {}
|
||||
|
||||
# balance cache
|
||||
self._cached_balance: Optional[int] = None
|
||||
@@ -182,7 +184,7 @@ class StrikeWallet(Wallet):
|
||||
if btc and "available" in btc:
|
||||
available_btc = Decimal(btc["available"]) # Get available BTC amount.
|
||||
msats = int(
|
||||
available_btc * Decimal(1e11)
|
||||
available_btc * Decimal("1e11")
|
||||
) # Convert BTC to millisatoshis.
|
||||
self._cached_balance = msats
|
||||
self._cached_balance_ts = now
|
||||
@@ -203,10 +205,10 @@ class StrikeWallet(Wallet):
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
try:
|
||||
btc_amt = (Decimal(amount) / Decimal(1e8)).quantize(
|
||||
btc_amt = (Decimal(amount) / Decimal("1e8")).quantize(
|
||||
Decimal("0.00000001")
|
||||
) # Convert amount from millisatoshis to BTC.
|
||||
payload: Dict[str, Any] = {
|
||||
payload: dict[str, Any] = {
|
||||
"bolt11": {
|
||||
"amount": {
|
||||
"currency": "BTC",
|
||||
@@ -269,7 +271,7 @@ class StrikeWallet(Wallet):
|
||||
# Network fee → msat.
|
||||
fee_obj = data.get("lightningNetworkFee") or data.get("totalFee") or {}
|
||||
fee_btc = Decimal(fee_obj.get("amount", "0"))
|
||||
fee_msat = int(fee_btc * Decimal(1e11)) # millisatoshis.
|
||||
fee_msat = int(fee_btc * Decimal("1e11")) # millisatoshis.
|
||||
|
||||
if state in {"SUCCEEDED", "COMPLETED"}:
|
||||
preimage = data.get("preimage") or data.get("preImage")
|
||||
@@ -426,9 +428,9 @@ class StrikeWallet(Wallet):
|
||||
orderby: Optional[str] = None,
|
||||
skip: Optional[int] = None,
|
||||
top: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
params: Dict[str, Any] = {}
|
||||
params: dict[str, Any] = {}
|
||||
if filters:
|
||||
params["$filter"] = filters
|
||||
if orderby:
|
||||
@@ -464,7 +466,7 @@ class StrikeWallet(Wallet):
|
||||
try:
|
||||
if currency_str == "BTC":
|
||||
fee_btc_decimal = Decimal(amount_str)
|
||||
fee_msat = int(fee_btc_decimal * Decimal(1e11))
|
||||
fee_msat = int(fee_btc_decimal * Decimal("1e11"))
|
||||
elif currency_str == "SAT":
|
||||
fee_sat_decimal = Decimal(amount_str)
|
||||
fee_msat = int(fee_sat_decimal * 1000)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.helpers import normalize_endpoint
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
@@ -27,7 +29,7 @@ class ZBDWallet(Wallet):
|
||||
if not settings.zbd_api_key:
|
||||
raise ValueError("cannot initialize ZBDWallet: missing zbd_api_key")
|
||||
|
||||
self.endpoint = self.normalize_endpoint(settings.zbd_api_endpoint)
|
||||
self.endpoint = normalize_endpoint(settings.zbd_api_endpoint)
|
||||
headers = {
|
||||
"apikey": settings.zbd_api_key,
|
||||
"User-Agent": settings.user_agent,
|
||||
@@ -66,7 +68,7 @@ class ZBDWallet(Wallet):
|
||||
# https://api.zebedee.io/v0/charges
|
||||
|
||||
msats_amount = amount * 1000
|
||||
data: Dict = {
|
||||
data: dict = {
|
||||
"amount": f"{msats_amount}",
|
||||
"expiresIn": 3600,
|
||||
"callbackUrl": "",
|
||||
|
||||
Generated
+873
-264
File diff suppressed because it is too large
Load Diff
+25
-25
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "lnbits"
|
||||
version = "1.2.0-rc2"
|
||||
version = "1.2.0"
|
||||
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
||||
authors = ["Alan Bits <alan@lnbits.com>"]
|
||||
readme = "README.md"
|
||||
@@ -14,49 +14,49 @@ packages = [
|
||||
[tool.poetry.dependencies]
|
||||
python = "~3.12 | ~3.11 | ~3.10"
|
||||
bech32 = "1.2.0"
|
||||
click = "8.1.7"
|
||||
ecdsa = "0.19.0"
|
||||
fastapi = "0.115.2"
|
||||
click = "8.2.1"
|
||||
ecdsa = "0.19.1"
|
||||
fastapi = "0.115.13"
|
||||
httpx = "0.27.0"
|
||||
jinja2 = "3.1.4"
|
||||
jinja2 = "3.1.6"
|
||||
lnurl = "0.5.3"
|
||||
pydantic = "1.10.18"
|
||||
pyqrcode = "1.2.1"
|
||||
shortuuid = "1.0.13"
|
||||
sse-starlette = "1.8.2"
|
||||
typing-extensions = "4.12.2"
|
||||
uvicorn = "0.34.2"
|
||||
sse-starlette = "2.3.6"
|
||||
typing-extensions = "4.14.0"
|
||||
uvicorn = "0.34.3"
|
||||
sqlalchemy = "1.4.54"
|
||||
aiosqlite = "0.20.0"
|
||||
aiosqlite = "0.21.0"
|
||||
asyncpg = "0.30.0"
|
||||
uvloop = "0.21.0"
|
||||
websockets = "11.0.3"
|
||||
loguru = "0.7.2"
|
||||
websockets = "15.0.1"
|
||||
loguru = "0.7.3"
|
||||
grpcio = "1.69.0"
|
||||
protobuf = "5.29.1"
|
||||
pyln-client = "24.11"
|
||||
pywebpush = "1.14.1"
|
||||
pyln-client = "25.5"
|
||||
pywebpush = "2.0.3"
|
||||
slowapi = "0.1.9"
|
||||
websocket-client = "1.8.0"
|
||||
pycryptodomex = "3.20.0"
|
||||
packaging = "24.0"
|
||||
bolt11 = "2.1.0"
|
||||
pyjwt = "2.9.0"
|
||||
pycryptodomex = "3.23.0"
|
||||
packaging = "25.0"
|
||||
bolt11 = "2.1.1"
|
||||
pyjwt = "2.10.1"
|
||||
passlib = "1.7.4"
|
||||
itsdangerous = "2.2.0"
|
||||
fastapi-sso = "0.15.0"
|
||||
fastapi-sso = "0.18.0"
|
||||
# needed for boltz, lnurldevice, watchonly extensions
|
||||
embit = "0.8.0"
|
||||
# needed for cashu, lnurlp, nostrclient, nostrmarket, nostrrelay extensions
|
||||
secp256k1 = "0.14.0"
|
||||
# keep for backwards compatibility with lnurlp and cashu
|
||||
environs = "9.5.0"
|
||||
environs = "14.2.0"
|
||||
# needed for scheduler extension
|
||||
python-crontab = "3.2.0"
|
||||
# needed for liquid support boltz
|
||||
wallycore = {version = "1.4.0", optional = true}
|
||||
# needed for breez funding source
|
||||
breez-sdk = {version = "0.6.6", optional = true}
|
||||
breez-sdk = {version = "0.8.0", optional = true}
|
||||
|
||||
jsonpath-ng = "^1.7.0"
|
||||
pynostr = "^0.6.2"
|
||||
@@ -69,15 +69,15 @@ breez = ["breez-sdk"]
|
||||
liquid = ["wallycore"]
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = "^24.8.0"
|
||||
black = "^25.1.0"
|
||||
mypy = "^1.11.2"
|
||||
types-protobuf = "^5.27.0.20240626"
|
||||
pre-commit = "^3.8.0"
|
||||
types-protobuf = "^6.30.2.20250516"
|
||||
pre-commit = "^4.2.0"
|
||||
openapi-spec-validator = "^0.7.1"
|
||||
ruff = "^0.6.4"
|
||||
ruff = "^0.12.0"
|
||||
types-passlib = "^1.7.7.20240327"
|
||||
openai = "^1.39.0"
|
||||
json5 = "^0.9.25"
|
||||
json5 = "^0.12.0"
|
||||
asgi-lifespan = "^2.1.0"
|
||||
anyio = "^4.7.0"
|
||||
pytest = "^8.3.4"
|
||||
|
||||
@@ -8,10 +8,12 @@ from pytest_mock.plugin import MockerFixture
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.models import CreateInvoice, Payment
|
||||
from lnbits.core.views.payment_api import api_payment
|
||||
from lnbits.fiat.base import FiatInvoiceResponse
|
||||
from lnbits.settings import Settings
|
||||
|
||||
from ..helpers import (
|
||||
get_random_invoice_data,
|
||||
get_random_string,
|
||||
)
|
||||
|
||||
|
||||
@@ -155,6 +157,60 @@ async def test_create_invoice_fiat_amount(client, inkey_headers_to):
|
||||
assert extra["fiat_rate"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_fiat_invoice(
|
||||
client, inkey_headers_to, settings: Settings, mocker: MockerFixture
|
||||
):
|
||||
data = await get_random_invoice_data()
|
||||
data["unit"] = "EUR"
|
||||
data["fiat_provider"] = "stripe"
|
||||
|
||||
settings.stripe_enabled = True
|
||||
settings.stripe_api_secret_key = "mock_sk_test_4eC39HqLyjWDarjtT1zdp7dc"
|
||||
|
||||
fiat_payment_request = "https://stripe.com/pay/session_123"
|
||||
fiat_mock_response = FiatInvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=f"session_123_{get_random_string(10)}",
|
||||
payment_request=fiat_payment_request,
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"lnbits.fiat.StripeWallet.create_invoice",
|
||||
AsyncMock(return_value=fiat_mock_response),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.utils.exchange_rates.get_fiat_rate_satoshis",
|
||||
AsyncMock(return_value=1000), # 1 BTC = 100 000 EUR, so 1 EUR = 1000 sats
|
||||
)
|
||||
response = await client.post(
|
||||
"/api/v1/payments", json=data, headers=inkey_headers_to
|
||||
)
|
||||
assert response.status_code == 201
|
||||
invoice = response.json()
|
||||
decode = bolt11.decode(invoice["bolt11"])
|
||||
assert decode.amount_msat == 10_000_000
|
||||
assert decode.payment_hash
|
||||
assert invoice["fiat_provider"] == "stripe"
|
||||
assert invoice["status"] == "pending"
|
||||
assert invoice["extra"]["fiat_checking_id"]
|
||||
assert invoice["extra"]["fiat_payment_request"] == fiat_payment_request
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/payments/{decode.payment_hash}", headers=inkey_headers_to
|
||||
)
|
||||
assert response.is_success
|
||||
data = response.json()
|
||||
assert data["status"] == "pending"
|
||||
invoice = data["details"]
|
||||
|
||||
assert invoice["fiat_provider"] == "stripe"
|
||||
assert invoice["status"] == "pending"
|
||||
assert invoice["amount"] == 10_000_000
|
||||
assert invoice["extra"]["fiat_checking_id"]
|
||||
assert invoice["extra"]["fiat_payment_request"] == fiat_payment_request
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("currency", ("msat", "RRR"))
|
||||
async def test_create_invoice_validates_used_currency(
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import shortuuid
|
||||
from httpx import AsyncClient
|
||||
|
||||
from lnbits.core.models.users import User
|
||||
from lnbits.settings import Settings
|
||||
from lnbits.utils.nostr import generate_keypair
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_user_success(http_client: AsyncClient, superuser_token):
|
||||
tiny_id = shortuuid.uuid()[:8]
|
||||
data = {
|
||||
"username": f"user_{tiny_id}",
|
||||
"password": "secret1234",
|
||||
"password_repeat": "secret1234",
|
||||
"email": f"user_{tiny_id}@lnbits.com",
|
||||
}
|
||||
response = await http_client.post(
|
||||
"/users/api/v1/user",
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
resp = response.json()
|
||||
assert resp["username"] == data["username"]
|
||||
assert resp["email"] == data["email"]
|
||||
assert resp["id"] is not None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_user_passwords_do_not_match(
|
||||
http_client: AsyncClient, superuser_token
|
||||
):
|
||||
tiny_id = shortuuid.uuid()[:8]
|
||||
data = {
|
||||
"username": f"user_{tiny_id}",
|
||||
"password": "secret1234",
|
||||
"password_repeat": "secret0000",
|
||||
"email": f"user_{tiny_id}@lnbits.com",
|
||||
}
|
||||
response = await http_client.post(
|
||||
"/users/api/v1/user",
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Passwords do not match."
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_user_missing_username_with_password(
|
||||
http_client: AsyncClient, superuser_token
|
||||
):
|
||||
data = {
|
||||
"password": "secret1234",
|
||||
"password_repeat": "secret1234",
|
||||
"email": "nouser@lnbits.com",
|
||||
}
|
||||
response = await http_client.post(
|
||||
"/users/api/v1/user",
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Username required when password provided."
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_user_no_password_random_generated(
|
||||
http_client: AsyncClient, superuser_token
|
||||
):
|
||||
tiny_id = shortuuid.uuid()[:8]
|
||||
data = {
|
||||
"username": f"user_{tiny_id}",
|
||||
"email": f"user_{tiny_id}@lnbits.com",
|
||||
}
|
||||
response = await http_client.post(
|
||||
"/users/api/v1/user",
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
resp = response.json()
|
||||
assert resp["username"] == data["username"]
|
||||
assert resp["email"] == data["email"]
|
||||
assert resp["id"] is not None
|
||||
assert resp["password"] is not None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_user_with_extensions_and_extra(
|
||||
http_client: AsyncClient, superuser_token
|
||||
):
|
||||
tiny_id = shortuuid.uuid()[:8]
|
||||
data = {
|
||||
"username": f"user_{tiny_id}",
|
||||
"password": "secret1234",
|
||||
"password_repeat": "secret1234",
|
||||
"email": f"user_{tiny_id}@lnbits.com",
|
||||
"extensions": ["testext1", "testext2"],
|
||||
"extra": {"provider": "custom", "foo": "bar"},
|
||||
}
|
||||
response = await http_client.post(
|
||||
"/users/api/v1/user",
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
resp = response.json()
|
||||
assert resp["username"] == data["username"]
|
||||
assert resp["email"] == data["email"]
|
||||
assert resp["id"] is not None
|
||||
assert resp["extra"]["provider"] == "custom"
|
||||
assert "foo" not in resp["extra"], "random fields should not be in extra"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_user_minimum_fields(http_client: AsyncClient, superuser_token):
|
||||
data: dict[str, str] = {}
|
||||
response = await http_client.post(
|
||||
"/users/api/v1/user",
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
resp = response.json()
|
||||
assert resp["id"] is not None
|
||||
assert resp["extra"]["provider"] == "lnbits"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_user_duplicate_username(
|
||||
http_client: AsyncClient, superuser_token
|
||||
):
|
||||
tiny_id = shortuuid.uuid()[:8]
|
||||
username = f"user_{tiny_id}"
|
||||
data = {
|
||||
"username": username,
|
||||
"password": "secret1234",
|
||||
"password_repeat": "secret1234",
|
||||
"email": f"user_{tiny_id}@lnbits.com",
|
||||
}
|
||||
# First creation should succeed
|
||||
response1 = await http_client.post(
|
||||
"/users/api/v1/user",
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert response1.status_code == 200
|
||||
# Second creation with same username should fail
|
||||
data2 = data.copy()
|
||||
data2["email"] = f"other_{tiny_id}@lnbits.com"
|
||||
response2 = await http_client.post(
|
||||
"/users/api/v1/user",
|
||||
json=data2,
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert response2.status_code == 400 or response2.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_user_duplicate_email(http_client: AsyncClient, superuser_token):
|
||||
tiny_id = shortuuid.uuid()[:8]
|
||||
email = f"user_{tiny_id}@lnbits.com"
|
||||
data = {
|
||||
"username": f"user_{tiny_id}",
|
||||
"password": "secret1234",
|
||||
"password_repeat": "secret1234",
|
||||
"email": email,
|
||||
}
|
||||
# First creation should succeed
|
||||
response1 = await http_client.post(
|
||||
"/users/api/v1/user",
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert response1.status_code == 200
|
||||
# Second creation with same email should fail
|
||||
data2 = data.copy()
|
||||
data2["username"] = f"other_{tiny_id}"
|
||||
response2 = await http_client.post(
|
||||
"/users/api/v1/user",
|
||||
json=data2,
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert response2.status_code == 400 or response2.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_user_success(http_client: AsyncClient, superuser_token):
|
||||
# Create a user first
|
||||
tiny_id = shortuuid.uuid()[:8]
|
||||
data = {
|
||||
"username": f"update_{tiny_id}",
|
||||
"password": "secret1234",
|
||||
"password_repeat": "secret1234",
|
||||
"email": f"update_{tiny_id}@lnbits.com",
|
||||
}
|
||||
create_resp = await http_client.post(
|
||||
"/users/api/v1/user",
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert create_resp.status_code == 200
|
||||
user_id = create_resp.json()["id"]
|
||||
|
||||
# Update the user
|
||||
_, pubkey = generate_keypair()
|
||||
update_data = {
|
||||
"id": user_id,
|
||||
"username": f"updated_{tiny_id}",
|
||||
"email": f"updated_{tiny_id}@lnbits.com",
|
||||
"pubkey": pubkey,
|
||||
"external_id": "external_1234",
|
||||
"extra": {"provider": "lnbits"},
|
||||
"extensions": [],
|
||||
}
|
||||
resp = await http_client.put(
|
||||
f"/users/api/v1/user/{user_id}",
|
||||
json=update_data,
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["username"] == update_data["username"]
|
||||
assert resp.json()["email"] == update_data["email"]
|
||||
assert resp.json()["pubkey"] == update_data["pubkey"]
|
||||
assert resp.json()["external_id"] == update_data["external_id"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_bad_external_id(
|
||||
http_client: AsyncClient, user_alan: User, superuser_token
|
||||
):
|
||||
|
||||
update_data = {"id": user_alan.id, "external_id": "external 1234"}
|
||||
resp = await http_client.put(
|
||||
f"/users/api/v1/user/{user_alan.id}",
|
||||
json=update_data,
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert (
|
||||
resp.json()["detail"] == "Invalid external id. "
|
||||
"Max length is 256 characters. Space and newlines are not allowed."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_user_id_mismatch(http_client: AsyncClient, superuser_token):
|
||||
# Create a user first
|
||||
tiny_id = shortuuid.uuid()[:8]
|
||||
data = {
|
||||
"username": f"mismatch_{tiny_id}",
|
||||
"password": "secret1234",
|
||||
"password_repeat": "secret1234",
|
||||
"email": f"mismatch_{tiny_id}@lnbits.com",
|
||||
}
|
||||
create_resp = await http_client.post(
|
||||
"/users/api/v1/user",
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert create_resp.status_code == 200
|
||||
user_id = create_resp.json()["id"]
|
||||
|
||||
# Try to update with mismatched id
|
||||
update_data: dict[str, Any] = {
|
||||
"id": "wrongid",
|
||||
"username": f"updated_{tiny_id}",
|
||||
"email": f"updated_{tiny_id}@lnbits.com",
|
||||
"extra": {"provider": "lnbits"},
|
||||
"extensions": [],
|
||||
}
|
||||
resp = await http_client.put(
|
||||
f"/users/api/v1/user/{user_id}",
|
||||
json=update_data,
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"] == "User Id missmatch."
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_user_password_fields(http_client: AsyncClient, superuser_token):
|
||||
# Create a user first
|
||||
tiny_id = shortuuid.uuid()[:8]
|
||||
data = {
|
||||
"username": f"pwfield_{tiny_id}",
|
||||
"password": "secret1234",
|
||||
"password_repeat": "secret1234",
|
||||
"email": f"pwfield_{tiny_id}@lnbits.com",
|
||||
}
|
||||
create_resp = await http_client.post(
|
||||
"/users/api/v1/user",
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert create_resp.status_code == 200
|
||||
user_id = create_resp.json()["id"]
|
||||
|
||||
# Try to update with password fields set
|
||||
update_data = {
|
||||
"id": user_id,
|
||||
"username": f"updated_{tiny_id}",
|
||||
"email": f"updated_{tiny_id}@lnbits.com",
|
||||
"extra": {"provider": "lnbits"},
|
||||
"extensions": [],
|
||||
"password": "newpass1234",
|
||||
"password_repeat": "newpass1234",
|
||||
}
|
||||
resp = await http_client.put(
|
||||
f"/users/api/v1/user/{user_id}",
|
||||
json=update_data,
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"] == "Use 'reset password' functionality."
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_user_invalid_username(http_client: AsyncClient, superuser_token):
|
||||
# Create a user first
|
||||
tiny_id = shortuuid.uuid()[:8]
|
||||
data = {
|
||||
"username": f"valid_{tiny_id}",
|
||||
"password": "secret1234",
|
||||
"password_repeat": "secret1234",
|
||||
"email": f"valid_{tiny_id}@lnbits.com",
|
||||
}
|
||||
create_resp = await http_client.post(
|
||||
"/users/api/v1/user",
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert create_resp.status_code == 200
|
||||
user_id = create_resp.json()["id"]
|
||||
|
||||
# Try to update with invalid username
|
||||
update_data = {
|
||||
"id": user_id,
|
||||
"username": "!@#invalid", # invalid username
|
||||
"email": f"valid_{tiny_id}@lnbits.com",
|
||||
"extra": {"provider": "lnbits"},
|
||||
"extensions": [],
|
||||
}
|
||||
resp = await http_client.put(
|
||||
f"/users/api/v1/user/{user_id}",
|
||||
json=update_data,
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"] == "Invalid username."
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_superuser_only_allowed_by_superuser(
|
||||
http_client: AsyncClient, user_alan: User, settings: Settings
|
||||
):
|
||||
response = await http_client.post("/api/v1/auth/usr", json={"usr": user_alan.id})
|
||||
|
||||
assert response.status_code == 200, "Alan logs in OK."
|
||||
alan_access_token = response.json().get("access_token")
|
||||
assert alan_access_token is not None, "Expected access token after login."
|
||||
settings.lnbits_admin_users = [user_alan.id]
|
||||
update_data: dict[str, Any] = {
|
||||
"id": settings.super_user,
|
||||
"username": "superadmin",
|
||||
"email": "superadmin@lnbits.com",
|
||||
"extra": {"provider": "lnbits"},
|
||||
"extensions": [],
|
||||
}
|
||||
resp = await http_client.put(
|
||||
f"/users/api/v1/user/{settings.super_user}",
|
||||
json=update_data,
|
||||
headers={"Authorization": f"Bearer {alan_access_token}"},
|
||||
)
|
||||
|
||||
assert resp.json()["detail"] == "Action only allowed for super user."
|
||||
+4
-3
@@ -19,10 +19,10 @@ from lnbits.core.crud import (
|
||||
from lnbits.core.models import Account, CreateInvoice, PaymentState, User
|
||||
from lnbits.core.models.users import UpdateSuperuserPassword
|
||||
from lnbits.core.services import create_user_account, update_wallet_balance
|
||||
from lnbits.core.services.payments import create_wallet_invoice
|
||||
from lnbits.core.views.auth_api import first_install
|
||||
from lnbits.core.views.payment_api import _api_payments_create_invoice
|
||||
from lnbits.db import DB_TYPE, SQLITE, Database
|
||||
from lnbits.settings import AuthMethods, Settings
|
||||
from lnbits.settings import AuthMethods, FiatProviderLimits, Settings
|
||||
from lnbits.settings import settings as lnbits_settings
|
||||
from lnbits.wallets.fake import FakeWallet
|
||||
from tests.helpers import (
|
||||
@@ -255,7 +255,7 @@ async def adminkey_headers_to(to_wallet):
|
||||
async def invoice(to_wallet):
|
||||
data = await get_random_invoice_data()
|
||||
invoice_data = CreateInvoice(**data)
|
||||
invoice = await _api_payments_create_invoice(invoice_data, to_wallet)
|
||||
invoice = await create_wallet_invoice(to_wallet.id, invoice_data)
|
||||
yield invoice
|
||||
del invoice
|
||||
|
||||
@@ -312,3 +312,4 @@ def _settings_cleanup(settings: Settings):
|
||||
settings.lnbits_admin_users = []
|
||||
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()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from .helpers import get_hold_invoice, get_real_invoice
|
||||
from .helpers import get_hold_invoice, get_real_invoice, get_real_invoice_noroute
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
@@ -22,3 +22,13 @@ async def real_amountless_invoice():
|
||||
invoice = get_real_invoice(0)
|
||||
yield invoice["payment_request"]
|
||||
del invoice
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
async def real_invoice_noroute():
|
||||
invoice = get_real_invoice_noroute(100)
|
||||
yield {
|
||||
"bolt11": invoice["payment_request"],
|
||||
"payment_hash": invoice["r_hash"],
|
||||
}
|
||||
del invoice
|
||||
|
||||
@@ -3,7 +3,6 @@ import json
|
||||
import os
|
||||
import time
|
||||
from subprocess import PIPE, Popen, TimeoutExpired
|
||||
from typing import Tuple
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -39,6 +38,17 @@ docker_lightning_unconnected_cli = [
|
||||
]
|
||||
|
||||
|
||||
docker_lightning_noroute_cli = [
|
||||
"docker",
|
||||
"exec",
|
||||
"lnbits-lnd-4-1",
|
||||
"lncli",
|
||||
"--network",
|
||||
"regtest",
|
||||
"--rpcserver=lnd-4",
|
||||
]
|
||||
|
||||
|
||||
def run_cmd(cmd: list) -> str:
|
||||
timeout = 10
|
||||
process = Popen(cmd, stdout=PIPE, stderr=PIPE)
|
||||
@@ -73,7 +83,7 @@ def run_cmd_json(cmd: list) -> dict:
|
||||
raise
|
||||
|
||||
|
||||
def get_hold_invoice(sats: int) -> Tuple[str, dict]:
|
||||
def get_hold_invoice(sats: int) -> tuple[str, dict]:
|
||||
preimage = os.urandom(32)
|
||||
preimage_hash = hashlib.sha256(preimage).hexdigest()
|
||||
cmd = docker_lightning_cli.copy()
|
||||
@@ -100,6 +110,12 @@ def get_real_invoice(sats: int) -> dict:
|
||||
return run_cmd_json(cmd)
|
||||
|
||||
|
||||
def get_real_invoice_noroute(sats: int) -> dict:
|
||||
cmd = docker_lightning_noroute_cli.copy()
|
||||
cmd.extend(["addinvoice", str(sats)])
|
||||
return run_cmd_json(cmd)
|
||||
|
||||
|
||||
def pay_real_invoice(invoice: str) -> str:
|
||||
cmd = docker_lightning_cli.copy()
|
||||
cmd.extend(["payinvoice", "--force", invoice])
|
||||
|
||||
@@ -69,6 +69,68 @@ async def test_pay_real_invoice(
|
||||
assert prev_balance - balance == 100
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.skipif(is_fake, reason="this only works in regtest")
|
||||
async def test_pay_real_invoice_noroute(
|
||||
client,
|
||||
real_invoice_noroute,
|
||||
adminkey_headers_from,
|
||||
inkey_headers_from,
|
||||
):
|
||||
response = await client.post(
|
||||
"/api/v1/payments", json=real_invoice_noroute, headers=adminkey_headers_from
|
||||
)
|
||||
assert response.status_code == 520
|
||||
invoice = response.json()
|
||||
|
||||
assert invoice["status"] == "failed"
|
||||
|
||||
# check the payment status
|
||||
response = await client.get(
|
||||
f'/api/v1/payments/{real_invoice_noroute["payment_hash"]}',
|
||||
headers=inkey_headers_from,
|
||||
)
|
||||
assert response.status_code < 300
|
||||
payment = response.json()
|
||||
assert payment["paid"] is False
|
||||
assert payment["status"] == "failed"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.skipif(is_fake, reason="this only works in regtest")
|
||||
async def test_pay_real_invoice_mainnet(
|
||||
client,
|
||||
adminkey_headers_from,
|
||||
inkey_headers_from,
|
||||
):
|
||||
"""regtest should fail paying a mainnet invoice"""
|
||||
inv = (
|
||||
"lnbc100n1p59ujlrpp5mn5g5tu7fz0up6asnz0gcceru4hwz0w42g7fuz8gxw67jl7kjjeqcqzyssp5qq"
|
||||
"5y92fwazqdtnu8u9p9qf333hqnkuvtuu5csdze5ak4q86hyrhq9q7sqqqqqqqqqqqqqqqqqqqsqqqqqys"
|
||||
"gqdq2f38xy6t5wvmqz9gxqrrssrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glc"
|
||||
"lll4ttz7sp6kpvqqqqlgqqqqqeqqjqm9fkydmtwsxcxx3j44x9fckjqttg54zlxzw92yeaz9nzn8w7hgv"
|
||||
"87ph5ug4wmgxqpk929k7l6dsnc2y9532daaqlpg9tfjglshuh48cpjh0dua"
|
||||
)
|
||||
payment_hash = "dce88a2f9e489fc0ebb0989e8c6323e56ee13dd5523c9e08e833b5e97fd694b2"
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/payments", json={"bolt11": inv}, headers=adminkey_headers_from
|
||||
)
|
||||
assert response.status_code == 520
|
||||
invoice = response.json()
|
||||
assert invoice["status"] == "failed"
|
||||
|
||||
# check the payment status
|
||||
response = await client.get(
|
||||
f"/api/v1/payments/{payment_hash}",
|
||||
headers=inkey_headers_from,
|
||||
)
|
||||
assert response.status_code < 300
|
||||
payment = response.json()
|
||||
assert payment["paid"] is False
|
||||
assert payment["status"] == "failed"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.skipif(is_fake, reason="this only works in regtest")
|
||||
async def test_create_real_invoice(client, adminkey_headers_from, inkey_headers_from):
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock.plugin import MockerFixture
|
||||
|
||||
from lnbits.core.crud.payments import get_payments
|
||||
from lnbits.core.crud.users import get_user
|
||||
from lnbits.core.crud.wallets import create_wallet
|
||||
from lnbits.core.models.payments import CreateInvoice, PaymentState
|
||||
from lnbits.core.models.users import User
|
||||
from lnbits.core.models.wallets import Wallet
|
||||
from lnbits.core.services import payments
|
||||
from lnbits.core.services.fiat_providers import (
|
||||
check_stripe_signature,
|
||||
handle_fiat_payment_confirmation,
|
||||
)
|
||||
from lnbits.core.services.users import create_user_account
|
||||
from lnbits.fiat.base import FiatInvoiceResponse, FiatPaymentStatus
|
||||
from lnbits.settings import Settings
|
||||
from tests.helpers import get_random_string
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_wallet_fiat_invoice_missing_provider():
|
||||
invoice_data = CreateInvoice(
|
||||
unit="USD", amount=1.0, memo="Test", fiat_provider=None
|
||||
)
|
||||
with pytest.raises(ValueError, match="Fiat provider is required"):
|
||||
await payments.create_fiat_invoice("wallet_id", invoice_data)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_wallet_fiat_invoice_provider_not_enabled(settings: Settings):
|
||||
settings.stripe_enabled = False
|
||||
invoice_data = CreateInvoice(
|
||||
unit="USD", amount=1.0, memo="Test", fiat_provider="notarealprovider"
|
||||
)
|
||||
with pytest.raises(
|
||||
ValueError, match="Fiat provider 'notarealprovider' is not enabled"
|
||||
):
|
||||
await payments.create_fiat_invoice("wallet_id", invoice_data)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_wallet_fiat_invoice_with_sat_unit(settings: Settings):
|
||||
settings.stripe_enabled = True
|
||||
invoice_data = CreateInvoice(
|
||||
unit="sat", amount=1.0, memo="Test", fiat_provider="stripe"
|
||||
)
|
||||
with pytest.raises(ValueError, match="Fiat provider cannot be used with satoshis"):
|
||||
await payments.create_fiat_invoice("wallet_id", invoice_data)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_wallet_fiat_invoice_allowed_users(
|
||||
to_user: User, settings: Settings
|
||||
):
|
||||
|
||||
settings.stripe_enabled = False
|
||||
settings.stripe_limits.allowed_users = []
|
||||
user = await get_user(to_user.id)
|
||||
assert user
|
||||
assert user.fiat_providers == []
|
||||
|
||||
settings.stripe_enabled = True
|
||||
user = await get_user(to_user.id)
|
||||
assert user
|
||||
assert user.fiat_providers == ["stripe"]
|
||||
|
||||
settings.stripe_limits.allowed_users = ["some_other_user_id"]
|
||||
user = await get_user(to_user.id)
|
||||
assert user
|
||||
assert user.fiat_providers == []
|
||||
|
||||
settings.stripe_limits.allowed_users.append(to_user.id)
|
||||
user = await get_user(to_user.id)
|
||||
assert user
|
||||
assert user.fiat_providers == ["stripe"]
|
||||
|
||||
settings.stripe_enabled = False
|
||||
user = await get_user(to_user.id)
|
||||
assert user
|
||||
assert user.fiat_providers == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_wallet_fiat_invoice_fiat_limits_fail(
|
||||
to_wallet: Wallet, settings: Settings, mocker: MockerFixture
|
||||
):
|
||||
|
||||
settings.stripe_enabled = True
|
||||
settings.stripe_limits.service_min_amount_sats = 0
|
||||
settings.stripe_limits.service_max_amount_sats = 105
|
||||
settings.stripe_limits.service_faucet_wallet_id = None
|
||||
invoice_data = CreateInvoice(
|
||||
unit="USD", amount=1.0, memo="Test", fiat_provider="stripe"
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"lnbits.utils.exchange_rates.get_fiat_rate_satoshis",
|
||||
AsyncMock(return_value=1000), # 1 BTC = 100 000 USD, so 1 USD = 1000 sats
|
||||
)
|
||||
with pytest.raises(ValueError, match="Maximum amount is 105 sats for 'stripe'."):
|
||||
await payments.create_fiat_invoice(to_wallet.id, invoice_data)
|
||||
|
||||
settings.stripe_limits.service_min_amount_sats = 1001
|
||||
settings.stripe_limits.service_max_amount_sats = 10000
|
||||
|
||||
with pytest.raises(ValueError, match="Minimum amount is 1001 sats for 'stripe'."):
|
||||
await payments.create_fiat_invoice(to_wallet.id, invoice_data)
|
||||
|
||||
settings.stripe_limits.service_min_amount_sats = 10
|
||||
settings.stripe_limits.service_max_amount_sats = 10000
|
||||
settings.stripe_limits.service_max_fee_sats = 100
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="Fiat provider 'stripe' service fee wallet missing."
|
||||
):
|
||||
await payments.create_fiat_invoice(to_wallet.id, invoice_data)
|
||||
|
||||
settings.stripe_limits.service_fee_wallet_id = "not_a_real_wallet_id"
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="Fiat provider 'stripe' service fee wallet not found."
|
||||
):
|
||||
await payments.create_fiat_invoice(to_wallet.id, invoice_data)
|
||||
|
||||
settings.stripe_limits.service_fee_wallet_id = to_wallet.id
|
||||
settings.stripe_limits.service_faucet_wallet_id = "not_a_real_wallet_id"
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="Fiat provider 'stripe' faucet wallet not found."
|
||||
):
|
||||
await payments.create_fiat_invoice(to_wallet.id, invoice_data)
|
||||
|
||||
user = await create_user_account()
|
||||
wallet = await create_wallet(user_id=user.id)
|
||||
settings.stripe_limits.service_faucet_wallet_id = wallet.id
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="The amount exceeds the 'stripe'faucet wallet balance."
|
||||
):
|
||||
await payments.create_fiat_invoice(to_wallet.id, invoice_data)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_wallet_fiat_provider_fails(
|
||||
settings: Settings, mocker: MockerFixture
|
||||
):
|
||||
settings.stripe_enabled = True
|
||||
settings.stripe_api_secret_key = "mock_sk_test_4eC39HqLyjWDarjtT1zdp7dc"
|
||||
invoice_data = CreateInvoice(
|
||||
unit="USD", amount=2.0, memo="Test", fiat_provider="stripe"
|
||||
)
|
||||
|
||||
fiat_mock_response = FiatInvoiceResponse(
|
||||
ok=False,
|
||||
error_message="Failed to create invoice",
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"lnbits.fiat.StripeWallet.create_invoice",
|
||||
AsyncMock(return_value=fiat_mock_response),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.utils.exchange_rates.get_fiat_rate_satoshis",
|
||||
AsyncMock(return_value=1000), # 1 BTC = 100 000 USD, so 1 USD = 1000 sats
|
||||
)
|
||||
|
||||
user = await create_user_account()
|
||||
wallet = await create_wallet(user_id=user.id)
|
||||
with pytest.raises(ValueError, match="Cannot create payment request for 'stripe'."):
|
||||
await payments.create_fiat_invoice(wallet.id, invoice_data)
|
||||
|
||||
wallet_payments = await get_payments(wallet_id=wallet.id)
|
||||
assert len(wallet_payments) == 1
|
||||
assert wallet_payments[0].status == PaymentState.FAILED
|
||||
assert wallet_payments[0].amount == 2000000
|
||||
assert wallet_payments[0].fee == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_wallet_fiat_invoice_success(
|
||||
to_wallet: Wallet, settings: Settings, mocker: MockerFixture
|
||||
):
|
||||
settings.stripe_enabled = True
|
||||
settings.stripe_api_secret_key = "mock_sk_test_4eC39HqLyjWDarjtT1zdp7dc"
|
||||
settings.stripe_limits.service_min_amount_sats = 0
|
||||
settings.stripe_limits.service_max_amount_sats = 0
|
||||
settings.stripe_limits.service_faucet_wallet_id = None
|
||||
|
||||
invoice_data = CreateInvoice(
|
||||
unit="USD", amount=1.0, memo="Test", fiat_provider="stripe"
|
||||
)
|
||||
fiat_mock_response = FiatInvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=f"session_123_{get_random_string(10)}",
|
||||
payment_request="https://stripe.com/pay/session_123",
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"lnbits.fiat.StripeWallet.create_invoice",
|
||||
AsyncMock(return_value=fiat_mock_response),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.utils.exchange_rates.get_fiat_rate_satoshis",
|
||||
AsyncMock(return_value=1000), # 1 BTC = 100 000 USD, so 1 USD = 1000 sats
|
||||
)
|
||||
payment = await payments.create_fiat_invoice(to_wallet.id, invoice_data)
|
||||
assert payment.status == PaymentState.PENDING
|
||||
assert payment.amount == 1000_000
|
||||
assert payment.fiat_provider == "stripe"
|
||||
assert payment.extra.get("fiat_checking_id") == fiat_mock_response.checking_id
|
||||
assert (
|
||||
payment.extra.get("fiat_payment_request")
|
||||
== "https://stripe.com/pay/session_123"
|
||||
)
|
||||
assert payment.checking_id.startswith("fiat_stripe_")
|
||||
assert payment.fee <= 0
|
||||
|
||||
status = await payment.check_status()
|
||||
assert status.success is False
|
||||
assert status.pending is True
|
||||
|
||||
fiat_mock_status = FiatPaymentStatus(paid=True, fee=123)
|
||||
mocker.patch(
|
||||
"lnbits.fiat.StripeWallet.get_invoice_status",
|
||||
AsyncMock(return_value=fiat_mock_status),
|
||||
)
|
||||
status = await payment.check_status()
|
||||
assert status.paid is True
|
||||
assert status.success is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fiat_service_fee(settings: Settings):
|
||||
# settings.stripe_limits.service_min_amount_sats = 0
|
||||
amount_msats = 100_000
|
||||
fee = payments.service_fee_fiat(amount_msats, "no_such_fiat_provider")
|
||||
assert fee == 0
|
||||
|
||||
settings.stripe_limits.service_fee_wallet_id = None
|
||||
fee = payments.service_fee_fiat(amount_msats, "stripe")
|
||||
assert fee == 0
|
||||
|
||||
settings.stripe_limits.service_fee_wallet_id = "wallet_id"
|
||||
fee = payments.service_fee_fiat(amount_msats, "stripe")
|
||||
assert fee == 0
|
||||
|
||||
settings.stripe_limits.service_max_fee_sats = 5
|
||||
settings.stripe_limits.service_fee_percent = 20
|
||||
fee = payments.service_fee_fiat(amount_msats, "stripe")
|
||||
assert fee == 5000
|
||||
|
||||
fee = payments.service_fee_fiat(-amount_msats, "stripe")
|
||||
assert fee == 5000
|
||||
|
||||
settings.stripe_limits.service_max_fee_sats = 5
|
||||
settings.stripe_limits.service_fee_percent = 3
|
||||
fee = payments.service_fee_fiat(amount_msats, "stripe")
|
||||
assert fee == 3000
|
||||
|
||||
fee = payments.service_fee_fiat(-amount_msats, "stripe")
|
||||
assert fee == 3000
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_handle_fiat_payment_confirmation(
|
||||
to_wallet: Wallet, settings: Settings, mocker: MockerFixture
|
||||
):
|
||||
user = await create_user_account()
|
||||
service_fee_wallet = await create_wallet(user_id=user.id)
|
||||
faucet_wallet = await create_wallet(user_id=user.id)
|
||||
await payments.update_wallet_balance(wallet=faucet_wallet, amount=100_000_000)
|
||||
|
||||
settings.stripe_api_secret_key = "mock_sk_test_4eC39HqLyjWDarjtT1zdp7dc"
|
||||
invoice_data = CreateInvoice(
|
||||
unit="USD", amount=1.0, memo="Test", fiat_provider="stripe"
|
||||
)
|
||||
|
||||
settings.stripe_enabled = True
|
||||
settings.stripe_limits.service_min_amount_sats = 0
|
||||
settings.stripe_limits.service_max_amount_sats = 0
|
||||
|
||||
settings.stripe_limits.service_fee_percent = 20
|
||||
settings.stripe_limits.service_fee_wallet_id = service_fee_wallet.id
|
||||
settings.stripe_limits.service_faucet_wallet_id = faucet_wallet.id
|
||||
|
||||
fiat_mock_response = FiatInvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=f"session_1000_{get_random_string(10)}",
|
||||
payment_request="https://stripe.com/pay/session_1000",
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"lnbits.fiat.StripeWallet.create_invoice",
|
||||
AsyncMock(return_value=fiat_mock_response),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.utils.exchange_rates.get_fiat_rate_satoshis",
|
||||
AsyncMock(return_value=10000), # 1 BTC = 100 000 USD, so 1 USD = 1000 sats
|
||||
)
|
||||
payment = await payments.create_fiat_invoice(to_wallet.id, invoice_data)
|
||||
assert payment.status == PaymentState.PENDING
|
||||
assert payment.amount == 10_000_000
|
||||
|
||||
await handle_fiat_payment_confirmation(payment)
|
||||
# await asyncio.sleep(1) # Simulate async delay
|
||||
|
||||
service_fee_payments = await get_payments(wallet_id=service_fee_wallet.id)
|
||||
assert len(service_fee_payments) == 1
|
||||
assert service_fee_payments[0].amount == 2_000_000
|
||||
assert service_fee_payments[0].fee == 0
|
||||
assert service_fee_payments[0].status == PaymentState.SUCCESS
|
||||
assert service_fee_payments[0].fiat_provider is None
|
||||
|
||||
faucet_wallet_payments = await get_payments(wallet_id=faucet_wallet.id)
|
||||
|
||||
# Background tasks may create more payments, so we check for at least 2
|
||||
# One for the service fee, one for the top-up)
|
||||
assert len(faucet_wallet_payments) >= 2
|
||||
faucet_payment = next(
|
||||
(p for p in faucet_wallet_payments if p.payment_hash == payment.payment_hash),
|
||||
None,
|
||||
)
|
||||
assert faucet_payment
|
||||
assert faucet_payment.amount == -10_000_000
|
||||
assert faucet_payment.fee == 0
|
||||
assert faucet_payment.status == PaymentState.SUCCESS
|
||||
assert faucet_payment.fiat_provider is None
|
||||
assert (
|
||||
faucet_payment.extra.get("fiat_checking_id") == fiat_mock_response.checking_id
|
||||
)
|
||||
assert (
|
||||
faucet_payment.extra.get("fiat_payment_request")
|
||||
== fiat_mock_response.payment_request
|
||||
)
|
||||
assert faucet_payment.checking_id.startswith("internal_fiat_stripe_")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [b'{"id": "evt_test"}', b"{}", b""])
|
||||
def test_check_stripe_signature_success(payload):
|
||||
secret = "whsec_testsecret"
|
||||
sig_header, _, _ = _make_stripe_sig_header(payload, secret)
|
||||
# Should not raise
|
||||
check_stripe_signature(payload, sig_header, secret)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [b'{"id": "evt_test"}'])
|
||||
def test_check_stripe_signature_missing_header(payload):
|
||||
secret = "whsec_testsecret"
|
||||
with pytest.raises(ValueError, match="Stripe-Signature header is missing"):
|
||||
check_stripe_signature(payload, None, secret)
|
||||
|
||||
|
||||
def test_check_stripe_signature_missing_secret():
|
||||
payload = b'{"id": "evt_test"}'
|
||||
sig_header, _, _ = _make_stripe_sig_header(payload, "whsec_testsecret")
|
||||
with pytest.raises(ValueError, match="Stripe webhook cannot be verified"):
|
||||
check_stripe_signature(payload, sig_header, None)
|
||||
|
||||
|
||||
def test_check_stripe_signature_invalid_signature():
|
||||
payload = b'{"id": "evt_test"}'
|
||||
secret = "whsec_testsecret"
|
||||
_, timestamp, _ = _make_stripe_sig_header(payload, secret)
|
||||
# Tamper with signature
|
||||
bad_sig_header = f"t={timestamp},v1=deadbeef"
|
||||
with pytest.raises(ValueError, match="Stripe signature verification failed"):
|
||||
check_stripe_signature(payload, bad_sig_header, secret)
|
||||
|
||||
|
||||
def test_check_stripe_signature_old_timestamp():
|
||||
payload = b'{"id": "evt_test"}'
|
||||
secret = "whsec_testsecret"
|
||||
old_timestamp = int(time.time()) - 10000 # way outside default tolerance
|
||||
sig_header, _, _ = _make_stripe_sig_header(payload, secret, timestamp=old_timestamp)
|
||||
with pytest.raises(ValueError, match="Timestamp outside tolerance"):
|
||||
check_stripe_signature(payload, sig_header, secret)
|
||||
|
||||
|
||||
def test_check_stripe_signature_future_timestamp():
|
||||
payload = b'{"id": "evt_test"}'
|
||||
secret = "whsec_testsecret"
|
||||
future_timestamp = int(time.time()) + 10000
|
||||
sig_header, _, _ = _make_stripe_sig_header(
|
||||
payload, secret, timestamp=future_timestamp
|
||||
)
|
||||
with pytest.raises(ValueError, match="Timestamp outside tolerance"):
|
||||
check_stripe_signature(payload, sig_header, secret)
|
||||
|
||||
|
||||
def test_check_stripe_signature_malformed_header():
|
||||
payload = b'{"id": "evt_test"}'
|
||||
secret = "whsec_testsecret"
|
||||
# Missing v1 part
|
||||
bad_header = "t=1234567890"
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
check_stripe_signature(payload, bad_header, secret)
|
||||
# Missing t part
|
||||
bad_header2 = "v1=abcdef"
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
check_stripe_signature(payload, bad_header2, secret)
|
||||
# Not split by =
|
||||
bad_header3 = "t1234567890,v1abcdef"
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
check_stripe_signature(payload, bad_header3, secret)
|
||||
|
||||
|
||||
def test_check_stripe_signature_non_utf8_payload():
|
||||
secret = "whsec_testsecret"
|
||||
payload = b"\xff\xfe\xfd" # not valid utf-8
|
||||
timestamp = int(time.time())
|
||||
# This will raise UnicodeDecodeError inside check_stripe_signature
|
||||
signed_payload = f"{timestamp}." + payload.decode(errors="ignore")
|
||||
signature = hmac.new(
|
||||
secret.encode(), signed_payload.encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
sig_header = f"t={timestamp},v1={signature}"
|
||||
with pytest.raises(UnicodeDecodeError):
|
||||
check_stripe_signature(payload, sig_header, secret)
|
||||
|
||||
|
||||
# Helper to generate a valid Stripe signature header
|
||||
def _make_stripe_sig_header(payload, secret, timestamp=None):
|
||||
if timestamp is None:
|
||||
timestamp = int(time.time())
|
||||
signed_payload = f"{timestamp}.{payload.decode()}"
|
||||
signature = hmac.new(
|
||||
secret.encode(), signed_payload.encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
return f"t={timestamp},v1={signature}", timestamp, signature
|
||||
@@ -558,18 +558,14 @@ async def test_pay_external_invoice_success_bad_checking_id(
|
||||
AsyncMock(return_value=payment_reponse_success),
|
||||
)
|
||||
|
||||
await pay_invoice(
|
||||
wallet_id=from_wallet.id,
|
||||
payment_request=external_invoice.payment_request,
|
||||
)
|
||||
with pytest.raises(PaymentError):
|
||||
await pay_invoice(
|
||||
wallet_id=from_wallet.id,
|
||||
payment_request=external_invoice.payment_request,
|
||||
)
|
||||
|
||||
payment = await get_standalone_payment(bad_checking_id)
|
||||
assert payment
|
||||
assert payment.checking_id == bad_checking_id, "checking_id updated"
|
||||
assert payment.payment_hash == external_invoice.checking_id
|
||||
assert payment.amount == -2108_000
|
||||
assert payment.preimage == preimage
|
||||
assert payment.status == PaymentState.SUCCESS.value
|
||||
assert payment is None, "Payment should not be created with bad checking_id"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -590,19 +586,21 @@ async def test_no_checking_id(
|
||||
AsyncMock(return_value=payment_reponse_pending),
|
||||
)
|
||||
|
||||
await pay_invoice(
|
||||
wallet_id=from_wallet.id,
|
||||
payment_request=external_invoice.payment_request,
|
||||
)
|
||||
with pytest.raises(PaymentError):
|
||||
await pay_invoice(
|
||||
wallet_id=from_wallet.id,
|
||||
payment_request=external_invoice.payment_request,
|
||||
)
|
||||
|
||||
payment = await get_standalone_payment(external_invoice.checking_id)
|
||||
|
||||
assert payment
|
||||
assert payment.status == PaymentState.FAILED.value
|
||||
|
||||
assert payment.checking_id == external_invoice.checking_id
|
||||
assert payment.payment_hash == external_invoice.checking_id
|
||||
assert payment.amount == -2110_000
|
||||
assert payment.preimage is None
|
||||
assert payment.status == PaymentState.PENDING.value
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user