Compare commits

..
19 Commits
Author SHA1 Message Date
dni ⚡andGitHub 9aeecd4dc0 chore: update to version v1.2.0-rc1 (#3202) 2025-06-17 20:05:22 +03:00
e750165cc3 Allow custom text and icon for Keycloak (#3181)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-06-06 13:20:55 +03:00
dni ⚡andGitHub 77906bc817 feat: more verbose aes decrypt function (#3177) 2025-05-30 18:49:11 +03:00
Vlad StanandGitHub 63e728710d fix: accept soft deleted wallets (#3179) 2025-05-30 18:48:23 +03:00
blackcoffeexbtandGitHub 27fd510142 Add funding sources comparison table (#3183) 2025-05-29 13:45:08 +03:00
Vlad StanandGitHub e6de66e1b1 fix: handle node absent in 1ml.com (#3180) 2025-05-27 17:02:15 +03:00
dni ⚡andGitHub 4071925f65 feat: mask unexcepted error and add a exception id (#3178) 2025-05-27 12:37:38 +03:00
dni ⚡andGitHub 3c4d186dba feat: add urlsafe enc to lnbits-cli (#3173) 2025-05-27 11:51:26 +03:00
Vlad StanandGitHub 56aebb9d8f feat: better service fee payment memo (#3176) 2025-05-27 11:50:39 +03:00
Vlad StanandGitHub beee24bd92 [feat] ui support for high number of wallets and payments (#3174) 2025-05-27 11:41:25 +03:00
Vlad StanandGitHub 375b95c004 fix: allow ports for domains (#3171) 2025-05-26 11:35:02 +03:00
Tiago VasconcelosandGitHub 5345ccaf4e [Fix] QR readability (#3163) 2025-05-20 13:06:53 +03:00
ArcandGitHub efc5233399 fix: frontend scroll area on admin settings (#3162) 2025-05-20 11:52:07 +03:00
ArcandGitHub e4d09c6d12 docs: passing vars to appimage (#3164) 2025-05-20 11:51:52 +03:00
Vlad StanandGitHub 7d0545dae1 [fix] timezone for payment list (#3165) 2025-05-20 11:51:32 +03:00
00fccf513e feat: Add Strike Wallet Integration (#3150)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-05-20 11:51:05 +03:00
dni ⚡andGitHub 34b8490a2d test: conftest smaller funding amounts (#3167) 2025-05-20 11:25:08 +03:00
dni ⚡andGitHub cbbba5c4c7 fix: regtest nodemanager lndrest issue (#3166) 2025-05-20 09:23:59 +02:00
dni ⚡andGitHub 4a0ef7fa1a feat: add ssl proxy settings to docker and .env.example (#3161) 2025-05-19 12:47:30 +01:00
46 changed files with 1444 additions and 153 deletions
+9 -3
View File
@@ -40,7 +40,7 @@ PORT=5000
######################################
# which fundingsources are allowed in the admin ui
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet"
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet"
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
@@ -105,6 +105,10 @@ BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
BOLTZ_CLIENT_WALLET="lnbits"
# StrikeWallet
STRIKE_API_ENDPOINT=https://api.strike.me/v1
STRIKE_API_KEY=YOUR_STRIKE_API_KEY
# ZBDWallet
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
ZBD_API_KEY=ZBD_ACCESS_TOKEN
@@ -163,14 +167,16 @@ GITHUB_CLIENT_SECRET=""
KEYCLOAK_CLIENT_ID=""
KEYCLOAK_CLIENT_SECRET=""
KEYCLOAK_DISCOVERY_URL=""
KEYCLOAK_CLIENT_CUSTOM_ORG=""
KEYCLOAK_CLIENT_CUSTOM_ICON=""
######################################
# uvicorn variable, uncomment to allow https behind a proxy
# uvicorn variable, allow https behind a proxy
# IMPORTANT: this also needs the webserver to be configured to forward the headers
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
# FORWARDED_ALLOW_IPS="*"
FORWARDED_ALLOW_IPS="*"
# Server security, rate limiting ips, blocked ips, allowed ips
LNBITS_RATE_LIMIT_NO="200"
-1
View File
@@ -36,7 +36,6 @@ LNBITS_DATA_FOLDER="${LNBITS_DATA_FOLDER:-$LAUNCH_DIR/lnbits/database}"
LNBITS_EXTENSIONS_PATH="${LNBITS_EXTENSIONS_PATH:-$LAUNCH_DIR/lnbits/extensions}"
export LNBITS_DATA_FOLDER
export LNBITS_EXTENSIONS_PATH
export LNBITS_ADMIN_UI=true
# Define the LNbits URL
URL="http://0.0.0.0:5000"
+1 -1
View File
@@ -55,4 +55,4 @@ ENV LNBITS_HOST="0.0.0.0"
EXPOSE 5000
CMD ["sh", "-c", "poetry run lnbits --port $LNBITS_PORT --host $LNBITS_HOST"]
CMD ["sh", "-c", "poetry run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
+30
View File
@@ -0,0 +1,30 @@
# LNbits Funding Sources Comparison Table
LNbits can use a number of different Lightning Network funding source.
There may be trade-offs between the funding sources used, for example funding LNbits using Strike requires the user to KYC themselves and has some
privacy compromises versus running your own LND node. However the technical barrier to entry of using Strike is lower than using LND.
The table below offers a comparison of the different Lightning Network funding sources that can be used with LNbits.
## LNbits Lightning Network Funding Sources Comparison Table
| **Funding Source** | **Custodial Type** | **KYC Required** | **Technical Knowledge Needed** | **Node Hosting Required** | **Privacy Level** | **Liquidity Management** | **Ease of Setup** | **Maintenance Effort** | **Cost Implications** | **Scalability** | **Notes** |
| -------------------------- | ------------------ | ------------------- | ------------------------------ | ------------------------- | ----------------- | ------------------------ | ----------------- | ---------------------- | -------------------------------------------- | --------------- | ---------------------------------------------------------------- |
| LND (gRPC) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | gRPC interface for LND; suitable for advanced integrations. |
| CoreLightning (CLN) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Requires setting up and managing your own CLN node. |
| Phoenixd | Self-custodial | ❌ | Medium | ❌ | Medium | Automatic | Moderate | Low | Minimal fees | Medium | Mobile wallet backend; suitable for mobile integrations. |
| Nostr Wallet Connect (NWC) | Custodial | Depends on provider | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur fees | Medium | Connects via Nostr protocol; depends on provider's policies. |
| Boltz | Self-custodial | ❌ | Medium | ❌ | Medium | Provider-managed | Moderate | Moderate | Minimal fees | Medium | Uses submarine swaps; connects to Boltz client. |
| LND (REST) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for LND; suitable for web integrations. |
| CoreLightning REST | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for CLN; suitable for web integrations. |
| LNbits (another instance) | Custodial | Depends on host | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur hosting fees | Medium | Connects to another LNbits instance; depends on host's policies. |
| Alby | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Browser extension wallet; suitable for web users. |
| Breez SDK | Self-custodial | ❌ | Medium | ❌ | High | Automatic | Moderate | Low | Minimal fees | Medium | SDK for integrating Breez wallet functionalities. |
| OpenNode | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for merchants. |
| Blink | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; focuses on mobile integrations. |
| ZBD | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Gaming-focused payment platform. |
| Spark (CLN) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Web interface for CLN; requires Spark server setup. |
| Cliche Wallet | Self-custodial | ❌ | Medium | ❌ | Medium | Manual | Moderate | Moderate | Minimal fees | Medium | Lightweight wallet; suitable for embedded systems. |
| Strike | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
| LNPay | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
+1 -1
View File
@@ -18,7 +18,7 @@ Go to [releases](https://github.com/lnbits/lnbits/releases) and pull latest AppI
sudo apt-get install libfuse2
wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
chmod +x LNbits-latest.AppImage
./LNbits-latest.AppImage --host 0.0.0.0 --port 5000
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
```
LNbits will create a folder for db and extension files in the folder the AppImage runs from.
+2
View File
@@ -10,6 +10,8 @@ LNbits can run on top of many Lightning Network funding sources with more being
A backend wallet can be configured using the following LNbits environment variables:
You can [compare the LNbits compatible Lightning Network funding sources here](wallets.md).
### CoreLightning
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
+10 -4
View File
@@ -518,12 +518,15 @@ def encrypt_macaroon():
@encrypt.command("aes")
@click.option("-p", "--payload", required=True, help="Payload to encrypt.")
def encrypt_aes(payload: str):
@click.option(
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64encode."
)
def encrypt_aes(payload: str, urlsafe: bool = False):
"""AES encrypts a payload"""
key = getpass("Enter encryption key: ")
aes = AESCipher(key.encode())
try:
encrypted = aes.encrypt(payload.encode())
encrypted = aes.encrypt(payload.encode(), urlsafe=urlsafe)
except Exception as ex:
click.echo(f"Error encrypting payload: {ex}")
return
@@ -533,12 +536,15 @@ def encrypt_aes(payload: str):
@decrypt.command("aes")
@click.option("-p", "--payload", required=True, help="Payload to decrypt.")
def decrypt_aes(payload: str):
@click.option(
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64decode."
)
def decrypt_aes(payload: str, urlsafe: bool = False):
"""AES decrypts a payload"""
key = getpass("Enter encryption key: ")
aes = AESCipher(key.encode())
try:
decrypted = aes.decrypt(payload)
decrypted = aes.decrypt(payload, urlsafe=urlsafe)
except Exception as ex:
click.echo(f"Error decrypting payload: {ex}")
return
+40 -13
View File
@@ -123,12 +123,8 @@ async def get_payments_paginated(
values["wallet_id"] = wallet_id
clause.append("wallet_id = :wallet_id")
elif user_id:
wallet_ids = await get_wallets_ids(user_id=user_id, conn=conn) or [
"no-wallets-for-user"
]
# wallet ids are safe to use in sql queries
wallet_ids_str = [f"'{w}'" for w in wallet_ids]
clause.append(f""" wallet_id IN ({", ".join(wallet_ids_str)}) """)
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
clause.append(only_user_wallets)
if complete and pending:
clause.append(
@@ -366,12 +362,19 @@ async def get_payments_history(
async def get_payment_count_stats(
field: PaymentCountField,
filters: Optional[Filters[PaymentFilters]] = None,
user_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> list[PaymentCountStat]:
if not filters:
filters = Filters()
clause = filters.where()
extra_stmts = []
if user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
extra_stmts.append(only_user_wallets)
clause = filters.where(extra_stmts)
data = await (conn or db).fetchall(
query=f"""
SELECT {field} as field, count(*) as total
@@ -389,18 +392,26 @@ async def get_payment_count_stats(
async def get_daily_stats(
filters: Optional[Filters[PaymentFilters]] = None,
user_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> Tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
if not filters:
filters = Filters()
in_clause = filters.where(
["(apipayments.status = 'success' AND apipayments.amount > 0)"]
)
out_clause = filters.where(
["(apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)"]
)
in_where_stmts = ["(apipayments.status = 'success' AND apipayments.amount > 0)"]
out_where_stmts = [
"(apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)"
]
if user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
in_where_stmts.append(only_user_wallets)
out_where_stmts.append(only_user_wallets)
in_clause = filters.where(in_where_stmts)
out_clause = filters.where(out_where_stmts)
date_trunc = db.datetime_grouping("day")
query = """
SELECT {date_trunc} date,
@@ -431,6 +442,7 @@ async def get_daily_stats(
async def get_wallets_stats(
filters: Optional[Filters[PaymentFilters]] = None,
user_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> list[PaymentWalletStats]:
@@ -449,6 +461,10 @@ async def get_wallets_stats(
)
""",
]
if user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
where_stmts.append(only_user_wallets)
clauses = filters.where(where_stmts)
data = await (conn or db).fetchall(
@@ -524,3 +540,14 @@ async def mark_webhook_sent(payment_hash: str, status: str) -> None:
""",
{"status": status, "hash": payment_hash},
)
async def _only_user_wallets_statement(
user_id: str, conn: Optional[Connection] = None
) -> str:
wallet_ids = await get_wallets_ids(user_id=user_id, conn=conn) or [
"no-wallets-for-user"
]
# wallet ids are safe to use in sql queries
wallet_ids_str = [f"'{w}'" for w in wallet_ids]
return f""" wallet_id IN ({", ".join(wallet_ids_str)}) """
+25 -1
View File
@@ -4,7 +4,8 @@ from typing import Optional
from uuid import uuid4
from lnbits.core.db import db
from lnbits.db import Connection
from lnbits.core.models.wallets import WalletsFilters
from lnbits.db import Connection, Filters, Page
from lnbits.settings import settings
from ..models import Wallet
@@ -135,6 +136,29 @@ async def get_wallets(
)
async def get_wallets_paginated(
user_id: str,
deleted: Optional[bool] = None,
filters: Optional[Filters[WalletsFilters]] = None,
conn: Optional[Connection] = None,
) -> Page[Wallet]:
if deleted is None:
deleted = False
where: list[str] = [""" "user" = :user AND deleted = :deleted """]
return await (conn or db).fetch_page(
"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
""",
where=where,
values={"user": user_id, "deleted": deleted},
filters=filters,
model=Wallet,
)
async def get_wallets_ids(
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> list[str]:
+3
View File
@@ -27,6 +27,9 @@ class UserExtra(BaseModel):
# - "google | github | ...": the user was created using an SSO provider
provider: str | None = "lnbits" # auth provider
# how many wallets are shown in the user interface
visible_wallet_count: int | None = 10
class EndpointAccess(BaseModel):
path: str
+12
View File
@@ -9,6 +9,7 @@ from enum import Enum
from ecdsa import SECP256k1, SigningKey
from pydantic import BaseModel, Field
from lnbits.db import FilterModel
from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings
@@ -25,6 +26,7 @@ class BaseWallet(BaseModel):
class WalletExtra(BaseModel):
icon: str = "flash_on"
color: str = "primary"
pinned: bool = False
class Wallet(BaseModel):
@@ -83,3 +85,13 @@ class KeyType(Enum):
class WalletTypeInfo:
key_type: KeyType
wallet: Wallet
class WalletsFilters(FilterModel):
__search_fields__ = ["id", "name", "currency"]
__sort_fields__ = ["id", "name", "currency", "created_at", "updated_at"]
id: str | None
name: str | None
currency: str | None
+9 -4
View File
@@ -84,8 +84,12 @@ async def pay_invoice(
payment = await _pay_invoice(wallet.id, create_payment_model, conn)
service_fee_memo = f"""
Service fee for payment of {abs(payment.sat)} sats.
Wallet: '{wallet.name}' ({wallet.id})."""
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
await _credit_service_fee_wallet(payment, new_conn)
await _credit_service_fee_wallet(payment, service_fee_memo, new_conn)
return payment
@@ -399,8 +403,9 @@ async def check_transaction_status(
async def get_payments_daily_stats(
filters: Filters[PaymentFilters],
user_id: Optional[str] = None,
) -> list[PaymentDailyStats]:
data_in, data_out = await get_daily_stats(filters)
data_in, data_out = await get_daily_stats(filters, user_id=user_id)
balance_total: float = 0
_none = PaymentDailyStats(date=datetime.now(timezone.utc))
@@ -704,7 +709,7 @@ def _validate_payment_request(
async def _credit_service_fee_wallet(
payment: Payment, conn: Optional[Connection] = None
payment: Payment, memo: str, conn: Optional[Connection] = None
):
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
@@ -715,7 +720,7 @@ async def _credit_service_fee_wallet(
bolt11=payment.bolt11,
payment_hash=payment.payment_hash,
amount_msat=abs(service_fee_msat),
memo="Service fee",
memo=memo,
)
await create_payment(
checking_id=f"service_fee_{payment.payment_hash}",
+19 -3
View File
@@ -132,8 +132,8 @@
>
<strong class="q-my-none q-mb-sm">Keycloak Auth</strong>
<div class="row">
<div class="col-md-4 col-sm-12 q-pr-sm">
<div class="row q-col-gutter-sm q-col-gutter-y-md">
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="formData.keycloak_discovery_url"
@@ -141,7 +141,7 @@
>
</q-input>
</div>
<div class="col-md-4 col-sm-12 q-pr-sm">
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="formData.keycloak_client_id"
@@ -159,6 +159,22 @@
>
</q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="formData.keycloak_client_custom_org"
:label="$t('auth_keycloak_custom_org_label')"
>
</q-input>
</div>
<div class="col-md-8 col-sm-12">
<q-input
filled
v-model="formData.keycloak_client_custom_icon"
:label="$t('auth_keycloak_custom_icon_label')"
>
</q-input>
</div>
</div>
</q-card-section>
<q-separator></q-separator>
+20 -17
View File
@@ -171,23 +171,26 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
<template v-slot:after>
<q-form name="settings_form" id="settings_form">
<q-tab-panels
v-model="tab"
animated
swipeable
vertical
transition-prev="jump-up"
transition-next="jump-up"
>
{% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %}
{% include "admin/_tab_exchange_providers.html" %} {% include
"admin/_tab_extensions.html" %} {% include
"admin/_tab_notifications.html" %} {% include
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
%}{% include "admin/_tab_audit.html"%}{% include
"admin/_tab_library.html"%}
</q-tab-panels>
<q-scroll-area style="height: 100vh">
<q-tab-panels
v-model="tab"
animated
swipeable
vertical
scroll
transition-prev="jump-up"
transition-next="jump-up"
>
{% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html"
%} {% include "admin/_tab_exchange_providers.html" %} {% include
"admin/_tab_extensions.html" %} {% include
"admin/_tab_notifications.html" %} {% include
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
%}{% include "admin/_tab_audit.html"%}{% include
"admin/_tab_library.html"%}
</q-tab-panels>
</q-scroll-area>
</q-form>
</template>
</q-splitter>
+16
View File
@@ -335,6 +335,22 @@
</q-btn-dropdown>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('visible_wallet_count')"></span>
</div>
<div class="col-8">
<q-input
v-model="user.extra.visible_wallet_count"
:label="$t('visible_wallet_count')"
filled
dense
type="number"
class="q-mb-md"
></q-input>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('color_scheme')"></span>
+1 -1
View File
@@ -183,7 +183,7 @@
<span v-text="$t('existing_account_question')"></span>
<span
class="text-secondary cursor-pointer"
class="text-secondary cursor-pointer q-ml-sm"
@click="showLogin('username-password')"
v-text="$t('login')"
></span>
+17
View File
@@ -242,6 +242,23 @@
{{ SITE_TITLE }} Wallet:
<strong><em v-text="g.wallet.name"></em></strong>
</div>
<q-space></q-space>
<div class="float-right">
<q-btn
@click="updateWallet({ pinned: !g.wallet.extra.pinned })"
round
class="float-right"
:color="g.wallet.extra.pinned ? 'primary' : 'grey-5'"
text-color="black"
size="sm"
icon="push_pin"
style="transform: rotate(30deg)"
>
<q-tooltip
><span v-text="$t('pin_wallet')"></span
></q-tooltip>
</q-btn>
</div>
</div>
</q-card-section>
<q-card-section class="q-pa-none">
+165
View File
@@ -0,0 +1,165 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm q-pl-lg">
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<q-btn
@click="showAddWalletDialog.show = true"
:label="$t('add_wallet')"
color="primary"
>
</q-btn>
</div>
<div class="float-left">
<q-input
:label="$t('search_wallets')"
dense
class="float-right q-pr-xl"
v-model="walletsTable.search"
>
<template v-slot:before>
<q-icon name="search"> </q-icon>
</template>
<template v-slot:append>
<q-icon
v-if="walletsTable.search !== ''"
name="close"
@click="walletsTable.search = ''"
class="cursor-pointer"
>
</q-icon>
</template>
</q-input>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div>
<div>
<div>
<q-table
grid
grid-header
flat
bordered
:rows="wallets"
:columns="walletsTable.columns"
v-model:pagination="walletsTable.pagination"
:loading="walletsTable.loading"
@request="getUserWallets"
row-key="id"
:filter="filter"
hide-header
>
<template v-slot:item="props">
<div class="q-pa-xs col-xs-12 col-sm-6 col-md-4">
<q-card
class="q-ma-sm cursor-pointer wallet-list-card"
style="text-decoration: none"
@click="goToWallet(props.row.id)"
>
<q-card-section>
<div class="row items-center">
<q-avatar
size="lg"
:text-color="$q.dark.isActive ? 'black' : 'grey-3'"
:color="props.row.extra.color"
:icon="props.row.extra.icon"
>
</q-avatar>
<div
class="text-h6 q-pl-md ellipsis"
class="text-bold"
v-text="props.row.name"
></div>
<q-space> </q-space>
<q-btn
v-if="props.row.extra.pinned"
round
color="primary"
text-color="black"
size="xs"
icon="push_pin"
class="float-right"
style="transform: rotate(30deg)"
></q-btn>
</div>
<div class="row items-center q-pt-sm">
<h6 class="q-my-none ellipsis full-width">
<strong
v-text="formatBalance(props.row.balance_msat / 1000)"
></strong>
</h6>
</div>
</q-card-section>
<q-separator />
<q-card-section class="text-left">
<small>
<strong>
<span v-text="$t('currency')"></span>
</strong>
<span v-text="props.row.currency || 'sat'"></span>
</small>
<br />
<small>
<strong>
<span v-text="$t('id')"></span>
:
</strong>
<span v-text="props.row.id"></span>
</small>
</q-card-section>
</q-card>
</div>
</template>
</q-table>
</div>
</div>
</div>
<q-dialog
v-model="showAddWalletDialog.show"
persistent
@hide="showAddWalletDialog = {show: false}"
>
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-h6">
<span v-text="$t('wallet_name')"></span>
</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-input
dense
v-model="showAddWalletDialog.name"
autofocus
@keyup.enter="submitAddWallet()"
></q-input>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat :label="$t('cancel')" v-close-popup></q-btn>
<q-btn
flat
:label="$t('add_wallet')"
v-close-popup
@click="submitAddWallet()"
></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
{% endblock %}
+30 -13
View File
@@ -15,19 +15,36 @@
</q-card>
</div>
<div v-else-if="activeWallet.show">
<div class="row q-mb-lg">
<div class="col">
<q-btn
icon="arrow_back_ios"
@click="backToUsersPage()"
:label="$t('back')"
></q-btn>
<q-btn
@click="createWalletDialog.show = true"
:label="$t('create_new_wallet')"
color="primary"
class="q-ml-md"
></q-btn>
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm">
<div class="row">
<div class="q-pa-xs">
<q-btn
icon="arrow_back_ios"
@click="backToUsersPage()"
:label="$t('back')"
></q-btn>
</div>
<div class="q-pa-xs">
<q-btn
@click="createWalletDialog.show = true"
:label="$t('create_new_wallet')"
color="primary"
></q-btn>
</div>
<div class="q-pa-xs">
<q-btn
@click="deleteAllUserWallets(activeWallet.userId)"
:label="$t('delete_all_wallets')"
icon="delete"
color="negative"
></q-btn>
</div>
</div>
</div>
</q-card>
</div>
</div>
<q-card class="q-pa-md">
+20 -1
View File
@@ -216,6 +216,25 @@ async def account(
)
@generic_router.get(
"/wallets",
response_class=HTMLResponse,
description="show wallets page",
)
async def wallets(
request: Request,
user: User = Depends(check_user_exists),
):
return template_renderer().TemplateResponse(
request,
"core/wallets.html",
{
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get("/service-worker.js")
async def service_worker(request: Request):
return template_renderer().TemplateResponse(
@@ -402,7 +421,7 @@ async def audit_index(request: Request, user: User = Depends(check_admin)):
@generic_router.get("/payments", response_class=HTMLResponse)
async def payments_index(request: Request, user: User = Depends(check_admin)):
async def payments_index(request: Request, user: User = Depends(check_user_exists)):
return template_renderer().TemplateResponse(
"payments/index.html",
{
+4 -1
View File
@@ -212,7 +212,10 @@ async def api_get_1ml_stats(node: Node = Depends(require_node)) -> Optional[Node
r = await client.get(url=f"https://1ml.com/node/{node_id}/json", timeout=15)
try:
r.raise_for_status()
return r.json()["noderank"]
data = r.json()
if "noderank" not in data:
return None
return data["noderank"]
except httpx.HTTPStatusError as exc:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Node not found on 1ml.com"
+25 -21
View File
@@ -44,7 +44,6 @@ from lnbits.core.services.payments import (
from lnbits.db import Filters, Page
from lnbits.decorators import (
WalletTypeInfo,
check_admin,
check_user_exists,
parse_filters,
require_admin_key,
@@ -116,30 +115,44 @@ async def api_payments_history(
@payment_router.get(
"/stats/count",
name="Get payments history for all users",
dependencies=[Depends(check_admin)],
response_model=List[PaymentCountStat],
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments_counting_stats(
count_by: PaymentCountField = Query("tag"),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
user: User = Depends(check_user_exists),
):
return await get_payment_count_stats(count_by, filters)
if user.admin:
# admin user can see payments from all wallets
for_user_id = None
else:
# regular user can only see payments from their wallets
for_user_id = user.id
return await get_payment_count_stats(count_by, filters=filters, user_id=for_user_id)
@payment_router.get(
"/stats/wallets",
name="Get payments history for all users",
dependencies=[Depends(check_admin)],
response_model=List[PaymentWalletStats],
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments_wallets_stats(
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
user: User = Depends(check_user_exists),
):
return await get_wallets_stats(filters)
if user.admin:
# admin user can see payments from all wallets
for_user_id = None
else:
# regular user can only see payments from their wallets
for_user_id = user.id
return await get_wallets_stats(filters, user_id=for_user_id)
@payment_router.get(
@@ -153,22 +166,13 @@ async def api_payments_daily_stats(
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
):
if not user.admin:
exc = HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Missing wallet id.",
)
wallet_filter = next(
(f for f in filters.filters if f.field == "wallet_id"), None
)
if not wallet_filter:
raise exc
wallet_id = list((wallet_filter.values or {}).values())
if len(wallet_id) == 0:
raise exc
if not user.get_wallet(wallet_id[0]):
raise exc
return await get_payments_daily_stats(filters)
if user.admin:
# admin user can see payments from all wallets
for_user_id = None
else:
# regular user can only see payments from their wallets
for_user_id = user.id
return await get_payments_daily_stats(filters, user_id=for_user_id)
@payment_router.get(
+23 -1
View File
@@ -148,7 +148,7 @@ async def api_update_user(
async def api_users_delete_user(
user_id: str, user: User = Depends(check_admin)
) -> SimpleStatus:
wallets = await get_wallets(user_id)
wallets = await get_wallets(user_id, deleted=False)
if len(wallets) > 0:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
@@ -257,6 +257,28 @@ async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> SimpleSta
return SimpleStatus(success=True, message="Wallet is already active.")
@users_router.delete(
"/user/{user_id}/wallets",
name="Delete all wallets for user",
summary="Soft delete (only sets a flag) all user wallets.",
)
async def api_users_delete_all_user_wallet(user_id: str) -> SimpleStatus:
if user_id == settings.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Action not allowed.",
)
wallets = await get_wallets(user_id, deleted=False)
for wallet in wallets:
await delete_wallet(user_id=user_id, wallet_id=wallet.id)
return SimpleStatus(
success=True,
message=f"Deleted '{len(wallets)}' wallets. ",
)
@users_router.delete(
"/user/{user_id}/wallet/{wallet}",
name="Delete wallet by id",
+27
View File
@@ -9,13 +9,18 @@ from fastapi import (
HTTPException,
)
from lnbits.core.crud.wallets import get_wallets_paginated
from lnbits.core.models import CreateWallet, KeyType, User, Wallet
from lnbits.core.models.wallets import WalletsFilters
from lnbits.db import Filters, Page
from lnbits.decorators import (
WalletTypeInfo,
check_user_exists,
parse_filters,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import generate_filter_params_openapi
from ..crud import (
create_wallet,
@@ -38,6 +43,26 @@ async def api_wallet(key_info: WalletTypeInfo = Depends(require_invoice_key)):
return res
@wallet_router.get(
"/paginated",
name="Wallet List",
summary="get paginated list of user wallets",
response_description="list of user wallets",
response_model=Page[Wallet],
openapi_extra=generate_filter_params_openapi(WalletsFilters),
)
async def api_wallets_paginated(
user: User = Depends(check_user_exists),
filters: Filters = Depends(parse_filters(WalletsFilters)),
):
page = await get_wallets_paginated(
user_id=user.id,
filters=filters,
)
return page
@wallet_router.put("/{new_name}")
async def api_update_wallet_name(
new_name: str, key_info: WalletTypeInfo = Depends(require_admin_key)
@@ -74,6 +99,7 @@ async def api_update_wallet(
icon: Optional[str] = Body(None),
color: Optional[str] = Body(None),
currency: Optional[str] = Body(None),
pinned: Optional[bool] = Body(None),
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> Wallet:
wallet = await get_wallet(key_info.wallet.id)
@@ -82,6 +108,7 @@ async def api_update_wallet(
wallet.name = name or wallet.name
wallet.extra.icon = icon or wallet.extra.icon
wallet.extra.color = color or wallet.extra.color
wallet.extra.pinned = pinned if pinned is not None else wallet.extra.pinned
wallet.currency = currency if currency is not None else wallet.currency
await update_wallet(wallet)
return wallet
+4 -2
View File
@@ -7,6 +7,7 @@ from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, RedirectResponse, Response
from loguru import logger
from shortuuid import uuid
from lnbits.settings import settings
@@ -71,10 +72,11 @@ def register_exception_handlers(app: FastAPI):
async def exception_handler(request: Request, exc: Exception):
etype, _, tb = sys.exc_info()
traceback.print_exception(etype, exc, tb)
logger.error(f"Exception: {exc!s}")
exception_id = uuid()
logger.error(f"Exception ID: {exception_id}\n{exc!s}")
return render_html_error(request, exc) or JSONResponse(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
content={"detail": str(exc)},
content={"detail": f"Unexpected error! ID: {exception_id}"},
)
@app.exception_handler(AssertionError)
+17 -5
View File
@@ -11,6 +11,7 @@ import jinja2
import jwt
import shortuuid
from fastapi.routing import APIRoute
from loguru import logger
from packaging import version
from pydantic.schema import field_schema
@@ -76,6 +77,8 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
"LNBITS_ADMIN_UI": settings.lnbits_admin_ui,
"LNBITS_AUDIT_ENABLED": settings.lnbits_audit_enabled,
"LNBITS_AUTH_METHODS": settings.auth_allowed_methods,
"LNBITS_AUTH_KEYCLOAK_ORG": settings.keycloak_client_custom_org,
"LNBITS_AUTH_KEYCLOAK_ICON": settings.keycloak_client_custom_icon,
"LNBITS_CUSTOM_IMAGE": settings.lnbits_custom_image,
"LNBITS_CUSTOM_BADGE": settings.lnbits_custom_badge,
"LNBITS_CUSTOM_BADGE_COLOR": settings.lnbits_custom_badge_color,
@@ -279,12 +282,21 @@ def is_lnbits_version_ok(
def check_callback_url(url: str):
netloc = urlparse(url).netloc
if not settings.lnbits_callback_url_rules:
# no rules, all urls are allowed
return
u = urlparse(url)
for rule in settings.lnbits_callback_url_rules:
if re.match(rule, netloc) is None:
raise ValueError(
f"Callback not allowed. URL: {url}. Netloc: {netloc}. Rule: {rule}"
)
try:
if re.match(rule, f"{u.scheme}://{u.netloc}") is not None:
return
except re.error:
logger.debug(f"Invalid regex rule: '{rule}'. ")
continue
raise ValueError(
f"Callback not allowed. URL: {url}. Netloc: {u.netloc}. "
f"Please check your admin settings."
)
def download_url(url, save_path):
+12 -1
View File
@@ -381,7 +381,7 @@ class SecuritySettings(LNbitsSettings):
lnbits_allowed_ips: list[str] = Field(default=[])
lnbits_blocked_ips: list[str] = Field(default=[])
lnbits_callback_url_rules: list[str] = Field(
default=["^(?!\\d+\\.\\d+\\.\\d+\\.\\d+$)(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,}$"]
default=["https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"]
)
lnbits_wallet_limit_max_balance: int = Field(default=0, ge=0)
@@ -556,6 +556,13 @@ class BoltzFundingSource(LNbitsSettings):
boltz_client_cert: str | None = Field(default=None)
class StrikeFundingSource(LNbitsSettings):
strike_api_endpoint: str | None = Field(
default="https://api.strike.me/v1", env="STRIKE_API_ENDPOINT"
)
strike_api_key: str | None = Field(default=None, env="STRIKE_API_KEY")
class LightningSettings(LNbitsSettings):
lightning_invoice_expiry: int = Field(default=3600, gt=0)
@@ -580,6 +587,7 @@ class FundingSourcesSettings(
LnTipsFundingSource,
NWCFundingSource,
BreezSdkFundingSource,
StrikeFundingSource,
):
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
# How long to wait for the payment to be confirmed before returning a pending status
@@ -659,6 +667,8 @@ class KeycloakAuthSettings(LNbitsSettings):
keycloak_discovery_url: str = Field(default="")
keycloak_client_id: str = Field(default="")
keycloak_client_secret: str = Field(default="")
keycloak_client_custom_org: str | None = Field(default=None)
keycloak_client_custom_icon: str | None = Field(default=None)
class AuditSettings(LNbitsSettings):
@@ -863,6 +873,7 @@ class SuperUserSettings(LNbitsSettings):
"VoidWallet",
"ZBDWallet",
"NWCWallet",
"StrikeWallet",
]
)
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+15 -3
View File
@@ -49,8 +49,11 @@ window.localisation.en = {
'This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.',
access_wallet_on_mobile: 'Mobile Access',
wallet: 'Wallet: ',
wallet_name: 'Wallet name',
wallets: 'Wallets',
add_wallet: 'Add a new wallet',
add_wallet: 'Add wallet',
add_new_wallet: 'Add a new wallet',
pin_wallet: 'Pin wallet',
delete_wallet: 'Delete wallet',
delete_wallet_desc:
'This whole wallet will be deleted, the funds will be UNRECOVERABLE.',
@@ -125,6 +128,7 @@ window.localisation.en = {
no_extensions: "You don't have any extensions installed :(",
created: 'Created',
search_extensions: 'Search extensions',
search_wallets: 'Search wallets',
extension_sources: 'Extension Sources',
ext_sources_hint: 'Repositories from where the extensions can be downloaded',
ext_sources_label:
@@ -264,6 +268,7 @@ window.localisation.en = {
notification_source_label:
'Source URL (only use the official LNbits status source, and sources you can trust)',
more: 'more',
more_count: '{count} more',
less: 'less',
releases: 'Releases',
watchdog: 'Watchdog',
@@ -278,7 +283,7 @@ window.localisation.en = {
callback_url_rules: 'Callback URL Rules',
enter_callback_url_rule: 'Enter URL rule as regex and hit enter',
callback_url_rule_hint:
'Callback URLs (like LNURL one) will be validated against all of these rules. No rule means all URLs are allowed.',
'Callback URLs (like LNURL one) will be validated against these rules. At leat one rule must match. No rule means all URLs are allowed.',
wallet_limiter: 'Wallet Limiter',
wallet_config: 'Wallet Config',
wallet_charts: 'Wallet Charts',
@@ -303,6 +308,9 @@ window.localisation.en = {
login_with_user_id: 'Login with user ID',
or: 'or',
create_new_wallet: 'Create New Wallet',
delete_all_wallets: 'Delete All Wallets',
confirm_delete_all_wallets:
'Are you sure you want to delete ALL wallets for this user?',
login_to_account: 'Login to your account',
create_account: 'Create account',
account_settings: 'Account Settings',
@@ -311,7 +319,7 @@ window.localisation.en = {
signin_with_nostr: 'Continue with Nostr',
signin_with_google: 'Sign in with Google',
signin_with_github: 'Sign in with GitHub',
signin_with_keycloak: 'Sign in with Keycloak',
signin_with_custom_org: 'Sign in with {custom_org}',
username_or_email: 'Username or Email',
password: 'Password',
password_config: 'Password Config',
@@ -330,6 +338,7 @@ window.localisation.en = {
username: 'Username',
pubkey: 'Public Key',
user_id: 'User ID',
id: 'ID',
email: 'Email',
first_name: 'First Name',
last_name: 'Last Name',
@@ -356,6 +365,7 @@ window.localisation.en = {
gradient_background: 'Gradient Background',
language: 'Language',
color_scheme: 'Color Scheme',
visible_wallet_count: 'Visible Wallet Count',
admin_settings: 'Admin Settings',
extension_cost: 'This release requires a payment of minimum {cost} sats.',
extension_paid_sats: 'You have already paid {paid_sats} sats.',
@@ -472,6 +482,8 @@ window.localisation.en = {
auth_keycloak_ci_hint:
'Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/keycloak/token',
auth_keycloak_cs_label: 'Keycloak Client Secret',
auth_keycloak_custom_org_label: 'Keycloak Custom Organization',
auth_keycloak_custom_icon_label: 'Keycloak Custom Icon (URL)',
currency_settings: 'Currency Settings',
allowed_currencies: 'Allowed Currencies',
allowed_currencies_hint: 'Limit the number of available fiat currencies',
+17 -8
View File
@@ -197,7 +197,8 @@ window.LNbits = {
email: data.email,
extensions: data.extensions,
wallets: data.wallets,
super_user: data.super_user
super_user: data.super_user,
extra: data.extra ?? {}
}
const mapWallet = this.wallet
obj.wallets = obj.wallets
@@ -205,6 +206,9 @@ window.LNbits = {
return mapWallet(obj)
})
.sort((a, b) => {
if (a.extra.pinned !== b.extra.pinned) {
return a.extra.pinned ? -1 : 1
}
return a.name.localeCompare(b.name)
})
obj.walletOptions = obj.wallets.map(obj => {
@@ -213,6 +217,10 @@ window.LNbits = {
value: obj.id
}
})
obj.hiddenWalletsCount = Math.max(
0,
data.wallets.length - data.extra.visible_wallet_count
)
return obj
},
wallet(data) {
@@ -252,13 +260,11 @@ window.LNbits = {
fiat_currency: data.fiat_currency
}
obj.date = Quasar.date.formatDate(new Date(obj.time), window.dateFormat)
obj.dateFrom = moment.utc(obj.date).fromNow()
obj.expirydate = Quasar.date.formatDate(
new Date(obj.expiry),
window.dateFormat
)
obj.expirydateFrom = moment.utc(obj.expirydate).fromNow()
obj.date = moment.utc(data.created_at).local().format(window.dateFormat)
obj.dateFrom = moment.utc(data.created_at).fromNow()
obj.expirydate = moment.utc(obj.expiry).local().format(window.dateFormat)
obj.expirydateFrom = moment.utc(obj.expiry).fromNow()
obj.msat = obj.amount
obj.sat = obj.msat / 1000
obj.tag = obj.extra?.tag
@@ -510,6 +516,9 @@ window.windowMixin = {
}
this.$q.localStorage.set('lnbits.walletFlip', this.walletFlip)
},
goToWallets() {
window.location = '/wallets'
},
submitAddWallet() {
if (
this.showAddWalletDialog.name &&
+5 -10
View File
@@ -103,14 +103,7 @@ window.app.component('lnbits-extension-list', {
window.app.component('lnbits-manage', {
mixins: [window.windowMixin],
template: '#lnbits-manage',
props: [
'showAdmin',
'showNode',
'showExtensions',
'showUsers',
'showAudit',
'showPayments'
],
props: ['showAdmin', 'showNode', 'showExtensions', 'showUsers', 'showAudit'],
methods: {
isActive(path) {
return window.location.pathname === path
@@ -210,7 +203,7 @@ window.app.component('lnbits-qrcode', {
data() {
return {
custom: {
margin: 1,
margin: 3,
width: 350,
size: 350,
logo: LNBITS_QR_LOGO
@@ -566,7 +559,9 @@ window.app.component('username-password', {
username: this.userName,
password: this.password_1,
passwordRepeat: this.password_2,
reset_key: this.resetKey
reset_key: this.resetKey,
keycloakOrg: LNBITS_AUTH_KEYCLOAK_ORG || 'Keycloak',
keycloakIcon: LNBITS_AUTH_KEYCLOAK_ICON
}
},
methods: {
@@ -197,6 +197,14 @@ window.app.component('lnbits-funding-sources', {
breez_greenlight_device_cert: 'Greenlight Device Cert',
breez_greenlight_invite_code: 'Greenlight Invite Code'
}
],
[
'StrikeWallet',
'Strike (alpha)',
{
strike_api_endpoint: 'API Endpoint',
strike_api_key: 'API Key'
}
]
]
}
+9
View File
@@ -193,6 +193,15 @@ const routes = [
scripts: ['/static/js/account.js']
}
},
{
path: '/wallets',
name: 'Wallets',
component: DynamicComponent,
props: {
fetchUrl: '/wallets',
scripts: ['/static/js/wallets.js']
}
},
{
path: '/node',
name: 'Node',
+17
View File
@@ -312,6 +312,23 @@ window.UsersPageLogic = {
.catch(LNbits.utils.notifyApiError)
})
},
deleteAllUserWallets(userId) {
LNbits.utils
.confirmDialog(this.$t('confirm_delete_all_wallets'))
.onOk(() => {
LNbits.api
.request('DELETE', `/users/api/v1/user/${userId}/wallets`)
.then(response => {
Quasar.Notify.create({
type: 'positive',
message: response.data.message,
icon: null
})
this.fetchWallets(userId)
})
.catch(LNbits.utils.notifyApiError)
})
},
copyWalletLink(walletId) {
const url = `${window.location.origin}/wallet?usr=${this.activeWallet.userId}&wal=${walletId}`
this.copyText(url)
+1 -1
View File
@@ -662,7 +662,7 @@ window.WalletPageLogic = {
}
}
Quasar.Notify.create({
message: 'Wallet and user updated.',
message: 'Wallet updated.',
type: 'positive',
timeout: 3500
})
+89
View File
@@ -0,0 +1,89 @@
window.WalletsPageLogic = {
mixins: [window.windowMixin],
data() {
return {
user: null,
tab: 'wallets',
wallets: [],
showAddWalletDialog: {show: false},
walletsTable: {
columns: [
{
name: 'name',
align: 'left',
label: 'Name',
field: 'name',
sortable: true
},
{
name: 'currency',
align: 'center',
label: 'Currency',
field: 'currency',
sortable: true
},
{
name: 'updated_at',
align: 'right',
label: 'Last Updated',
field: 'updated_at',
sortable: true
}
],
pagination: {
sortBy: 'updated_at',
rowsPerPage: 12,
page: 1,
descending: true,
rowsNumber: 10
},
search: '',
hideEmpty: true,
loading: false
}
}
},
watch: {
'walletsTable.search': {
handler() {
const props = {}
if (this.walletsTable.search) {
props['search'] = this.walletsTable.search
}
this.getUserWallets()
}
}
},
methods: {
async getUserWallets(props) {
try {
this.walletsTable.loading = true
const params = LNbits.utils.prepareFilterQuery(this.walletsTable, props)
const {data} = await LNbits.api.request(
'GET',
`/api/v1/wallet/paginated?${params}`,
null
)
this.wallets = data.data
this.walletsTable.pagination.rowsNumber = data.total
} catch (e) {
LNbits.utils.notifyApiError(e)
} finally {
this.walletsTable.loading = false
}
},
goToWallet(walletId) {
window.location = `/wallet?wal=${walletId}`
},
formattedFiatAmount(amount, currency) {
return LNbits.utils.formatCurrency(Number(amount).toFixed(2), currency)
},
formattedSatAmount(amount) {
return LNbits.utils.formatMsat(amount) + ' sat'
}
},
async created() {
await this.getUserWallets()
}
}
+36 -8
View File
@@ -179,12 +179,12 @@
>
<q-scroll-area style="height: 100%">
<q-item>
<q-item-section>
<q-item-section class="cursor-pointer" @click="goToWallets()">
<q-item-label
:style="$q.dark.isActive ? 'color:rgba(255, 255, 255, 0.64)' : ''"
class="q-item__label q-item__label--header q-pa-none"
header
v-text="$t('wallets')"
v-text="$t('wallets') + ' (' + g.user.wallets.length + ')'"
></q-item-label>
</q-item-section>
<q-item-section side>
@@ -211,7 +211,6 @@
:show-admin="'{{LNBITS_ADMIN_UI}}' == 'True'"
:show-users="'{{LNBITS_ADMIN_UI}}' == 'True'"
:show-audit="'{{LNBITS_AUDIT_ENABLED}}' == 'True'"
:show-payments="'{{LNBITS_ADMIN_UI}}' == 'True'"
:show-node="'{{LNBITS_NODE_UI}}' == 'True'"
:show-extensions="'{{LNBITS_EXTENSIONS_DEACTIVATE_ALL}}' == 'False'"
></lnbits-manage>
@@ -248,7 +247,7 @@
@click="showAddWalletDialog.show = true"
>
<q-tooltip
><span v-text="$t('add_wallet')"></span
><span v-text="$t('add_new_wallet')"></span
></q-tooltip>
</q-btn>
<q-dialog
@@ -258,7 +257,9 @@
>
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-h6">Wallet name</div>
<div class="text-h6">
<span v-text="$t('wallet_name')"></span>
</div>
</q-card-section>
<q-card-section class="q-pt-none">
@@ -271,10 +272,14 @@
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn
flat
label="Add wallet"
:label="$t('cancel')"
v-close-popup
></q-btn>
<q-btn
flat
:label="$t('add_wallet')"
v-close-popup
@click="submitAddWallet()"
></q-btn>
@@ -285,7 +290,7 @@
</q-card-section>
</q-card>
<q-card
v-for="wallet in g.user.wallets"
v-for="wallet in g.user.wallets.slice(0, g.user.extra.visible_wallet_count || 10)"
:key="wallet.id"
clickable
@click="selectWallet(wallet)"
@@ -331,6 +336,29 @@
</div>
</q-card-section>
</q-card>
<q-card
v-if="g.user.hiddenWalletsCount"
class="wallet-list-card"
>
<q-card-section
class="flex flex-center column full-height text-center"
>
<div>
<q-btn
round
color="primary"
icon="more_horiz"
@click="goToWallets()"
>
<q-tooltip
><span
v-text="$t('more_count', {count: g.user.hiddenWalletsCount})"
></span
></q-tooltip>
</q-btn>
</div>
</q-card-section>
</q-card>
</div>
</q-scroll-area>
+49 -18
View File
@@ -5,7 +5,10 @@
class="lnbits-drawer__q-list"
>
<q-item
v-for="walletRec in g.user.wallets"
v-for="walletRec in g.user.wallets.slice(
0,
g.user.extra.visible_wallet_count || 10
)"
:key="walletRec.id"
clickable
:active="g.wallet && g.wallet.id === walletRec.id"
@@ -43,6 +46,22 @@
<q-item-section side v-show="g.wallet && g.wallet.id === walletRec.id">
</q-item-section>
</q-item>
<q-item
v-if="g.user.hiddenWalletsCount > 0"
clickable
@click="goToWallets()"
>
<q-item-section side>
<q-icon name="more_horiz" color="grey-5" size="md"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label
lines="1"
class="text-caption"
v-text="$t('more_count', {count: g.user.hiddenWalletsCount})"
></q-item-label>
</q-item-section>
</q-item>
<q-item clickable @click="showForm = !showForm">
<q-item-section side>
<q-icon
@@ -55,7 +74,7 @@
<q-item-label
lines="1"
class="text-caption"
v-text="$t('add_wallet')"
v-text="$t('add_new_wallet')"
></q-item-label>
</q-item-section>
</q-item>
@@ -178,19 +197,19 @@
<q-item-label lines="1" v-text="$t('api_watch')"></q-item-label>
</q-item-section>
</q-item>
<q-item v-if="showPayments" to="/payments">
<q-item-section side>
<q-icon
name="query_stats"
:color="isActive('/payments') ? 'primary' : 'grey-5'"
size="md"
></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" v-text="$t('payments')"></q-item-label>
</q-item-section>
</q-item>
</div>
<q-item to="/payments">
<q-item-section side>
<q-icon
name="query_stats"
:color="isActive('/payments') ? 'primary' : 'grey-5'"
size="md"
></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" v-text="$t('payments')"></q-item-label>
</q-item-section>
</q-item>
<q-item v-if="showExtensions" to="/extensions">
<q-item-section side>
<q-icon
@@ -987,7 +1006,7 @@
</div>
</q-card-section>
<q-card-section>
<div class="row">
<div class="row q-gutter-x-sm">
<q-btn
v-if="
props.row.isIn && props.row.isPending && props.row.bolt11
@@ -1185,7 +1204,7 @@
color="primary"
:disable="walletName == ''"
type="submit"
:label="$t('add_wallet')"
:label="$t('add_new_wallet')"
class="full-width q-mb-sm"
></q-btn>
<q-btn
@@ -1453,10 +1472,22 @@
>
<q-avatar size="32px" class="q-mr-md">
<q-img
:src="`{{ static_url_for('static', 'images/keycloak-logo.png') }}`"
:src="
keycloakIcon
? keycloakIcon
: `{{ static_url_for('static', 'images/keycloak-logo.png') }}`
"
></q-img>
</q-avatar>
<div><span v-text="$t('signin_with_keycloak')"></span></div>
<div>
<span
v-text="
$t('signin_with_custom_org', {
custom_org: keycloakOrg
})
"
></span>
</div>
</q-btn>
</div>
</q-card-section>
+15 -6
View File
@@ -56,10 +56,11 @@ class AESCipher:
return data + (chr(length) * length).encode()
def unpad(self, data: bytes) -> bytes:
_last = data[-1]
if isinstance(_last, int):
return data[:-_last]
return data[: -ord(_last)]
padding = data[-1]
# Ensure padding is within valid range else there is no padding
if padding <= 0 or padding >= self.block_size:
return data
return data[:-padding]
def derive_iv_and_key(
self, salt: bytes, output_len: int = 32 + 16
@@ -93,9 +94,17 @@ class AESCipher:
try:
decrypted_bytes = aes.decrypt(encrypted_bytes)
return self.unpad(decrypted_bytes).decode()
except Exception as exc:
raise ValueError("Decryption error") from exc
raise ValueError("Could not decrypt payload") from exc
unpadded = self.unpad(decrypted_bytes)
if len(unpadded) == 0:
raise ValueError("Unpadding resulted in empty data.")
try:
return unpadded.decode()
except UnicodeDecodeError as exc:
raise ValueError("Decryption resulted in invalid UTF-8 data.") from exc
def encrypt(self, message: bytes, urlsafe: bool = False) -> str:
"""
+2
View File
@@ -28,6 +28,7 @@ from .nwc import NWCWallet
from .opennode import OpenNodeWallet
from .phoenixd import PhoenixdWallet
from .spark import SparkWallet
from .strike import StrikeWallet
from .void import VoidWallet
from .zbd import ZBDWallet
@@ -72,6 +73,7 @@ __all__ = [
"OpenNodeWallet",
"PhoenixdWallet",
"SparkWallet",
"StrikeWallet",
"VoidWallet",
"ZBDWallet",
]
+546
View File
@@ -0,0 +1,546 @@
import asyncio
import time
from decimal import Decimal
from typing import Any, AsyncGenerator, Dict, Optional
import httpx
from loguru import logger
from lnbits.settings import settings
from .base import (
InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
PaymentSuccessStatus,
StatusResponse,
Wallet,
)
class TokenBucket:
"""
Token bucket rate limiter for Strike API endpoints.
"""
def __init__(self, rate: int, period_seconds: int):
"""
Initialize a token bucket.
Args:
rate: Max requests allowed in the period
period_seconds: Time period in seconds.
"""
self.rate = rate
self.period = period_seconds
self.tokens = rate
self.last_refill = time.monotonic()
self.lock = asyncio.Lock()
async def consume(self) -> None:
"""Wait until a token is available and consume it."""
async with self.lock:
# Refill tokens based on elapsed time
now = time.monotonic()
elapsed = now - self.last_refill
if elapsed > 0:
new_tokens = int((elapsed / self.period) * self.rate)
self.tokens = min(self.rate, self.tokens + new_tokens)
self.last_refill = now
# If no tokens available, calculate wait time and wait for a token
if self.tokens < 1:
# Calculate time needed for one token
wait_time = (self.period / self.rate) * (1 - self.tokens)
await asyncio.sleep(wait_time)
# After waiting, update time and add one token
self.last_refill = time.monotonic()
self.tokens = 1
# Consume a token (will be 0 or more after consumption)
self.tokens -= 1
class StrikeWallet(Wallet):
"""
https://developer.strike.me/api
A minimal LNbits wallet backend for Strike.
"""
# --------------------------------------------------------------------- #
# construction / teardown #
# --------------------------------------------------------------------- #
def __init__(self):
if not settings.strike_api_endpoint:
raise ValueError("Missing strike_api_endpoint")
if not settings.strike_api_key:
raise ValueError("Missing strike_api_key")
super().__init__()
# throttle
self._sem = asyncio.Semaphore(value=20)
# Rate limiters for different API endpoints
# Invoice/payment operations: 250 requests / 1 minute
self._invoice_limiter = TokenBucket(250, 60)
self._payment_limiter = TokenBucket(250, 60)
# All other operations: 1,000 requests / 10 minutes
self._general_limiter = TokenBucket(1000, 600)
self.client = httpx.AsyncClient(
base_url=self.normalize_endpoint(settings.strike_api_endpoint),
headers={
"Authorization": f"Bearer {settings.strike_api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": settings.user_agent,
},
timeout=httpx.Timeout(connect=5.0, read=40.0, write=10.0, pool=None),
transport=httpx.AsyncHTTPTransport(
limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
retries=0, # we handle retries ourselves
),
)
# runtime state
self.pending_invoices: list[str] = [] # Keep it as a list
self.pending_payments: Dict[str, str] = {}
self.failed_payments: Dict[str, str] = {}
# balance cache
self._cached_balance: Optional[int] = None
self._cached_balance_ts: float = 0.0
self._cache_ttl = 30 # seconds
async def cleanup(self):
try:
await self.client.aclose()
except Exception:
logger.warning("Error closing Strike client")
# --------------------------------------------------------------------- #
# low-level request helpers #
# --------------------------------------------------------------------- #
async def _req(self, method: str, path: str, /, **kw) -> httpx.Response:
"""Make a Strike HTTP call with:
One Strike HTTP call with
rate limiting based on endpoint type
concurrency throttle
exponential back-off + jitter
explicit retry on 429/5xx
latency logging
"""
# Apply the appropriate rate limiter based on the endpoint path.
if path.startswith("/invoices") or path.startswith("/receive-requests"):
await self._invoice_limiter.consume()
elif path.startswith("/payment-quotes"):
await self._payment_limiter.consume()
else:
await self._general_limiter.consume()
async with self._sem:
return await self.client.request(method, path, **kw)
# Typed wrappers - so call-sites stay tidy.
async def _get(self, path: str, **kw) -> httpx.Response: # GET request.
return await self._req("GET", path, **kw)
async def _post(self, path: str, **kw) -> httpx.Response:
return await self._req("POST", path, **kw)
async def _patch(self, path: str, **kw) -> httpx.Response:
return await self._req("PATCH", path, **kw)
# --------------------------------------------------------------------- #
# LNbits wallet API implementation #
# --------------------------------------------------------------------- #
async def status(self) -> StatusResponse:
"""
Return wallet balance (millisatoshis) with a 30-second cache.
"""
now = time.time()
if (
self._cached_balance is not None
and now - self._cached_balance_ts < self._cache_ttl
):
return StatusResponse(None, self._cached_balance)
try:
r = await self._get("/balances")
r.raise_for_status()
data = r.json()
balances = data.get("data", []) if isinstance(data, dict) else data
btc = next((b for b in balances if b.get("currency") == "BTC"), None)
if btc and "available" in btc:
available_btc = Decimal(btc["available"]) # Get available BTC amount.
msats = int(
available_btc * Decimal(1e11)
) # Convert BTC to millisatoshis.
self._cached_balance = msats
self._cached_balance_ts = now
return StatusResponse(None, msats)
return StatusResponse(None, 0)
except Exception as e:
logger.warning(e)
return StatusResponse("Connection error", 0)
async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: bytes | None = None, # Add this parameter
**kwargs,
) -> InvoiceResponse:
try:
btc_amt = (Decimal(amount) / Decimal(1e8)).quantize(
Decimal("0.00000001")
) # Convert amount from millisatoshis to BTC.
payload: Dict[str, Any] = {
"bolt11": {
"amount": {
"currency": "BTC",
"amount": str(btc_amt),
},
"description": memo or "",
},
"targetCurrency": "BTC",
}
if description_hash:
payload["bolt11"]["descriptionHash"] = description_hash.hex()
r = await self._post(
"/receive-requests",
json=payload,
)
r.raise_for_status()
resp = r.json()
invoice_id = resp.get("receiveRequestId")
bolt11 = resp.get("bolt11", {}).get("invoice")
if not invoice_id or not bolt11:
return InvoiceResponse(
ok=False, error_message="Invalid invoice response"
)
self.pending_invoices.append(invoice_id)
return InvoiceResponse(
ok=True, checking_id=invoice_id, payment_request=bolt11
)
except httpx.HTTPStatusError as e:
logger.warning(e)
msg = e.response.json().get("message", e.response.text)
return InvoiceResponse(ok=False, error_message=f"Strike API error: {msg}")
except Exception as e:
logger.warning(e)
return InvoiceResponse(ok=False, error_message="Connection error")
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
try:
# 1) Create a payment quote.
q = await self._post(
"/payment-quotes/lightning",
json={"lnInvoice": bolt11},
)
q.raise_for_status()
quote_id = q.json().get("paymentQuoteId")
if not quote_id:
return PaymentResponse(
ok=False, error_message="Strike: missing payment quote Id"
)
# 2) Execute the payment quote.
e = await self._patch(f"/payment-quotes/{quote_id}/execute")
e.raise_for_status()
data = e.json() if e.content else {}
payment_id = data.get("paymentId")
state = data.get("state", "").upper()
# 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.
if state in {"SUCCEEDED", "COMPLETED"}:
preimage = data.get("preimage") or data.get("preImage")
return PaymentResponse(
ok=True,
checking_id=payment_id,
fee_msat=fee_msat,
preimage=preimage,
)
failed_states = {
"CANCELED",
"FAILED",
"TIMED_OUT",
}
if state in failed_states:
return PaymentResponse(
ok=False, checking_id=payment_id, error_message=f"State: {state}"
)
# Store mapping for later polling.
if payment_id:
# todo: this will be lost on server restart
self.pending_payments[payment_id] = quote_id
# Treat all other states as pending (including unknown states).
return PaymentResponse(ok=None, checking_id=payment_id)
except Exception as e:
logger.warning(e)
# Keep pending. Not sure if the payment went trough or not.
return PaymentResponse(ok=None, error_message="Connection error")
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
r = await self._get(f"/receive-requests/{checking_id}/receives")
if r.status_code == 200:
data = r.json()
items = data.get("items", [])
if not items:
# Still pending.
return PaymentPendingStatus()
for item in items:
if item.get("state") == "COMPLETED":
preimage = None
lightning_data = item.get("lightning")
if lightning_data:
preimage = lightning_data.get(
"preimage"
) or lightning_data.get("preImage")
return PaymentSuccessStatus(fee_msat=0, preimage=preimage)
return PaymentPendingStatus()
if r.status_code == 404:
logger.warning(f"Payment '{checking_id}' not found. Marking as failed.")
return PaymentFailedStatus(False)
r.raise_for_status()
return PaymentPendingStatus()
except httpx.HTTPStatusError as e:
logger.warning(
f"HTTPStatusError in get_invoice_status for checking_id {checking_id} "
f"on URL {e.request.url}: {e.response.status_code} - {e.response.text}"
)
# Default to Pending to allow retries by paid_invoices_stream.
return PaymentPendingStatus()
except Exception as e:
logger.warning(e)
return PaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
quote_id = self.pending_payments.get(checking_id)
try:
# Attempt 1: Use quote_id if available (from in-memory store)
if quote_id:
status = await self._get_payment_status_by_quote_id(
checking_id, quote_id
)
if status:
return status
except Exception as e:
logger.warning(e)
logger.debug(f"Error while fetching payment by quote id {checking_id}.")
try:
# Attempt 2: Fallback - Use paymentId (checking_id) directly.
return await self._get_payment_status_by_checking_id(checking_id)
except Exception as e:
logger.warning(e)
logger.debug(f"Error while fetching payment {checking_id}.")
return PaymentPendingStatus()
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
"""
Poll Strike for invoice settlement while respecting the documented API limits.
Uses dynamic adjustment of polling frequency based on activity.
"""
min_poll, max_poll = 1, 15
# 1,000 requests / 10 minutes = ~100 requests/minute.
rate_limit = 100
sleep_s = min_poll
# Main loop for polling invoices.
self._running = True
while self._running and settings.lnbits_running:
loop_start = time.time()
had_activity = False
req_budget = max(
1, rate_limit * sleep_s // 60
) # Calculate request budget based on sleep time.
processed = 0
for inv in list(self.pending_invoices):
if processed >= req_budget: # If request budget is exhausted.
break
status = await self.get_invoice_status(inv)
processed += 1
if status.success or status.failed:
self.pending_invoices.remove(inv)
if status.success:
had_activity = True
yield inv
# Dynamic adjustment of polling frequency based on activity.
sleep_s = (
max(min_poll, sleep_s // 2)
if had_activity
else min(max_poll, sleep_s * 2)
)
# Sleep to respect rate limits.
elapsed = time.time() - loop_start
# Ensure we respect the rate limit, even with dynamic adjustment.
min_sleep_for_rate = processed * 60 / rate_limit - elapsed
await asyncio.sleep(max(sleep_s, min_sleep_for_rate, 0))
# ------------------------------------------------------------------ #
# misc Strike helpers #
# ------------------------------------------------------------------ #
async def get_invoices(
self,
filters: Optional[str] = None,
orderby: Optional[str] = None,
skip: Optional[int] = None,
top: Optional[int] = None,
) -> Dict[str, Any]:
try:
params: Dict[str, Any] = {}
if filters:
params["$filter"] = filters
if orderby:
params["$orderby"] = orderby
if skip is not None:
params["$skip"] = skip
if top is not None:
params["$top"] = top
r = await self._get(
"/invoices", params=params
) # Get invoices from Strike API.
r.raise_for_status()
return r.json()
except Exception:
logger.warning("Error in get_invoices()")
return {"error": "unable to fetch invoices"}
async def _get_payment_status_by_quote_id(
self, checking_id: str, quote_id: str
) -> Optional[PaymentStatus]:
resp = await self._get(f"/payment-quotes/{quote_id}")
resp.raise_for_status()
data = resp.json()
state = data.get("state", "").upper()
preimage = data.get("preimage") or data.get("preImage")
fee_msat = 0
fee_obj = data.get("lightningNetworkFee") or data.get("totalFee")
if fee_obj and fee_obj.get("amount") and fee_obj.get("currency"):
amount_str = fee_obj.get("amount")
currency_str = fee_obj.get("currency").upper()
try:
if currency_str == "BTC":
fee_btc_decimal = Decimal(amount_str)
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)
except Exception as e:
logger.warning(e)
logger.warning(
f"Fee parse error. Quote: '{quote_id}'. "
f"Payment '{checking_id}'."
)
fee_msat = 0
if state in {"SUCCEEDED", "COMPLETED"}:
self.pending_payments.pop(checking_id, None)
return PaymentSuccessStatus(fee_msat=fee_msat, preimage=preimage)
if state == "FAILED":
self.pending_payments.pop(checking_id, None)
return PaymentFailedStatus()
return None
async def _get_payment_status_by_checking_id(
self, checking_id: str
) -> PaymentStatus:
r_payment = await self._get(f"/payments/{checking_id}")
if r_payment.status_code == 200:
data = r_payment.json()
state = data.get("state", "").upper()
preimage = None
fee_msat = 0
if state in {"SUCCEEDED", "COMPLETED"}:
self.pending_payments.pop(checking_id, None)
return PaymentSuccessStatus(fee_msat=fee_msat, preimage=preimage)
if state == "FAILED":
self.pending_payments.pop(checking_id, None)
return PaymentFailedStatus()
return PaymentPendingStatus()
if r_payment.status_code == 400:
try:
error_data = r_payment.json()
# Check for Strike's specific validation
# error structure for paymentId format
if error_data.get("data", {}).get("code") == "INVALID_DATA":
validation_errors = error_data.get("data", {}).get(
"validationErrors", {}
)
if "paymentId" in validation_errors:
for err_detail in validation_errors["paymentId"]:
is_invalid = err_detail.get(
"code"
) == "INVALID_DATA" and "is not valid." in err_detail.get(
"message", ""
)
if not is_invalid:
continue
logger.error(
f"Payment '{checking_id}' not a valid Strike payment. "
f"Marked as failed. Response: {r_payment.text}"
)
self.pending_payments.pop(checking_id, None)
return PaymentFailedStatus()
except Exception as e:
logger.warning(e)
return PaymentPendingStatus()
if r_payment.status_code == 404:
logger.warning(f"Payment {checking_id} not found. Marking as failed.")
self.pending_payments.pop(checking_id, None)
return PaymentFailedStatus()
logger.debug(
f"Error fetching payment {checking_id} directly: "
f"{r_payment.status_code} - {r_payment.text}"
)
return PaymentPendingStatus()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "lnbits"
version = "1.1.0"
version = "1.2.0-rc1"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = ["Alan Bits <alan@lnbits.com>"]
readme = "README.md"
+2 -2
View File
@@ -130,7 +130,7 @@ async def from_wallet(from_user):
wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_from")
await update_wallet_balance(
wallet=wallet,
amount=999999999,
amount=9999999,
)
yield wallet
@@ -174,7 +174,7 @@ async def to_wallet(to_user):
wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_to")
await update_wallet_balance(
wallet=wallet,
amount=999999999,
amount=9999999,
)
yield wallet
+4
View File
@@ -152,6 +152,10 @@ async def test_set_channel_fees(node_client):
@pytest.mark.anyio
@pytest.mark.skipif(
funding_source.__class__.__name__ == "LndRestWallet",
reason="Lndrest is slow / async with channel commands",
)
async def test_channel_management(node_client):
async def get_channels():
# lndrest is slow / async with channel commands
+84
View File
@@ -0,0 +1,84 @@
import pytest
from lnbits.helpers import check_callback_url
from lnbits.settings import Settings
@pytest.mark.anyio
def test_check_callback_url_not_allowed(settings: Settings):
settings.lnbits_callback_url_rules = [
"https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"
]
with pytest.raises(ValueError, match="Callback not allowed. URL: xx. Netloc: ."):
check_callback_url("xx")
with pytest.raises(
ValueError,
match="Callback not allowed. URL: http://localhost:3000/callback. "
"Netloc: localhost:3000. Please check your admin settings.",
):
check_callback_url("http://localhost:3000/callback")
with pytest.raises(
ValueError,
match="Callback not allowed. URL: https://localhost:3000/callback. "
"Netloc: localhost:3000. Please check your admin settings.",
):
check_callback_url("https://localhost:3000/callback")
with pytest.raises(
ValueError,
match="Callback not allowed. URL: http://192.168.2.2:3000/callback. "
"Netloc: 192.168.2.2:3000. Please check your admin settings.",
):
check_callback_url("http://192.168.2.2:3000/callback")
@pytest.mark.anyio
def test_check_callback_url_no_rules(settings: Settings):
settings.lnbits_callback_url_rules = [
"https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"
]
settings.lnbits_callback_url_rules.append(".*")
check_callback_url("xyz")
@pytest.mark.anyio
def test_check_callback_url_allow_all(settings: Settings):
settings.lnbits_callback_url_rules = []
check_callback_url("xyz")
@pytest.mark.anyio
def test_check_callback_url_allowed(settings: Settings):
settings.lnbits_callback_url_rules = [
"https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"
]
check_callback_url("http://google.com/callback")
check_callback_url("http://google.com:80/callback")
check_callback_url("http://google.com:8080/callback")
check_callback_url("https://google.com/callback")
check_callback_url("https://google.com:443/callback")
@pytest.mark.anyio
def test_check_callback_url_multiple_rules(settings: Settings):
with pytest.raises(
ValueError,
match="Callback not allowed. URL: http://localhost:3000/callback. "
"Netloc: localhost:3000. Please check your admin settings.",
):
check_callback_url("http://localhost:3000/callback")
settings.lnbits_callback_url_rules.append("http://localhost:3000")
check_callback_url("http://localhost:3000/callback") # should not raise
with pytest.raises(
ValueError,
match="Callback not allowed. URL: https://localhost:3000/callback. "
"Netloc: localhost:3000. Please check your admin settings.",
):
check_callback_url("https://localhost:3000/callback")
settings.lnbits_callback_url_rules.append("https://localhost:3000")
check_callback_url("https://localhost:3000/callback") # should not raise