Compare commits

..
24 Commits
Author SHA1 Message Date
dni ⚡ c4aa0b598c chore: update to version 1.2.0 2025-07-08 10:22:22 +02:00
dni ⚡ aab76e691d chore: update to version v1.2.0-rc3 (#3231) 2025-07-08 10:17:30 +02:00
Vlad Stananddni ⚡ 3ed30be93e fix: condition for available, but not installed extension (#3232) 2025-07-08 10:17:29 +02:00
Vlad Stananddni ⚡ 37f99e7f96 doc: LNBITS_EXTENSIONS_PATH (#3229) 2025-07-08 10:17:28 +02:00
dni ⚡ a16078a6ba chore: update python packages + formatting + cleanup (#3221) 2025-07-08 10:17:27 +02:00
dni ⚡ c4c03d96a3 test: add regtest testcase for no route failure (#3189) 2025-07-08 10:17:25 +02:00
Vlad Stananddni ⚡ ab3248da15 fix: url_for should not return extension upgrades temp paths (#3227) 2025-07-08 10:17:24 +02:00
dni ⚡ b4b37cd733 fix: circular import on handle_fiat_payments (#3226) 2025-07-08 10:17:23 +02:00
dni ⚡ 26eb7a449c feat: add internal_invoice_queue_put to get rid of dynamic import (#3223) 2025-07-08 10:17:21 +02:00
Vlad Stananddni ⚡ 695e9b6471 [feat] Add stripe payments (#3184) 2025-07-08 10:17:19 +02:00
blackcoffeexbtanddni ⚡ 5c9511ccfe Label tweaks (#3224) 2025-07-08 10:17:18 +02:00
Vlad Stananddni ⚡ c7b7832a88 feat: add external id for users (#3219) 2025-07-08 10:17:16 +02:00
dni ⚡andGitHub ff24847980 feat: add nostr-sdk library (#3217) 2025-06-25 13:58:20 +03:00
659ec31edd CI: lnbits-boltz docker, for those who want instant payments + boltz funding source UX tweak (#3192)
Co-authored-by: jackstar12 <62219658+jackstar12@users.noreply.github.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
Co-authored-by: dni  <office@dnilabs.com>
2025-06-25 12:20:46 +02:00
edb7d66efc feat: Add trimmed mean filtering for exchange rate outlier detection (#3206)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-06-25 13:16:00 +03:00
efcc5e2148 feat: improved nostr pubkey field label & tooltip and set password tooltip (#3205)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-06-25 12:06:42 +02:00
dni ⚡andGitHub 55889abd88 chore: update poetry, disable nix workflow and python package updates (#3213) 2025-06-24 18:09:48 +02:00
dni ⚡andGitHub 063aa4fd06 chore: update to lnbits version 1.2.0-rc2 (#3216) 2025-06-24 17:38:58 +02:00
Tiago VasconcelosandGitHub 7c0a955b30 fix: error handling (#3214) 2025-06-24 18:27:28 +03:00
dni ⚡andGitHub b1a09af82c chore: update py version in lnbits.sh (#3215) 2025-06-24 18:25:33 +03:00
dni ⚡andGitHub 6d2f39d390 fix: rounding errors on eclair total balance (#3196) 2025-06-24 16:09:53 +02:00
Tiago VasconcelosandGitHub 18496f0dd9 feat: add QR code for wallet keys (#3198) 2025-06-23 14:04:21 +03:00
Tiago VasconcelosandGitHub acf6c732ad [feat] Add payment status filters (#3203) 2025-06-20 14:45:12 +03:00
Vlad StanandGitHub b39bd257e0 fix: wallets navigation (#3208) 2025-06-20 13:45:08 +03:00
131 changed files with 7448 additions and 3697 deletions
+4 -2
View File
@@ -101,8 +101,10 @@ ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
# BoltzWallet # BoltzWallet
BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002 BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING # HEXSTRING instead of path also possible
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroons/admin.macaroon"
# HEXSTRING instead of path also possible
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert"
BOLTZ_CLIENT_WALLET="lnbits" BOLTZ_CLIENT_WALLET="lnbits"
# StrikeWallet # StrikeWallet
+1 -6
View File
@@ -5,9 +5,6 @@ inputs:
description: "Python Version" description: "Python Version"
required: true required: true
default: "3.10" default: "3.10"
poetry-version:
description: "Poetry Version"
default: "1.8.5"
node-version: node-version:
description: "Node Version" description: "Node Version"
default: "20.x" default: "20.x"
@@ -27,10 +24,8 @@ runs:
# cache poetry install via pip # cache poetry install via pip
cache: "pip" cache: "pip"
- name: Set up Poetry ${{ inputs.poetry-version }} - name: Set up Poetry
uses: abatilo/actions-poetry@v2 uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ inputs.poetry-version }}
- name: Setup a local virtual environment (if no poetry.toml file) - name: Setup a local virtual environment (if no poetry.toml file)
shell: bash shell: bash
-2
View File
@@ -88,8 +88,6 @@ jobs:
strategy: strategy:
matrix: matrix:
python-version: ["3.10"] python-version: ["3.10"]
poetry-version: ["1.5.1"]
uses: ./.github/workflows/jmeter.yml uses: ./.github/workflows/jmeter.yml
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
poetry-version: ${{ matrix.poetry-version }}
+11
View File
@@ -51,3 +51,14 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache
- name: Build and push boltz
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.boltz
push: true
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits-boltz:${{ inputs.tag }}
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
-5
View File
@@ -8,11 +8,6 @@ on:
required: true required: true
default: "3.10" default: "3.10"
type: string type: string
poetry-version:
description: "Poetry Version"
required: true
default: "1.5.1"
type: string
jobs: jobs:
jmeter: jmeter:
+1
View File
@@ -25,6 +25,7 @@ on:
jobs: jobs:
nix: nix:
if: false # temporarly disable nix support until the `poetry2nix` issue is resolved
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+1
View File
@@ -7,6 +7,7 @@ __pycache__
.mypy_cache .mypy_cache
.vscode .vscode
*-lock.json *-lock.json
.python-version
*.egg *.egg
*.egg-info *.egg-info
+4 -3
View File
@@ -4,13 +4,14 @@ RUN apt-get clean
RUN apt-get update RUN apt-get update
RUN apt-get install -y curl pkg-config build-essential libnss-myhostname RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
RUN curl -sSL https://install.python-poetry.org | python3 - --version 1.8.5 RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH" ENV PATH="/root/.local/bin:$PATH"
WORKDIR /app WORKDIR /app
# Only copy the files required to install the dependencies # Only copy the files required to install the dependencies
COPY pyproject.toml poetry.lock ./ COPY pyproject.toml poetry.lock ./
RUN touch README.md
RUN mkdir data RUN mkdir data
@@ -20,7 +21,7 @@ ENV POETRY_NO_INTERACTION=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache POETRY_CACHE_DIR=/tmp/poetry_cache
ARG POETRY_INSTALL_ARGS="--only main" ARG POETRY_INSTALL_ARGS="--only main"
RUN poetry install ${POETRY_INSTALL_ARGS} RUN poetry install --no-root ${POETRY_INSTALL_ARGS}
FROM python:3.12-slim-bookworm FROM python:3.12-slim-bookworm
@@ -33,7 +34,7 @@ RUN apt-get update && apt-get -y upgrade && \
apt-get -y install postgresql-client-14 postgresql-client-common && \ apt-get -y install postgresql-client-14 postgresql-client-common && \
apt-get clean all && rm -rf /var/lib/apt/lists/* apt-get clean all && rm -rf /var/lib/apt/lists/*
RUN curl -sSL https://install.python-poetry.org | python3 - --version 1.8.5 RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH" ENV PATH="/root/.local/bin:$PATH"
ENV POETRY_NO_INTERACTION=1 \ ENV POETRY_NO_INTERACTION=1 \
+30
View File
@@ -0,0 +1,30 @@
FROM boltz/boltz-client:latest AS boltz
FROM lnbits/lnbits:latest
COPY --from=boltz /bin/boltzd /bin/boltzcli /usr/local/bin/
RUN ls -l /usr/local/bin/boltzd
RUN apt-get update && apt-get -y upgrade && \
apt-get install -y netcat-openbsd
# Reinstall dependencies just in case (needed for CMD usage)
ARG POETRY_INSTALL_ARGS="--only main"
RUN poetry install ${POETRY_INSTALL_ARGS}
# LNbits + boltzd configuration
ENV LNBITS_PORT="5000"
ENV LNBITS_HOST="0.0.0.0"
ENV LNBITS_BACKEND_WALLET_CLASS="BoltzWallet"
ENV FUNDING_SOURCE_MAX_RETRIES=10
ENV BOLTZ_CLIENT_ENDPOINT="127.0.0.1:9002"
ENV BOLTZ_CLIENT_MACAROON="/root/.boltz/macaroons/admin.macaroon"
ENV BOLTZ_CLIENT_CERT="/root/.boltz/tls.cert"
ENV BOLTZ_CLIENT_WALLET="lnbits"
EXPOSE 5000
# Entrypoint to start boltzd and LNbits
COPY dockerboltz.sh /dockerboltz.sh
RUN chmod +x /dockerboltz.sh
CMD ["/dockerboltz.sh"]
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
set -e
boltzd --standalone --referralId lnbits &
# Capture boltzd PID to monitor if needed
BOLTZ_PID=$!
# Wait for boltzd to start
for i in {1..10}; do
if nc -z localhost 9002; then
echo "boltzd is up!"
break
fi
echo "Waiting for boltzd to start..."
sleep 1
done
# Optional: check if still not up
if ! nc -z localhost 9002; then
echo "boltzd did not start successfully."
exit 1
fi
echo "Starting LNbits on $LNBITS_HOST:$LNBITS_PORT..."
exec poetry run lnbits --port "$LNBITS_PORT" --host "$LNBITS_HOST" --forwarded-allow-ips='*'
+8
View File
@@ -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 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. Build the image yourself.
```sh ```sh
+6 -6
View File
@@ -6,15 +6,15 @@ if [ ! -d lnbits/data ]; then
# Update package list and install prerequisites non-interactively # Update package list and install prerequisites non-interactively
sudo apt update -y sudo apt update -y
sudo apt install -y software-properties-common sudo apt install -y software-properties-common
# Add the deadsnakes PPA repository non-interactively # Add the deadsnakes PPA repository non-interactively
sudo add-apt-repository -y ppa:deadsnakes/ppa sudo add-apt-repository -y ppa:deadsnakes/ppa
# Install Python 3.9 and distutils non-interactively # Install Python 3.10 and distutils non-interactively
sudo apt install -y python3.9 python3.9-distutils sudo apt install -y python3.10 python3.10-distutils
# Install Poetry # Install Poetry
curl -sSL https://install.python-poetry.org | python3.9 - curl -sSL https://install.python-poetry.org | python3.10 -
# Add Poetry to PATH for the current session # Add Poetry to PATH for the current session
export PATH="/home/$USER/.local/bin:$PATH" export PATH="/home/$USER/.local/bin:$PATH"
@@ -51,4 +51,4 @@ export LNBITS_ADMIN_UI=true
export HOST=0.0.0.0 export HOST=0.0.0.0
# Run LNbits # Run LNbits
poetry run lnbits poetry run lnbits
+5 -8
View File
@@ -9,16 +9,13 @@ from .decorators import (
from .exceptions import InvoiceError, PaymentError from .exceptions import InvoiceError, PaymentError
__all__ = [ __all__ = [
# decorators "InvoiceError",
"require_admin_key", "PaymentError",
"require_invoice_key",
"check_admin", "check_admin",
"check_super_user", "check_super_user",
"check_user_exists", "check_user_exists",
# services
"pay_invoice",
"create_invoice", "create_invoice",
# exceptions "pay_invoice",
"PaymentError", "require_admin_key",
"InvoiceError", "require_invoice_key",
] ]
-3
View File
@@ -65,7 +65,6 @@ from .middleware import (
add_ip_block_middleware, add_ip_block_middleware,
add_ratelimit_middleware, add_ratelimit_middleware,
) )
from .requestvars import g
from .tasks import ( from .tasks import (
check_pending_payments, check_pending_payments,
internal_invoice_listener, internal_invoice_listener,
@@ -171,8 +170,6 @@ def create_app() -> FastAPI:
name="library", name="library",
) )
g().base_url = f"http://{settings.host}:{settings.port}"
app.add_middleware( app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"] CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
) )
+4
View File
@@ -5,7 +5,9 @@ from .views.admin_api import admin_router
from .views.api import api_router from .views.api import api_router
from .views.audit_api import audit_router from .views.audit_api import audit_router
from .views.auth_api import auth_router from .views.auth_api import auth_router
from .views.callback_api import callback_router
from .views.extension_api import extension_router from .views.extension_api import extension_router
from .views.fiat_api import fiat_router
# this compat is needed for usermanager extension # this compat is needed for usermanager extension
from .views.generic import generic_router from .views.generic import generic_router
@@ -34,10 +36,12 @@ def init_core_routers(app: FastAPI):
app.include_router(wallet_router) app.include_router(wallet_router)
app.include_router(api_router) app.include_router(api_router)
app.include_router(websocket_router) app.include_router(websocket_router)
app.include_router(callback_router)
app.include_router(tinyurl_router) app.include_router(tinyurl_router)
app.include_router(webpush_router) app.include_router(webpush_router)
app.include_router(users_router) app.include_router(users_router)
app.include_router(audit_router) app.include_router(audit_router)
app.include_router(fiat_router)
__all__ = ["core_app", "core_app_extra", "db"] __all__ = ["core_app", "core_app_extra", "db"]
+57 -66
View File
@@ -85,87 +85,78 @@ from .webpush import (
) )
__all__ = [ __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", "DateTrunc",
"check_internal", "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_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_account",
"delete_accounts_no_wallets", "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",
"get_account_by_email", "get_account_by_email",
"get_account_by_pubkey", "get_account_by_pubkey",
"get_account_by_username", "get_account_by_username",
"get_account_by_username_or_email", "get_account_by_username_or_email",
"get_accounts", "get_accounts",
"get_user", "get_admin_settings",
"get_user_from_account", "get_db_version",
"get_user_access_control_lists", "get_db_versions",
"update_account", "get_installed_extension",
# wallets "get_installed_extensions",
"create_wallet", "get_latest_payments_by_extension",
"delete_unused_wallets", "get_payment",
"delete_wallet", "get_payments",
"delete_wallet_by_id", "get_payments_history",
"force_delete_wallet", "get_payments_paginated",
"get_standalone_payment",
"get_super_settings",
"get_tinyurl",
"get_tinyurl_by_url",
"get_total_balance", "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",
"get_wallet_for_key", "get_wallet_for_key",
"get_wallet_payment",
"get_wallets", "get_wallets",
"remove_deleted_wallets",
"update_wallet",
# webpush
"create_webpush_subscription",
"delete_webpush_subscription",
"delete_webpush_subscriptions",
"get_webpush_subscription", "get_webpush_subscription",
"get_webpush_subscriptions_for_user", "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",
] ]
+2 -2
View File
@@ -1,5 +1,5 @@
from time import time 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.crud.wallets import get_total_balance, get_wallet, get_wallets_ids
from lnbits.core.db import db from lnbits.core.db import db
@@ -394,7 +394,7 @@ async def get_daily_stats(
filters: Optional[Filters[PaymentFilters]] = None, filters: Optional[Filters[PaymentFilters]] = None,
user_id: Optional[str] = None, user_id: Optional[str] = None,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Tuple[list[PaymentDailyStats], list[PaymentDailyStats]]: ) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
if not filters: if not filters:
filters = Filters() filters = Filters()
+14 -8
View File
@@ -68,6 +68,7 @@ async def get_accounts(
accounts.username, accounts.username,
accounts.email, accounts.email,
accounts.pubkey, accounts.pubkey,
accounts.external_id,
SUM(COALESCE(( SUM(COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)) as balance_msat, ), 0)) as balance_msat,
@@ -128,8 +129,8 @@ async def get_account_by_username(
if len(username) == 0: if len(username) == 0:
return None return None
return await (conn or db).fetchone( return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE username = :username", "SELECT * FROM accounts WHERE LOWER(username) = :username",
{"username": username}, {"username": username.lower()},
Account, Account,
) )
@@ -138,8 +139,8 @@ async def get_account_by_pubkey(
pubkey: str, conn: Optional[Connection] = None pubkey: str, conn: Optional[Connection] = None
) -> Optional[Account]: ) -> Optional[Account]:
return await (conn or db).fetchone( return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE pubkey = :pubkey", "SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey",
{"pubkey": pubkey}, {"pubkey": pubkey.lower()},
Account, Account,
) )
@@ -150,8 +151,8 @@ async def get_account_by_email(
if len(email) == 0: if len(email) == 0:
return None return None
return await (conn or db).fetchone( return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE email = :email", "SELECT * FROM accounts WHERE LOWER(email) = :email",
{"email": email}, {"email": email.lower()},
Account, Account,
) )
@@ -160,8 +161,11 @@ async def get_account_by_username_or_email(
username_or_email: str, conn: Optional[Connection] = None username_or_email: str, conn: Optional[Connection] = None
) -> Optional[Account]: ) -> Optional[Account]:
return await (conn or db).fetchone( 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, Account,
) )
@@ -183,6 +187,7 @@ async def get_user_from_account(
email=account.email, email=account.email,
username=account.username, username=account.username,
pubkey=account.pubkey, pubkey=account.pubkey,
external_id=account.external_id,
extra=account.extra, extra=account.extra,
created_at=account.created_at, created_at=account.created_at,
updated_at=account.updated_at, updated_at=account.updated_at,
@@ -190,6 +195,7 @@ async def get_user_from_account(
wallets=wallets, wallets=wallets,
admin=account.is_admin, admin=account.is_admin,
super_user=account.is_super_user, super_user=account.is_super_user,
fiat_providers=account.fiat_providers,
has_password=account.password_hash is not None, has_password=account.password_hash is not None,
) )
+12
View File
@@ -711,3 +711,15 @@ async def m031_add_color_and_icon_to_wallets(db: Connection):
Adds icon and color columns to wallets. Adds icon and color columns to wallets.
""" """
await db.execute("ALTER TABLE wallets ADD COLUMN extra TEXT") 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")
+32 -40
View File
@@ -48,62 +48,54 @@ from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
from .webpush import CreateWebPushSubscription, WebPushSubscription from .webpush import CreateWebPushSubscription, WebPushSubscription
__all__ = [ __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", "AccessTokenPayload",
"Account", "Account",
"AccountFilters", "AccountFilters",
"AccountOverview", "AccountOverview",
"UserAcls", "AuditEntry",
"AuditFilters",
"BalanceDelta",
"BaseWallet",
"Callback",
"ConversionData",
"CoreAppExtra",
"CreateInvoice",
"CreateLnurl",
"CreateLnurlAuth",
"CreatePayment",
"CreateUser", "CreateUser",
"RegisterUser", "CreateWallet",
"CreateWebPushSubscription",
"DbVersion",
"DecodePayment",
"KeyType",
"LoginUsernamePassword", "LoginUsernamePassword",
"LoginUsr", "LoginUsr",
"PayInvoice",
"PayLnurlWData",
"Payment",
"PaymentCountField",
"PaymentCountStat",
"PaymentDailyStats",
"PaymentExtra",
"PaymentFilters",
"PaymentHistoryPoint",
"PaymentState",
"PaymentWalletStats",
"PaymentsStatusCount",
"RegisterUser",
"ResetUserPassword", "ResetUserPassword",
"SimpleStatus",
"TinyURL",
"UpdateBalance", "UpdateBalance",
"UpdateSuperuserPassword", "UpdateSuperuserPassword",
"UpdateUser", "UpdateUser",
"UpdateUserPassword", "UpdateUserPassword",
"UpdateUserPubkey", "UpdateUserPubkey",
"User", "User",
"UserAcls",
"UserExtra", "UserExtra",
# wallets
"BaseWallet",
"CreateWallet",
"KeyType",
"Wallet", "Wallet",
"WalletTypeInfo", "WalletTypeInfo",
# webpush
"CreateWebPushSubscription",
"WebPushSubscription", "WebPushSubscription",
] ]
-11
View File
@@ -1,9 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from typing import Callable from typing import Callable
import bcrypt
from pydantic import BaseModel from pydantic import BaseModel
@@ -11,15 +9,6 @@ def _do_nothing(*_):
pass pass
@dataclass
class SolveBugBcryptWarning:
__version__: str = getattr(bcrypt, "__version__") # noqa
# fix annoying warning in the logs
setattr(bcrypt, "__about__", SolveBugBcryptWarning()) # noqa
class CoreAppExtra: class CoreAppExtra:
register_new_ext_routes: Callable = _do_nothing register_new_ext_routes: Callable = _do_nothing
register_new_ratelimiter: Callable register_new_ratelimiter: Callable
+54 -2
View File
@@ -8,6 +8,13 @@ from fastapi import Query
from pydantic import BaseModel, Field, validator from pydantic import BaseModel, Field, validator
from lnbits.db import FilterModel 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.utils.exchange_rates import allowed_currencies
from lnbits.wallets import get_funding_source from lnbits.wallets import get_funding_source
from lnbits.wallets.base import ( from lnbits.wallets.base import (
@@ -60,6 +67,8 @@ class Payment(BaseModel):
amount: int amount: int
fee: int fee: int
bolt11: str bolt11: str
# payment_request: str | None
fiat_provider: str | None = None
status: str = PaymentState.PENDING status: str = PaymentState.PENDING
memo: str | None = None memo: str | None = None
expiry: datetime | None = None expiry: datetime | None = None
@@ -107,14 +116,23 @@ class Payment(BaseModel):
@property @property
def is_internal(self) -> bool: 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.is_internal:
if self.success: if self.success:
return PaymentSuccessStatus() return PaymentSuccessStatus()
if self.failed: if self.failed:
return PaymentFailedStatus() 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() return PaymentPendingStatus()
funding_source = get_funding_source() funding_source = get_funding_source()
if self.is_out: if self.is_out:
@@ -123,6 +141,39 @@ class Payment(BaseModel):
status = await funding_source.get_invoice_status(self.checking_id) status = await funding_source.get_invoice_status(self.checking_id)
return status 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): class PaymentFilters(FilterModel):
__search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"] __search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"]
@@ -206,6 +257,7 @@ class CreateInvoice(BaseModel):
webhook: str | None = None webhook: str | None = None
bolt11: str | None = None bolt11: str | None = None
lnurl_callback: str | None = None lnurl_callback: str | None = None
fiat_provider: str | None = None
@validator("unit") @validator("unit")
@classmethod @classmethod
+1 -2
View File
@@ -1,5 +1,4 @@
"""Keycloak SSO Login Helper """Keycloak SSO Login Helper"""
"""
from typing import Optional from typing import Optional
+26 -3
View File
@@ -9,7 +9,12 @@ from pydantic import BaseModel, Field
from lnbits.core.models.misc import SimpleItem from lnbits.core.models.misc import SimpleItem
from lnbits.db import FilterModel 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 lnbits.settings import settings
from .wallets import Wallet from .wallets import Wallet
@@ -93,6 +98,7 @@ class UserAcls(BaseModel):
class Account(BaseModel): class Account(BaseModel):
id: str id: str
external_id: str | None = None # for external account linking
username: str | None = None username: str | None = None
password_hash: str | None = None password_hash: str | None = None
pubkey: 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_super_user: bool = Field(default=False, no_database=True)
is_admin: 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): def __init__(self, **data):
super().__init__(**data) super().__init__(**data)
self.is_super_user = settings.is_super_user(self.id) self.is_super_user = settings.is_super_user(self.id)
self.is_admin = settings.is_admin_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: def hash_password(self, password: str) -> str:
"""sets and returns the hashed password""" """sets and returns the hashed password"""
@@ -130,6 +138,11 @@ class Account(BaseModel):
raise ValueError("Invalid email.") raise ValueError("Invalid email.")
if self.pubkey and not is_valid_pubkey(self.pubkey): if self.pubkey and not is_valid_pubkey(self.pubkey):
raise ValueError("Invalid 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) user_uuid4 = UUID(hex=self.id, version=4)
if user_uuid4.hex != self.id: if user_uuid4.hex != self.id:
raise ValueError("User ID is not valid UUID4 hex string.") raise ValueError("User ID is not valid UUID4 hex string.")
@@ -143,7 +156,14 @@ class AccountOverview(Account):
class AccountFilters(FilterModel): class AccountFilters(FilterModel):
__search_fields__ = ["user", "email", "username", "pubkey", "wallet_id"] __search_fields__ = [
"user",
"email",
"username",
"pubkey",
"external_id",
"wallet_id",
]
__sort_fields__ = [ __sort_fields__ = [
"balance_msat", "balance_msat",
"email", "email",
@@ -157,6 +177,7 @@ class AccountFilters(FilterModel):
user: str | None = None user: str | None = None
username: str | None = None username: str | None = None
pubkey: str | None = None pubkey: str | None = None
external_id: str | None = None
wallet_id: str | None = None wallet_id: str | None = None
@@ -167,10 +188,12 @@ class User(BaseModel):
email: str | None = None email: str | None = None
username: str | None = None username: str | None = None
pubkey: str | None = None pubkey: str | None = None
external_id: str | None = None # for external account linking
extensions: list[str] = [] extensions: list[str] = []
wallets: list[Wallet] = [] wallets: list[Wallet] = []
admin: bool = False admin: bool = False
super_user: bool = False super_user: bool = False
fiat_providers: list[str] = []
has_password: bool = False has_password: bool = False
extra: UserExtra = UserExtra() extra: UserExtra = UserExtra()
@@ -207,13 +230,13 @@ class CreateUser(BaseModel):
password: str | None = Query(default=None, min_length=8, max_length=50) 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) password_repeat: str | None = Query(default=None, min_length=8, max_length=50)
pubkey: str = Query(default=None, max_length=64) pubkey: str = Query(default=None, max_length=64)
external_id: str = Query(default=None, max_length=256)
extensions: list[str] | None = None extensions: list[str] | None = None
extra: UserExtra | None = None extra: UserExtra | None = None
class UpdateUser(BaseModel): class UpdateUser(BaseModel):
user_id: str user_id: str
email: str | None = Query(default=None)
username: str | None = Query(default=..., min_length=2, max_length=20) username: str | None = Query(default=..., min_length=2, max_length=20)
extra: UserExtra | None = None extra: UserExtra | None = None
+23 -22
View File
@@ -8,11 +8,15 @@ from .payments import (
calculate_fiat_amounts, calculate_fiat_amounts,
check_transaction_status, check_transaction_status,
check_wallet_limits, check_wallet_limits,
create_fiat_invoice,
create_invoice, create_invoice,
create_wallet_invoice,
fee_reserve, fee_reserve,
fee_reserve_total, fee_reserve_total,
get_payments_daily_stats,
pay_invoice, pay_invoice,
service_fee, service_fee,
update_pending_payment,
update_pending_payments, update_pending_payments,
update_wallet_balance, update_wallet_balance,
) )
@@ -30,36 +34,33 @@ from .users import (
from .websockets import websocket_manager, websocket_updater from .websockets import websocket_manager, websocket_updater
__all__ = [ __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", "calculate_fiat_amounts",
"check_admin_settings",
"check_transaction_status", "check_transaction_status",
"check_wallet_limits", "check_wallet_limits",
"create_invoice",
"fee_reserve",
"fee_reserve_total",
"pay_invoice",
"service_fee",
"update_pending_payments",
"update_wallet_balance",
# settings
"check_webpush_settings", "check_webpush_settings",
"update_cached_settings", "create_fiat_invoice",
# users "create_invoice",
"check_admin_settings",
"create_user_account", "create_user_account",
"create_user_account_no_ckeck", "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_account",
"update_user_extensions", "update_user_extensions",
# websockets "update_wallet_balance",
"websocket_manager", "websocket_manager",
"websocket_updater", "websocket_updater",
] ]
+189
View File
@@ -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 -2
View File
@@ -1,5 +1,4 @@
import asyncio import asyncio
from typing import Tuple
import httpx import httpx
from loguru import logger from loguru import logger
@@ -48,7 +47,7 @@ async def send_nostr_dm(
return dm_event.to_dict() 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("@") identifier, domain = identifier.split("@")
if not identifier or not domain: if not identifier or not domain:
raise ValueError("Invalid NIP5 identifier") raise ValueError("Invalid NIP5 identifier")
+2 -2
View File
@@ -4,7 +4,7 @@ import smtplib
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from http import HTTPStatus from http import HTTPStatus
from typing import Optional, Tuple from typing import Optional
import httpx import httpx
from loguru import logger from loguru import logger
@@ -186,7 +186,7 @@ def is_message_type_enabled(message_type: NotificationType) -> bool:
def _notification_message_to_text( def _notification_message_to_text(
notification_message: NotificationMessage, notification_message: NotificationMessage,
) -> Tuple[str, str]: ) -> tuple[str, str]:
message_type = notification_message.message_type.value message_type = notification_message.message_type.value
meesage_value = NOTIFICATION_TEMPLATES.get(message_type, message_type) meesage_value = NOTIFICATION_TEMPLATES.get(message_type, message_type)
try: try:
+207 -38
View File
@@ -1,8 +1,10 @@
import asyncio import asyncio
import json
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
import httpx
from bolt11 import Bolt11, MilliSatoshi, Tags from bolt11 import Bolt11, MilliSatoshi, Tags
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from bolt11 import encode as bolt11_encode 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.crud.payments import get_daily_stats
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models import PaymentDailyStats, PaymentFilters from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.payments import CreateInvoice
from lnbits.db import Connection, Filters from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError 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.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.crypto import fake_privkey, random_secret_and_hash
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source from lnbits.wallets import fake_wallet, get_funding_source
@@ -94,6 +99,122 @@ async def pay_invoice(
return payment 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( async def create_invoice(
*, *,
wallet_id: str, wallet_id: str,
@@ -226,6 +347,26 @@ def service_fee(amount_msat: int, internal: bool = False) -> int:
return 0 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( async def update_wallet_balance(
wallet: Wallet, wallet: Wallet,
amount: int, amount: int,
@@ -284,10 +425,7 @@ async def update_wallet_balance(
) )
payment.status = PaymentState.SUCCESS payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn) await update_payment(payment, conn=conn)
# notify receiver asynchronously await internal_invoice_queue_put(payment.checking_id)
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(payment.checking_id)
async def check_wallet_limits( async def check_wallet_limits(
@@ -586,41 +724,28 @@ async def _pay_external_invoice(
logger.debug(f"payment timeout, {checking_id} is still pending") logger.debug(f"payment timeout, {checking_id} is still pending")
return payment return payment
if payment_response.checking_id and payment_response.checking_id != checking_id: # payment failed
logger.warning( if (
f"backend sent unexpected checking_id (expected: {checking_id} got:" payment_response.checking_id is None
f" {payment_response.checking_id})" or payment_response.ok is False
) or payment_response.checking_id != 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.status = PaymentState.FAILED payment.status = PaymentState.FAILED
await update_payment(payment, conn=conn) await update_payment(payment, conn=conn)
raise PaymentError( message = payment_response.error_message or "without an error message."
f"Payment failed: {payment_response.error_message}" raise PaymentError(f"Payment failed: {message}", status="failed")
or "Payment failed, but backend didn't give us an error message.",
status="failed", # payment.ok can be True (paid) or None (pending)!
) payment.status = (
else: PaymentState.SUCCESS if payment_response.ok is True else PaymentState.PENDING
logger.warning( )
"didn't receive checking_id from backend, payment may be stuck in" payment.fee = -(abs(payment_response.fee_msat or 0) + abs(service_fee_msat))
f" database: {checking_id}" 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 return payment
@@ -728,3 +853,47 @@ async def _credit_service_fee_wallet(
status=PaymentState.SUCCESS, status=PaymentState.SUCCESS,
conn=conn, 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.",
)
+2 -1
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import traceback import traceback
from typing import Callable, Coroutine from collections.abc import Coroutine
from typing import Callable
from loguru import logger 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>
+9 -1
View File
@@ -115,6 +115,13 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
><q-tooltip v-if="!$q.screen.gt.sm" ><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('exchanges')"></span></q-tooltip ><span v-text="$t('exchanges')"></span></q-tooltip
></q-tab> ></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 <q-tab
name="users" name="users"
icon="group" icon="group"
@@ -183,7 +190,8 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
> >
{% include "admin/_tab_funding.html" %} {% include {% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" "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_extensions.html" %} {% include
"admin/_tab_notifications.html" %} {% include "admin/_tab_notifications.html" %} {% include
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html" "admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
+22
View File
@@ -48,6 +48,17 @@
class="cursor-pointer q-ml-sm" class="cursor-pointer q-ml-sm"
@click="copyText(wallet.adminkey)" @click="copyText(wallet.adminkey)"
></q-icon> ></q-icon>
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
<q-popup-proxy>
<div class="q-pa-md">
<lnbits-qrcode
:value="wallet.adminkey"
:options="{ width: 250 }"
class="rounded-borders"
></lnbits-qrcode>
</div>
</q-popup-proxy>
</q-icon>
</div> </div>
</q-item-section> </q-item-section>
</q-item> </q-item>
@@ -70,6 +81,17 @@
class="cursor-pointer q-ml-sm" class="cursor-pointer q-ml-sm"
@click="copyText(wallet.inkey)" @click="copyText(wallet.inkey)"
></q-icon> ></q-icon>
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
<q-popup-proxy>
<div class="q-pa-md">
<lnbits-qrcode
:value="wallet.inkey"
:options="{ width: 250 }"
class="rounded-borders"
></lnbits-qrcode>
</div>
</q-popup-proxy>
</q-icon>
</div> </div>
</q-item-section> </q-item-section>
</q-item> </q-item>
+17
View File
@@ -272,10 +272,21 @@
class="q-mb-md" class="q-mb-md"
> >
</q-input> </q-input>
<q-input
v-model="user.external_id"
:label="$t('external_id')"
filled
dense
readonly
class="q-mb-md"
>
</q-input>
<q-input <q-input
v-model="user.extra.picture" v-model="user.extra.picture"
:label="$t('picture')" :label="$t('picture')"
filled filled
dense
class="q-mb-md" class="q-mb-md"
> >
</q-input> </q-input>
@@ -548,6 +559,12 @@
</q-select> </q-select>
</div> </div>
</div> </div>
<q-separator></q-separator>
<div class="row q-mb-md q-mt-md">
<q-btn @click="updateAccount" unelevated color="primary">
<span v-text="$t('update_account')"></span>
</q-btn>
</div>
</q-tab-panel> </q-tab-panel>
<q-tab-panel name="api_acls"> <q-tab-panel name="api_acls">
<div class="row q-mb-md"> <div class="row q-mb-md">
+9 -7
View File
@@ -166,11 +166,12 @@
v-if="authAction === 'login' && allowedRegister" v-if="authAction === 'login' && allowedRegister"
class="q-mb-none" class="q-mb-none"
> >
Not registered? Create an Not registered?
<span <a
href="#"
class="text-secondary cursor-pointer" class="text-secondary cursor-pointer"
@click="showRegister('username-password')" @click.prevent="showRegister('username-password')"
>Account</span >Create an Account</a
> >
</p> </p>
<p <p
@@ -182,11 +183,12 @@
<p v-else-if="authAction === 'register'" class="q-mb-none"> <p v-else-if="authAction === 'register'" class="q-mb-none">
<span v-text="$t('existing_account_question')"></span> <span v-text="$t('existing_account_question')"></span>
<span <a
href="#"
class="text-secondary cursor-pointer q-ml-sm" class="text-secondary cursor-pointer q-ml-sm"
@click="showLogin('username-password')" @click.prevent="showLogin('username-password')"
v-text="$t('login')" v-text="$t('login')"
></span> ></a>
</p> </p>
</div> </div>
</username-password> </username-password>
+104 -24
View File
@@ -211,7 +211,12 @@
</q-card> </q-card>
<div id="hiddenQrCodeContainer" style="display: none"> <div id="hiddenQrCodeContainer" style="display: none">
<lnbits-qrcode <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> ></lnbits-qrcode>
</div> </div>
</div> </div>
@@ -619,14 +624,30 @@
:readonly="receive.lnurl && receive.lnurl.fixed" :readonly="receive.lnurl && receive.lnurl.fixed"
></q-input> ></q-input>
{% else %} {% else %}
<q-select <div class="row">
filled <div class="col-10">
dense <q-select
v-model="receive.unit" filled
type="text" dense
:label="$t('unit')" v-model="receive.unit"
:options="receive.units" type="text"
></q-select> :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 <q-input
ref="setAmount" ref="setAmount"
filled filled
@@ -644,9 +665,59 @@
<q-input <q-input
filled filled
dense dense
type="textarea"
rows="2"
v-model.trim="receive.data.memo" v-model.trim="receive.data.memo"
:label="$t('memo')" :label="$t('memo')"
></q-input> ></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"> <div v-if="receive.status == 'pending'" class="row q-mt-lg">
<q-btn <q-btn
unelevated unelevated
@@ -680,7 +751,14 @@
class="q-pa-lg q-pt-xl lnbits__dialog-card" class="q-pa-lg q-pt-xl lnbits__dialog-card"
> >
<div class="text-center q-mb-lg"> <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> <div v-html="invoiceQrCode"></div>
</a> </a>
</div> </div>
@@ -691,25 +769,27 @@
<h5 v-if="receive.unit != 'sat'" class="q-mt-none q-mb-sm"> <h5 v-if="receive.unit != 'sat'" class="q-mt-none q-mb-sm">
<span v-text="formattedSatAmount"></span> <span v-text="formattedSatAmount"></span>
</h5> </h5>
<q-chip v-if="hasNfc" outline square color="positive"> <div v-if="!receive.fiatPaymentReq">
<q-avatar <q-chip v-if="hasNfc" outline square color="positive">
icon="nfc" <q-avatar
color="positive" icon="nfc"
text-color="white" color="positive"
></q-avatar> text-color="white"
<span v-text="$t('nfc_supported')"></span> ></q-avatar>
</q-chip> <span v-text="$t('nfc_supported')"></span>
<span </q-chip>
v-else <span
class="text-caption text-grey" v-else
v-text="$t('nfc_not_supported')" class="text-caption text-grey"
></span> v-text="$t('nfc_not_supported')"
></span>
</div>
</div> </div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
outline outline
color="grey" color="grey"
@click="copyText(receive.paymentReq)" @click="copyText(receive.fiatPaymentReq || receive.paymentReq)"
:label="$t('copy_invoice')" :label="$t('copy_invoice')"
></q-btn> ></q-btn>
<q-btn <q-btn
+50 -8
View File
@@ -232,15 +232,57 @@
/> />
</template> </template>
</q-input> </q-input>
<q-select <q-btn
v-else-if="['status'].includes(col.name)" v-else-if="['status'].includes(col.name)"
v-model="searchData[col.name]" flat
:options="searchOptions[col.name]" dense
@update:model-value="searchPaymentsBy()" :label="$q.screen.gt.md ? 'Status' : null"
:label="col.label" icon="filter_alt"
clearable color="grey"
style="width: 100px" class="text-capitalize"
></q-select> >
<q-menu anchor="top right" self="top start">
<q-item dense>
<q-checkbox
v-model="statusFilters.success"
@click="handleFilterChanged"
label="Success Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="statusFilters.pending"
@click="handleFilterChanged"
label="Pending Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="statusFilters.failed"
@click="handleFilterChanged"
label="Failed Payments"
></q-checkbox>
</q-item>
<q-separator></q-separator>
<q-item dense>
<q-checkbox
v-model="statusFilters.incoming"
@click="handleFilterChanged"
label="Incoming Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="statusFilters.outgoing"
@click="handleFilterChanged"
label="Outgoing Payments"
></q-checkbox>
</q-item>
</q-menu>
<q-tooltip>
<span v-text="$t('filter_payments')"></span>
</q-tooltip>
</q-btn>
<q-select <q-select
v-else-if="['tag'].includes(col.name)" v-else-if="['tag'].includes(col.name)"
v-model="searchData[col.name]" v-model="searchData[col.name]"
+11 -2
View File
@@ -61,7 +61,7 @@
:label="$t('set_password')" :label="$t('set_password')"
v-model="activeUser.setPassword" v-model="activeUser.setPassword"
> >
<q-tooltip>Toggle Admin</q-tooltip> <q-tooltip v-text="$t('set_password_tooltip')"></q-tooltip>
</q-toggle> </q-toggle>
<q-input <q-input
@@ -105,11 +105,12 @@
<q-input <q-input
v-model="activeUser.data.pubkey" v-model="activeUser.data.pubkey"
:label="$t('pubkey')" :label="'Nostr '+ $t('pubkey')"
filled filled
dense dense
class="q-mb-md" class="q-mb-md"
> >
<q-tooltip v-text="$t('nostr_pubkey_tooltip')"></q-tooltip>
</q-input> </q-input>
<q-input <q-input
v-model="activeUser.data.email" v-model="activeUser.data.email"
@@ -146,6 +147,14 @@
class="q-mb-md" class="q-mb-md"
> >
</q-input> </q-input>
<q-input
v-model="activeUser.data.external_id"
:label="$t('external_id')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input <q-input
v-model="activeUser.data.extra.picture" v-model="activeUser.data.extra.picture"
:label="$t('picture')" :label="$t('picture')"
+12 -6
View File
@@ -130,14 +130,20 @@ async def api_lnurlscan(
headers = {"User-Agent": settings.user_agent} headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
check_callback_url(url) check_callback_url(url)
r = await client.get(url, timeout=5) try:
r.raise_for_status() r = await client.get(url, timeout=5)
if r.is_error: r.raise_for_status()
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
raise HTTPException(HTTPStatus.NOT_FOUND, "Not found") from exc
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE, status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={"domain": domain, "message": "failed to get parameters"}, detail={
) "domain": domain,
"message": "failed to get parameters",
},
) from exc
try: try:
data = json.loads(r.text) data = json.loads(r.text)
except json.decoder.JSONDecodeError as exc: except json.decoder.JSONDecodeError as exc:
+1 -13
View File
@@ -411,23 +411,13 @@ async def update(
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
if data.username and not is_valid_username(data.username): if data.username and not is_valid_username(data.username):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
if data.email != user.email:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Email mismatch.",
)
if ( if (
data.username data.username
and user.username != data.username and user.username != data.username
and await get_account_by_username(data.username) and await get_account_by_username(data.username)
): ):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
if (
data.email
and 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) account = await get_account(user.id)
if not account: if not account:
@@ -435,8 +425,6 @@ async def update(
if data.username: if data.username:
account.username = data.username account.username = data.username
if data.email:
account.email = data.email
if data.extra: if data.extra:
account.extra = data.extra account.extra = data.extra
+35
View File
@@ -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}'.",
)
+18
View File
@@ -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)
+2 -2
View File
@@ -1,5 +1,5 @@
from http import HTTPStatus from http import HTTPStatus
from typing import Annotated, List, Optional, Union from typing import Annotated, Optional, Union
from urllib.parse import urlencode, urlparse from urllib.parse import urlencode, urlparse
import httpx import httpx
@@ -70,7 +70,7 @@ async def robots():
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse) @generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
async def extensions(request: Request, user: User = Depends(check_user_exists)): 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] installed_exts_ids = [e.id for e in installed_exts]
installable_exts = await InstallableExtension.get_installable_extensions() installable_exts = await InstallableExtension.get_installable_extensions()
+4 -4
View File
@@ -1,5 +1,5 @@
from http import HTTPStatus from http import HTTPStatus
from typing import List, Optional from typing import Optional
import httpx import httpx
from fastapi import APIRouter, Body, Depends, HTTPException from fastapi import APIRouter, Body, Depends, HTTPException
@@ -91,7 +91,7 @@ async def api_get_info(
@node_router.get("/channels") @node_router.get("/channels")
async def api_get_channels( async def api_get_channels(
node: Node = Depends(require_node), node: Node = Depends(require_node),
) -> Optional[List[NodeChannel]]: ) -> Optional[list[NodeChannel]]:
return await node.get_channels() return await node.get_channels()
@@ -121,7 +121,7 @@ async def api_delete_channel(
output_index: Optional[int], output_index: Optional[int],
force: bool = False, force: bool = False,
node: Node = Depends(require_node), node: Node = Depends(require_node),
) -> Optional[List[NodeChannel]]: ) -> Optional[list[NodeChannel]]:
return await node.close_channel( return await node.close_channel(
short_id, short_id,
( (
@@ -170,7 +170,7 @@ async def api_get_invoices(
@node_router.get("/peers") @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() return await node.get_peers()
+22 -80
View File
@@ -2,7 +2,7 @@ import json
import ssl import ssl
from http import HTTPStatus from http import HTTPStatus
from math import ceil from math import ceil
from typing import List, Optional from typing import Optional
from urllib.parse import urlparse from urllib.parse import urlparse
import httpx import httpx
@@ -34,13 +34,8 @@ from lnbits.core.models import (
PaymentFilters, PaymentFilters,
PaymentHistoryPoint, PaymentHistoryPoint,
PaymentWalletStats, PaymentWalletStats,
Wallet,
) )
from lnbits.core.models.users import User 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.db import Filters, Page
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
@@ -67,9 +62,12 @@ from ..crud import (
get_wallet_for_key, get_wallet_for_key,
) )
from ..services import ( from ..services import (
create_invoice, create_fiat_invoice,
create_wallet_invoice,
fee_reserve_total, fee_reserve_total,
get_payments_daily_stats,
pay_invoice, pay_invoice,
update_pending_payment,
update_pending_payments, update_pending_payments,
) )
@@ -81,7 +79,7 @@ payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
name="Payment List", name="Payment List",
summary="get list of payments", summary="get list of payments",
response_description="list of payments", response_description="list of payments",
response_model=List[Payment], response_model=list[Payment],
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments( async def api_payments(
@@ -100,7 +98,7 @@ async def api_payments(
@payment_router.get( @payment_router.get(
"/history", "/history",
name="Get payments history", name="Get payments history",
response_model=List[PaymentHistoryPoint], response_model=list[PaymentHistoryPoint],
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments_history( async def api_payments_history(
@@ -115,7 +113,7 @@ async def api_payments_history(
@payment_router.get( @payment_router.get(
"/stats/count", "/stats/count",
name="Get payments history for all users", name="Get payments history for all users",
response_model=List[PaymentCountStat], response_model=list[PaymentCountStat],
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments_counting_stats( async def api_payments_counting_stats(
@@ -137,7 +135,7 @@ async def api_payments_counting_stats(
@payment_router.get( @payment_router.get(
"/stats/wallets", "/stats/wallets",
name="Get payments history for all users", name="Get payments history for all users",
response_model=List[PaymentWalletStats], response_model=list[PaymentWalletStats],
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments_wallets_stats( async def api_payments_wallets_stats(
@@ -158,7 +156,7 @@ async def api_payments_wallets_stats(
@payment_router.get( @payment_router.get(
"/stats/daily", "/stats/daily",
name="Get payments history per day", name="Get payments history per day",
response_model=List[PaymentDailyStats], response_model=list[PaymentDailyStats],
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments_daily_stats( async def api_payments_daily_stats(
@@ -198,69 +196,6 @@ async def api_payments_paginated(
return page 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( @payment_router.get(
"/all/paginated", "/all/paginated",
name="Payment List", name="Payment List",
@@ -308,6 +243,7 @@ async def api_payments_create(
invoice_data: CreateInvoice, invoice_data: CreateInvoice,
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> Payment: ) -> Payment:
wallet_id = wallet.wallet.id
if invoice_data.out is True and wallet.key_type == KeyType.admin: if invoice_data.out is True and wallet.key_type == KeyType.admin:
if not invoice_data.bolt11: if not invoice_data.bolt11:
raise HTTPException( raise HTTPException(
@@ -315,21 +251,24 @@ async def api_payments_create(
detail="Missing BOLT11 invoice", detail="Missing BOLT11 invoice",
) )
payment = await pay_invoice( payment = await pay_invoice(
wallet_id=wallet.wallet.id, wallet_id=wallet_id,
payment_request=invoice_data.bolt11, payment_request=invoice_data.bolt11,
extra=invoice_data.extra, extra=invoice_data.extra,
) )
return payment return payment
elif not invoice_data.out: if invoice_data.out:
# invoice key
return await _api_payments_create_invoice(invoice_data, wallet.wallet)
else:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, status_code=HTTPStatus.FORBIDDEN,
detail="Invoice (or Admin) key required.", 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") @payment_router.get("/fee-reserve")
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse: 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, "details": payment}
return {"paid": True, "preimage": payment.preimage} return {"paid": True, "preimage": payment.preimage}
if payment.failed:
return {"paid": False, "status": "failed", "details": payment}
try: try:
status = await payment.check_status() status = await payment.check_status()
except Exception: except Exception:
+4 -2
View File
@@ -2,7 +2,7 @@ import base64
import json import json
import time import time
from http import HTTPStatus from http import HTTPStatus
from typing import List, Optional from typing import Optional
from uuid import uuid4 from uuid import uuid4
import shortuuid import shortuuid
@@ -100,6 +100,7 @@ async def api_create_user(data: CreateUser) -> CreateUser:
username=data.username, username=data.username,
email=data.email, email=data.email,
pubkey=data.pubkey, pubkey=data.pubkey,
external_id=data.external_id,
extra=data.extra, extra=data.extra,
) )
account.validate_fields() account.validate_fields()
@@ -132,6 +133,7 @@ async def api_update_user(
username=data.username, username=data.username,
email=data.email, email=data.email,
pubkey=data.pubkey, pubkey=data.pubkey,
external_id=data.external_id,
extra=data.extra or UserExtra(), extra=data.extra or UserExtra(),
) )
await update_user_account(account) 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") @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) return await get_wallets(user_id)
+16 -3
View File
@@ -1,5 +1,5 @@
from http import HTTPStatus from http import HTTPStatus
from typing import Annotated, Literal, Optional, Type, Union from typing import Annotated, Literal, Optional, Union
import jwt import jwt
from fastapi import Cookie, Depends, Query, Request, Security from fastapi import Cookie, Depends, Query, Request, Security
@@ -28,7 +28,7 @@ from lnbits.core.models import (
WalletTypeInfo, WalletTypeInfo,
) )
from lnbits.db import Connection, Filter, Filters, TFilterModel 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 from lnbits.settings import AuthMethods, settings
oauth2_scheme = OAuth2PasswordBearer( oauth2_scheme = OAuth2PasswordBearer(
@@ -223,7 +223,7 @@ async def check_super_user(user: Annotated[User, Depends(check_user_exists)]) ->
return user return user
def parse_filters(model: Type[TFilterModel]): def parse_filters(model: type[TFilterModel]):
""" """
Parses the query params as filters. Parses the query params as filters.
:param model: model used for validation of filter values :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.") raise HTTPException(HTTPStatus.FORBIDDEN, "Path not allowed.")
if not endpoint.supports_method(method): if not endpoint.supports_method(method):
raise HTTPException(HTTPStatus.FORBIDDEN, "Method not allowed.") 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]
+5 -1
View File
@@ -26,6 +26,10 @@ class InvoiceError(Exception):
self.status = status self.status = status
class UnsupportedError(Exception):
pass
def render_html_error(request: Request, exc: Exception) -> Optional[Response]: def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
# Only the browser sends "text/html" request # Only the browser sends "text/html" request
# not fail proof, but everything else get's a JSON response # 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 status_code = HTTPStatus.NOT_FOUND
message: str = "Page 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 status_code = HTTPStatus.FORBIDDEN
message = f"Extension '{path}' not installed. Ask the admin to install it." message = f"Extension '{path}' not installed. Ask the admin to install it."
+46
View File
@@ -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",
]
+135
View File
@@ -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 ""
+174
View File
@@ -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
View File
@@ -3,7 +3,7 @@ import json
import re import re
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Optional, Type from typing import Any, Optional
from urllib import request from urllib import request
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -17,7 +17,6 @@ from pydantic.schema import field_schema
from lnbits.jinja2_templating import Jinja2Templates from lnbits.jinja2_templating import Jinja2Templates
from lnbits.nodes import get_node_class from lnbits.nodes import get_node_class
from lnbits.requestvars import g
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.crypto import AESCipher 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: 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 = "?" url_params = "?"
for key, value in params.items(): for key, value in params.items():
url_params += f"{key}={value}&" url_params += f"{key}={value}&"
@@ -154,7 +153,7 @@ def get_current_extension_name() -> str:
return ext_name 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 Generate openapi documentation for Filters. This is intended to be used along
parse_filters (see example) parse_filters (see example)
@@ -198,6 +197,14 @@ def is_valid_username(username: str) -> bool:
return re.fullmatch(username_regex, username) is not None 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: def is_valid_pubkey(pubkey: str) -> bool:
if len(pubkey) != 64: if len(pubkey) != 64:
return False return False
@@ -355,3 +362,14 @@ def safe_upload_file_path(filename: str, directory: str = "images") -> Path:
# Prevent filename with subdirectories # Prevent filename with subdirectories
file_path = image_folder / filename.split("/")[-1] file_path = image_folder / filename.split("/")[-1]
return file_path.resolve() 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
View File
@@ -58,9 +58,9 @@ class LnurlErrorResponseHandler(APIRoute):
__all__ = [ __all__ = [
"LnurlErrorResponse",
"LnurlErrorResponseHandler",
"decode", "decode",
"encode", "encode",
"handle", "handle",
"LnurlErrorResponse",
"LnurlErrorResponseHandler",
] ]
+2 -2
View File
@@ -2,7 +2,7 @@ import asyncio
import json import json
from datetime import datetime, timezone from datetime import datetime, timezone
from http import HTTPStatus 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 import FastAPI, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
@@ -61,7 +61,7 @@ class InstalledExtensionMiddleware:
await self.app(scope, receive, send) await self.app(scope, receive, send)
def _response_by_accepted_type( 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]: ) -> Union[HTMLResponse, JSONResponse]:
""" """
Build an HTTP response containing the `msg` as HTTP body and the `status_code` Build an HTTP response containing the `msg` as HTTP body and the `status_code`
-10
View File
@@ -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()
+71
View File
@@ -553,7 +553,9 @@ class BoltzFundingSource(LNbitsSettings):
boltz_client_endpoint: str | None = Field(default="127.0.0.1:9002") boltz_client_endpoint: str | None = Field(default="127.0.0.1:9002")
boltz_client_macaroon: str | None = Field(default=None) boltz_client_macaroon: str | None = Field(default=None)
boltz_client_wallet: str | None = Field(default="lnbits") boltz_client_wallet: str | None = Field(default="lnbits")
boltz_client_password: str = Field(default="")
boltz_client_cert: str | None = Field(default=None) boltz_client_cert: str | None = Field(default=None)
boltz_mnemonic: str | None = Field(default=None)
class StrikeFundingSource(LNbitsSettings): class StrikeFundingSource(LNbitsSettings):
@@ -563,6 +565,34 @@ class StrikeFundingSource(LNbitsSettings):
strike_api_key: str | None = Field(default=None, env="STRIKE_API_KEY") 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): class LightningSettings(LNbitsSettings):
lightning_invoice_expiry: int = Field(default=3600, gt=0) lightning_invoice_expiry: int = Field(default=3600, gt=0)
@@ -595,6 +625,40 @@ class FundingSourcesSettings(
lnbits_funding_source_pay_invoice_wait_seconds: int = Field(default=5, ge=0) 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): class WebPushSettings(LNbitsSettings):
lnbits_webpush_pubkey: str | None = Field(default=None) lnbits_webpush_pubkey: str | None = Field(default=None)
lnbits_webpush_privkey: str | None = Field(default=None) lnbits_webpush_privkey: str | None = Field(default=None)
@@ -767,6 +831,7 @@ class EditableSettings(
SecuritySettings, SecuritySettings,
NotificationsSettings, NotificationsSettings,
FundingSourcesSettings, FundingSourcesSettings,
FiatProvidersSettings,
LightningSettings, LightningSettings,
WebPushSettings, WebPushSettings,
NodeUISettings, NodeUISettings,
@@ -961,6 +1026,12 @@ class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettin
def is_installed_extension_id(self, ext_id: str) -> bool: def is_installed_extension_id(self, ext_id: str) -> bool:
return ext_id in self.lnbits_installed_extensions_ids 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): class SuperSettings(EditableSettings):
super_user: str super_user: str
File diff suppressed because one or more lines are too long
+10 -10
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -338,7 +338,7 @@ window.localisation.br = {
node_balance: 'Saldo do Nó: {balance} sats', node_balance: 'Saldo do Nó: {balance} sats',
lnbits_balance: 'Saldo do LNbits: {balance} sats', lnbits_balance: 'Saldo do LNbits: {balance} sats',
funding_reserve_percent: 'Reserve Percentual: {percent} %', funding_reserve_percent: 'Reserve Percentual: {percent} %',
node_managment: 'Gerenciamento de Nós', node_management: 'Gerenciamento de Nós',
node_management_not_supported: node_management_not_supported:
'Gerenciamento de nó não suportado pela fonte de financiamento ativa', 'Gerenciamento de nó não suportado pela fonte de financiamento ativa',
toggle_node_ui: 'Interface do Nó', toggle_node_ui: 'Interface do Nó',
+1 -1
View File
@@ -320,7 +320,7 @@ window.localisation.cn = {
node_balance: '节点余额:{balance} sats', node_balance: '节点余额:{balance} sats',
lnbits_balance: 'LNbits 余额:{balance} sats', lnbits_balance: 'LNbits 余额:{balance} sats',
funding_reserve_percent: '保留百分比: {percent} %', funding_reserve_percent: '保留百分比: {percent} %',
node_managment: '节点管理', node_management: '节点管理',
node_management_not_supported: '活动资金来源不支持节点管理', node_management_not_supported: '活动资金来源不支持节点管理',
toggle_node_ui: '节点用户界面', toggle_node_ui: '节点用户界面',
toggle_public_node_ui: '公共节点用户界面', toggle_public_node_ui: '公共节点用户界面',
+1 -1
View File
@@ -332,7 +332,7 @@ window.localisation.cs = {
node_balance: 'Stav uzlu: {balance} sats', node_balance: 'Stav uzlu: {balance} sats',
lnbits_balance: 'Zůstatek LNbits: {balance} sats', lnbits_balance: 'Zůstatek LNbits: {balance} sats',
funding_reserve_percent: 'Rezervovat procento: {percent} %', funding_reserve_percent: 'Rezervovat procento: {percent} %',
node_managment: 'Správa uzlů', node_management: 'Správa uzlů',
node_management_not_supported: node_management_not_supported:
'Správa uzlů není podporována aktivním zdrojem financování', 'Správa uzlů není podporována aktivním zdrojem financování',
toggle_node_ui: 'Uživatelské rozhraní uzlu', toggle_node_ui: 'Uživatelské rozhraní uzlu',
+1 -1
View File
@@ -342,7 +342,7 @@ window.localisation.de = {
node_balance: 'Kontostand: {balance} Sats', node_balance: 'Kontostand: {balance} Sats',
lnbits_balance: 'LNbits-Guthaben: {balance} Sats', lnbits_balance: 'LNbits-Guthaben: {balance} Sats',
funding_reserve_percent: 'Reservieren Prozent: {percent} %', funding_reserve_percent: 'Reservieren Prozent: {percent} %',
node_managment: 'Knotenverwaltung', node_management: 'Knotenverwaltung',
node_management_not_supported: node_management_not_supported:
'Knotenverwaltung wird von der aktiven Finanzierungsquelle nicht unterstützt', 'Knotenverwaltung wird von der aktiven Finanzierungsquelle nicht unterstützt',
toggle_node_ui: 'Node-Benutzeroberfläche', toggle_node_ui: 'Node-Benutzeroberfläche',
+47 -3
View File
@@ -22,6 +22,7 @@ window.localisation.en = {
active_channels: 'Active Channels', active_channels: 'Active Channels',
connect_peer: 'Connect Peer', connect_peer: 'Connect Peer',
connect: 'Connect', connect: 'Connect',
reconnect: 'Reconnect',
open_channel: 'Open Channel', open_channel: 'Open Channel',
open: 'Open', open: 'Open',
close_channel: 'Close Channel', close_channel: 'Close Channel',
@@ -60,6 +61,7 @@ window.localisation.en = {
rename_wallet: 'Rename wallet', rename_wallet: 'Rename wallet',
update_name: 'Update name', update_name: 'Update name',
fiat_tracking: 'Fiat tracking', fiat_tracking: 'Fiat tracking',
fiat_providers: 'Fiat providers',
currency: 'Currency', currency: 'Currency',
update_currency: 'Update currency', update_currency: 'Update currency',
press_to_claim: 'Press to claim bitcoin', press_to_claim: 'Press to claim bitcoin',
@@ -141,6 +143,7 @@ window.localisation.en = {
uninstall: 'Uninstall', uninstall: 'Uninstall',
drop_db: 'Remove Data', drop_db: 'Remove Data',
enable: 'Enable', enable: 'Enable',
enabled: 'Enabled',
pay_to_enable: 'Pay To Enable', pay_to_enable: 'Pay To Enable',
enable_extension_details: 'Enable extension for current user', enable_extension_details: 'Enable extension for current user',
disable: 'Disable', disable: 'Disable',
@@ -171,12 +174,33 @@ window.localisation.en = {
payment_hash: 'Payment Hash', payment_hash: 'Payment Hash',
fee: 'Fee', fee: 'Fee',
amount: 'Amount', amount: 'Amount',
amount_limits: 'Amount Limits',
amount_sats: 'Amount (sats)', 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', tag: 'Tag',
unit: 'Unit', unit: 'Unit',
description: 'Description', description: 'Description',
expiry: 'Expiry', expiry: 'Expiry',
webhook: 'Webhook', 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', payment_proof: 'Payment Proof',
update: 'Update', update: 'Update',
update_available: 'Update {version} available!', update_available: 'Update {version} available!',
@@ -328,7 +352,9 @@ window.localisation.en = {
change_password: 'Change Password', change_password: 'Change Password',
update_credentials: 'Update Credentials', update_credentials: 'Update Credentials',
update_pubkey: 'Update Public Key', update_pubkey: 'Update Public Key',
nostr_pubkey_tooltip: "Enter this user's Nostr public key (hex value)",
set_password: 'Set Password', set_password: 'Set Password',
set_password_tooltip: 'Set a password for this user',
invalid_password: 'Password must have at least 8 characters', invalid_password: 'Password must have at least 8 characters',
invalid_password_repeat: 'Passwords do not match', invalid_password_repeat: 'Passwords do not match',
reset_key_generated: 'A reset key has been generated.', reset_key_generated: 'A reset key has been generated.',
@@ -348,12 +374,15 @@ window.localisation.en = {
update_account: 'Update Account', update_account: 'Update Account',
invalid_username: 'Invalid Username', invalid_username: 'Invalid Username',
auth_provider: 'Auth Provider', auth_provider: 'Auth Provider',
external_id: 'External ID',
my_account: 'My Account', my_account: 'My Account',
existing_account_question: 'Already have an account?', existing_account_question: 'Already have an account?',
background_image: 'Background Image', background_image: 'Background Image',
back: 'Back', back: 'Back',
logout: 'Logout', logout: 'Logout',
look_and_feel: 'Look and Feel', look_and_feel: 'Look and Feel',
endpoint: 'Endpoint',
api: 'API',
api_token: 'API Token', api_token: 'API Token',
api_tokens: 'API Tokens', api_tokens: 'API Tokens',
access_control_list: 'Access Control List', access_control_list: 'Access Control List',
@@ -371,6 +400,8 @@ window.localisation.en = {
extension_paid_sats: 'You have already paid {paid_sats} sats.', extension_paid_sats: 'You have already paid {paid_sats} sats.',
release_details_error: 'Cannot get the release details.', release_details_error: 'Cannot get the release details.',
pay_from_wallet: 'Pay from Wallet', pay_from_wallet: 'Pay from Wallet',
pay_with: 'Pay with {provider}',
select_payment_provider: 'Select payment provider',
wallet_required: 'Wallet *', wallet_required: 'Wallet *',
show_qr: 'Show QR', show_qr: 'Show QR',
retry_install: 'Retry Install', retry_install: 'Retry Install',
@@ -383,6 +414,9 @@ window.localisation.en = {
'The {name} extension requires a payment of minimum {amount} sats to enable.', 'The {name} extension requires a payment of minimum {amount} sats to enable.',
hide_empty_wallets: 'Hide empty wallets', hide_empty_wallets: 'Hide empty wallets',
recheck: 'Recheck', recheck: 'Recheck',
check: 'Check',
check_connection: 'Check Connection',
check_webhook: 'Check Webhook',
contributors: 'Contributors', contributors: 'Contributors',
license: 'License', license: 'License',
reset_key: 'Reset Key', reset_key: 'Reset Key',
@@ -444,7 +478,7 @@ window.localisation.en = {
node_balance: 'Node Balance: {balance} sats', node_balance: 'Node Balance: {balance} sats',
lnbits_balance: 'LNbits Balance: {balance} sats', lnbits_balance: 'LNbits Balance: {balance} sats',
funding_reserve_percent: 'Reserve Percent: {percent} %', funding_reserve_percent: 'Reserve Percent: {percent} %',
node_managment: 'Node Management', node_management: 'Node Management',
node_management_not_supported: node_management_not_supported:
'Node Management not supported by active funding source', 'Node Management not supported by active funding source',
toggle_node_ui: 'Node UI', toggle_node_ui: 'Node UI',
@@ -489,7 +523,9 @@ window.localisation.en = {
allowed_currencies_hint: 'Limit the number of available fiat currencies', allowed_currencies_hint: 'Limit the number of available fiat currencies',
default_account_currency: 'Default Account Currency', default_account_currency: 'Default Account Currency',
default_account_currency_hint: 'Default currency for accounting', 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: 'Max Incoming Payment Amount',
max_incoming_payment_amount_desc: max_incoming_payment_amount_desc:
'Maximum amount allowed for generating an invoice', 'Maximum amount allowed for generating an invoice',
@@ -551,6 +587,7 @@ window.localisation.en = {
admin_users_label: 'User ID', admin_users_label: 'User ID',
allowed_users: 'Allowed Users', allowed_users: 'Allowed Users',
allowed_users_hint: 'Only these users can use LNbits', allowed_users_hint: 'Only these users can use LNbits',
allowed_users_hint_feature: 'Only these users can use {feature}',
allowed_users_label: 'User ID', allowed_users_label: 'User ID',
allow_creation_user: 'Allow creation of new users', allow_creation_user: 'Allow creation of new users',
allow_creation_user_desc: 'Allow creation of new users on the index page', allow_creation_user_desc: 'Allow creation of new users on the index page',
@@ -585,5 +622,12 @@ window.localisation.en = {
view_column: 'View wallets as rows', view_column: 'View wallets as rows',
filter_payments: 'Filter payments', filter_payments: 'Filter payments',
filter_date: 'Filter by date', 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'
} }
+1 -1
View File
@@ -343,7 +343,7 @@ window.localisation.es = {
node_balance: 'Balance de Nodo: {balance} sats', node_balance: 'Balance de Nodo: {balance} sats',
lnbits_balance: 'Saldo de LNbits: {balance} sats', lnbits_balance: 'Saldo de LNbits: {balance} sats',
funding_reserve_percent: 'Reserve Porcentaje: {percent} %', funding_reserve_percent: 'Reserve Porcentaje: {percent} %',
node_managment: 'Gestión de nodos', node_management: 'Gestión de nodos',
node_management_not_supported: node_management_not_supported:
'La gestión de nodos no es compatible con la fuente de financiación activa', 'La gestión de nodos no es compatible con la fuente de financiación activa',
toggle_node_ui: 'Interfaz de usuario de nodo', toggle_node_ui: 'Interfaz de usuario de nodo',
+1 -1
View File
@@ -434,7 +434,7 @@ window.localisation.fi = {
node_balance: 'Solmun saldo: {balance} sats', node_balance: 'Solmun saldo: {balance} sats',
lnbits_balance: 'LNbits-saldo: {balance} sat', lnbits_balance: 'LNbits-saldo: {balance} sat',
funding_reserve_percent: 'Omavaraisuusaste: {percent} %', funding_reserve_percent: 'Omavaraisuusaste: {percent} %',
node_managment: 'Solmun hallinta', node_management: 'Solmun hallinta',
node_management_not_supported: node_management_not_supported:
'Solmun hallinta ei ole mahdollista valitun rahoituslähteen kanssa.', 'Solmun hallinta ei ole mahdollista valitun rahoituslähteen kanssa.',
toggle_node_ui: 'Solmun käyttöliittymä', toggle_node_ui: 'Solmun käyttöliittymä',
+1 -1
View File
@@ -346,7 +346,7 @@ window.localisation.fr = {
node_balance: 'Solde du nœud : {balance} sats', node_balance: 'Solde du nœud : {balance} sats',
lnbits_balance: 'Solde LNbits : {balance} sats', lnbits_balance: 'Solde LNbits : {balance} sats',
funding_reserve_percent: 'Pourcentage de Réserve : {percent} %', funding_reserve_percent: 'Pourcentage de Réserve : {percent} %',
node_managment: 'Gestion des nœuds', node_management: 'Gestion des nœuds',
node_management_not_supported: node_management_not_supported:
"La gestion des nœuds n'est pas prise en charge par la source de financement active", "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', toggle_node_ui: 'Interface utilisateur de nœud',
+1 -1
View File
@@ -343,7 +343,7 @@ window.localisation.it = {
node_balance: 'Saldo Nodo: {balance} sats', node_balance: 'Saldo Nodo: {balance} sats',
lnbits_balance: 'Saldo LNbits: {balance} sats', lnbits_balance: 'Saldo LNbits: {balance} sats',
funding_reserve_percent: 'Riserva Percentuale: {percent} %', funding_reserve_percent: 'Riserva Percentuale: {percent} %',
node_managment: 'Gestione dei nodi', node_management: 'Gestione dei nodi',
node_management_not_supported: node_management_not_supported:
'La gestione dei nodi non è supportata dalla fonte di finanziamento attiva.', 'La gestione dei nodi non è supportata dalla fonte di finanziamento attiva.',
toggle_node_ui: 'Interfaccia utente del nodo', toggle_node_ui: 'Interfaccia utente del nodo',
+1 -1
View File
@@ -334,7 +334,7 @@ window.localisation.jp = {
node_balance: 'ノード残高: {balance} サッツ', node_balance: 'ノード残高: {balance} サッツ',
lnbits_balance: 'LNbits残高: {balance} sats', lnbits_balance: 'LNbits残高: {balance} sats',
funding_reserve_percent: '予約パーセント: {percent} %', funding_reserve_percent: '予約パーセント: {percent} %',
node_managment: 'ノード管理', node_management: 'ノード管理',
node_management_not_supported: node_management_not_supported:
'アクティブな資金源ではノード管理がサポートされていません', 'アクティブな資金源ではノード管理がサポートされていません',
toggle_node_ui: 'ノードUI', toggle_node_ui: 'ノードUI',
+1 -1
View File
@@ -331,7 +331,7 @@ window.localisation.kr = {
node_balance: '노드 잔액: {balance} 사토시', node_balance: '노드 잔액: {balance} 사토시',
lnbits_balance: 'LNbits 잔액: {balance} sats', lnbits_balance: 'LNbits 잔액: {balance} sats',
funding_reserve_percent: '예약 비율: {percent} %', funding_reserve_percent: '예약 비율: {percent} %',
node_managment: '노드 관리', node_management: '노드 관리',
node_management_not_supported: node_management_not_supported:
'활성화된 자금 출처에 의해 노드 관리는 지원되지 않습니다.', '활성화된 자금 출처에 의해 노드 관리는 지원되지 않습니다.',
toggle_node_ui: '노드 UI', toggle_node_ui: '노드 UI',
+1 -1
View File
@@ -341,7 +341,7 @@ window.localisation.nl = {
node_balance: 'Node Balans: {balance} sats', node_balance: 'Node Balans: {balance} sats',
lnbits_balance: 'LNbits Saldo: {balance} sats', lnbits_balance: 'LNbits Saldo: {balance} sats',
funding_reserve_percent: 'Reservepercentage: {percent} %', funding_reserve_percent: 'Reservepercentage: {percent} %',
node_managment: 'Nodebeheer', node_management: 'Nodebeheer',
node_management_not_supported: node_management_not_supported:
'Nodebeheer wordt niet ondersteund door de actieve financieringsbron', 'Nodebeheer wordt niet ondersteund door de actieve financieringsbron',
toggle_node_ui: 'Node UI', toggle_node_ui: 'Node UI',
+1 -1
View File
@@ -337,7 +337,7 @@ window.localisation.pi = {
node_balance: 'Node Balance: {balance} doubloons', node_balance: 'Node Balance: {balance} doubloons',
lnbits_balance: "LNbits Balance: {balance} pieces o' eight", lnbits_balance: "LNbits Balance: {balance} pieces o' eight",
funding_reserve_percent: 'Reserve Percent: {percent} %', funding_reserve_percent: 'Reserve Percent: {percent} %',
node_managment: 'Node Management', node_management: 'Node Management',
node_management_not_supported: node_management_not_supported:
'Node Management not be supported by active funding source', 'Node Management not be supported by active funding source',
toggle_node_ui: 'Node Main Deck', toggle_node_ui: 'Node Main Deck',
+1 -1
View File
@@ -337,7 +337,7 @@ window.localisation.pl = {
node_balance: 'Saldo węzła: {balance} sats', node_balance: 'Saldo węzła: {balance} sats',
lnbits_balance: 'Saldo LNbits: {balance} sats', lnbits_balance: 'Saldo LNbits: {balance} sats',
funding_reserve_percent: 'Rezerwa procentowa: {percent} %', funding_reserve_percent: 'Rezerwa procentowa: {percent} %',
node_managment: 'Zarządzanie węzłami', node_management: 'Zarządzanie węzłami',
node_management_not_supported: node_management_not_supported:
'Zarządzanie węzłami nie jest obsługiwane przez aktywne źródło finansowania.', 'Zarządzanie węzłami nie jest obsługiwane przez aktywne źródło finansowania.',
toggle_node_ui: 'Interfejs użytkownika węzła', toggle_node_ui: 'Interfejs użytkownika węzła',
+1 -1
View File
@@ -340,7 +340,7 @@ window.localisation.pt = {
node_balance: 'Saldo do Nó: {balance} sats', node_balance: 'Saldo do Nó: {balance} sats',
lnbits_balance: 'Saldo do LNbits: {balance} sats', lnbits_balance: 'Saldo do LNbits: {balance} sats',
funding_reserve_percent: 'Reserve Percentagem: {percent} %', funding_reserve_percent: 'Reserve Percentagem: {percent} %',
node_managment: 'Gerenciamento de Nós', node_management: 'Gerenciamento de Nós',
node_management_not_supported: node_management_not_supported:
'Gerenciamento de nós não suportado pela fonte de financiamento ativa', 'Gerenciamento de nós não suportado pela fonte de financiamento ativa',
toggle_node_ui: 'Interface do Usuário de Nó', toggle_node_ui: 'Interface do Usuário de Nó',
+1 -1
View File
@@ -335,7 +335,7 @@ window.localisation.sk = {
node_balance: 'Stav uzla: {balance} sats', node_balance: 'Stav uzla: {balance} sats',
lnbits_balance: 'Zostatok LNbits: {balance} sats', lnbits_balance: 'Zostatok LNbits: {balance} sats',
funding_reserve_percent: 'Rezervovať percento: {percent} %', funding_reserve_percent: 'Rezervovať percento: {percent} %',
node_managment: 'Správa uzlov', node_management: 'Správa uzlov',
node_management_not_supported: node_management_not_supported:
'Správa uzlov nie je podporovaná aktívnym zdrojom financovania', 'Správa uzlov nie je podporovaná aktívnym zdrojom financovania',
toggle_node_ui: 'Používateľské rozhranie uzla', toggle_node_ui: 'Používateľské rozhranie uzla',
+1 -1
View File
@@ -335,7 +335,7 @@ window.localisation.we = {
node_balance: 'Cydbwysedd Nôd: {balance} sats', node_balance: 'Cydbwysedd Nôd: {balance} sats',
lnbits_balance: 'Cydbwysedd LNbits: {balance} sats', lnbits_balance: 'Cydbwysedd LNbits: {balance} sats',
funding_reserve_percent: 'Cadw Canran: {percent} %', funding_reserve_percent: 'Cadw Canran: {percent} %',
node_managment: 'Rheoli Nodau', node_management: 'Rheoli Nodau',
node_management_not_supported: node_management_not_supported:
'Nid yw Rheoli Nodau yn cael ei gefnogi gan ffynhonnell ariannu weithredol', 'Nid yw Rheoli Nodau yn cael ei gefnogi gan ffynhonnell ariannu weithredol',
toggle_node_ui: 'Node UI', toggle_node_ui: 'Node UI',
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+32
View File
@@ -54,6 +54,7 @@ window.AdminPageLogic = {
chartReady: false, chartReady: false,
formAddAdmin: '', formAddAdmin: '',
formAddUser: '', formAddUser: '',
formAddStripeUser: '',
hideInputToggle: true, hideInputToggle: true,
formAddExtensionsManifest: '', formAddExtensionsManifest: '',
nostrNotificationIdentifier: '', nostrNotificationIdentifier: '',
@@ -187,6 +188,23 @@ window.AdminPageLogic = {
let allowed_users = this.formData.lnbits_allowed_users let allowed_users = this.formData.lnbits_allowed_users
this.formData.lnbits_allowed_users = allowed_users.filter(u => u !== user) 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() { addIncludePath() {
if (!this.formAddIncludePath) { if (!this.formAddIncludePath) {
return return
@@ -613,6 +631,20 @@ window.AdminPageLogic = {
.catch(LNbits.utils.notifyApiError) .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() { downloadBackup() {
window.open('/admin/api/v1/backup', '_blank') window.open('/admin/api/v1/backup', '_blank')
}, },
+8 -3
View File
@@ -19,14 +19,16 @@ window.LNbits = {
amount, amount,
memo, memo,
unit = 'sat', unit = 'sat',
lnurlCallback = null lnurlCallback = null,
fiatProvider = null
) { ) {
return this.request('post', '/api/v1/payments', wallet.inkey, { return this.request('post', '/api/v1/payments', wallet.inkey, {
out: false, out: false,
amount: amount, amount: amount,
memo: memo, memo: memo,
lnurl_callback: lnurlCallback, lnurl_callback: lnurlCallback,
unit: unit unit: unit,
fiat_provider: fiatProvider
}) })
}, },
payInvoice(wallet, bolt11) { payInvoice(wallet, bolt11) {
@@ -197,6 +199,7 @@ window.LNbits = {
email: data.email, email: data.email,
extensions: data.extensions, extensions: data.extensions,
wallets: data.wallets, wallets: data.wallets,
fiat_providers: data.fiat_providers || [],
super_user: data.super_user, super_user: data.super_user,
extra: data.extra ?? {} extra: data.extra ?? {}
} }
@@ -517,7 +520,9 @@ window.windowMixin = {
this.$q.localStorage.set('lnbits.walletFlip', this.walletFlip) this.$q.localStorage.set('lnbits.walletFlip', this.walletFlip)
}, },
goToWallets() { goToWallets() {
window.location = '/wallets' this.$router.push({
path: '/wallets'
})
}, },
submitAddWallet() { submitAddWallet() {
if ( if (
@@ -8,6 +8,10 @@ window.app.component('lnbits-funding-sources', {
fundingSource => fundingSource[0] === item fundingSource => fundingSource[0] === item
) )
return fundingSource ? fundingSource[1] : item return fundingSource ? fundingSource[1] : item
},
showQRValue(value) {
this.qrValue = value
this.showQRDialog = true
} }
}, },
computed: { computed: {
@@ -17,7 +21,8 @@ window.app.component('lnbits-funding-sources', {
const tmpObj = {} const tmpObj = {}
if (obj !== null) { if (obj !== null) {
for (let [k, v] of Object.entries(obj)) { for (let [k, v] of Object.entries(obj)) {
tmpObj[k] = {label: v, value: null} tmpObj[k] =
typeof v === 'string' ? {label: v, value: null} : v || {}
} }
} }
tmp.push([key, tmpObj]) tmp.push([key, tmpObj])
@@ -31,6 +36,8 @@ window.app.component('lnbits-funding-sources', {
data() { data() {
return { return {
hideInput: true, hideInput: true,
showQRDialog: false,
qrValue: '',
rawFundingSources: [ rawFundingSources: [
['VoidWallet', 'Void Wallet', null], ['VoidWallet', 'Void Wallet', null],
[ [
@@ -138,7 +145,14 @@ window.app.component('lnbits-funding-sources', {
boltz_client_endpoint: 'Endpoint', boltz_client_endpoint: 'Endpoint',
boltz_client_macaroon: 'Admin Macaroon path or hex', boltz_client_macaroon: 'Admin Macaroon path or hex',
boltz_client_cert: 'Certificate path or hex', boltz_client_cert: 'Certificate path or hex',
boltz_client_wallet: 'Wallet Name' boltz_client_wallet: 'Wallet Name',
boltz_client_password: 'Wallet Password (can be empty)',
boltz_mnemonic: {
label: 'Liquid mnemonic (copy into greenwallet)',
readonly: true,
copy: true,
qrcode: true
}
} }
], ],
[ [
+39 -1
View File
@@ -8,9 +8,15 @@ window.PaymentsPageLogic = {
searchData: { searchData: {
wallet_id: null, wallet_id: null,
payment_hash: null, payment_hash: null,
status: null,
memo: null memo: null
}, },
statusFilters: {
success: true,
pending: true,
failed: true,
incoming: true,
outgoing: true
},
chartData: { chartData: {
showPaymentStatus: true, showPaymentStatus: true,
showPaymentTags: true, showPaymentTags: true,
@@ -131,6 +137,7 @@ window.PaymentsPageLogic = {
if (this.searchDate.to) { if (this.searchDate.to) {
filter['time[le]'] = this.searchDate.to + 'T23:59:59' filter['time[le]'] = this.searchDate.to + 'T23:59:59'
} }
this.paymentsTable.filter = filter this.paymentsTable.filter = filter
try { try {
@@ -198,7 +205,38 @@ window.PaymentsPageLogic = {
this.fetchPayments() this.fetchPayments()
}, },
handleFilterChanged() {
const {success, pending, failed, incoming, outgoing} = this.statusFilters
delete this.searchData['status[ne]']
delete this.searchData['status[eq]']
if (success && pending && failed) {
// do nothing, all statuses are selected
} else if (success && pending) {
this.searchData['status[ne]'] = 'failed'
} else if (success && failed) {
this.searchData['status[ne]'] = 'pending'
} else if (failed && pending) {
this.searchData['status[ne]'] = 'success'
} else if (success) {
this.searchData['status[eq]'] = 'success'
} else if (pending) {
this.searchData['status[eq]'] = 'pending'
} else if (failed) {
this.searchData['status[eq]'] = 'failed'
}
delete this.searchData['amount[ge]']
delete this.searchData['amount[le]']
if (incoming && outgoing) {
// do nothing
} else if (incoming) {
this.searchData['amount[ge]'] = '0'
} else if (outgoing) {
this.searchData['amount[le]'] = '0'
}
this.fetchPayments()
},
showDetailsToggle(payment) { showDetailsToggle(payment) {
this.paymentDetails = payment this.paymentDetails = payment
return (this.showDetails = !this.showDetails) return (this.showDetails = !this.showDetails)
+11 -4
View File
@@ -35,6 +35,7 @@ window.WalletPageLogic = {
lnurl: null, lnurl: null,
units: ['sat'], units: ['sat'],
unit: 'sat', unit: 'sat',
fiatProvider: '',
data: { data: {
amount: null, amount: null,
memo: '' memo: ''
@@ -253,12 +254,15 @@ window.WalletPageLogic = {
this.receive.data.amount, this.receive.data.amount,
this.receive.data.memo, this.receive.data.memo,
this.receive.unit, this.receive.unit,
this.receive.lnurl && this.receive.lnurl.callback this.receive.lnurl && this.receive.lnurl.callback,
this.receive.fiatProvider
) )
.then(response => { .then(response => {
this.g.updatePayments = !this.g.updatePayments this.g.updatePayments = !this.g.updatePayments
this.receive.status = 'success' this.receive.status = 'success'
this.receive.paymentReq = response.data.bolt11 this.receive.paymentReq = response.data.bolt11
this.receive.fiatPaymentReq =
response.data.extra?.fiat_payment_request
this.receive.amountMsat = response.data.amount this.receive.amountMsat = response.data.amount
this.receive.paymentHash = response.data.payment_hash this.receive.paymentHash = response.data.payment_hash
if (!this.receive.lnurl) { if (!this.receive.lnurl) {
@@ -338,9 +342,6 @@ window.WalletPageLogic = {
'/api/v1/lnurlscan/' + this.parse.data.request, '/api/v1/lnurlscan/' + this.parse.data.request,
this.g.wallet.adminkey this.g.wallet.adminkey
) )
.catch(err => {
LNbits.utils.notifyApiError(err)
})
.then(response => { .then(response => {
const data = response.data const data = response.data
@@ -378,6 +379,9 @@ window.WalletPageLogic = {
} }
} }
}) })
.catch(err => {
LNbits.utils.notifyApiError(err)
})
}, },
decodeQR(res) { decodeQR(res) {
this.parse.data.request = res[0].rawValue this.parse.data.request = res[0].rawValue
@@ -820,6 +824,9 @@ window.WalletPageLogic = {
}, },
swapBalancePriority() { swapBalancePriority() {
this.isFiatPriority = !this.isFiatPriority this.isFiatPriority = !this.isFiatPriority
this.receive.unit = this.isFiatPriority
? this.g.wallet.currency || 'sat'
: 'sat'
this.$q.localStorage.setItem('lnbits.isFiatPriority', this.isFiatPriority) this.$q.localStorage.setItem('lnbits.isFiatPriority', this.isFiatPriority)
}, },
handleFiatTracking() { handleFiatTracking() {
+32 -18
View File
@@ -2,11 +2,9 @@ import asyncio
import time import time
import traceback import traceback
import uuid import uuid
from collections.abc import Coroutine
from typing import ( from typing import (
Callable, Callable,
Coroutine,
Dict,
List,
Optional, Optional,
) )
@@ -18,11 +16,12 @@ from lnbits.core.crud import (
update_payment, update_payment,
) )
from lnbits.core.models import Payment, PaymentState 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.settings import settings
from lnbits.wallets import get_funding_source from lnbits.wallets import get_funding_source
tasks: List[asyncio.Task] = [] tasks: list[asyncio.Task] = []
unique_tasks: Dict[str, asyncio.Task] = {} unique_tasks: dict[str, asyncio.Task] = {}
def create_task(coro: Coroutine) -> 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) 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 # 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) 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: async def internal_invoice_listener() -> None:
""" """
internal_invoice_queue will be filled directly in core/services.py 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. invoice_listeners from core and extensions.
""" """
payment = await get_standalone_payment(checking_id, incoming=True) payment = await get_standalone_payment(checking_id, incoming=True)
if payment and payment.is_in: if not payment:
status = await payment.check_status() logger.warning(f"No payment found for '{checking_id}'.")
payment.fee = status.fee_msat or 0 return
# only overwrite preimage if status.preimage provides it if not payment.is_in:
payment.preimage = status.preimage or payment.preimage logger.warning(f"Payment '{checking_id}' is not incoming, skipping.")
payment.status = PaymentState.SUCCESS return
await update_payment(payment)
internal = "internal" if is_internal else "" status = await payment.check_status(skip_internal_payment_notifications=True)
logger.success(f"{internal} invoice {checking_id} settled") payment.fee = status.fee_msat or payment.fee
for name, send_chan in invoice_listeners.items(): # only overwrite preimage if status.preimage provides it
logger.trace(f"invoice listeners: sending to `{name}`") payment.preimage = status.preimage or payment.preimage
await send_chan.put(payment) 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)
+51 -6
View File
@@ -998,11 +998,23 @@
v-if="props.row.isIn && props.row.isPending && props.row.bolt11" v-if="props.row.isIn && props.row.isPending && props.row.bolt11"
class="text-center q-my-lg" class="text-center q-my-lg"
> >
<a :href="'lightning:' + props.row.bolt11"> <div v-if="props.row.extra.fiat_payment_request">
<lnbits-qrcode <a
:value="'lightning:' + props.row.bolt11.toUpperCase()" :href="props.row.extra.fiat_payment_request"
></lnbits-qrcode> target="_blank"
</a> >
<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> </div>
</q-card-section> </q-card-section>
<q-card-section> <q-card-section>
@@ -1013,7 +1025,11 @@
" "
outline outline
color="grey" color="grey"
@click="copyText(props.row.bolt11)" @click="
copyText(
props.row.extra.fiat_payment_request || props.row.bolt11
)
"
:label="$t('copy_invoice')" :label="$t('copy_invoice')"
></q-btn> ></q-btn>
<q-btn <q-btn
@@ -1141,12 +1157,41 @@
:type="hideInput ? 'password' : 'text'" :type="hideInput ? 'password' : 'text'"
:label="prop.label" :label="prop.label"
:hint="prop.hint" :hint="prop.hint"
:readonly="prop.readonly || false"
> >
<q-btn
v-if="prop.copy"
@click="copyText(formData[key])"
icon="content_copy"
class="cursor-pointer"
color="grey"
flat
dense
></q-btn>
<q-btn
v-if="prop.qrcode"
@click="showQRValue(formData[key])"
icon="qr_code"
class="cursor-pointer"
color="grey"
flat
dense
></q-btn>
</q-input> </q-input>
</div> </div>
</div> </div>
</div> </div>
</q-list> </q-list>
<q-dialog v-model="showQRDialog">
<q-card class="q-pa-md">
<q-card-section>
<lnbits-qrcode :value="qrValue" :size="200"></lnbits-qrcode>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Close" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</div> </div>
</template> </template>
+52 -1
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import statistics
from typing import Optional from typing import Optional
import httpx import httpx
@@ -187,6 +188,54 @@ def allowed_currencies() -> list[str]:
return list(currencies.keys()) return list(currencies.keys())
def apply_trimmed_mean_filter(
rates: list[tuple[str, float]], threshold_percentage: float = 0.01
) -> list[tuple[str, float]]:
"""
Apply trimmed mean filtering to remove outliers from exchange rates.
Args:
rates: List of (provider_name, rate_value) tuples
threshold_percentage: Percentage threshold for outlier removal (default 1%)
Returns:
Filtered list of rates with outliers removed
"""
if len(rates) < 3:
# Need at least 3 rates to apply filtering
return rates
rates_values = [r[1] for r in rates]
median_value = statistics.median(rates_values)
# Filter out values that are more than threshold_percentage away from median
filtered_rates = []
for rate in rates:
provider_name, value = rate
deviation = abs(value - median_value) / median_value
if deviation <= threshold_percentage:
logger.debug(
f"Keeping {provider_name}: {value} (deviation: {deviation:.4f})"
)
filtered_rates.append(rate)
else:
logger.debug(
f"Removing outlier {provider_name}: {value} "
f"(deviation: {deviation:.4f})"
)
# If we still have at least 2 rates after filtering, use them
if len(filtered_rates) >= 2:
logger.debug(f"Filtered rates: {filtered_rates}")
return filtered_rates
else:
# Fall back to median if filtering removed too many values
logger.debug("Filtering removed too many values, using median instead")
# Find the rate closest to median
closest_rate = min(rates, key=lambda x: abs(x[1] - median_value))
return [closest_rate]
async def btc_rates(currency: str) -> list[tuple[str, float]]: async def btc_rates(currency: str) -> list[tuple[str, float]]:
if currency.upper() not in allowed_currencies(): if currency.upper() not in allowed_currencies():
raise ValueError(f"Currency '{currency}' not allowed.") raise ValueError(f"Currency '{currency}' not allowed.")
@@ -236,7 +285,9 @@ async def btc_rates(currency: str) -> list[tuple[str, float]]:
] ]
results = await asyncio.gather(*calls) results = await asyncio.gather(*calls)
return [r for r in results if r is not None] all_rates = [r for r in results if r is not None]
return apply_trimmed_mean_filter(all_rates)
async def btc_price(currency: str) -> float: async def btc_price(currency: str) -> float:
+7 -7
View File
@@ -2,7 +2,7 @@ import base64
import hashlib import hashlib
import json import json
import re import re
from typing import Dict, Tuple, Union from typing import Union
from urllib.parse import urlparse from urllib.parse import urlparse
import secp256k1 import secp256k1
@@ -13,7 +13,7 @@ from Cryptodome.Util.Padding import pad, unpad
from pynostr.key import PrivateKey from pynostr.key import PrivateKey
def generate_keypair() -> Tuple[str, str]: def generate_keypair() -> tuple[str, str]:
private_key = PrivateKey() private_key = PrivateKey()
public_key = private_key.public_key public_key = private_key.public_key
return private_key.hex(), public_key.hex() return private_key.hex(), public_key.hex()
@@ -82,7 +82,7 @@ def decrypt_content(
return decrypted return decrypted
def verify_event(event: Dict) -> bool: def verify_event(event: dict) -> bool:
""" """
Verify the event signature Verify the event signature
@@ -115,8 +115,8 @@ def verify_event(event: Dict) -> bool:
def sign_event( def sign_event(
event: Dict, account_public_key_hex: str, account_private_key: secp256k1.PrivateKey event: dict, account_public_key_hex: str, account_private_key: secp256k1.PrivateKey
) -> Dict: ) -> dict:
""" """
Signs the event (in place) with the service secret Signs the event (in place) with the service secret
@@ -149,7 +149,7 @@ def sign_event(
return 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. Converts a Python dictionary to a JSON string with compact encoding.
@@ -159,7 +159,7 @@ def json_dumps(data: Union[Dict, list]) -> str:
Returns: Returns:
str: The compact JSON string. 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} data = {k: v for k, v in data.items() if v is not None}
return json.dumps(data, separators=(",", ":"), ensure_ascii=False) return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
+5 -5
View File
@@ -58,17 +58,17 @@ __all__ = [
"BlinkWallet", "BlinkWallet",
"BoltzWallet", "BoltzWallet",
"BreezSdkWallet", "BreezSdkWallet",
"ClicheWallet",
"CoreLightningWallet",
"CLightningWallet", "CLightningWallet",
"ClicheWallet",
"CoreLightningRestWallet", "CoreLightningRestWallet",
"CoreLightningWallet",
"EclairWallet", "EclairWallet",
"FakeWallet", "FakeWallet",
"LNbitsWallet",
"LndWallet",
"LndRestWallet",
"LNPayWallet", "LNPayWallet",
"LNbitsWallet",
"LnTipsWallet", "LnTipsWallet",
"LndRestWallet",
"LndWallet",
"NWCWallet", "NWCWallet",
"OpenNodeWallet", "OpenNodeWallet",
"PhoenixdWallet", "PhoenixdWallet",
+4 -2
View File
@@ -1,11 +1,13 @@
import asyncio import asyncio
import hashlib import hashlib
import json import json
from typing import AsyncGenerator, Optional from collections.abc import AsyncGenerator
from typing import Optional
import httpx import httpx
from loguru import logger from loguru import logger
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings from lnbits.settings import settings
from .base import ( from .base import (
@@ -27,7 +29,7 @@ class AlbyWallet(Wallet):
if not settings.alby_access_token: if not settings.alby_access_token:
raise ValueError("cannot initialize AlbyWallet: missing 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 = { self.auth = {
"Authorization": "Bearer " + settings.alby_access_token, "Authorization": "Bearer " + settings.alby_access_token,
"User-Agent": settings.user_agent, "User-Agent": settings.user_agent,
+2 -15
View File
@@ -2,7 +2,8 @@ from __future__ import annotations
import asyncio import asyncio
from abc import ABC, abstractmethod 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 from loguru import logger
@@ -150,17 +151,3 @@ class Wallet(ABC):
except Exception as exc: except Exception as exc:
logger.error(f"could not get status of invoice {invoice}: '{exc}' ") logger.error(f"could not get status of invoice {invoice}: '{exc}' ")
await asyncio.sleep(5) 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
+6 -4
View File
@@ -1,15 +1,17 @@
import asyncio import asyncio
import hashlib import hashlib
import json import json
from typing import AsyncGenerator, Optional from collections.abc import AsyncGenerator
from typing import Optional
import httpx import httpx
from loguru import logger from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
from websockets.client import WebSocketClientProtocol, connect from websockets.legacy.client import WebSocketClientProtocol, connect
from websockets.typing import Subprotocol from websockets.typing import Subprotocol
from lnbits import bolt11 from lnbits import bolt11
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings from lnbits.settings import settings
from .base import ( from .base import (
@@ -34,13 +36,13 @@ class BlinkWallet(Wallet):
if not settings.blink_token: if not settings.blink_token:
raise ValueError("cannot initialize BlinkWallet: missing 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 = { self.auth = {
"X-API-KEY": settings.blink_token, "X-API-KEY": settings.blink_token,
"User-Agent": settings.user_agent, "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 = { self.ws_auth = {
"type": "connection_init", "type": "connection_init",
"payload": {"X-API-KEY": settings.blink_token}, "payload": {"X-API-KEY": settings.blink_token},
+54 -5
View File
@@ -1,10 +1,12 @@
import asyncio import asyncio
from typing import AsyncGenerator, Optional from collections.abc import AsyncGenerator
from typing import Optional
from bolt11.decode import decode from bolt11.decode import decode
from grpc.aio import AioRpcError from grpc.aio import AioRpcError
from loguru import logger from loguru import logger
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.wallets.boltz_grpc_files import boltzrpc_pb2, boltzrpc_pb2_grpc from lnbits.wallets.boltz_grpc_files import boltzrpc_pb2, boltzrpc_pb2_grpc
from lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc import 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" "cannot initialize BoltzWallet: missing boltz_client_wallet"
) )
self.endpoint = self.normalize_endpoint( self.endpoint = normalize_endpoint(
settings.boltz_client_endpoint, add_proto=True settings.boltz_client_endpoint, add_proto=True
) )
@@ -63,6 +65,27 @@ class BoltzWallet(Wallet):
self.rpc = boltzrpc_pb2_grpc.BoltzStub(channel) self.rpc = boltzrpc_pb2_grpc.BoltzStub(channel)
self.wallet_id: int = 0 self.wallet_id: int = 0
# Auto-create wallet if running in Docker mode
async def _init_boltz_wallet():
try:
wallet_name = settings.boltz_client_wallet or "lnbits"
mnemonic = await self._fetch_wallet(wallet_name)
if mnemonic:
logger.info(
"✅ Mnemonic found for Boltz wallet, saving to settings"
)
settings.boltz_mnemonic = mnemonic
from lnbits.core.crud.settings import set_settings_field
await set_settings_field("boltz_mnemonic", mnemonic)
else:
logger.warning("⚠️ No mnemonic returned from Boltz")
except Exception as e:
logger.error(f"❌ Failed to auto-create Boltz wallet: {e}")
self._init_wallet_task = asyncio.create_task(_init_boltz_wallet())
async def status(self) -> StatusResponse: async def status(self) -> StatusResponse:
try: try:
request = boltzrpc_pb2.GetWalletRequest(name=settings.boltz_client_wallet) request = boltzrpc_pb2.GetWalletRequest(name=settings.boltz_client_wallet)
@@ -70,9 +93,10 @@ class BoltzWallet(Wallet):
request, metadata=self.metadata request, metadata=self.metadata
) )
except AioRpcError as exc: except AioRpcError as exc:
logger.warning(exc)
return StatusResponse( return StatusResponse(
exc.details() "make sure you have macaroon and certificate configured,"
+ " make sure you have macaroon and certificate configured, unless your client runs without", # noqa: E501 "unless your client runs without",
0, 0,
) )
@@ -193,7 +217,10 @@ class BoltzWallet(Wallet):
async def get_payment_status(self, checking_id: str) -> PaymentStatus: async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try: try:
response: boltzrpc_pb2.GetSwapInfoResponse = await self.rpc.GetSwapInfo( response: boltzrpc_pb2.GetSwapInfoResponse = await self.rpc.GetSwapInfo(
boltzrpc_pb2.GetSwapInfoRequest(id=checking_id), metadata=self.metadata boltzrpc_pb2.GetSwapInfoRequest(
payment_hash=bytes.fromhex(checking_id)
),
metadata=self.metadata,
) )
swap = response.swap swap = response.swap
except AioRpcError: except AioRpcError:
@@ -225,3 +252,25 @@ class BoltzWallet(Wallet):
" 5 seconds" " 5 seconds"
) )
await asyncio.sleep(5) await asyncio.sleep(5)
async def _fetch_wallet(self, wallet_name: str) -> Optional[str]:
try:
request = boltzrpc_pb2.GetWalletRequest(name=wallet_name)
response = await self.rpc.GetWallet(request, metadata=self.metadata)
logger.info(f"Wallet '{wallet_name}' already exists with ID {response.id}")
return settings.boltz_mnemonic
except AioRpcError as exc:
details = exc.details() or "unknown error"
if exc.code() != grpc.StatusCode.NOT_FOUND:
logger.error(f"Error checking wallet existence: {details}")
raise
logger.info(f"Creating new wallet '{wallet_name}'")
params = boltzrpc_pb2.WalletParams(
name=wallet_name,
currency=boltzrpc_pb2.LBTC,
password=settings.boltz_client_password,
)
create_request = boltzrpc_pb2.CreateWalletRequest(params=params)
response = await self.rpc.CreateWallet(create_request, metadata=self.metadata)
return response.mnemonic
+198 -20
View File
@@ -1,7 +1,7 @@
syntax = "proto3"; syntax = "proto3";
package boltzrpc; package boltzrpc;
option go_package = "github.com/BoltzExchange/boltz-client/boltzrpc"; option go_package = "github.com/BoltzExchange/boltz-client/v2/pkg/boltzrpc";
import "google/protobuf/empty.proto"; import "google/protobuf/empty.proto";
service Boltz { service Boltz {
@@ -122,6 +122,23 @@ service Boltz {
*/ */
rpc GetWallet (GetWalletRequest) returns (Wallet); rpc GetWallet (GetWalletRequest) returns (Wallet);
/*
Calculates the fee for an equivalent `WalletSend` request.
If `address` is left empty, a dummy swap address will be used, allowing for a fee estimation of a swap lockup transaction.
*/
rpc GetWalletSendFee (WalletSendRequest) returns (WalletSendFee);
/*
Returns recent transactions from a wallet.
*/
rpc ListWalletTransactions (ListWalletTransactionsRequest) returns (ListWalletTransactionsResponse);
/*
Increase the fee of a transaction using RBF.
The transaction has to belong to one of the clients wallets.
*/
rpc BumpTransaction (BumpTransactionRequest) returns (BumpTransactionResponse);
/* /*
Returns the credentials of a wallet. The password will be required if the wallet is encrypted. Returns the credentials of a wallet. The password will be required if the wallet is encrypted.
*/ */
@@ -132,6 +149,16 @@ service Boltz {
*/ */
rpc RemoveWallet (RemoveWalletRequest) returns (RemoveWalletResponse); rpc RemoveWallet (RemoveWalletRequest) returns (RemoveWalletResponse);
/*
Send coins from a wallet. Only the confirmed balance can be spent.
*/
rpc WalletSend (WalletSendRequest) returns (WalletSendResponse);
/*
Get a new address of the wallet.
*/
rpc WalletReceive (WalletReceiveRequest) returns (WalletReceiveResponse);
/* /*
Gracefully stops the daemon. Gracefully stops the daemon.
*/ */
@@ -265,11 +292,12 @@ message SwapInfo {
repeated ChannelId chan_ids = 14; repeated ChannelId chan_ids = 14;
optional string blinding_key = 15; optional string blinding_key = 15;
int64 created_at = 16; int64 created_at = 16;
optional uint64 service_fee = 17; optional int64 service_fee = 17;
optional uint64 onchain_fee = 18; optional uint64 onchain_fee = 18;
// internal wallet which was used to pay the swap // internal wallet which was used to pay the swap
optional uint64 wallet_id = 20; optional uint64 wallet_id = 20;
uint64 tenant_id = 21; uint64 tenant_id = 21;
bool is_auto = 23;
} }
enum SwapType { enum SwapType {
@@ -322,7 +350,8 @@ message ReverseSwapInfo {
string redeem_script = 7; string redeem_script = 7;
string invoice = 8; string invoice = 8;
string claim_address = 9; string claim_address = 9;
int64 onchain_amount = 10; uint64 onchain_amount = 10;
uint64 invoice_amount = 25;
uint32 timeout_block_height = 11; uint32 timeout_block_height = 11;
string lockup_transaction_id = 12; string lockup_transaction_id = 12;
string claim_transaction_id = 13; string claim_transaction_id = 13;
@@ -331,11 +360,12 @@ message ReverseSwapInfo {
optional string blinding_key = 16; optional string blinding_key = 16;
int64 created_at = 17; int64 created_at = 17;
optional int64 paid_at = 23; // the time when the invoice was paid optional int64 paid_at = 23; // the time when the invoice was paid
optional uint64 service_fee = 18; optional int64 service_fee = 18;
optional uint64 onchain_fee = 19; optional uint64 onchain_fee = 19;
optional uint64 routing_fee_msat = 20; optional uint64 routing_fee_msat = 20;
bool external_pay = 21; bool external_pay = 21;
uint64 tenant_id = 22; uint64 tenant_id = 22;
bool is_auto = 24;
} }
message BlockHeights { message BlockHeights {
@@ -406,17 +436,44 @@ enum IncludeSwaps {
AUTO = 2; AUTO = 2;
} }
message AnySwapInfo {
string id = 1;
SwapType type = 2;
Pair pair = 3;
SwapState state = 4;
optional string error = 5;
string status = 6;
// The expected amount to be sent to the lockup address for submarine and chain swaps and
// the invoice amount for reverse swaps.
uint64 from_amount = 7;
// `from_amount` minus the service and network fee.
uint64 to_amount = 13;
int64 created_at = 8;
optional int64 service_fee = 9;
// inclues the routing fee for reverse swaps
optional uint64 onchain_fee = 10;
bool is_auto = 11;
uint64 tenant_id = 12;
}
message ListSwapsRequest { message ListSwapsRequest {
optional Currency from = 1; optional Currency from = 1;
optional Currency to = 2; optional Currency to = 2;
optional SwapState state = 4; optional SwapState state = 4;
IncludeSwaps include = 5; IncludeSwaps include = 5;
optional uint64 limit = 6;
optional uint64 offset = 7;
// wether to return swaps in the shared `all_swaps` list or in the detailed lists.
// the `limit` and `offset` are only considered when `unify` is true.
optional bool unify = 8;
} }
message ListSwapsResponse { message ListSwapsResponse {
repeated SwapInfo swaps = 1; repeated SwapInfo swaps = 1;
repeated CombinedChannelSwapInfo channel_creations = 2; repeated CombinedChannelSwapInfo channel_creations = 2 [deprecated = true];
repeated ReverseSwapInfo reverse_swaps = 3; repeated ReverseSwapInfo reverse_swaps = 3;
repeated ChainSwapInfo chain_swaps = 4; repeated ChainSwapInfo chain_swaps = 4;
// populated when `unify` is set to true in the request
repeated AnySwapInfo all_swaps = 5;
} }
message GetStatsRequest { message GetStatsRequest {
@@ -448,7 +505,12 @@ message ClaimSwapsResponse {
} }
message GetSwapInfoRequest { message GetSwapInfoRequest {
string id = 1; string id = 1 [deprecated = true];
oneof identifier {
string swap_id = 2;
// Only implemented for submarine swaps
bytes payment_hash = 3;
}
} }
message GetSwapInfoResponse { message GetSwapInfoResponse {
SwapInfo swap = 1; SwapInfo swap = 1;
@@ -471,22 +533,29 @@ message DepositResponse {
} }
message CreateSwapRequest { message CreateSwapRequest {
// amount of sats to be received on lightning.
// related: `invoice` field
uint64 amount = 1; uint64 amount = 1;
Pair pair = 2; Pair pair = 2;
// not yet supported
// repeated string chan_ids = 3;
// the daemon will pay the swap using the onchain wallet specified in the `wallet` field or any wallet otherwise. // the daemon will pay the swap using the onchain wallet specified in the `wallet` field
// or the first internal wallet with the correct currency otherwise.
bool send_from_internal = 4; bool send_from_internal = 4;
// address where the coins should go if the swap fails. Refunds will go to any of the daemons wallets otherwise. // address where the coins should go if the swap fails. Refunds will go to any of the daemons wallets otherwise.
optional string refund_address = 5; optional string refund_address = 5;
// wallet to pay swap from. only used if `send_from_internal` is set to true // wallet to pay swap from. only used if `send_from_internal` is set to true
optional uint64 wallet_id = 6; optional uint64 wallet_id = 6;
// invoice to use for the swap. if not set, the daemon will get a new invoice from the lightning node // bolt11 invoice, lnurl, or lnaddress to use for the swap.
// required in standalone mode.
// when connected to a lightning node, a new invoice for `amount` sats will be fetched
// the `amount` field has to be populated in case of a lnurl and lnaddress
optional string invoice = 7; optional string invoice = 7;
// Boltz does not accept 0-conf for Liquid transactions with a fee of 0.01 sat/vByte; optional bool zero_conf = 8 [deprecated = true];
// when `zero_conf` is enabled, a fee of 0.1 sat/vByte will be used for Liquid lockup transactions // Fee rate to use when sending from internal wallet
optional bool zero_conf = 8; optional double sat_per_vbyte = 9;
// Rates to accept for the swap. Queries latest from boltz otherwise
// The recommended way to use this is to pass a user approved value from a previous `GetPairInfo` call
optional PairInfo accepted_pair = 10;
} }
message CreateSwapResponse { message CreateSwapResponse {
string id = 1; string id = 1;
@@ -530,6 +599,16 @@ message CreateReverseSwapRequest {
optional bool external_pay = 8; optional bool external_pay = 8;
// Description of the invoice which will be created for the swap // Description of the invoice which will be created for the swap
optional string description = 9; optional string description = 9;
// Description hash of the invoice which will be created for the swap. Takes precedence over `description`
optional bytes description_hash = 10;
// Expiry of the reverse swap invoice in seconds
optional uint64 invoice_expiry = 11;
// Rates to accept for the swap. Queries latest from boltz otherwise
// The recommended way to use this is to pass a user approved value from a previous `GetPairInfo` call
optional PairInfo accepted_pair = 12;
// The routing fee limit for paying the lightning invoice in ppm (parts per million)
optional uint64 routing_fee_limit_ppm = 13;
} }
message CreateReverseSwapResponse { message CreateReverseSwapResponse {
string id = 1; string id = 1;
@@ -545,7 +624,8 @@ message CreateReverseSwapResponse {
message CreateChainSwapRequest { message CreateChainSwapRequest {
// Amount of satoshis to swap. It is the amount expected to be sent to the lockup address. // Amount of satoshis to swap. It is the amount expected to be sent to the lockup address.
uint64 amount = 1; // If left empty, any amount within the limits will be accepted.
optional uint64 amount = 1;
Pair pair = 2; Pair pair = 2;
// Address where funds will be swept to if the swap succeeds // Address where funds will be swept to if the swap succeeds
optional string to_address = 3; optional string to_address = 3;
@@ -561,9 +641,12 @@ message CreateChainSwapRequest {
optional bool accept_zero_conf = 7; optional bool accept_zero_conf = 7;
// If set, the daemon will not pay the swap from an internal wallet. // If set, the daemon will not pay the swap from an internal wallet.
optional bool external_pay = 8; optional bool external_pay = 8;
// Boltz does not accept 0-conf for Liquid transactions with a fee of 0.01 sat/vByte; optional bool lockup_zero_conf = 9 [deprecated = true];
// when `lockup_zero_conf` is enabled, a fee of 0.1 sat/vByte will be used for Liquid lockup transactions // Fee rate to use when sending from internal wallet
optional bool lockup_zero_conf = 9; optional double sat_per_vbyte = 10;
// Rates to accept for the swap. Queries latest from boltz otherwise
// The recommended way to use this is to pass a user approved value from a previous `GetPairInfo` call
optional PairInfo accepted_pair = 11;
} }
message ChainSwapInfo { message ChainSwapInfo {
@@ -574,7 +657,7 @@ message ChainSwapInfo {
string status = 5; string status = 5;
string preimage = 6; string preimage = 6;
bool is_auto = 8; bool is_auto = 8;
optional uint64 service_fee = 9; optional int64 service_fee = 9;
double service_fee_percent = 10; double service_fee_percent = 10;
optional uint64 onchain_fee = 11; optional uint64 onchain_fee = 11;
int64 created_at = 12; int64 created_at = 12;
@@ -614,9 +697,9 @@ message LightningChannel {
} }
message SwapStats { message SwapStats {
uint64 total_fees = 1; int64 total_fees = 1;
uint64 total_amount = 2; uint64 total_amount = 2;
uint64 avg_fees = 3; int64 avg_fees = 3;
uint64 avg_amount = 4; uint64 avg_amount = 4;
uint64 count = 5; uint64 count = 5;
uint64 success_count = 6; uint64 success_count = 6;
@@ -687,6 +770,75 @@ message GetWalletRequest {
optional uint64 id = 2; optional uint64 id = 2;
} }
message WalletSendFee {
// amount of sats which would be sent
uint64 amount = 1;
uint64 fee = 2;
// the fee rate used for the estimation in sat/vbyte
double fee_rate = 3;
}
message ListWalletTransactionsRequest {
uint64 id = 1;
optional bool exclude_swap_related = 2;
optional uint64 limit = 3;
optional uint64 offset = 4;
}
enum TransactionType {
UNKNOWN = 0;
LOCKUP = 1;
REFUND = 2;
CLAIM = 3;
CONSOLIDATION = 4;
}
message WalletTransaction {
string id = 1;
// balance change of the wallet in satoshis.
// its the sum of all output values minus the sum of all input values which are controlled by the wallet.
// positive values indicate incoming transactions, negative values outgoing transactions
int64 balance_change = 2;
int64 timestamp = 3;
repeated TransactionOutput outputs = 4;
uint32 block_height = 6;
// additional informations about the tx (type, related swaps etc.)
repeated TransactionInfo infos = 7;
}
message BumpTransactionRequest {
oneof previous {
// Id of the transaction to bump. The transaction has to belong to one of the clients wallets
string tx_id = 1;
// Depending on the state of the swap, the lockup, refund or claim transaction will be bumped
string swap_id = 2;
}
// Fee rate for the new transaction. if not specified, the daemon will query the fee rate from the configured provider
// and bump the fee by at least 1 sat/vbyte.
optional double sat_per_vbyte = 3;
}
message BumpTransactionResponse {
string tx_id = 1;
}
message TransactionInfo {
// will be populated for LOCKUP, REFUND and CLAIM
optional string swap_id = 1;
TransactionType type = 2;
}
message TransactionOutput {
string address = 1;
uint64 amount = 2;
// wether the address is controlled by the wallet
bool is_our_address = 3;
}
message ListWalletTransactionsResponse {
repeated WalletTransaction transactions = 1;
}
message GetWalletCredentialsRequest { message GetWalletCredentialsRequest {
uint64 id = 1; uint64 id = 1;
optional string password = 2; optional string password = 2;
@@ -696,6 +848,31 @@ message RemoveWalletRequest {
uint64 id = 1; uint64 id = 1;
} }
message WalletSendRequest {
uint64 id = 1;
string address = 2;
// Amount of satoshis to be sent to 'address`
uint64 amount = 3;
// Fee rate to use for the transaction
optional double sat_per_vbyte = 4;
// Sends all available funds to the address. The `amount` field is ignored.
optional bool send_all = 5;
// whether `address` is the lockup of a swap.
optional bool is_swap_address = 6;
}
message WalletSendResponse {
string tx_id = 1;
}
message WalletReceiveRequest {
uint64 id = 1;
}
message WalletReceiveResponse {
string address = 1;
}
message Wallet { message Wallet {
uint64 id = 1; uint64 id = 1;
@@ -720,6 +897,7 @@ message Subaccount {
Balance balance = 1; Balance balance = 1;
uint64 pointer = 2; uint64 pointer = 2;
string type = 3; string type = 3;
repeated string descriptors = 4;
} }
message RemoveWalletResponse {} message RemoveWalletResponse {}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,3 +1,3 @@
wget https://raw.githubusercontent.com/BoltzExchange/boltz-client/master/boltzrpc/boltzrpc.proto -O lnbits/wallets/boltz_grpc_files/boltzrpc.proto wget https://raw.githubusercontent.com/BoltzExchange/boltz-client/refs/heads/master/pkg/boltzrpc/boltzrpc.proto -O boltz_grpc_files/boltzrpc.proto
poetry run python -m grpc_tools.protoc -I . --python_out=. --grpc_python_out=. --pyi_out=. lnbits/wallets/boltz_grpc_files/boltzrpc.proto poetry run python -m grpc_tools.protoc -I . --python_out=. --grpc_python_out=. --pyi_out=. boltz_grpc_files/boltzrpc.proto
+4 -2
View File
@@ -1,5 +1,7 @@
import base64 import base64
from lnbits.exceptions import UnsupportedError
try: try:
import breez_sdk # type: ignore import breez_sdk # type: ignore
@@ -18,8 +20,9 @@ if not BREEZ_SDK_INSTALLED:
else: else:
import asyncio import asyncio
from collections.abc import AsyncGenerator
from pathlib import Path from pathlib import Path
from typing import AsyncGenerator, Optional from typing import Optional
from loguru import logger from loguru import logger
@@ -34,7 +37,6 @@ else:
PaymentStatus, PaymentStatus,
PaymentSuccessStatus, PaymentSuccessStatus,
StatusResponse, StatusResponse,
UnsupportedError,
Wallet, Wallet,
) )
+4 -2
View File
@@ -1,11 +1,13 @@
import asyncio import asyncio
import hashlib import hashlib
import json import json
from typing import AsyncGenerator, Optional from collections.abc import AsyncGenerator
from typing import Optional
from loguru import logger from loguru import logger
from websocket import create_connection from websocket import create_connection
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings from lnbits.settings import settings
from .base import ( from .base import (
@@ -25,7 +27,7 @@ class ClicheWallet(Wallet):
if not settings.cliche_endpoint: if not settings.cliche_endpoint:
raise ValueError("cannot initialize ClicheWallet: missing 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): async def cleanup(self):
pass pass
+1 -1
View File
@@ -8,6 +8,7 @@ from bolt11.exceptions import Bolt11Exception
from loguru import logger from loguru import logger
from pyln.client import LightningRpc, RpcError from pyln.client import LightningRpc, RpcError
from lnbits.exceptions import UnsupportedError
from lnbits.nodes.cln import CoreLightningNode from lnbits.nodes.cln import CoreLightningNode
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.crypto import random_secret_and_hash from lnbits.utils.crypto import random_secret_and_hash
@@ -20,7 +21,6 @@ from .base import (
PaymentStatus, PaymentStatus,
PaymentSuccessStatus, PaymentSuccessStatus,
StatusResponse, StatusResponse,
UnsupportedError,
Wallet, Wallet,
) )
+4 -3
View File
@@ -9,6 +9,8 @@ from bolt11 import Bolt11Exception
from bolt11.decode import decode from bolt11.decode import decode
from loguru import logger from loguru import logger
from lnbits.exceptions import UnsupportedError
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.crypto import random_secret_and_hash from lnbits.utils.crypto import random_secret_and_hash
@@ -18,7 +20,6 @@ from .base import (
PaymentResponse, PaymentResponse,
PaymentStatus, PaymentStatus,
StatusResponse, StatusResponse,
UnsupportedError,
Wallet, Wallet,
) )
from .macaroon import load_macaroon from .macaroon import load_macaroon
@@ -43,7 +44,7 @@ class CoreLightningRestWallet(Wallet):
"invalid corelightning_rest_macaroon provided" "invalid corelightning_rest_macaroon provided"
) )
self.url = self.normalize_endpoint(settings.corelightning_rest_url) self.url = normalize_endpoint(settings.corelightning_rest_url)
headers = { headers = {
"macaroon": macaroon, "macaroon": macaroon,
"encodingtype": "hex", "encodingtype": "hex",
@@ -217,7 +218,7 @@ class CoreLightningRestWallet(Wallet):
data = exc.response.json() data = exc.response.json()
error_code = int(data["error"]["code"]) error_code = int(data["error"]["code"])
if error_code in self.pay_failure_error_codes: 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) return PaymentResponse(ok=False, error_message=error_message)
error_message = f"REST failed with {data['error']['message']}." error_message = f"REST failed with {data['error']['message']}."
return PaymentResponse(error_message=error_message) return PaymentResponse(error_message=error_message)

Some files were not shown because too many files have changed in this diff Show More