Compare commits

..
1 Commits
Author SHA1 Message Date
dni ⚡andarcbtc d8d898b20b feat: install lnbits.sh bash script (#2684)
Co-authored-by: arcbtc <ben@arc.wales>
2024-09-12 08:04:07 +02:00
501 changed files with 62998 additions and 126582 deletions
-7
View File
@@ -23,10 +23,3 @@ mypy.ini
package-lock.json
package.json
pytest.ini
.mypy_cache
.github
.pytest_cache
.vscode
bin
dist
+69 -197
View File
@@ -1,136 +1,41 @@
#For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/
######################################
###### .env ONLY SETTINGS ############
########### Admin Settings ###########
######################################
# The following settings are ONLY set in your .env file.
# They are NOT managed by the Admin UI and are not stored in the database.
# === First Install Token ===
# If set the user is required to enter this token on the /first_install page
# FIRST_INSTALL_TOKEN="myaccesstoken"
# === Security ===
# When enabled (recommended), auth cookies require HTTPS and SSO will reject insecure HTTP.
AUTH_HTTPS_ONLY=true
# === Logging and Development ===
DEBUG=False
DEBUG_DATABASE=False
BUNDLE_ASSETS=True
# add `?profiler=true` to the url to enable the profiler for that request
PROFILER=False
# logging into LNBITS_DATA_FOLDER/logs/
ENABLE_LOG_TO_FILE=true
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
LOG_ROTATION="100 MB"
LOG_RETENTION="3 months"
# for database cleanup commands
# CLEANUP_WALLETS_DAYS=90
# Hard limit for total created users. Set to 0 to disable the limit.
# LNBITS_MAX_USERS=0
# Hard limit for total installed extensions. Set to 0 to disable the limit.
# LNBITS_MAX_EXTENSIONS=0
# === Admin Settings ===
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available.
# Warning: Enabling this will make LNbits ignore most configurations in file. Only the
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
# The rest of the settings will be stored in your database and you will be able to change them
# only through the Admin UI.
# Disable this and clear `settings` table from database to make LNbits use this config file again.
LNBITS_ADMIN_UI=true
HOST=127.0.0.1
PORT=5000
# VERSION=
# USER_AGENT=
# === LNbits ===
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://...
# for both PostgreSQL and CockroachDB, you'll need to install
# psycopg2 as an additional dependency
LNBITS_DATA_FOLDER="./data"
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
# The extension must be removed from this list in order to not be re-installed.
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
# LNBITS_EXTENSIONS_MANIFESTS="https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json,https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions-trial.json"
# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
# which fundingsources are allowed in the admin ui
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet, SparkWallet, SparkL2Wallet"
# uvicorn variable, allow https behind a proxy
# IMPORTANT: this also needs the webserver to be configured to forward the headers
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
FORWARDED_ALLOW_IPS="*"
# Path where extensions will be installed (defaults to `./lnbits/`).
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
# ID of the super user. The user ID must exist.
# SUPER_USER=""
# LNBITS_TITLE="LNbits API"
# LNBITS_PATH="folder/path"
# === Auth Configurations ===
# Secret Key: will default to the hash of the super user.
# !!!!! It is strongly recommended that you set your own strong random value !!!!
AUTH_SECRET_KEY=""
# === Funding Source ===# How many times to retry connectiong to the Funding Source before defaulting to the VoidWallet
# FUNDING_SOURCE_MAX_RETRIES=4
######################################
###### END .env ONLY SETTINGS ########
######################################
######################################
####### Auth Configurations ##########
######################################
AUTH_TOKEN_EXPIRE_MINUTES=525600
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth, oidc-auth
AUTH_ALLOWED_METHODS="user-id-only, username-password"
# Set this flag if HTTP is used for OAuth
# OAUTHLIB_INSECURE_TRANSPORT="1"
######################################
########### Admin Settings ###########
######################################
# Disable this to make LNbits use this config file again.
LNBITS_ADMIN_UI=false
# Change theme
LNBITS_SITE_TITLE="LNbits"
LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run for yourself, for others, or as part of a stack."
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
# Toggle the background styling on burger menus / drawers
# LNBITS_DEFAULT_BURGER_MENU_BACKGROUND=true
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
HOST=127.0.0.1
PORT=5000
######################################
########## Funding Source ############
######################################
# which fundingsources are allowed in the admin ui
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet"
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
# just so you can see the UI before dealing with this file.
# How many times to retry connectiong to the Funding Source before defaulting to the VoidWallet
# FUNDING_SOURCE_MAX_RETRIES=4
# Invoice expiry for LND, CLN, Eclair, LNbits funding sources
LIGHTNING_INVOICE_EXPIRY=3600
@@ -144,20 +49,6 @@ CLICHE_ENDPOINT=ws://127.0.0.1:12000
SPARK_URL=http://localhost:9737/rpc
SPARK_TOKEN=myaccesstoken
#CLNRest (using runes)
CLNREST_URL=https://127.0.0.1:3010
CLNREST_CA=/home/lightningd/.lightning/bitcoin/ca.pem
CLNREST_CERT=/home/lightningd/.lightning/bitcoin/server.pem
# CLNREST_CA = cat ca.pem | awk '{printf "%s\\n", $0} END {printf "\n"}'
# CLNREST_CERT = cat server.pem | awk '{printf "%s\\n", $0} END {printf "\n"}'
CLNREST_READONLY_RUNE=lightning-cli createrune restrictions='[["method=listfunds", "method=listpays", "method=listinvoices", "method=getinfo", "method=summary", "method=waitanyinvoice"]]' | jq -r .rune
CLNREST_INVOICE_RUNE=lightning-cli createrune restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' | jq -r .rune
CLNREST_PAY_RUNE=lightning-cli createrune restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune
#CLNREST_RENEPAY_RUNE=lightning-cli createrune restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune
#CLNREST_LAST_PAY_INDEX='lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max'
#CLNREST_NODEID=lightning-cli getinfo | jq -r .id # only required for v23.08
# CoreLightningWallet
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
@@ -198,23 +89,9 @@ ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
# BoltzWallet
BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
# HEXSTRING instead of path also possible
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroons/admin.macaroon"
# HEXSTRING instead of path also possible
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert"
# SparkL2Wallet (external sidecar: https://github.com/lnbits/spark_sidecar)
SPARK_L2_NETWORK=MAINNET
SPARK_L2_EXTERNAL_ENDPOINT=http://127.0.0.1:8765
SPARK_L2_EXTERNAL_API_KEY=
# optional tuning
# SPARK_L2_PAY_WAIT_MS=4000
# SPARK_L2_PAY_POLL_MS=500
# SPARK_L2_STREAM_KEEPALIVE_MS=15000
# StrikeWallet
STRIKE_API_ENDPOINT=https://api.strike.me/v1
STRIKE_API_KEY=YOUR_STRIKE_API_KEY
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
BOLTZ_CLIENT_WALLET="lnbits"
# ZBDWallet
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
@@ -256,14 +133,17 @@ BREEZ_GREENLIGHT_SEED=SEED
BREEZ_GREENLIGHT_INVITE_CODE=CODE
BREEZ_GREENLIGHT_DEVICE_KEY="/path/to/breezsdk/device.pem" # or BASE64/HEXSTRING
BREEZ_GREENLIGHT_DEVICE_CERT="/path/to/breezsdk/device.crt" # or BASE64/HEXSTRING
# BREEZ_USE_TRAMPOLINE=true
# BreezLiquidSdkWallet
# get your own api key here https://breez.technology/request-api-key/#contact-us-form-sdk
# or keep the api key empty to use the LNbits key for referrals (API key is not a secret)
# BREEZ_LIQUID_API_KEY=""
BREEZ_LIQUID_SEED="MNEMONIC SEED PHRASE"
# BREEZ_LIQUID_FEE_OFFSET_SAT=50
######################################
####### Auth Configurations ##########
######################################
# Secret Key: will default to the hash of the super user. It is strongly recommended that you set your own value.
AUTH_SECRET_KEY=""
AUTH_TOKEN_EXPIRE_MINUTES=525600
# Possible authorization methods: user-id-only, username-password, google-auth, github-auth, keycloak-auth
AUTH_ALLOWED_METHODS="user-id-only, username-password"
# Set this flag if HTTP is used for OAuth
# OAUTHLIB_INSECURE_TRANSPORT="1"
# Google OAuth Config
# Make sure that the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token
@@ -280,56 +160,15 @@ GITHUB_CLIENT_SECRET=""
KEYCLOAK_CLIENT_ID=""
KEYCLOAK_CLIENT_SECRET=""
KEYCLOAK_DISCOVERY_URL=""
KEYCLOAK_CLIENT_CUSTOM_ORG=""
KEYCLOAK_CLIENT_CUSTOM_ICON=""
# OIDC OAuth Config
# Generic OIDC provider configuration
# Make sure that the redirect URI in your OIDC provider is set to: https://{domain}/api/v1/auth/oidc/token
# Required scopes: openid, email, profile
# The discovery URL must be accessible from your LNbits server
# Always use HTTPS in production environments
# The CUSTOM_ORG and CUSTOM_ICON settings allow you to customize the login button
# For example: "Login via Zitadel" with the Zitadel logo
OIDC_DISCOVERY_URL=""
OIDC_CLIENT_ID=""
OIDC_CLIENT_SECRET=""
OIDC_CLIENT_CUSTOM_ORG=""
OIDC_CLIENT_CUSTOM_ICON=""
# Example OIDC configurations for various providers:
#
# ZITADEL:
# OIDC_DISCOVERY_URL=https://login.yourdomain.de/.well-known/openid-configuration
# OIDC_CLIENT_ID=your-zitadel-client-id@project-id
# OIDC_CLIENT_SECRET=your-zitadel-client-secret
# OIDC_CLIENT_CUSTOM_ORG=Zitadel
# OIDC_CLIENT_CUSTOM_ICON=/static/images/zitadel.png
#
# AUTHENTIK:
# OIDC_DISCOVERY_URL=https://authentik.yourdomain.com/application/o/lnbits/.well-known/openid-configuration
# OIDC_CLIENT_ID=your-authentik-client-id
# OIDC_CLIENT_SECRET=your-authentik-client-secret
# OIDC_CLIENT_CUSTOM_ORG=Authentik
# OIDC_CLIENT_CUSTOM_ICON=/static/images/authentik.png
#
# AUTHELIA:
# OIDC_DISCOVERY_URL=https://auth.yourdomain.com/.well-known/openid-configuration
# OIDC_CLIENT_ID=your-authelia-client-id
# OIDC_CLIENT_SECRET=your-authelia-client-secret
# OIDC_CLIENT_CUSTOM_ORG=Authelia
# OIDC_CLIENT_CUSTOM_ICON=/static/images/authelia.png
#
# OKTA:
# OIDC_DISCOVERY_URL=https://your-domain.okta.com/.well-known/openid-configuration
# OIDC_CLIENT_ID=your-okta-client-id
# OIDC_CLIENT_SECRET=your-okta-client-secret
# OIDC_CLIENT_CUSTOM_ORG=Okta
# OIDC_CLIENT_CUSTOM_ICON=/static/images/okta.png
######################################
# uvicorn variable, uncomment to allow https behind a proxy
# IMPORTANT: this also needs the webserver to be configured to forward the headers
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
# FORWARDED_ALLOW_IPS="*"
# Server security, rate limiting ips, blocked ips, allowed ips
LNBITS_RATE_LIMIT_NO="200"
LNBITS_RATE_LIMIT_UNIT="minute"
@@ -340,10 +179,11 @@ LNBITS_BLOCKED_IPS=""
# if set new users will not be able to create accounts
LNBITS_ALLOWED_USERS=""
LNBITS_ADMIN_USERS=""
# ID of the super user. The user ID must exist.
# SUPER_USER=""
# Extensions only admin can access
LNBITS_ADMIN_EXTENSIONS="ngrok, nostrclient"
LNBITS_ADMIN_EXTENSIONS="ngrok, admin"
# Extensions enabled by default when a user is created
LNBITS_USER_DEFAULT_EXTENSIONS="lnurlp"
@@ -373,6 +213,26 @@ LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
# Hides wallet api, extensions can choose to honor
LNBITS_HIDE_API=false
# LNBITS_EXTENSIONS_MANIFESTS="https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json,https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions-trial.json"
# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
# Path where extensions will be installed (defaults to `./lnbits/`).
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
# The extension must be removed from this list in order to not be re-installed.
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://...
# for both PostgreSQL and CockroachDB, you'll need to install
# psycopg2 as an additional dependency
LNBITS_DATA_FOLDER="./data"
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
# the service fee (in percent)
LNBITS_SERVICE_FEE=0.0
# the wallet where fees go to
@@ -382,12 +242,10 @@ LNBITS_SERVICE_FEE=0.0
# disable fees for internal transactions
# LNBITS_SERVICE_FEE_IGNORE_INTERNAL=true
# The minimum fee reserved per payment (millisats)
# value in millisats
LNBITS_RESERVE_FEE_MIN=2000
# The percentage of the payment amount to reserve for routing fees (percent)
# value in percent
LNBITS_RESERVE_FEE_PERCENT=1.0
# The default time to wait to for status response from the funding source when paying an invoice
LNBITS_FUNDING_SOURCE_PAY_INVOICE_WAIT_SECONDS=5
# limit the maximum balance for each wallet
# throw an error if the wallet attempts to create a new invoice
@@ -402,3 +260,17 @@ LNBITS_FUNDING_SOURCE_PAY_INVOICE_WAIT_SECONDS=5
######################################
###### Logging and Development #######
######################################
DEBUG=false
DEBUG_DATABASE=false
BUNDLE_ASSETS=true
# logging into LNBITS_DATA_FOLDER/logs/
ENABLE_LOG_TO_FILE=true
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
LOG_ROTATION="100 MB"
LOG_RETENTION="3 months"
# for database cleanup commands
# CLEANUP_WALLETS_DAYS=90
+1 -1
View File
@@ -1 +1 @@
custom: https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg
custom: https://demo.lnbits.com/lnurlp/link/fH59GD
+22 -6
View File
@@ -4,7 +4,10 @@ inputs:
python-version:
description: "Python Version"
required: true
default: "3.10"
default: "3.9"
poetry-version:
description: "Poetry Version"
default: "1.7.0"
node-version:
description: "Node Version"
default: "20.x"
@@ -21,16 +24,29 @@ runs:
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
# cache poetry install via pip
cache: "pip"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Set up Poetry ${{ inputs.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
enable-cache: true
python-version: ${{ inputs.python-version }}
poetry-version: ${{ inputs.poetry-version }}
- name: Setup a local virtual environment (if no poetry.toml file)
shell: bash
run: |
poetry config virtualenvs.create true --local
poetry config virtualenvs.in-project true --local
- uses: actions/cache@v4
name: Define a cache for the virtual environment based on the dependencies lock file
with:
path: ./.venv
key: venv-${{ hashFiles('poetry.lock') }}
- name: Install the project dependencies
shell: bash
run: uv sync --locked --all-extras --dev
run: poetry install
- name: Use Node.js ${{ inputs.node-version }}
if: ${{ (inputs.npm == 'true') }}
-80
View File
@@ -1,80 +0,0 @@
#!/bin/sh
LAUNCH_DIR="$PWD"
# Define persistent storage for extracted LNbits
PERSISTENT_DIR="$HOME/.local/share/lnbits"
# Remove existing LNbits directory before extraction
if [ -d "$PERSISTENT_DIR" ]; then
echo "Removing existing LNbits directory..."
rm -rf "$PERSISTENT_DIR"
fi
# Ensure the persistent directory exists
mkdir -p "$PERSISTENT_DIR"
# Extract LNbits from the AppImage if not already extracted
echo "Extracting LNbits to disk for better performance..."
cp -r "$APPDIR/usr/lnbits"/* "$PERSISTENT_DIR/"
chmod +x "$PERSISTENT_DIR/dist/lnbits"
# Check if the directory exists, and create it if it doesn't
if [ ! -d "$LAUNCH_DIR/lnbits/database" ]; then
mkdir -p "$LAUNCH_DIR/lnbits/database"
echo "Created database directory at $LAUNCH_DIR/lnbits/database"
fi
if [ ! -d "$LAUNCH_DIR/lnbits/extensions" ]; then
mkdir -p "$LAUNCH_DIR/lnbits/extensions"
echo "Created extensions directory at $LAUNCH_DIR/lnbits/extensions"
fi
cd "$PERSISTENT_DIR"
# Export the directory as an environment variable for the app
LNBITS_DATA_FOLDER="${LNBITS_DATA_FOLDER:-$LAUNCH_DIR/lnbits/database}"
LNBITS_EXTENSIONS_PATH="${LNBITS_EXTENSIONS_PATH:-$LAUNCH_DIR/lnbits/extensions}"
export LNBITS_DATA_FOLDER
export LNBITS_EXTENSIONS_PATH
# Define the LNbits URL
URL="http://0.0.0.0:5000"
"./dist/lnbits" "$@" &
LNBITS_PID=$!
# Wait for LNbits to be ready before showing the popup
sleep 3
CLOSED=false
# Function to stop LNbits gracefully
kill_lnbits() {
LN_PIDS=$(lsof -t -i:5000 2>/dev/null) # Capture all PIDs
if [ -n "$LN_PIDS" ]; then
echo "Stopping LNbits (PIDs: $LN_PIDS)..."
kill -2 $LN_PIDS # Send SIGINT to all processes on port 5000
CLOSED=true
fi
}
# Show a GUI with a clickable link to open the browser
if command -v zenity >/dev/null 2>&1; then
while [ "$CLOSED" = false ]; do
zenity --info --title="LNbits" --width=400 --text="<b>LNbits is running.</b>\n\n<a href='$URL'>$URL</a>\n\nClick 'Close Server' to stop LNbits." --ok-label="Close Server"
kill_lnbits
sleep 1
done
elif command -v yad >/dev/null 2>&1; then
while [ "$CLOSED" = false ]; do
yad --title="LNbits" --width=400 --text="<b>LNbits is running.</b>\n\n<a href='$URL'>$URL</a>\n\nClick 'Close Server' to stop LNbits." --button="Close Server":0
kill_lnbits
sleep 1
done
else
echo "No GUI tool found. LNbits is running at $URL"
fi
# Ensure the script doesn't hang after closing
if ps -p $LNBITS_PID >/dev/null 2>&1; then
wait $LNBITS_PID 2>/dev/null || true
fi
@@ -1,6 +0,0 @@
[Desktop Entry]
Name=LNbits
Exec=lnbits
Icon=lnbits
Type=Application
Categories=X-Bitcoin;X-LightningNetwork;X-Finance;Network;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

-110
View File
@@ -1,110 +0,0 @@
name: Build LNbits AppImage
on:
workflow_call:
inputs:
tag_name:
description: 'The tag name for the release'
required: true
type: string
workflow_dispatch:
inputs:
tag_name:
description: 'The tag name for the release'
required: true
type: string
jobs:
build-linux-package:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install system deps and uv
run: |
sudo apt-get update
sudo apt-get install -y libfuse2
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
shell: bash
- name: Cache uv and venv
uses: actions/cache@v4
with:
path: |
~/.cache/uv
.venv
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock', 'pyproject.toml') }}
- name: Prepare packaging & clone LNbits
run: |
mv .github/packaging packaging
mkdir -p packaging/linux/AppDir/usr
git clone https://github.com/lnbits/lnbits.git packaging/linux/AppDir/usr/lnbits
shell: bash
- name: Build LNbits binary (uv + PyInstaller)
run: |
cd packaging/linux/AppDir/usr/lnbits
uv sync --all-extras --no-dev
uv pip install pyinstaller
uv run pyinstaller \
--onefile \
--name lnbits \
--hidden-import=embit \
--hidden-import=bitstring.bitstore_bitarray \
--collect-all embit \
--collect-all bitstring \
--collect-all bitarray \
--collect-all lnbits \
--collect-all sqlalchemy \
--collect-all breez_sdk \
--collect-binaries breez_sdk \
--collect-all breez_sdk_liquid \
--collect-binaries breez_sdk_liquid \
--collect-all aiosqlite \
--hidden-import=passlib.handlers.bcrypt \
"$(uv run which lnbits)"
cd ../../../../..
chmod +x packaging/linux/AppDir/AppRun
chmod +x packaging/linux/AppDir/lnbits.desktop
chmod +x packaging/linux/AppDir/usr/lnbits/dist/lnbits
# keep AppDir slim
find packaging/linux/AppDir/usr/lnbits -mindepth 1 -maxdepth 1 \
! -name 'dist' \
! -name 'lnbits' \
-exec rm -rf {} +
# Build AppImage
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x appimagetool-x86_64.AppImage
TAG_NAME=${{ inputs.tag_name }}
APPIMAGE_NAME="LNbits-${TAG_NAME}.AppImage"
./appimagetool-x86_64.AppImage \
--updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" \
packaging/linux/AppDir "$APPIMAGE_NAME"
chmod +x "$APPIMAGE_NAME"
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
# 🔎 quick audit: show glibc versions referenced by the binary
echo "Runner glibc:"
ldd --version | head -n1 || true
echo "Symbols needed by lnbits:"
strings "$APPIMAGE_NAME" | grep -o 'GLIBC_[0-9.]*' | sort -u || true
shell: bash
- name: Upload Linux Release Asset
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ inputs.tag_name }}" "${{ env.APPIMAGE_NAME }}" --clobber
-33
View File
@@ -1,33 +0,0 @@
name: bundle
on:
workflow_call:
jobs:
bundle:
permissions:
contents: write
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.head_ref || github.event.pull_request.head.sha }}
- uses: lnbits/lnbits/.github/actions/prepare@dev
with:
python-version: "3.10"
node-version: "24.x"
npm: true
- name: Build and commit bundle (same-repo PR)
if: github.event.pull_request.head.repo.full_name == github.repository
run: |
make bundle
git config user.name "alan"
git config user.email "alan@lnbits.com"
git add lnbits/static
if git diff --cached --quiet; then
exit 0
fi
git commit -m "chore: make bundle [skip ci]"
git push
- name: Check bundle is up-to-date (fork PR)
if: github.event.pull_request.head.repo.full_name != github.repository
run: make checkbundle
+18 -22
View File
@@ -1,9 +1,14 @@
name: LNbits CI
on:
push:
branches:
- main
- dev
pull_request:
jobs:
lint:
uses: ./.github/workflows/lint.yml
@@ -11,11 +16,11 @@ jobs:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.10", "3.12"]
python-version: ["3.9", "3.10"]
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml
with:
custom-pytest: "uv run pytest tests/api"
custom-pytest: "poetry run pytest tests/api"
python-version: ${{ matrix.python-version }}
db-url: ${{ matrix.db-url }}
secrets:
@@ -25,11 +30,11 @@ jobs:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.10", "3.12"]
python-version: ["3.9", "3.10"]
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml
with:
custom-pytest: "uv run pytest tests/wallets"
custom-pytest: "poetry run pytest tests/wallets"
python-version: ${{ matrix.python-version }}
db-url: ${{ matrix.db-url }}
secrets:
@@ -39,11 +44,11 @@ jobs:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.10", "3.12"]
python-version: ["3.9", "3.10"]
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml
with:
custom-pytest: "uv run pytest tests/unit"
custom-pytest: "poetry run pytest tests/unit"
python-version: ${{ matrix.python-version }}
db-url: ${{ matrix.db-url }}
secrets:
@@ -53,7 +58,7 @@ jobs:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.10", "3.12"]
python-version: ["3.9", "3.10"]
uses: ./.github/workflows/migration.yml
with:
python-version: ${{ matrix.python-version }}
@@ -69,17 +74,10 @@ jobs:
uses: ./.github/workflows/regtest.yml
strategy:
matrix:
python-version: ["3.12"]
backend-wallet-class:
- BoltzWallet
- LndRestWallet
- LndWallet
- CoreLightningWallet
- CoreLightningRestWallet
- LNbitsWallet
- EclairWallet
python-version: ["3.9"]
backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
with:
custom-pytest: "uv run pytest tests/regtest"
custom-pytest: "poetry run pytest tests/regtest"
python-version: ${{ matrix.python-version }}
backend-wallet-class: ${{ matrix.backend-wallet-class }}
secrets:
@@ -89,11 +87,9 @@ jobs:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.12"]
python-version: ["3.9"]
poetry-version: ["1.5.1"]
uses: ./.github/workflows/jmeter.yml
with:
python-version: ${{ matrix.python-version }}
bundle:
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi, regtest, jmeter ]
uses: ./.github/workflows/bundle.yml
poetry-version: ${{ matrix.poetry-version }}
+1 -1
View File
@@ -10,7 +10,7 @@ on:
jobs:
analyze:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
+1 -23
View File
@@ -19,7 +19,7 @@ on:
jobs:
push_to_dockerhub:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -51,25 +51,3 @@ jobs:
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
- name: Build and push boltz
uses: docker/build-push-action@v5
with:
context: docker/boltzclient
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
build-args: LNBITS_TAG=${{ inputs.tag }}
- name: Build and push sparkl2
uses: docker/build-push-action@v5
with:
context: docker/sparkl2
push: true
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits-sparkl2:${{ inputs.tag }}
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
build-args: LNBITS_TAG=${{ inputs.tag }}
+9 -21
View File
@@ -6,12 +6,17 @@ on:
python-version:
description: "Python Version"
required: true
default: "3.10"
default: "3.9"
type: string
poetry-version:
description: "Poetry Version"
required: true
default: "1.5.1"
type: string
jobs:
jmeter:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -22,23 +27,13 @@ jobs:
- name: run LNbits
env:
LNBITS_ADMIN_UI: true
AUTH_HTTPS_ONLY: false
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
run: |
uv run lnbits &
sleep 10
- name: setup java version
run: |
update-java-alternatives --list
sudo update-java-alternatives --set /usr/lib/jvm/temurin-8-jdk-amd64
java -version
poetry run lnbits &
sleep 5
- name: clone lnbits-extensions, install jmeter and run tests
env:
JAVA_HOME: /usr/lib/jvm/temurin-8-jdk-amd64
EXTENSIONS_MANIFEST_PATH: "/lnbits/lnbits-extensions/refs/heads/main/extensions.json"
run: |
git clone https://github.com/lnbits/lnbits-extensions
cd lnbits-extensions
@@ -48,13 +43,6 @@ jobs:
make start-mirror-server
make test
- name: print lnbits log
if: ${{ always() }}
run: |
# catch up time for lnbits
sleep 1
cat data/logs/debug.log
- name: upload jmeter test results
uses: actions/upload-artifact@v4
if: ${{ always() }}
+22 -2
View File
@@ -6,30 +6,50 @@ jobs:
black:
uses: ./.github/workflows/make.yml
strategy:
matrix:
python-version: ["3.9", "3.10"]
with:
make: checkblack
python-version: ${{ matrix.python-version }}
ruff:
uses: ./.github/workflows/make.yml
strategy:
matrix:
python-version: ["3.9", "3.10"]
with:
make: checkruff
python-version: ${{ matrix.python-version }}
mypy:
uses: ./.github/workflows/make.yml
strategy:
matrix:
python-version: ["3.9", "3.10"]
with:
make: mypy
python-version: ${{ matrix.python-version }}
pyright:
uses: ./.github/workflows/make.yml
strategy:
matrix:
python-version: ["3.9", "3.10"]
with:
make: pyright
python-version: ${{ matrix.python-version }}
npm: true
prettier:
uses: ./.github/workflows/make.yml
with:
make: checkprettier
npm: true
poetry:
uses: ./.github/workflows/poetry.yml
bundle:
uses: ./.github/workflows/make.yml
with:
make: checkbundle
npm: true
+3 -3
View File
@@ -14,15 +14,15 @@ on:
python-version:
description: "python version"
type: string
default: "3.12"
default: "3.10"
jobs:
make:
name: ${{ inputs.make }} (${{ inputs.python-version }})
strategy:
matrix:
os-version: ["ubuntu-24.04"]
node-version: ["24.x"]
os-version: ["ubuntu-latest"]
node-version: ["18.x"]
runs-on: ${{ matrix.os-version }}
steps:
- uses: actions/checkout@v4
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
name: migration (${{ inputs.python-version }})
strategy:
matrix:
os-version: ["ubuntu-24.04"]
os-version: ["ubuntu-latest"]
runs-on: ${{ matrix.os-version }}
services:
postgres:
+3 -3
View File
@@ -14,18 +14,18 @@ on:
- 'flake.nix'
- 'flake.lock'
- 'pyproject.toml'
- 'uv.lock'
- 'poetry.lock'
- '.github/workflows/nix.yml'
pull_request:
paths:
- 'flake.nix'
- 'flake.lock'
- 'pyproject.toml'
- 'uv.lock'
- 'poetry.lock'
jobs:
nix:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v27
-24
View File
@@ -1,24 +0,0 @@
name: poetry
on:
workflow_call:
inputs:
python-version:
description: "python version"
type: string
default: "3.10"
jobs:
poetry:
name: run poetry install to check lock file
runs-on: "ubuntu-24.04"
steps:
- uses: actions/checkout@v4
- name: set up python ${{ inputs.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ inputs.python-version }}
- name: install poetry
run: |
curl -sSL https://install.python-poetry.org | python3 -
poetry install
+17 -21
View File
@@ -8,10 +8,10 @@ on:
required: true
type: string
python-version:
default: "3.12"
default: "3.9"
type: string
os-version:
default: "ubuntu-24.04"
default: "ubuntu-latest"
type: string
backend-wallet-class:
required: true
@@ -32,41 +32,37 @@ jobs:
run: |
docker build -t lnbits/lnbits .
- name: Setup Regtest
run: |
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker
chmod +x ./tests
./tests
sudo chmod -R a+rwx .
- uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
- name: Setup Regtest
run: |
cd docker/regtest
chmod +x ./start-regtest
./start-regtest
sudo chmod -R a+rwx .
- name: Run pytest
uses: pavelzw/pytest-action@v2
env:
LNBITS_DATABASE_URL: ${{ inputs.db-url }}
LNBITS_BACKEND_WALLET_CLASS: ${{ inputs.backend-wallet-class }}
LND_REST_ENDPOINT: https://localhost:8081/
LND_REST_CERT: ./docker/regtest/data/lnd-3/tls.cert
LND_REST_MACAROON: ./docker/regtest/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
LND_REST_CERT: ./docker/data/lnd-3/tls.cert
LND_REST_MACAROON: ./docker/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
LND_GRPC_ENDPOINT: localhost
LND_GRPC_PORT: 10009
LND_GRPC_CERT: ./docker/regtest/data/lnd-3/tls.cert
LND_GRPC_MACAROON: ./docker/regtest/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
CORELIGHTNING_RPC: ./docker/regtest/data/clightning-1/regtest/lightning-rpc
LND_GRPC_CERT: docker/data/lnd-3/tls.cert
LND_GRPC_MACAROON: docker/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
CORELIGHTNING_RPC: ./docker/data/clightning-1/regtest/lightning-rpc
CORELIGHTNING_REST_URL: https://localhost:3001
CORELIGHTNING_REST_MACAROON: ./docker/regtest/data/clightning-2-rest/access.macaroon
CORELIGHTNING_REST_CERT: ./docker/regtest/data/clightning-2-rest/certificate.pem
CORELIGHTNING_REST_MACAROON: ./docker/data/clightning-2-rest/access.macaroon
CORELIGHTNING_REST_CERT: ./docker/data/clightning-2-rest/certificate.pem
LNBITS_ENDPOINT: http://localhost:5001
LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee"
ECLAIR_URL: http://127.0.0.1:8082
BOLTZ_CLIENT_ENDPOINT: 127.0.0.1:9002
BOLTZ_MNEMONIC: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
LNBITS_MAX_OUTGOING_PAYMENT_AMOUNT_SATS: 1000000000
LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000
LNBITS_FUNDING_SOURCE_PAY_INVOICE_WAIT_SECONDS: ${{ inputs.backend-wallet-class == 'CoreLightningRestWallet' && 60 || 5 }}
ECLAIR_PASS: lnbits
PYTHONUNBUFFERED: 1
DEBUG: true
+12 -34
View File
@@ -3,7 +3,7 @@ name: release-rc
on:
push:
tags:
- "*-rc[0-9]+"
- "*-rc[0-9]"
permissions:
contents: write
@@ -11,18 +11,17 @@ permissions:
jobs:
release:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create github pre-release
- name: Create github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --prerelease --generate-notes --draft
gh release create "$tag" --generate-notes --prerelease
docker:
if: github.repository == 'lnbits/lnbits'
needs: [ release ]
uses: ./.github/workflows/docker.yml
with:
@@ -31,36 +30,15 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
docker-latest-rc:
if: github.repository == 'lnbits/lnbits'
needs: [ release ]
uses: ./.github/workflows/docker.yml
with:
tag: latest-rc
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi:
if: github.repository == 'lnbits/lnbits'
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Install dependencies for building secp256k1
run: |
sudo apt-get update
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
- uses: actions/checkout@v4
- name: Set up Python 3.10
uses: actions/setup-python@v5
- name: Build and publish to pypi
uses: JRubics/poetry-publish@v1.15
with:
python-version: "3.10"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Build the project
run: uv build
- name: Publish to pypi
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_KEY }}
run: uv publish
appimage:
needs: [ release ]
uses: ./.github/workflows/appimage.yml
with:
tag_name: ${{ github.ref_name }}
pypi_token: ${{ secrets.PYPI_API_KEY }}
+9 -32
View File
@@ -12,7 +12,7 @@ permissions:
jobs:
release:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create github release
@@ -23,7 +23,6 @@ jobs:
gh release create "$tag" --generate-notes --draft
docker:
if: github.repository == 'lnbits/lnbits'
needs: [ release ]
uses: ./.github/workflows/docker.yml
with:
@@ -33,7 +32,6 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
docker-latest:
if: github.repository == 'lnbits/lnbits'
needs: [ release ]
uses: ./.github/workflows/docker.yml
with:
@@ -42,36 +40,15 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
docker-latest-rc:
if: github.repository == 'lnbits/lnbits'
needs: [ release ]
uses: ./.github/workflows/docker.yml
with:
tag: latest-rc
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi:
if: github.repository == 'lnbits/lnbits'
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Install dependencies for building secp256k1
run: |
sudo apt-get update
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
- uses: actions/checkout@v4
- name: Set up Python 3.10
uses: actions/setup-python@v5
- name: Build and publish to pypi
uses: JRubics/poetry-publish@v1.15
with:
python-version: "3.10"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Build the project
run: uv build
- name: Publish to pypi
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_KEY }}
run: uv publish
appimage:
needs: [ release ]
uses: ./.github/workflows/appimage.yml
with:
tag_name: ${{ github.ref_name }}
pypi_token: ${{ secrets.PYPI_API_KEY }}
+2 -2
View File
@@ -8,10 +8,10 @@ on:
required: true
type: string
python-version:
default: "3.12"
default: "3.9"
type: string
os-version:
default: "ubuntu-24.04"
default: "ubuntu-latest"
type: string
db-url:
default: ""
+3 -11
View File
@@ -6,9 +6,7 @@ __pycache__
*$py.class
.mypy_cache
.vscode
.codex
*-lock.json
.python-version
*.egg
*.egg-info
@@ -37,12 +35,10 @@ __bundle__
coverage.xml
node_modules
lnbits/static/bundle.js
lnbits/static/bundle-components.js
lnbits/static/bundle.css
lnbits/static/bundle.min.js.old
lnbits/static/bundle.min.css.old
lnbits/static/bundle-components.min.js.old
lnbits/upgrades
docker
# Nix
*result*
@@ -53,12 +49,8 @@ fly.toml
lnbits-backup.zip
# Ignore extensions (post installable extension PR)
/lnbits/extensions
/lnbits/upgrades/
extensions
upgrades/
# builded python package
dist
# jetbrains
.idea
.venv
-1
View File
@@ -1 +0,0 @@
min-release-age=7
+5 -5
View File
@@ -2,7 +2,7 @@ exclude: '^lnbits/static/bundle.*|^docs/.*|^lnbits/static/vendor/.*|^lnbits/exte
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
rev: v4.3.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
@@ -14,16 +14,16 @@ repos:
- id: mixed-line-ending
- id: check-case-conflict
- repo: https://github.com/psf/black
rev: 26.3.1
rev: 24.2.0
hooks:
- id: black
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.10
rev: v0.3.2
hooks:
- id: ruff
args: [ --fix, --exit-non-zero-on-fix ]
- repo: https://github.com/rbubley/mirrors-prettier
rev: v3.7.4
- repo: https://github.com/pre-commit/mirrors-prettier
rev: "v4.0.0-alpha.8"
hooks:
- id: prettier
types_or: [css, javascript, html, json]
-1
View File
@@ -10,7 +10,6 @@
**/lnbits/static/vendor
**/lnbits/static/bundle.*
**/lnbits/static/bundle-components.*
**/lnbits/static/css/*
flake.lock
-60
View File
@@ -1,60 +0,0 @@
# AGENTS.md - AI Coding Agent Guide for LNbits
This file guides AI coding agents working on LNbits. Keep changes small, verified, and aligned with existing project patterns.
## Core Behavior
- Think before coding. State material assumptions. Ask when ambiguity affects correctness, security, payments, wallets, or data migrations.
- Prefer the simplest implementation that solves the request.
- Make surgical changes. Every changed line should trace back to the task.
- Do not refactor, reformat, rename, or clean adjacent code unless required.
- Remove only dead code or imports created by your own changes.
- Define success criteria for non-trivial work and verify them before reporting done.
## LNbits Architecture
- Keep core lean. Prefer/assess extensions for non-core features.
- Preserve compatibility with existing extensions and wallet backends.
- Follow existing patterns in `lnbits/core`, `lnbits/wallets`, `lnbits/extensions`, and frontend code.
- Use existing CRUD, services, settings, and migration patterns.
- Do not edit generated files, bundled vendor files, or unrelated extension code.
## Security-Sensitive Areas
Be extra cautious with payments, wallet balances, admin routes, keys, LNURL, Bolt11, funding sources, migrations, and authentication.
Do not expose raw stack traces or sensitive values. Do not add synchronous blocking work in hot async paths without justification.
## Commands and Verification
Read `Makefile` before running project commands.
Use Makefile targets instead of hand-written commands when available:
- `make check` for full checks.
- `make test-unit` for unit tests.
- `make test-api` for API tests.
- `make test-wallets` for wallet tests.
- `make checkbundle` when bundled frontend assets may be affected.
- `make format` only when formatting is intended.
Do not run `make test` by default. Use the targeted tests available in the Makefile that are related to the work done, unless the user explicitly asks for broader test coverage.
## Dependencies
Do not add dependencies without approval. If approved, update the correct project files and explain why the dependency is necessary.
## Maintenance
LNbits maintainers own this file. They should update it when the development workflow, architecture, or verification commands materially change.
Do not edit, commit, push, or include changes to this file in a PR as part of normal feature work unless the user explicitly asks for `AGENTS.md` changes.
## Reporting
When finished, report:
- Summary of what changed.
- Files touched.
- Makefile targets or checks run.
- Anything not verified and why.
+20 -10
View File
@@ -1,23 +1,27 @@
FROM python:3.12-slim-bookworm AS builder
FROM python:3.10-slim-bookworm AS builder
RUN apt-get clean
RUN apt-get update
RUN apt-get install -y curl pkg-config build-essential libnss-myhostname automake
RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH"
WORKDIR /app
# Only copy the files required to install the dependencies
COPY pyproject.toml uv.lock ./
RUN touch README.md
COPY pyproject.toml poetry.lock ./
RUN mkdir data
RUN uv sync --all-extras
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
FROM python:3.12-slim-bookworm
RUN poetry install --only main
FROM python:3.10-slim-bookworm
# needed for backups postgresql-client version 14 (pg_dump)
RUN apt-get update && apt-get -y upgrade && \
@@ -28,19 +32,25 @@ RUN apt-get update && apt-get -y upgrade && \
apt-get -y install postgresql-client-14 postgresql-client-common && \
apt-get clean all && rm -rf /var/lib/apt/lists/*
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH"
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH"
WORKDIR /app
COPY . .
COPY --from=builder /app/.venv .venv
RUN uv sync --all-extras
RUN poetry install --only main
ENV LNBITS_PORT="5000"
ENV LNBITS_HOST="0.0.0.0"
EXPOSE 5000
CMD ["sh", "-c", "uv --offline run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
CMD ["sh", "-c", "poetry run lnbits --port $LNBITS_PORT --host $LNBITS_HOST"]
+30 -33
View File
@@ -9,65 +9,61 @@ check: mypy pyright checkblack checkruff checkprettier checkbundle
test: test-unit test-wallets test-api test-regtest
prettier:
uv run ./node_modules/.bin/prettier --write .
poetry run ./node_modules/.bin/prettier --write .
pyright:
uv run ./node_modules/.bin/pyright
poetry run ./node_modules/.bin/pyright
mypy:
uv run mypy
poetry run mypy
black:
uv run black .
poetry run black .
ruff:
uv run ruff check . --fix
poetry run ruff check . --fix
checkruff:
uv run ruff check .
poetry run ruff check .
checkprettier:
uv run ./node_modules/.bin/prettier --check .
poetry run ./node_modules/.bin/prettier --check .
checkblack:
uv run black --check .
poetry run black --check .
checkeditorconfig:
editorconfig-checker
dev:
uv run lnbits --reload
docker:
docker build -t lnbits/lnbits .
poetry run lnbits --reload
test-wallets:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
uv run pytest tests/wallets
poetry run pytest tests/wallets
test-unit:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
uv run pytest tests/unit
poetry run pytest tests/unit
test-api:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
uv run pytest tests/api
poetry run pytest tests/api
test-regtest:
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
rm -rf ./tests/data \
uv run pytest tests/regtest
poetry run pytest tests/regtest
test-migration:
LNBITS_ADMIN_UI=True \
@@ -75,29 +71,27 @@ test-migration:
HOST=0.0.0.0 \
PORT=5002 \
LNBITS_DATA_FOLDER="./tests/data" \
timeout 5s uv run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
HOST=0.0.0.0 \
PORT=5002 \
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
LNBITS_ADMIN_UI=False \
timeout 5s uv run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
uv run python tools/conv.py
poetry run python tools/conv.py
migration:
uv run python tools/conv.py
poetry run python tools/conv.py
openapi:
LNBITS_ADMIN_UI=False \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
HOST=0.0.0.0 \
PORT=5003 \
uv run lnbits &
poetry run lnbits &
sleep 15
curl -s http://0.0.0.0:5003/openapi.json | uv run openapi-spec-validator --errors=all -
curl -s http://0.0.0.0:5003/openapi.json | poetry run openapi-spec-validator --errors=all -
# kill -9 %1
bak:
@@ -109,26 +103,29 @@ sass:
bundle:
npm install
npm run bundle
uv run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
npm run sass
npm run vendor_copy
npm run vendor_json
poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
npm run vendor_bundle_css
npm run vendor_minify_css
npm run vendor_bundle_js
npm run vendor_minify_js
checkbundle:
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
cp lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old
cp lnbits/static/bundle-components.min.js lnbits/static/bundle-components.min.js.old
make bundle
diff -q lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old || exit 1
diff -q lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old || exit 1
diff -q lnbits/static/bundle-components.min.js lnbits/static/bundle-components.min.js.old || exit 1
@echo "Bundle is OK"
rm lnbits/static/bundle.min.js.old
rm lnbits/static/bundle.min.css.old
rm lnbits/static/bundle-components.min.js.old
install-pre-commit-hook:
@echo "Installing pre-commit hook to git"
@echo "Uninstall the hook with uv run pre-commit uninstall"
uv run pre-commit install
@echo "Uninstall the hook with poetry run pre-commit uninstall"
poetry run pre-commit install
pre-commit:
uv run pre-commit run --all-files
poetry run pre-commit run --all-files
+30 -44
View File
@@ -1,52 +1,44 @@
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/logos/lnbits-full-inverse.svg">
<img src="docs/logos/lnbits-full.svg" alt="LNbits" style="width:300px">
</picture>
</a>
<picture >
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png" style="width:300px">
<img src="https://i.imgur.com/fyKPgVT.png" style="width:300px">
</picture>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
<img alt="lnbits_head" src="docs/assets/header.jpg" />
[![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=6b7280&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
![phase: beta](https://img.shields.io/badge/phase-beta-C41E3A) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-08A04B) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
![Lightning network wallet](https://i.imgur.com/DeIiO0y.png)
# LNbits — The most powerful Bitcoin & Lightning toolkit
# The world's most powerful suite of bitcoin tools.
> Run it for yourself, for your community, or as part of a larger stack.
## Run for yourself, for others, or as part of a stack.
## What is LNbits?
LNbits is beta, for responsible disclosure of any concerns please contact an admin in the community chat.
LNbits is a lightweight Python server that sits on top of your Lightning funding source. It gives you safe, isolated wallets, a clean API, and an extension system for rapidly adding features - without locking you into a single node implementation. The Inspiration for LNBits came from ideas pioneered by **OpenNode** and **LNPay** — both today work as funding sources for LNbits.
LNbits is a Python server that sits on top of any funding source. It can be used as:
## What you can do with LNbits
- Accounts system to mitigate the risk of exposing applications to your full balance via unique API keys for each wallet
- Extendable platform for exploring Lightning network functionality via the LNbits extension framework
- Part of a development stack via LNbits API
- Fallback wallet for the LNURL scheme
- Instant wallet for LN demonstrations
- **Harden app security:** Create per-wallet API keys so individual apps never touch your full balance.
- **Extend functionality fast:** Install extensions to explore and ship Lightning features with minimal code.
- **Build into your stack:** Use the LNbits HTTP API to integrate payments, wallets, and accounting.
- **Cover LNURL flows:** Use LNbits as a reliable fallback wallet for LNURL.
- **Demo in minutes:** Spin up instant wallets for workshops, proofs-of-concept, and user testing.
LNbits can run on top of almost all Lightning funding sources.
## Funding sources
See [LNbits manual](https://docs.lnbits.org/guide/wallets.html) for more detailed documentation about each funding source.
LNbits runs on top of most Lightning backends. Choose the one you already operate - or swap later without changing your app architecture.
Checkout the LNbits [YouTube](https://www.youtube.com/playlist?list=PLPj3KCksGbSYG0ciIQUWJru1dWstPHshe) video series.
- Read the [funding source guide](https://docs.lnbits.org/guide/wallets.html)
## Learn more
- Video series on [Youtube](https://www.youtube.com/@lnbits)
- Introduction Video [LNBits V1](https://www.youtube.com/watch?v=PFAHKxvgI9Y&t=19s)
LNbits is inspired by all the great work of [opennode.com](https://www.opennode.com/), and in particular [lnpay.co](https://lnpay.co/). Both work as funding sources for LNbits.
## Running LNbits
See the [install guide](https://github.com/lnbits/lnbits/blob/main/docs/guide/installation.md) for details on installation and setup.
Test on our demo server [demo.lnbits.com](https://demo.lnbits.com), or on [lnbits.com](https://lnbits.com) software as a service, where you can spin up an LNbits instance for 21sats per hr.
Get yourself familiar and test on our demo server [demo.lnbits.com](https://demo.lnbits.com), or on [lnbits.com](https://lnbits.com) software as a service, where you can spin up an LNbits instance for 21sats per hr.
See the [install guide](https://github.com/lnbits/lnbits/blob/main/docs/guide/installation.md) for details on installation and setup.
## LNbits account system
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv. Each wallet also comes with its own API keys, to help partition the exposure of your funding source.
<img alt="lnbits_wallet" src="docs/assets/wallet.jpg" />
<img src="https://i.imgur.com/w8jdGpF.png" style="width:800px">
## LNbits extension universe
@@ -54,35 +46,29 @@ Extend YOUR LNbits to meet YOUR needs.
All non-core features are installed as extensions, reducing your code base and making your LNbits unique to you. Extend your LNbits install in any direction, and even create and share your own extensions.
<img alt="lnbits_extensions" src="docs/assets/extensions.jpg" />
<img src="https://i.imgur.com/aEBpwJF.png" style="width:800px">
## LNbits API
LNbits has a powerful API, many projects use LNbits to do the heavy lifting for their bitcoin/lightning services.
<img alt="lnbits_api" src="docs/assets/api.jpg" />
<img src="https://i.imgur.com/V742sb9.png" style="width:800px">
## LNbits node manager
LNbits comes packaged with a light node management UI, to make running your node that much easier.
<img alt="lnbits_api" src="docs/assets/lightning_node.jpg" />
<img src="https://i.imgur.com/TYqIK60.png" style="width:800px">
## LNbits merchant tools
## LNbits across all your devices
The LNbits stack can process both bitcoin and fiat payments, making it a turnkey, all-in-one solution for merchants. With orders and inventory shared across extensions, and built-in notifications for Nostr, Telegram, and email, LNbits keeps everything in sync, freeing merchants to focus on their business.
As well as working great in a browser, LNbits has native IoS and Android apps as well as a chrome extension. So you can enjoy the same UI across ALL your devices.
<img alt="lnbits_merchants" src="docs/assets/merchants_small.webp" />
<img src="https://i.imgur.com/J96EbRf.png" style="width:800px">
## Powered by LNbits
## Tip us
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable.
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
[![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/) [![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=7c3aed&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
If you like this project [send some tip love](https://demo.lnbits.com/lnurlp/link/fH59GD)!
[docs]: https://github.com/lnbits/lnbits/wiki
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
-34
View File
@@ -1,34 +0,0 @@
ARG LNBITS_TAG=latest
FROM boltz/boltz-client:latest AS boltz
FROM lnbits/lnbits:${LNBITS_TAG}
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
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:$PATH"
# Reinstall dependencies just in case (needed for CMD usage)
RUN uv sync --all-extras
# LNbits
ENV LNBITS_PORT="5000"
ENV LNBITS_HOST="0.0.0.0"
ENV LNBITS_BACKEND_WALLET_CLASS="BoltzWallet"
ENV FUNDING_SOURCE_MAX_RETRIES="10"
# Boltzd
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
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
CMD ["/entrypoint.sh"]
-26
View File
@@ -1,26 +0,0 @@
#!/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 uv run lnbits --port "$LNBITS_PORT" --host "$LNBITS_HOST" --forwarded-allow-ips='*'
-18
View File
@@ -1,18 +0,0 @@
!data
data/*
!data/boltz
data/boltz/*
!data/boltz/boltz.conf
!data/boltz-client
data/boltz-client/*
!data/boltz-client/boltz.toml
!data/boltz-nginx
data/boltz-nginx/*
!data/boltz-nginx/default.conf
!data/eclair
data/eclair/*
!data/eclair/eclair.conf
-99
View File
@@ -1,99 +0,0 @@
![TESTS](https://github.com/lnbits/legend-regtest-enviroment/actions/workflows/ci.yml/badge.svg)
# nodes
- lnd-1: for locally testing your current lnbits
- lnd-2: used for boltz backend
- lnd-3: used for lnbits inside docker
- cln-1: for locally testing your current lnbits
- cln-2: used for clightning-REST
- eclair-1: for locally testing your current lnbits
# Installing regtest
get the regtest enviroment ready
```sh
# Install docker https://docs.docker.com/engine/install/
# Make sure your user has permission to use docker 'sudo usermod -aG docker ${USER}' then reboot
# Stop/start docker 'sudo systemctl stop docker' 'sudo systemctl start docker'
sudo apt install jq
git clone https://github.com/lnbits/lnbits.git
cd lnbits
docker build -t lnbits/lnbits .
mkdir docker
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker
chmod +x ./start-regtest
./start-regtest # start the regtest and also run tests
sudo chown -R $USER ./data # Give the data file permissions for user
```
# Running LNbits on regtest
add this ENV variables to your `.env` file
```sh
DEBUG=true
# LND
LNBITS_BACKEND_WALLET_CLASS="LndRestWallet"
LND_REST_ENDPOINT=https://127.0.0.1:8081/
LND_REST_CERT=/home/user/repos/lnbits/docker/data/lnd-1/tls.cert
LND_REST_MACAROON=/home/user/repos/lnbits/docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
# CLN
LNBITS_BACKEND_WALLET_CLASS="CoreLightningWallet"
CORELIGHTNING_RPC=./docker/data/clightning-1/regtest/lightning-rpc
# Run LNbits
uv run lnbits
# Run LNbits with hot reload
make dev
```
# testing
```sh
chmod +x ./start-regtest
./start-regtest
# short answer :)
./start-regtest && echo "PASSED" || echo "FAILED" > /dev/null
```
usage of the `bitcoin-cli-sim`, `lightning-cli-sim` and `lncli-sim` aliases
```sh
cd ~/lnbits/docker
source docker-scripts.sh
# use bitcoin core, mine a block
bitcoin-cli-sim -generate 1
# use c-lightning nodes
lightning-cli-sim 1 newaddr | jq -r '.bech32' # use node 1
lightning-cli-sim 2 getinfo # use node 2
lightning-cli-sim 3 getinfo # use node 3
# use lnd nodes
lncli-sim 1 newaddr p2wsh
lncli-sim 2 listpeers
```
# urls
- mempool: http://localhost:8080/
- boltz api: http://localhost:9001/
- lnd-1 rest: http://localhost:8081/
- lnbits: http://localhost:5001/
# debugging docker logs
```sh
docker logs lnbits-lnbits-1 -f
docker logs lnbits-boltz-1 -f
docker logs lnbits-clightning-1-1 -f
docker logs lnbits-lnd-2-1 -f
```
@@ -1,49 +0,0 @@
standalone = true
network = "regtest"
# Path the the log file
logfile = ""
electrumUrl = "electrs:19001"
electrumLiquidUrl = "electrs-liquid:19002"
[BOLTZ]
# By default the daemon automatically connects to the official Boltz instance for the network LND is on
# This value is used to override that
url = "http://boltz-nginx:9001"
[DATABASE]
# Path to the SQLite database file
# path = "/home/michael/test.db"
[RPC]
# Host of
host = "0.0.0.0"
# Port of the gRPC interface
port = 9002
# Whether the REST proxy for the gRPC interface should be disabled
restDisabled = false
# Host of the REST proxy
restHost = "0.0.0.0"
# Port of the REST proxy
restPort = 9003
# Path to the TLS cert for the gRPC and REST interface
tlsCert = ""
# Path to the TLS private key for the gRPC and REST interface
tlsKey = ""
noTls = true
# Whether the macaroon authentication for the gRPC and REST interface should be disabled
noMacaroons = true
# Path to the admin macaroon for the gRPC and REST interface
adminMacaroonPath = ""
# Path to the read only macaroon for the gRPC and REST interface
readOnlyMacaroonPath = ""
@@ -1,49 +0,0 @@
upstream boltz {
server boltz:9001;
}
upstream boltzr {
server boltz:9005;
}
upstream ws {
server boltz:9004;
}
server {
listen 9001;
listen [::]:9001;
server_name localhost;
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods 'GET, PATCH, DELETE, POST, OPTIONS' always;
add_header Access-Control-Allow-Headers "*" always;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
if ($request_method = OPTIONS) {
return 204;
}
location /v2/ws {
proxy_pass http://ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
location ~ ^/v2/(lightning|swap/rescue|swap/restore) {
proxy_pass http://boltzr;
}
location /streamswapstatus {
proxy_pass http://boltzr;
}
location / {
proxy_pass http://boltz;
}
}
-114
View File
@@ -1,114 +0,0 @@
[api]
host = "0.0.0.0"
port = 9_001
[grpc]
host = "0.0.0.0"
port = 9_000
[postgres]
host = "boltz-postgres"
port = 5432
database = "boltz"
username = "boltz"
password = "boltz"
[sidecar]
[sidecar.grpc]
host = "127.0.0.1"
port = 9003
[sidecar.ws]
host = "0.0.0.0"
port = 9004
[sidecar.api]
host = "0.0.0.0"
port = 9005
[swap]
deferredClaimSymbols = ["BTC", "L-BTC"]
[[pairs]]
base = "BTC"
quote = "BTC"
rate = 1
fee = 0.5
swapInFee = 0.1
maxSwapAmount = 40_294_967
minSwapAmount = 50_000
[pairs.timeoutDelta]
chain = 1440
reverse = 1440
swapMinimal = 1440
swapMaximal = 2880
swapTaproot = 10080
[[pairs]]
base = "L-BTC"
quote = "BTC"
fee = 0.25
swapInFee = 0.1
rate = 1
maxSwapAmount = 40_294_967
minSwapAmount = 100
[pairs.submarineSwap]
minSwapAmount = 1_000
minBatchedAmount = 21
[pairs.chainSwap]
minSwapAmount = 25_000
[pairs.timeoutDelta]
chain = 1440
reverse = 1440
swapMinimal = 1440
swapMaximal = 2880
swapTaproot = 10080
[[currencies]]
symbol = "BTC"
network = "bitcoinRegtest"
minWalletBalance = 10_000_000
minChannelBalance = 10_000_000
maxSwapAmount = 40_294_967
minSwapAmount = 10_000
maxZeroConfAmount = 0
[currencies.chain]
# mempoolSpace = "http://mempool-web:8090/api"
host = "bitcoind"
zmqpubrawtx = "tcp://bitcoind:29000"
zmqpubrawblock = "tcp://bitcoind:29001"
port = 18_443
cookie = "/root/.bitcoin/regtest/.cookie"
feeFloor = 0.2
wallet = "lnbits"
[currencies.lnd]
host = "lnd-2"
port = 10_009
certpath = "/data/lnd/tls.cert"
macaroonpath = "/data/lnd/data/chain/bitcoin/regtest/admin.macaroon"
[liquid]
symbol = "L-BTC"
network = "liquidRegtest"
maxSwapAmount = 40_294_967
minSwapAmount = 10_000
maxZeroConfAmount = 40_294_967
[liquid.chain]
host = "elementsd"
port = 18884
cookie = "/root/.elements/liquidregtest/.cookie"
zmqpubrawtx = "tcp://elementsd:31000"
zmqpubhashblock = "tcp://elementsd:31002"
wallet = "lnbits"
-28
View File
@@ -1,28 +0,0 @@
eclair {
chain = "regtest"
api {
binding-ip = "0.0.0.0"
enabled = true
port = 8080
password = "lnbits"
}
bitcoind {
host = "bitcoind"
rpcport = 18443
auth = "safecookie"
cookie = "/root/.bitcoin/regtest/.cookie"
zmqblock = "tcp://bitcoind:29002"
zmqtx = "tcp://bitcoind:29000"
}
channel {
max-funding-satoshis = 10000000000
}
features {
option_support_large_channel = mandatory
}
}
-434
View File
@@ -1,434 +0,0 @@
services:
lnbits:
hostname: lnbits
depends_on:
- lnd-3
image: lnbits/lnbits
restart: on-failure
user: "0:0"
environment:
LNBITS_PORT: 5001
DEBUG: true
LNBITS_ADMIN_UI: false
LNBITS_BACKEND_WALLET_CLASS: "LndRestWallet"
LNBITS_DATA_FOLDER: "./data"
LND_REST_ENDPOINT: "https://lnd-3:8081/"
LND_REST_CERT: "./lnd/tls.cert"
LND_REST_MACAROON: "./lnd/data/chain/bitcoin/regtest/admin.macaroon"
ports:
- 5001:5001
volumes:
- lnbits-data:/app/data
- ./data/lnd-3:/app/lnd:uid=1000,gid=1000
boltz:
hostname: boltz
depends_on:
- lnd-2
- boltz-postgres
restart: always
image: boltz/boltz:v3.12.1
ports:
- 9000:9000
entrypoint: "sh -c 'sleep 30; /boltz-backend/bin/boltzd'"
volumes:
- ./data/lnd-2:/data/lnd/
- ./data/boltz/:/root/.boltz/
- elements-data:/root/.elements
- bitcoin-data:/root/.bitcoin
boltz-client:
hostname: boltz-client
depends_on:
- boltz
restart: always
image: boltz/boltz-client:latest
ports:
- 9002:9002
- 9003:9003
expose:
- 9002
healthcheck:
test: ['CMD', 'boltzcli', '--host', 'boltz-client', 'getinfo']
interval: 5s
timeout: 3s
retries: 10
start_period: 0s
volumes:
- elements-data:/root/.elements
- ./data/boltz-client:/root/.boltz
boltz-backend-nginx:
hostname: boltz-nginx
restart: always
image: nginx:latest
ports:
- 9001:9001
volumes:
- nginx-data:/etc/nginx/conf.d
healthcheck:
test: ['CMD-SHELL', 'curl http://localhost:9001/version']
timeout: 1s
retries: 10
interval: 1s
start_period: 0s
boltz-postgres:
hostname: boltz-postgres
restart: always
image: postgres:14-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready --dbname boltz --username boltz"]
interval: 5s
timeout: 30s
retries: 10
start_period: 5s
environment:
- POSTGRES_DB=boltz
- POSTGRES_USER=boltz
- POSTGRES_PASSWORD=boltz
expose:
- 5432
bitcoind:
hostname: bitcoind
image: boltz/bitcoin-core:25.0
command:
- -regtest
- -fallbackfee=0.00000253
- -zmqpubrawtx=tcp://0.0.0.0:29000
- -zmqpubrawblock=tcp://0.0.0.0:29001
- -zmqpubhashblock=tcp://0.0.0.0:29002
- -txindex
- -rpcallowip=0.0.0.0/0
- -rpcbind=0.0.0.0
- -addresstype=bech32
- -changetype=bech32
- -dbcache=2048
- -rpcworkqueue=256
volumes:
- bitcoin-data:/root/.bitcoin
expose:
- 29000
- 29001
- 29002
- 18443
- 18444
healthcheck:
test:
[
"CMD",
"bitcoin-cli",
"--rpccookiefile=/root/.bitcoin/regtest/.cookie",
"-regtest",
"getblockchaininfo",
]
timeout: 1s
retries: 1
interval: 1s
start_period: 0s
clightning-1:
hostname: clightning-1
depends_on:
- bitcoind
image: boltz/c-lightning:24.11
command:
- --large-channels
- --network=regtest
- --grpc-port=9736
- --bind-addr=0.0.0.0:9735
- --bitcoin-rpcconnect=bitcoind
- --bitcoin-rpcport=18443
- --clnrest-host=0.0.0.0
- --clnrest-port=3010
expose:
- 9735
ports:
- 9736:9736
- 3010:3010
volumes:
- ./data/clightning-1:/root/.lightning/
- bitcoin-data:/root/.bitcoin
clightning-2:
hostname: clightning-2
depends_on:
- bitcoind
image: boltz/c-lightning:22.11.1
command:
- --large-channels
- --network=regtest
- --grpc-port=9737
- --bind-addr=0.0.0.0:9735
- --bitcoin-rpcconnect=bitcoind
- --bitcoin-rpcport=18443
expose:
- 9735
ports:
- 9737:9737
volumes:
- ./data/clightning-2:/root/.lightning/
- bitcoin-data:/root/.bitcoin
clightning-2-rest:
hostname: clightning-2-rest
depends_on:
- clightning-2
image: saubyk/c-lightning-rest:0.10.7
entrypoint: "sh -c 'sleep 35 && /sbin/tini -g -- ./docker-entrypoint.sh'"
ports:
- 3001:3001
expose:
- 3001
volumes:
- ./data/clightning-2:/root/.lightning/:uid=1000,gid=1000
- ./data/clightning-2-rest:/usr/src/app/certs/
- bitcoin-data:/root/.bitcoin
clightning-3:
hostname: clightning-3
depends_on:
- bitcoind
image: boltz/c-lightning:24.11
command:
- --large-channels
- --network=regtest
- --grpc-port=9738
- --bind-addr=0.0.0.0:9735
- --bitcoin-rpcconnect=bitcoind
- --bitcoin-rpcport=18443
expose:
- 9735
ports:
- 9738:9738
volumes:
- ./data/clightning-3:/root/.lightning/
- bitcoin-data:/root/.bitcoin
lnd-1:
hostname: lnd-1
depends_on:
- bitcoind
image: boltz/lnd:0.18.4-beta
restart: on-failure
command:
- --listen=lnd-1:9735
- --rpclisten=lnd-1:10009
- --restlisten=lnd-1:8081
- --bitcoin.active
- --bitcoin.regtest
- --bitcoin.node=bitcoind
- --bitcoind.rpchost=bitcoind
- --bitcoind.rpccookie=/root/.bitcoin/regtest/.cookie
- --bitcoind.zmqpubrawtx=bitcoind:29000
- --bitcoind.zmqpubrawblock=bitcoind:29001
- --noseedbackup
- --protocol.wumbo-channels
expose:
- 8081
- 9735
- 10009
volumes:
- ./data/lnd-1:/root/.lnd/
- bitcoin-data:/root/.bitcoin
lnd-2:
hostname: lnd-2
depends_on:
- bitcoind
image: boltz/lnd:0.19.3-beta
restart: on-failure
command:
- --listen=lnd-2:9735
- --rpclisten=lnd-2:10009
- --restlisten=lnd-2:8081
- --bitcoin.active
- --bitcoin.regtest
- --bitcoin.node=bitcoind
- --bitcoind.rpchost=bitcoind
- --bitcoind.rpccookie=/root/.bitcoin/regtest/.cookie
- --bitcoind.zmqpubrawtx=bitcoind:29000
- --bitcoind.zmqpubrawblock=bitcoind:29001
- --noseedbackup
- --protocol.wumbo-channels
expose:
- 8081
- 9735
- 10009
volumes:
- ./data/lnd-2:/root/.lnd/
- bitcoin-data:/root/.bitcoin
lnd-3:
hostname: lnd-3
depends_on:
- bitcoind
image: boltz/lnd:0.18.4-beta
restart: on-failure
command:
- --listen=lnd-3:9735
- --rpclisten=lnd-3:10009
- --restlisten=lnd-3:8081
- --bitcoin.active
- --bitcoin.regtest
- --bitcoin.node=bitcoind
- --bitcoind.rpchost=bitcoind
- --bitcoind.rpccookie=/root/.bitcoin/regtest/.cookie
- --bitcoind.zmqpubrawtx=bitcoind:29000
- --bitcoind.zmqpubrawblock=bitcoind:29001
- --noseedbackup
- --protocol.wumbo-channels
ports:
- 8081:8081
- 10009:10009
expose:
- 8081
- 9735
- 10009
volumes:
- ./data/lnd-3:/root/.lnd/
- bitcoin-data:/root/.bitcoin
lnd-4:
hostname: lnd-4
depends_on:
- bitcoind
image: boltz/lnd:0.18.4-beta
restart: on-failure
command:
- --listen=lnd-4:9735
- --rpclisten=lnd-4:10009
- --restlisten=lnd-4:8081
- --bitcoin.active
- --bitcoin.regtest
- --bitcoin.node=bitcoind
- --bitcoind.rpchost=bitcoind
- --bitcoind.rpccookie=/root/.bitcoin/regtest/.cookie
- --bitcoind.zmqpubrawtx=bitcoind:29000
- --bitcoind.zmqpubrawblock=bitcoind:29001
- --noseedbackup
- --protocol.wumbo-channels
expose:
- 8081
- 9735
- 10009
volumes:
- ./data/lnd-4:/root/.lnd/
- bitcoin-data:/root/.bitcoin
eclair:
hostname: eclair
depends_on:
- bitcoind
image: boltz/eclair:0.8.0
restart: on-failure
entrypoint: "sh -c 'JAVA_OPTS=-Xmx512m /eclair-node/bin/eclair-node.sh -Declair.datadir=/root/eclair -Declair.printToConsole'"
ports:
- 8082:8080
expose:
- 9735
- 8080
volumes:
- ./data/eclair:/root/eclair
- bitcoin-data:/root/.bitcoin
electrs:
hostname: electrs
restart: always
image: boltz/electrs:latest
entrypoint: ["electrs-bitcoin"]
command:
- --electrum-rpc-addr
- electrs:19001
- --http-addr
- electrs:3002
- --daemon-rpc-addr
- bitcoind:18443
- --network
- regtest
- --jsonrpc-import
healthcheck:
test: ["CMD-SHELL", "curl -s $(hostname):3002/blocks/tip/height"]
timeout: 1s
retries: 20
interval: 2s
start_period: 5s
ports:
- 19001:19001
- 3002:3002
volumes:
- bitcoin-data:/root/.bitcoin
elementsd:
hostname: elementsd
restart: always
image: boltz/elements:latest
expose:
- 31001
ports:
- 31000:31000
- 31002:31002
- 18884:18884
command:
- -chain=liquidregtest
- -txindex=1
- -rest=1
- -server=1
- -rpcallowip=0.0.0.0/0
- -validatepegin=0
- -initialfreecoins=2100000000000000
- -fallbackfee=0.000001
- -rpcbind=0.0.0.0
- -rpcport=18884
- -zmqpubrawtx=tcp://0.0.0.0:31000
- -zmqpubrawblock=tcp://0.0.0.0:31001
- -zmqpubhashblock=tcp://0.0.0.0:31002
- -acceptdiscountct=1
- -creatediscountct=1
volumes:
- elements-data:/root/.elements
electrs-liquid:
hostname: electrs-liquid
restart: always
image: boltz/electrs:latest
entrypoint: ["electrs-liquid"]
command:
- --electrum-rpc-addr
- electrs-liquid:19002
- --http-addr
- electrs-liquid:3003
- --daemon-rpc-addr
- elementsd:18884
- --daemon-dir
- /root/.elements
- --network
- liquidregtest
- --parent-network
- regtest
- --jsonrpc-import
healthcheck:
test: ["CMD-SHELL", "curl -s $(hostname):3003/blocks/tip/height"]
timeout: 15s
retries: 20
interval: 2s
start_period: 5s
ports:
- 19002:19002
- 3003:3003
volumes:
- elements-data:/root/.elements
volumes:
lnbits-data:
bitcoin-data:
elements-data:
nginx-data:
name: nginx-data
driver: local
driver_opts:
type: none
o: bind
device: ./data/boltz-nginx/
-322
View File
@@ -1,322 +0,0 @@
#!/bin/sh
export COMPOSE_PROJECT_NAME=lnbits
boltzcli-sim() {
docker exec -it lnbits-boltz-client-1 boltzcli "$@"
}
bitcoin-cli-sim() {
docker exec lnbits-bitcoind-1 bitcoin-cli -regtest "$@"
}
elements-cli-sim() {
docker exec lnbits-elementsd-1 elements-cli -rpcport=18884 -chain=liquidregtest "$@"
}
# args(i, cmd)
lightning-cli-sim() {
i=$1
shift # shift first argument so we can use $@
docker exec lnbits-clightning-$i-1 lightning-cli --network regtest "$@"
}
# args(i, cmd)
lncli-sim() {
i=$1
shift # shift first argument so we can use $@
docker exec lnbits-lnd-$i-1 lncli --network regtest --rpcserver=lnd-$i:10009 "$@"
}
get-eclair-pubkey() {
while true; do
pubkey=$(docker exec lnbits-eclair-1 curl http://localhost:8080/getinfo -X POST -s -u :lnbits | jq -r .nodeId 2> /dev/null)
pubkeyPrefix=$(echo $pubkey | cut -c1,2)
if [[ "$pubkeyPrefix" == "02" || "$pubkeyPrefix" == "03" ]]; then
echo $pubkey
break
fi
sleep 1
done
}
wait-for-eclair-channel() {
while true; do
state=$(docker exec lnbits-eclair-1 curl http://localhost:8080/channels -X POST -s -u :lnbits | jq -r ".[0].state")
pending=$(docker exec lnbits-eclair-1 curl -s http://localhost:8080/channels -X POST -u :lnbits| jq '. | length')
echo "eclair-1 pendingchannels: $pending, current state: $state"
if [[ "$state" == "NORMAL" ]]; then
break
fi
sleep 1
done
}
# args(i)
fund_boltz_client() {
# first address of seed: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
address="el1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f3mmz5l7uw5pqmx6xf5xy50hsn6vhkm5euwt72x878eq6zxx2z0z676mna6kdq"
echo "funding: $address on boltz-client"
elements-cli-sim -named sendtoaddress address=$address amount=30 fee_rate=100 > /dev/null
}
# args(i)
fund_clightning_node() {
address=$(lightning-cli-sim $1 newaddr | jq -r .bech32)
echo "funding: $address on clightning-node: $1"
bitcoin-cli-sim -named sendtoaddress address=$address amount=30 fee_rate=100 > /dev/null
}
# args(i)
fund_lnd_node() {
address=$(lncli-sim $1 newaddress p2wkh | jq -r .address)
echo "funding: $address on lnd-node: $1"
bitcoin-cli-sim -named sendtoaddress address=$address amount=30 fee_rate=100 > /dev/null
}
# args(i, j)
connect_clightning_node() {
pubkey=$(lightning-cli-sim $2 getinfo | jq -r '.id')
lightning-cli-sim $1 connect $pubkey@lnbits-clightning-$2-1:9735 | jq -r '.id'
}
lnbits-regtest-start(){
if ! command -v jq &> /dev/null
then
echo "jq is not installed"
exit
fi
if ! command -v docker &> /dev/null
then
echo "docker is not installed"
exit
fi
if ! command -v docker version &> /dev/null
then
echo "dockerd is not running"
exit
fi
lnbits-regtest-stop
docker compose up -d --remove-orphans
lnbits-regtest-init
}
lnbits-regtest-start-log(){
lnbits-regtest-stop
docker compose up --remove-orphans
lnbits-regtest-init
}
lnbits-regtest-stop(){
docker compose down --volumes
# clean up lightning node data
sudo rm -rf ./data/clightning-1 ./data/clightning-2 ./data/clightning-3 ./data/lnd-1 ./data/lnd-2 ./data/lnd-3 ./data/lnd-4 ./data/boltz/boltz.db ./data/eclair/regtest ./data/boltz-client/liquid-wallet ./data/boltz-client/bitcoin-wallet ./data/boltz-client/wallet ./data/boltz-client/boltz.db
# recreate lightning node data folders preventing permission errors
mkdir ./data/clightning-1 ./data/clightning-2 ./data/clightning-3 ./data/lnd-1 ./data/lnd-2 ./data/lnd-3 ./data/lnd-4
}
lnbits-regtest-restart(){
lnbits-regtest-stop
lnbits-regtest-start
}
boltz-client-init(){
for i in 0 1 2; do
fund_boltz_client
done
elements-cli-sim -generate 3 > /dev/null
}
lnbits-bitcoin-init(){
echo "init_bitcoin_wallet..."
bitcoin-cli-sim createwallet lnbits || bitcoin-cli-sim loadwallet lnbits
echo "mining 150 blocks..."
bitcoin-cli-sim -generate 150 > /dev/null
}
lnbits-elements-init(){
echo "init_elements_wallet..."
elements-cli-sim createwallet lnbits || elements-cli-sim loadwallet lnbits
echo "mining 150 blocks..."
elements-cli-sim -generate 150 > /dev/null
elements-cli-sim rescanblockchain
}
lnbits-init(){
echo "init_lnbits..."
docker exec lnbits-lnbits-1 uv run python tools/create_fake_admin.py
}
lnbits-regtest-init(){
lnbits-bitcoin-init
lnbits-elements-init
lnbits-lightning-sync
lnbits-lightning-init
boltz-client-init
lnbits-init
}
lnbits-lightning-sync(){
wait-for-clightning-sync 1
wait-for-clightning-sync 2
wait-for-clightning-sync 3
wait-for-lnd-sync 1
wait-for-lnd-sync 2
wait-for-lnd-sync 3
wait-for-lnd-sync 4
}
lnbits-lightning-init(){
# create 10 UTXOs for each node
for i in 0 1 2; do
fund_clightning_node 1
fund_clightning_node 2
fund_clightning_node 3
fund_lnd_node 1
fund_lnd_node 2
fund_lnd_node 3
fund_lnd_node 4
done
echo "mining 3 blocks..."
bitcoin-cli-sim -generate 3 > /dev/null
lnbits-lightning-sync
channel_confirms=6
channel_size=24000000 # 0.024 btc
balance_size=12000000 # 0.12 btc
balance_size_msat=12000000000 # 0.12 btc
# lnd-1 -> lnd-2
lncli-sim 1 connect $(lncli-sim 2 getinfo | jq -r '.identity_pubkey')@lnbits-lnd-2-1 > /dev/null
echo "open channel from lnd-1 to lnd-2"
lncli-sim 1 openchannel $(lncli-sim 2 getinfo | jq -r '.identity_pubkey') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 1
# lnd-1 -> lnd-3
lncli-sim 1 connect $(lncli-sim 3 getinfo | jq -r '.identity_pubkey')@lnbits-lnd-3-1 > /dev/null
echo "open channel from lnd-1 to lnd-3"
lncli-sim 1 openchannel $(lncli-sim 3 getinfo | jq -r '.identity_pubkey') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 1
# lnd-1 -> cln-1
lncli-sim 1 connect $(lightning-cli-sim 1 getinfo | jq -r '.id')@lnbits-clightning-1-1 > /dev/null
echo "open channel from lnd-1 to cln-1"
lncli-sim 1 openchannel $(lightning-cli-sim 1 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 1
# lnd-1 -> cln-2
lncli-sim 1 connect $(lightning-cli-sim 2 getinfo | jq -r '.id')@lnbits-clightning-2-1 > /dev/null
echo "open channel from lnd-1 to cln-2"
lncli-sim 1 openchannel $(lightning-cli-sim 2 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 1
# lnd-1 -> cln-3
lncli-sim 1 connect $(lightning-cli-sim 3 getinfo | jq -r '.id')@lnbits-clightning-3-1 > /dev/null
echo "open channel from lnd-1 to cln-3"
lncli-sim 1 openchannel $(lightning-cli-sim 3 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 1
# lnd-2 -> cln-2
lncli-sim 2 connect $(lightning-cli-sim 2 getinfo | jq -r '.id')@lnbits-clightning-2-1 > /dev/null
echo "open channel from lnd-2 to cln-2"
lncli-sim 2 openchannel $(lightning-cli-sim 2 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 2
# lnd-3 -> cln-3
lncli-sim 3 connect $(lightning-cli-sim 3 getinfo | jq -r '.id')@lnbits-clightning-3-1 > /dev/null
echo "open channel from lnd-3 to cln-1"
lncli-sim 3 openchannel $(lightning-cli-sim 3 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 3
# lnd-3 -> cln-1
lncli-sim 3 connect $(lightning-cli-sim 1 getinfo | jq -r '.id')@lnbits-clightning-1-1 > /dev/null
echo "open channel from lnd-3 to cln-1"
lncli-sim 3 openchannel $(lightning-cli-sim 1 getinfo | jq -r '.id') $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 3
# lnd-1 -> eclair-1
lncli-sim 1 connect $(get-eclair-pubkey)@lnbits-eclair-1 > /dev/null
echo "open channel from lnd-2 to eclair-1"
lncli-sim 1 openchannel $(get-eclair-pubkey) $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 1
# lnd-2 -> eclair-1
lncli-sim 2 connect $(get-eclair-pubkey)@lnbits-eclair-1 > /dev/null
echo "open channel from lnd-2 to eclair-1"
lncli-sim 2 openchannel $(get-eclair-pubkey) $channel_size $balance_size > /dev/null
bitcoin-cli-sim -generate $channel_confirms > /dev/null
wait-for-lnd-channel 2
wait-for-clightning-channel 1
wait-for-clightning-channel 2
wait-for-clightning-channel 3
wait-for-eclair-channel
lnbits-lightning-sync
}
wait-for-lnd-channel(){
while true; do
pending=$(lncli-sim $1 pendingchannels | jq -r '.pending_open_channels | length')
echo "lnd-$1 pendingchannels: $pending"
if [[ "$pending" == "0" ]]; then
break
fi
sleep 1
done
}
wait-for-lnd-sync(){
while true; do
if [[ "$(lncli-sim $1 getinfo 2>&1 | jq -r '.synced_to_chain' 2> /dev/null)" == "true" ]]; then
echo "lnd-$1 is synced!"
break
fi
echo "waiting for lnd-$1 to sync..."
sleep 1
done
}
wait-for-clightning-channel(){
while true; do
pending=$(lightning-cli-sim $1 getinfo | jq -r '.num_pending_channels | length')
echo "cln-$1 pendingchannels: $pending"
if [[ "$pending" == "0" ]]; then
if [[ "$(lightning-cli-sim $1 getinfo 2>&1 | jq -r '.warning_bitcoind_sync' 2> /dev/null)" == "null" ]]; then
if [[ "$(lightning-cli-sim $1 getinfo 2>&1 | jq -r '.warning_lightningd_sync' 2> /dev/null)" == "null" ]]; then
break
fi
fi
fi
sleep 1
done
}
wait-for-clightning-sync(){
while true; do
if [[ ! "$(lightning-cli-sim $1 getinfo 2>&1 | jq -r '.id' 2> /dev/null)" == "null" ]]; then
if [[ "$(lightning-cli-sim $1 getinfo 2>&1 | jq -r '.warning_bitcoind_sync' 2> /dev/null)" == "null" ]]; then
if [[ "$(lightning-cli-sim $1 getinfo 2>&1 | jq -r '.warning_lightningd_sync' 2> /dev/null)" == "null" ]]; then
echo "cln-$1 is synced!"
break
fi
fi
fi
echo "waiting for cln-$1 to sync..."
sleep 1
done
}
-74
View File
@@ -1,74 +0,0 @@
#!/bin/bash
print_success() {
printf "\033[;1;32mPASSED\033[;0m $1\n"
}
print_error() {
printf "\033[;1;31mFAILED\033[;0m $1\n"
}
run(){
label=$1
value=$2
cmd=$3
if [[ "$cmd" == "$value" ]]; then
print_success "$label is $cmd"
else
print_error "$label is $cmd, should be $value"
failed="true"
fi
}
failed="false"
blockheight=213
utxos=3
channel_size=24000000 # 0.024 btc
balance_size=12000000 # 0.012 btc
source $(pwd)/docker-scripts.sh
lnbits-regtest-start
echo "=================================="
printf "\033[;1;36mregtest started! starting tests...\033[;0m\n"
echo "=================================="
echo ""
for i in 1 2 3; do
run "lnd-$i .synced_to_chain" "true" $(lncli-sim $i getinfo | jq -r ".synced_to_chain")
run "lnd-$i utxo count" $utxos $(lncli-sim $i listunspent | jq -r ".utxos | length")
run "lnd-$i .block_height" $blockheight $(lncli-sim $i getinfo | jq -r ".block_height")
if [[ "$i" == "1" ]]; then
channel_count=6
else
channel_count=3
fi
run "lnd-$i openchannels" $channel_count $(lncli-sim $i listchannels | jq -r ".channels | length")
run "lnd-$i .channels[0].capacity" $channel_size $(lncli-sim $i listchannels | jq -r ".channels[0].capacity")
run "lnd-$i .channels[0].push_amount_sat" $balance_size $(lncli-sim $i listchannels | jq -r ".channels[0].push_amount_sat")
done
for i in 1 2 3; do
# run "cln-$i blockheight" $blockheight $(lightning-cli-sim $i getinfo | jq -r ".blockheight")
run "cln-$i utxo count" $utxos $(lightning-cli-sim $i listfunds | jq -r ".outputs | length")
run "cln-$i openchannels" 2 $(lightning-cli-sim $i getinfo | jq -r ".num_active_channels")
run "cln-$i channel[0].state" "CHANNELD_NORMAL" $(lightning-cli-sim $i listfunds | jq -r ".channels[0].state")
run "cln-$i channel[0].amount_msat" $(($channel_size * 1000)) $(lightning-cli-sim $i listfunds | jq -r ".channels[0].amount_msat" | sed 's/msat//g')
run "cln-$i channel[0].our_amount_msat" $(($balance_size * 1000)) $(lightning-cli-sim $i listfunds | jq -r ".channels[0].our_amount_msat" | sed 's/msat//g')
done
run "eclair-1 openchannels" 2 $(docker exec lnbits-eclair-1 curl -s http://localhost:8080/channels -X POST -u :lnbits| jq '. | length')
run "eclair-1 blockHeight" $blockheight $(docker exec lnbits-eclair-1 curl -s http://localhost:8080/getinfo -X POST -u :lnbits| jq '.blockHeight')
run "lnbits service status" "200" $(curl -s -o /dev/null -w "%{http_code}" "http://localhost:5001/")
run "boltz service status" "200" $(curl -s -o /dev/null --head -w "%{http_code}" "http://localhost:9001/version")
# return non-zero exit code if a test fails
if [[ "$failed" == "true" ]]; then
echo ""
echo "=================================="
print_error "one more more tests failed"
echo "=================================="
exit 1
else
echo ""
echo "=================================="
print_success "all tests passed! yay!"
echo "=================================="
fi
-41
View File
@@ -1,41 +0,0 @@
ARG LNBITS_TAG=latest
FROM lnbits/lnbits:${LNBITS_TAG}
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
RUN apt-get update && apt-get -y upgrade
RUN apt-get install -y ca-certificates curl gnupg netcat-openbsd git nodejs
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:$PATH"
# install sparksidecar
RUN git clone https://github.com/lnbits/spark_sidecar
RUN cd spark_sidecar && npm ci
# Reinstall dependencies for lnbits just in case (needed for CMD usage)
RUN uv sync --all-extras
# LNBITS
ENV LNBITS_PORT="5000"
ENV LNBITS_HOST="0.0.0.0"
ENV LNBITS_BACKEND_WALLET_CLASS="SparkL2Wallet"
ENV LNBITS_RESERVE_FEE_MIN="20000"
ENV LNBITS_RESERVE_FEE_PERCENT="1"
ENV LNBITS_FUNDING_SOURCE_PAY_INVOICE_WAIT_SECONDS="20"
ENV FUNDING_SOURCE_MAX_RETRIES="10"
# spark sidecar
ENV SPARK_NETWORK="MAINNET"
ENV SPARK_SIDECAR_PORT="8765"
ENV SPARK_PAY_WAIT_MS="20000"
ENV SPARK_MULTIPLICITY="3"
EXPOSE 5000
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Entrypoint to start sparksidebar and LNbits
CMD ["/entrypoint.sh"]
-25
View File
@@ -1,25 +0,0 @@
#!/bin/bash
set -e
node spark_sidecar/server.mjs &
SIDECAR_PID=$!
# Wait for startup
for i in {1..10}; do
if nc -z localhost $SPARK_SIDECAR_PORT; then
echo "sparksidebar is up!"
break
fi
echo "Waiting for sparksidebar to start..."
sleep 1
done
# Optional: check if still not up
if ! nc -z localhost $SPARK_SIDECAR_PORT; then
echo "sparksidebar did not start successfully."
exit 1
fi
echo "Starting LNbits on $LNBITS_HOST:$LNBITS_PORT..."
exec uv run lnbits --port "$LNBITS_PORT" --host "$LNBITS_HOST" --forwarded-allow-ips='*'
-1
View File
@@ -4,7 +4,6 @@ color_scheme: dark
logo: "/logos/lnbits-full-inverse.png"
search_enabled: true
url: https://docs.lnbits.org
markdown: gfm
aux_links:
"LNbits on GitHub":
- "//github.com/lnbits/lnbits"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

+6 -6
View File
@@ -11,13 +11,13 @@ Thanks for contributing :)
# Run
Follow the [Option 2 (recommended): UV](https://docs.lnbits.org/guide/installation.html)
guide to install uv and other dependencies.
Follow the [Basic installation: Option 1 (recommended): poetry](https://docs.lnbits.org/guide/installation.html#option-1-recommended-poetry)
guide to install poetry and other dependencies.
Then you can start LNbits uvicorn server with:
```bash
uv run lnbits
poetry run lnbits
```
Or you can use the following to start uvicorn with hot reloading enabled:
@@ -25,7 +25,7 @@ Or you can use the following to start uvicorn with hot reloading enabled:
```bash
make dev
# or
uv run lnbits --reload
poetry run lnbits --reload
```
You might need the following extra dependencies on clean installation of Debian:
@@ -50,7 +50,7 @@ make install-pre-commit-hook
This project has unit tests that help prevent regressions. Before you can run the tests, you must install a few dependencies:
```bash
uv sync --all-extras --dev
poetry install
npm i
```
@@ -69,7 +69,7 @@ make format
Run mypy checks:
```bash
make mypy
poetry run mypy
```
Run everything:
+6 -2
View File
@@ -42,10 +42,14 @@ mv templates/example templates/mysuperplugin # Rename templates folder.
DO NOT ADD NEW DEPENDENCIES. Try to use the dependencies that are available in `pyproject.toml`. Getting the LNbits project to accept a new dependency is time consuming and uncertain, and may result in your extension NOT being made available to others.
If for some reason your extensions must have a new python package to work, and its nees are not met in `pyproject.toml`, you can add a new package using `poerty` or `uv`:
If for some reason your extensions must have a new python package to work, and its nees are not met in `pyproject.toml`, you can add a new package using `poerty`:
```sh
$ poetry add <package>
```
**But we need an extra step to make sure LNbits doesn't break in production.**
Dependencies need to be added to `pyproject.toml`, then tested by running on `uv` and `poetry` compatibility can be tested with `nix build .#checks.x86_64-linux.vmTest`.
Dependencies need to be added to `pyproject.toml`, then tested by running on `poetry` compatibility can be tested with `nix build .#checks.x86_64-linux.vmTest`.
## SQLite to PostgreSQL migration
+49 -104
View File
@@ -1,133 +1,78 @@
---
layout: default
title: Admin UI
nav_order: 1
nav_order: 4
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
</picture>
</a>
# Admin UI
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
The LNbits Admin UI lets you change LNbits settings via the LNbits frontend.
It is disabled by default and the first time you set the environment variable `LNBITS_ADMIN_UI=true`
the settings are initialized and saved to the database and will be used from there as long the UI is enabled.
From there on the settings from the database are used.
# LNBits Admin UI
# Super User
[What you can do](#what-you-can-do-with-the-admin-ui) · [First Run](#first-run-and-super-user-id) · [Enable/Disable](#enabling-or-disabling-the-admin-ui) · [Reset](#reset-to-defaults) · [Allowed Users](#allowed-users) · [Guides](#additional-guides)
With the Admin UI we introduced the super user, it is created with the initialisation of the Admin UI and will be shown with a success message in the server logs.
The super user has access to the server and can change settings that may crash the server and make it unresponsive via the frontend and api, like changing funding sources.
**We introduced the Admin UI as the new default to make setup simpler and more straightforward**. Instead of hand editing the `.env` file, you configure key server settings directly in the frontend with clear labels and guardrails.
Also only the super user can brrrr satoshis to different wallets.
<ins>On a fresh install the Admin UI is enabled by default</ins>, and at first launch you are prompted to create **Super User** credentials so that sensitive operations, such as switching funding sources, remain in trusted hands. When the Admin UI is enabled, configuration is written to and read from the database; for all settings managed by the UI, the parameters in `.env` are largely no longer used. If you disable the Admin UI, the `.env` file becomes the single source of truth again.
The super user is only stored inside the settings table of the database and after the settings are "reset to defaults" and a restart happened,
a new super user is created.
For privileged actions and role details see **[Super User](./super_user.md)** & [User Roles](./user_roles.md)
For a complete reference of legacy variables consult **[.env.example](../../.env.example)**.
The super user is never sent over the api and the frontend only receives a bool if you are super user or not.
<img width="900" height="640" alt="grafik" src="https://github.com/user-attachments/assets/d8852b4b-21be-446f-a1e7-d3eb794d3505" />
We also added a decorator for the API routes to check for super user.
> [!WARNING]
> Some settings remain `.env` only. Use **[.env.example](../../.env.example#L3-L87)** as the authoritative reference for those variables.
There is also the possibility of posting the super user via webhook to another service when it is created. you can look it up here https://github.com/lnbits/lnbits/blob/main/lnbits/settings.py `class SaaSSettings`
## What you can do with the Admin UI
# Admin Users
- Switch funding sources and other server level settings
- Manage who can access LNbits (**[Allowed Users](#allowed-users)**)
- Promote or demote Admin Users
- Gate extensions to Admins only or disable them globally
- Adjust balances with credit or debit
- Adjust site customization
environment variable: `LNBITS_ADMIN_USERS`, comma-separated list of user ids
Admin Users can change settings in the admin ui as well, with the exception of funding source settings, because they require e server restart and could potentially make the server inaccessible. Also they have access to all the extension defined in `LNBITS_ADMIN_EXTENSIONS`.
> [!NOTE]
> See **[Super User](./super_user.md)** for the role and permission differences compared to Admin Users.
# Allowed Users
## First run and Super User ID
environment variable: `LNBITS_ALLOWED_USERS`, comma-separated list of user ids
By defining this users, LNbits will no longer be usable by the public, only defined users and admins can then access the LNbits frontend.
On first start with the Admin UI enabled you will be prompted to generate a Super User.
Setting this environment variable also disables account creation.
Account creation can be also disabled by setting `LNBITS_ALLOW_NEW_ACCOUNTS=false`
<img width="1573" height="976" alt="Admin_UI_first_install" src="https://github.com/user-attachments/assets/05aa634f-06ec-4a4d-a5c6-d90927c90991" />
# How to activate
If you need to read it from disk later:
```
$ sudo systemctl stop lnbits.service
$ cd ~/lnbits
$ sudo nano .env
```
```bash
cat /lnbits/data/.super_user
# example
-> set: `LNBITS_ADMIN_UI=true`
Now start LNbits once in the terminal window
```
$ poetry run lnbits
```
You can now `cat` the Super User ID:
```
$ cat data/.super_user
123de4bfdddddbbeb48c8bc8382fe123
```
> [!WARNING]
> For security reasons, Super Users and Admin users must authenticate with credentials (username and password).
You can access your super user account at `/wallet?usr=super_user_id`. You just have to append it to your normal LNbits web domain.
After login you will see **Settings** and **Users** in the sidebar between **Wallets** and **Extensions**, plus a role badge in the top left.
After that you will find the **`Admin` / `Manage Server`** between `Wallets` and `Extensions`
<img width="1353" height="914" alt="grafik" src="https://github.com/user-attachments/assets/06bb4f36-a23a-4058-87ec-60440d322c25" />
Here you can design the interface, it has TOPUP to fill wallets and you can restrict access rights to extensions only for admins or generally deactivated for everyone. You can make users admins or set up Allowed Users if you want to restrict access. And of course the classic settings of the .env file, e.g. to change the funding source wallet or set a charge fee.
## Enabling or disabling the Admin UI
Do not forget
The Admin UI is enabled by default on new installs. To change the state:
```
sudo systemctl start lnbits.service
```
1. Stop LNbits
```bash
sudo systemctl stop lnbits.service
```
2. Edit your `.env`
```
cd ~/lnbits
sudo nano .env
```
3. Set one of
```
# Enable Admin UI
LNBITS_ADMIN_UI=true
# Disable Admin UI
LNBITS_ADMIN_UI=false
```
4. Start LNbits
```
sudo systemctl start lnbits.service
```
> [!NOTE]
> With the Admin UI enabled, config is DB-backed and UI-managed settings ignore .env. Disable it to revert to [.env](../../.env.example) as the single source of truth.
## Reset to defaults
Using `Reset to defaults` in the Admin UI wipes stored settings. After a restart, a new `Super User` is created and the old one is no longer valid.
## Allowed Users
When set **at least one**, LNbits becomes private: only the listed users and Admins can access the frontend. Account creation is disabled automatically. You can also disable account creation explicitly.
<img width="1889" height="870" alt="grafik" src="https://github.com/user-attachments/assets/89011b75-a267-44ea-971a-1517968b7af5" />
> [!WARNING]
> Assign your own account first when enabling **Allowed Users** to avoid locking yourself out. If you do get locked out, use your Super User to recover access.
## Additional Guides
- **[Backend Wallets](./wallets.md)** — Explore options to fund your LNbits instance.
- **[User Roles](./user_roles.md)** — Overview of existing roles in LNbits.
- **[Funding sources](./funding-sources-table.md)** — What is available and how to configure each.
- **[Install LNBits](./installation.md)** — Choose your prefared way to install LNBits.
## Powered by LNbits
LNbits empowers everyone with modular, open source tools for building Bitcoin based systems — fast, free, and extendable.
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
[![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/)
A little hint, if you set `RESET TO DEFAULTS`, then a new Super User Account will also be created. The old one is then no longer valid.
-6
View File
@@ -1,9 +1,3 @@
---
layout: default
title: Extension Install
nav_order: 1
---
# Extension Install
Anyone can create an extension by following the [example extension](https://github.com/lnbits/example) and [making extensions](https://github.com/lnbits/lnbits/blob/main/docs/devs/extensions.md) dev guide.
@@ -1,9 +1,3 @@
---
layout: default
title: FastAPI extension upgrade
nav_order: 1
---
## Defining a route with path parameters
**old:**
-87
View File
@@ -1,87 +0,0 @@
---
layout: default
title: Wallet comparison
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
# Backend Wallet Comparison Table
LNbits lets you choose **how your wallets are funded** — from fully self-custodial nodes to simple hosted services. You can switch the funding source **without touching your apps, users, or extensions**. That means you can start fast, learn, and later upgrade to more control and privacy when you are ready.
**Why this matters**
- **Flexibility:** Pick the backend that fits your skills and constraints today, change it later with minimal friction.
- **Speed to ship:** Use a hosted option to get live quickly; move to a node when you need more control.
- **Scalability:** Match cost and maintenance to your stage — from hobby to production.
- **Privacy and compliance:** Choose between self-custody and provider-managed options depending on your requirements.
Below is a side-by-side comparison of Lightning funding sources you can use with LNbits.
> [!NOTE]
> “Backend Wallet” and “Funding Source” mean the same thing — the wallet or service that funds your LNbits.
## LNbits Lightning Network Funding Sources Comparison Table
| **Funding Source** | **Custodial Type** | **KYC Required** | **Technical Knowledge Needed** | **Node Hosting Required** | **Privacy Level** | **Liquidity Management** | **Ease of Setup** | **Maintenance Effort** | **Cost Implications** | **Scalability** | **Notes** |
| ------------------------------ | ------------------------ | ------------------- | ------------------------------ | ------------------------- | ----------------- | ------------------------ | ----------------- | ---------------------- | -------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------ |
| **LND (gRPC)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | gRPC interface for LND; suitable for advanced integrations. |
| **CoreLightning (CLN)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Requires setting up and managing your own CLN node. |
| **Phoenixd** | Self-custodial | ❌ | Medium | ❌ | Medium | Automatic | Moderate | Low | Minimal fees | Medium | Mobile wallet backend; suitable for mobile integrations. |
| **Nostr Wallet Connect (NWC)** | Custodial | Depends on provider | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur fees | Medium | Connects via Nostr protocol; depends on provider's policies. |
| **Boltz** | Self-custodial | ❌ | Medium | ❌ | Medium | Provider-managed | Moderate | Moderate | Minimal fees | Medium | Uses submarine swaps; connects to Boltz client. |
| **LND (REST)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for LND; suitable for web integrations. |
| **CoreLightning REST** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for CLN; suitable for web integrations. |
| **LNbits (another instance)** | Custodial | Depends on host | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur hosting fees | Medium | Connects to another LNbits instance; depends on host's policies. |
| **Alby** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Browser extension wallet; suitable for web users. |
| **Breez SDK** | Self-custodial | ❌ | Medium | ❌ | High | Automatic | Moderate | Low | Minimal fees | Medium | SDK for integrating Breez wallet functionalities. |
| **OpenNode** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for merchants. |
| **Blink** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; focuses on mobile integrations. |
| **ZBD** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Gaming-focused payment platform. |
| **Spark (CLN)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Web interface for CLN; requires Spark server setup. |
| **Spark (L2)** | Self-custodial | ❌ | Medium | ❌ | High | Automatic | Easy | Low | Minimal fees | Medium | Runs via Spark sidecar; seed-based self-custody. |
| **Cliche Wallet** | Self-custodial | ❌ | Medium | ❌ | Medium | Manual | Moderate | Moderate | Minimal fees | Medium | Lightweight wallet; suitable for embedded systems. |
| **Strike** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
| **LNPay** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
| **Eclair (ACINQ)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Connects via API; you run and manage your Eclair node. |
| **LN.tips** | Custodial/Self-Custodial | Depends on provider | Medium | ❌ | Low | Provider-managed | Moderate | Low | Transaction fees may apply | Medium | Simple hosted service; use LN.tips API as your backend. |
| **Fake Wallet** | Testing (simulated) | ❌ | Low | ❌ | N/A | N/A | Easy | Low | None (test only) | N/A | For testing only; mints accounting units in LNbits (no real sats, unit name configurable). |
## Spark (L2)
Spark L2 uses a local Node.js sidecar to expose an HTTP API that LNbits can use as a funding source. It is self-custodial and secured by a standard mnemonic seed. Sidecar repo `https://github.com/lnbits/spark_sidecar`.
### Notes for readers
- These are typical characteristics; your exact experience may vary by configuration and provider policy.
- Pick based on your constraints: compliance (KYC), privacy, ops effort, and time-to-ship.
---
## Additional Guides
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
- **[User Roles](./User_Roles.md)** — Quick Overview of existing Roles in LNBits.
- **[Funding sources](./funding-sources_table.md)** — Whats available and how to enable/configure each.
## Powered by LNbits
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable.
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
[![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/)
+169 -510
View File
@@ -1,142 +1,18 @@
---
layout: default
title: Installation
nav_order: 1
title: Basic installation
nav_order: 2
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![License: MIT](https://img.shields.io/badge/License-MIT-blue) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
# Basic installation
> [!NOTE]
> **Default DB:** LNbits uses SQLite by default (simple & effective). You can switch to PostgreSQL — see the section below.
The following sections explain how to install LNbits using varions package managers: `poetry`, `nix`, `Docker` and `Fly.io`.
## Table of contents
Note that by default LNbits uses SQLite as its database, which is simple and effective but you can configure it to use PostgreSQL instead which is also described in a section below.
- [Option 1: AppImage (Linux)](#option-1-appimage-linux)
- [Option 2: UV (recommended for developers)](#option-2-uv-recommended-for-developers)
- [Option 2a (Legacy): Poetry — Replaced by UV](#option-2a-legacy-poetry--replaced-by-uv)
- [Option 3: Install script (Debian/Ubuntu)](#option-3-install-script-debianubuntu)
- [Option 4: Nix](#option-4-nix)
- [Option 5: Docker](#option-5-docker)
- [Option 6: Fly.io](#option-6-flyio)
- [Troubleshooting](#troubleshooting)
- [Optional: PostgreSQL database](#optional-postgresql-database)
- [Using LNbits](#using-lnbits)
- [Additional guides](#additional-guides)
- [Update LNbits (all methods)](#update-lnbits-all-methods)
- [SQLite → PostgreSQL migration](#sqlite--postgresql-migration)
- [LNbits as a systemd service](#lnbits-as-a-systemd-service)
- [Reverse proxy with automatic HTTPS (Caddy)](#reverse-proxy-with-automatic-https-caddy)
- [Apache2 reverse proxy over HTTPS](#apache2-reverse-proxy-over-https)
- [Nginx reverse proxy over HTTPS](#nginx-reverse-proxy-over-https)
- [HTTPS without a reverse proxy (self-signed)](#https-without-a-reverse-proxy-self-signed)
- [LNbits on Umbrel behind Tor](#lnbits-on-umbrel-behind-tor)
- [FreeBSD notes](#freebsd-notes)
## Option 1 (recommended): Poetry
## Option 1: AppImage (Linux)
**Quickstart**
1. Download latest AppImage from [releases](https://github.com/lnbits/lnbits/releases) **or** run:
```sh
sudo apt-get install jq libfuse2
wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
chmod +x LNbits-latest.AppImage
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 AUTH_HTTPS_ONLY=false ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
```
- LNbits will create a folder for DB and extension files **in the same directory** as the AppImage.
> [!NOTE]
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
## Option 2: UV (recommended for developers)
> [!IMPORTANT]
> **It is recommended to use the latest version of UV & Make sure you have Python version 3.12 installed.**
### Verify Python
```sh
python3 --version
```
### Install UV
```sh
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
```
### Install LNbits
```sh
git clone https://github.com/lnbits/lnbits.git
cd lnbits
git checkout main
uv sync --all-extras
cp .env.example .env
# Optional: set funding source and other options in .env (e.g., `nano .env`)
```
### Run the server
```sh
uv run lnbits
# To change port/host: uv run lnbits --port 9000 --host 0.0.0.0
# Add --debug to the command above and set DEBUG=true in .env for verbose output
```
### LNbits CLI
```sh
# Useful for superuser ID, updating extensions, etc.
uv run lnbits-cli --help
```
### Update LNbits
```sh
cd lnbits
# Stop LNbits with Ctrl + X or your service manager
# sudo systemctl stop lnbits
# Update code
git pull --rebase
uv sync --all-extras
uv run lnbits
```
#### Use Admin UI → Extensions → "Update All" to bring extensions up to the proper level
> [!NOTE]
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
## Option 2a (Legacy): Poetry — _Replaced by UV_
<details>
<summary><strong>Poetry install and update (legacy workflow)</summary>
This legacy section is preserved for older environments.
**UV is the recommended (and faster) tool** for new installs. Use Poetry only if you have personal preferences or must support an older workflow.
> ![IMPORTANT](https://img.shields.io/badge/IMPORTANT-7c3aed?labelColor=494949)
> **It is recommended to use the latest version of Poetry & Make sure you have Python version 3.12 installed.**
It is recommended to use the latest version of Poetry. Make sure you have Python version 3.9 or higher installed.
### Verify Python version
@@ -147,72 +23,44 @@ python3 --version
### Install Poetry
```sh
# If path 'export PATH="$HOME/.local/bin:$PATH"' fails, use the path echoed by the install
curl -sSL https://install.python-poetry.org | python3 - && export PATH="$HOME/.local/bin:$PATH"
curl -sSL https://install.python-poetry.org | python3 -
# Once the above poetry install is completed, use the installation path printed to terminal and replace in the following command
export PATH="/home/user/.local/bin:$PATH"
```
### Install LNbits
```sh
git clone https://github.com/lnbits/lnbits.git
cd lnbits
poetry env use 3.12
git checkout main
poetry install --only main
cp .env.example .env
# Optional: to set funding source amongst other options via the env `nano .env`
# set funding source amongst other options
nano .env
```
#### Running the server
```sh
poetry run lnbits
# To change port/host: poetry run lnbits --port 9000 --host 0.0.0.0
# Add --debug to help troubleshooting (also set DEBUG=true in .env)
```
#### LNbits CLI
```sh
# A very useful terminal client for getting the superuser ID, updating extensions, etc.
poetry run lnbits-cli --help
# To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0'
# adding --debug in the start-up command above to help your troubleshooting and generate a more verbose output
# Note that you have to add the line DEBUG=true in your .env file, too.
```
#### Updating the server
```sh
```
cd lnbits
# Stop LNbits with Ctrl + X or with your service manager
# sudo systemctl stop lnbits
# Update LNbits
git pull --rebase
# Check your Poetry Python version
poetry env list
# If version is less than 3.12, update it:
poetry env use python3.12
poetry env remove python3.X
poetry env list
# Reinstall and start
# Stop LNbits with `ctrl + x`
git pull
# Keep your poetry install up to date, this can be done with `poetry self update`
poetry install --only main
poetry run lnbits
# Start LNbits with `poetry run lnbits`
```
#### Use Admin UI → Extensions → "Update All" to bring extensions up to the proper level
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949)
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
</details>
## Option 3: Install script (Debian/Ubuntu)
<details>
<summary><strong>Show install script</strong> (one-line setup)</summary>
## Option 2: Install script (on Debian/Ubuntu)
```sh
wget https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits.sh &&
@@ -220,58 +68,29 @@ chmod +x lnbits.sh &&
./lnbits.sh
```
- You can use `./lnbits.sh` to run, but for more control: `cd lnbits` and use `uv run lnbits` (see Option 2).
Now visit `0.0.0.0:5000` to make a super-user account.
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949)
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
`./lnbits.sh` can be used to run, but for more control `cd lnbits` and use `poetry run lnbits` (see previous option).
</details>
## Option 4: Nix
<details>
<summary><strong>Show Nix instructions</strong> (flakes, cachix, run)</summary>
## Option 3: Nix
```sh
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
sh <(curl --proto '=https' --tlsv1.2 -L https://nixos.org/nix/install) --daemon --yes
sh <(curl -L https://nixos.org/nix/install) --daemon
# Enable nix-command and flakes experimental features for nix:
grep -qxF 'experimental-features = nix-command flakes' /etc/nix/nix.conf || \
echo 'experimental-features = nix-command flakes' | sudo tee -a /etc/nix/nix.conf
# Add user to Nix
grep -qxF "trusted-users = root $USER" /etc/nix/nix.conf || \
echo "trusted-users = root $USER" | sudo tee -a /etc/nix/nix.conf
# Restart daemon so changes apply
sudo systemctl restart nix-daemon
# Clone and build LNbits
git clone https://github.com/lnbits/lnbits.git
cd lnbits
# Make data directory and persist data/extension folders
mkdir data
PROJECT_DIR="$(pwd)"
{
echo "export PYTHONPATH=\"$PROJECT_DIR/ns:\$PYTHONPATH\""
echo "export LNBITS_DATA_FOLDER=\"$PROJECT_DIR/data\""
echo "export LNBITS_EXTENSIONS_PATH=\"$PROJECT_DIR\""
} >> ~/.bashrc
grep -qxF '. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh' ~/.bashrc || \
echo '. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh' >> ~/.bashrc
. ~/.bashrc
echo 'experimental-features = nix-command flakes' >> /etc/nix/nix.conf
# Add cachix for cached binaries
nix-env -iA cachix -f https://cachix.org/api/v1/install
cachix use lnbits
# Build LNbits
# Clone and build LNbits
git clone https://github.com/lnbits/lnbits.git
cd lnbits
nix build
mkdir data
```
#### Running the server
@@ -285,39 +104,24 @@ but you can also set the env variables or pass command line arguments:
```sh
# .env variables are currently passed when running, but LNbits can be managed with the admin UI.
LNBITS_ADMIN_UI=true AUTH_HTTPS_ONLY=false ./result/bin/lnbits --port 9000 --host 0.0.0.0
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
# Once you have created a user, you can set as the super_user
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
```
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949)
> **Next steps**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
## Option 4: Docker
</details>
## Option 5: Docker
<details>
<summary><strong>Show Docker instructions</strong> (official image, volumes, extensions)</summary>
**Use latest image**
use latest version from docker hub
```sh
docker pull lnbits/lnbits
wget https://raw.githubusercontent.com/lnbits/lnbits/main/.env.example -O .env
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 ships **without any extensions**; by default, any extensions you install are stored **inside the container** and will be **lost** when the container is removed, so you should set `LNBITS_EXTENSIONS_PATH` to a directory thats **mapped to a persistent host volume** so extensions **survive rebuilds/recreates**—for example:
```sh
docker run ... -e "LNBITS_EXTENSIONS_PATH='/app/data/extensions'" --volume ${PWD}/data/:/app/data ...
```
**Build image yourself**
build the image yourself
```sh
git clone https://github.com/lnbits/lnbits.git
@@ -325,39 +129,18 @@ cd lnbits
docker build -t lnbits/lnbits .
cp .env.example .env
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
```
You can optionally override the install extras for both **Poetry** and **UV** to include optional features during build or setup:
## Option 5: Fly.io
- with Poetry, pass extras via the `POETRY_INSTALL_ARGS` Docker build-arg (e.g., to enable the **Breez** funding source: `docker build --build-arg POETRY_INSTALL_ARGS="-E breez" -t lnbits/lnbits .`);
- with UV, enable extras during environment sync (e.g., locally run `uv sync --extra breez` or `uv sync --all-extras`), and—**if your Dockerfile supports it**—you can mirror the same at build time via a build-arg such as `UV_SYNC_ARGS` (example pattern: `docker build --build-arg UV_SYNC_ARGS="--extra breez" -t lnbits/lnbits .`).
Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use.
**Enable Breez funding source at build**
First, sign up for an account at [Fly.io](https://fly.io) (no credit card required).
```sh
docker build --build-arg POETRY_INSTALL_ARGS="-E breez" -t lnbits/lnbits .
```
Then, install the Fly.io CLI onto your device [here](https://fly.io/docs/getting-started/installing-flyctl/).
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949)
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
</details>
## Option 6: Fly.io
<details>
<summary><strong>Deploy LNbits on Fly.io (free tier friendly)</summary>
**Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use.**
1. Create an account at [Fly.io](https://fly.io).
2. Install the Fly.io CLI ([guide](https://fly.io/docs/getting-started/installing-flyctl/)).
After install is complete, the command will output a command you should copy/paste/run to get `fly` into your `$PATH`. Something like:
```
flyctl was installed successfully to /home/ubuntu/.fly/bin/flyctl
@@ -366,9 +149,9 @@ Manually add the directory to your $HOME/.bash_profile (or similar)
export PATH="$FLYCTL_INSTALL/bin:$PATH"
```
3. You can either run those commands, then `source ~/.bash_profile` or, if you don't, you'll have to call Fly from `~/.fly/bin/flyctl`.
You can either run those commands, then `source ~/.bash_profile` or, if you don't, you'll have to call Fly from `~/.fly/bin/flyctl`.
- Once installed, run the following commands.
Once installed, run the following commands.
```
git clone https://github.com/lnbits/lnbits.git
@@ -382,16 +165,9 @@ You'll be prompted to enter an app name, region, postgres (choose no), deploy no
You'll now find a file in the directory called `fly.toml`. Open that file and modify/add the following settings.
> ![IMPORTANT](https://img.shields.io/badge/IMPORTANT-7c3aed?labelColor=494949)
> Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`.
> Environment variable strings should be quoted here. For example, if `.env` has
> `LNBITS_ENDPOINT=https://demo.lnbits.com`, then in `fly.toml` use
> `LNBITS_ENDPOINT="https://demo.lnbits.com"`.
Note: Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`. Environment variable strings should be quoted here, so if in `.env` you have `LNBITS_ENDPOINT=https://demo.lnbits.com` in `fly.toml` you should have `LNBITS_ENDPOINT="https://demo.lnbits.com"`.
> ![WARNING](https://img.shields.io/badge/WARNING-ea580c?labelColor=494949)
> Don't enter secret environment variables here. Fly.io offers **secrets** (via `fly secrets`) that are exposed as env vars at runtime.
> Example (LND REST funding source):
> `fly secrets set LND_REST_MACAROON=<hex_macaroon_data>`
Note: Don't enter secret environment variables here. Fly.io offers secrets (via the `fly secrets` command) that are exposed as environment variables in your runtime. So, for example, if using the LND_REST funding source, you can run `fly secrets set LND_REST_MACAROON=<hex_macaroon_data>`.
```
...
@@ -439,56 +215,33 @@ Problems installing? These commands have helped us install LNbits.
sudo apt install pkg-config libffi-dev libpq-dev
# build essentials for debian/ubuntu
sudo apt install python3.10-dev gcc build-essential
sudo apt install python3.9-dev gcc build-essential
# if the secp256k1 build fails:
# if you used poetry
poetry add setuptools wheel
```
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=0b0b0b)
>
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNbits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
### Optional: PostgreSQL database
## Troubleshooting
If you want to use LNbits at scale, we recommend using PostgreSQL as the backend database. Install Postgres and setup a database for LNbits:
```sh
sudo apt install pkg-config libffi-dev libpq-dev
# on debian/ubuntu 'sudo apt-get -y install postgresql'
# or follow instructions at https://www.postgresql.org/download/linux/
# build essentials (Debian/Ubuntu)
sudo apt install python3.10-dev gcc build-essential
# if secp256k1 build fails and you used poetry
poetry add setuptools wheel
```
</details>
</details>
## Optional: PostgreSQL database
> [!TIP]
> If you want to use LNbits at scale, we recommend using PostgreSQL as the backend database. Install Postgres and set up a database for LNbits.
```sh
# Debian/Ubuntu: sudo apt-get -y install postgresql
# or see https://www.postgresql.org/download/linux/
# Create a password for the postgres user
# Postgres doesn't have a default password, so we'll create one.
sudo -i -u postgres
psql
# in psql
ALTER USER postgres PASSWORD 'myPassword';
# on psql
ALTER USER postgres PASSWORD 'myPassword'; # choose whatever password you want
\q
# back as postgres user
# on postgres user
createdb lnbits
exit
```
**Configure LNbits**
You need to edit the `.env` file.
```sh
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
@@ -499,189 +252,44 @@ LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost:5432/lnbits"
# Using LNbits
Visit **[http://localhost:5000/](http://localhost:5000/)** (or `0.0.0.0:5000`).
Now you can visit your LNbits at http://localhost:5000/.
### Option A — First-run setup in the Browser (UI)
Now modify the `.env` file with any settings you prefer and add a proper [funding source](./wallets.md) by modifying the value of `LNBITS_BACKEND_WALLET_CLASS` and providing the extra information and credentials related to the chosen funding source.
1. On the **first start**, LNbits will **prompt you to Setup a SuperUser**.
2. After creating it, youll be **redirected to the Admin UI as SuperUser**.
3. In the Admin UI, **set your funding source** (backend wallet) and other preferences.
4. **Restart LNbits** if prompted or after changing critical settings.
Then you can restart it and it will be using the new settings.
> [!IMPORTANT]
> Use the **SuperUser only** for initial setup and instance settings (funding source, configuration, Topup).
> For maintenance, create a separate **Admin** account. For everyday usage (payments, wallets, etc.), **do not use the SuperUser** — use admin or regular user accounts instead. Its a bad behaviour.
> Read more about [SuperUser](./super_user.md) and [Admin UI](./admin_ui.md)
You might also need to install additional packages or perform additional setup steps, depending on the chosen backend. See [the short guide](./wallets.md) on each different funding source.
### Option B — Configure via `.env`
1. Edit your `.env` with preferred settings (funding, base URL, etc.).
2. Set a funding source by configuring:
- `LNBITS_BACKEND_WALLET_CLASS`
- plus the required credentials for your chosen backend (see **[wallets.md](./wallets.md)**).
3. **Restart LNbits** to apply changes.
---
> [!NOTE]
> **Paths overview**
>
> - **SuperUser file:** `<lnbits_root>/data/.super_user`
> Example: `~/lnbits/data/.super_user` • View: `cat ~/lnbits/data/.super_user`
> - **Environment file:** `<lnbits_root>/.env` (for bare-metal installs)
> - **Docker:** bind a host directory to `/app/data`.
> On the host the SuperUser file is at `<host_data_dir>/.super_user`.
> The container reads `/app/.env` (usually bind-mounted from your project root).
> [!TIP]
> **Local Lightning test network**
> Use **Polar** to spin up a safe local Lightning environment and test LNbits without touching your live setup.
> https://lightningpolar.com/
> [!TIP]
> **API comparison before updates**
> Use **TableTown** to diff your LNbits instance against another (dev vs prod) or the upstream dev branch. Spot endpoint changes before updating.
> Crafted by [Arbadacarbayk](https://github.com/arbadacarbaYK) - a standout contribution that makes pre-release reviews fast and reliable.
> https://arbadacarbayk.github.io/LNbits_TableTown/
Take a look at [Polar](https://lightningpolar.com/) for an excellent way of spinning up a Lightning Network dev environment.
# Additional guides
## Update LNbits (all methods)
## SQLite to PostgreSQL migration
> After updating, open **Admin UI → Extensions → “Update All”** to make sure extensions match the core version.
If you already have LNbits installed and running, on an SQLite database, we **highly** recommend you migrate to postgres if you are planning to run LNbits on scale.
<details>
<summary><strong>UV (recommended)</strong></summary>
```sh
cd lnbits
git pull --rebase
uv sync --all-extras
# restart (dev)
uv run lnbits
```
</details>
<details>
<summary><strong>Poetry (legacy)</strong></summary>
```sh
cd lnbits
git pull --rebase
# Optional: ensure Python 3.12
poetry env list
poetry env use python3.12
poetry install --only main
# restart (dev)
poetry run lnbits
```
</details>
<details>
<summary><strong>AppImage</strong></summary>
Download the latest AppImage from Releases and replace your old file **in the same directory** to keep the `./data` folder (DB, extensions).
</details>
<details>
<summary><strong>Install script (Debian/Ubuntu)</strong></summary>
```sh
# If you installed via lnbits.sh:
cd lnbits
git pull --rebase
# then use your chosen runner (UV recommended)
uv sync --all-extras
uv run lnbits
```
</details>
<details>
<summary><strong>Nix</strong></summary>
```sh
cd lnbits
git pull --rebase
nix build
# restart
nix run
```
</details>
<details>
<summary><strong>Docker (official image)</strong></summary>
```sh
docker pull lnbits/lnbits
docker stop lnbits && docker rm lnbits
docker run --detach --publish 5000:5000 --name lnbits \
--volume ${PWD}/.env:/app/.env \
--volume ${PWD}/data/:/app/data \
lnbits/lnbits
```
</details>
<details>
<summary><strong>Docker (build yourself)</strong></summary>
```sh
cd lnbits
git pull --rebase
docker build -t lnbits/lnbits .
docker stop lnbits && docker rm lnbits
docker run --detach --publish 5000:5000 --name lnbits \
--volume ${PWD}/.env:/app/.env \
--volume ${PWD}/data/:/app/data \
lnbits/lnbits
```
</details>
<details>
<summary><strong>Fly.io</strong></summary>
```sh
# If using Dockerfile in repo (recommended)
cd lnbits
git pull --rebase
fly deploy
# Logs & shell if needed
fly logs
fly ssh console
```
</details>
## SQLite → PostgreSQL migration
> [!TIP]
> If you run on SQLite and plan to scale, migrate to Postgres.
There's a script included that can do the migration easy. You should have Postgres already installed and there should be a password for the user (see Postgres install guide above). Additionally, your LNbits instance should run once on postgres to implement the database schema before the migration works:
```sh
# STOP LNbits
# Edit .env with Postgres URL
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
# postgres://<user>:<password>@<host>/<database> - alter line bellow with your user, password and db name
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
# save and exit
# START then STOP LNbits once to apply schema
uv run python tools/conv.py
# START LNbits
# STOP LNbits
poetry run python tools/conv.py
# or
make migration
```
- Launch LNbits again and verify.
Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly.
## LNbits as a systemd service
Create `/etc/systemd/system/lnbits.service`:
Systemd is great for taking care of your LNbits instance. It will start it on boot and restart it in case it crashes. If you want to run LNbits as a systemd service on your Debian/Ubuntu/Raspbian server, create a file at `/etc/systemd/system/lnbits.service` with the following content:
```
# Systemd unit for lnbits
@@ -689,14 +297,17 @@ Create `/etc/systemd/system/lnbits.service`:
[Unit]
Description=LNbits
# Optional: start after your backend
# you can uncomment these lines if you know what you're doing
# it will make sure that lnbits starts after lnd (replace with your own backend service)
#Wants=lnd.service
#After=lnd.service
[Service]
# replace with the absolute path of your lnbits installation
WorkingDirectory=/home/lnbits/lnbits
# Find uv path via `which uv`
ExecStart=/home/lnbits/.local/bin/uv run lnbits
# same here. run `which poetry` if you can't find the poetry binary
ExecStart=/home/lnbits/.local/bin/poetry run lnbits
# replace with the user that you're running lnbits on
User=lnbits
Restart=always
TimeoutSec=120
@@ -707,47 +318,73 @@ Environment=PYTHONUNBUFFERED=1
WantedBy=multi-user.target
```
Enable & start:
Save the file and run the following commands:
```sh
sudo systemctl enable lnbits.service
sudo systemctl start lnbits.service
```
## Reverse proxy with automatic HTTPS (Caddy)
## Reverse proxy with automatic HTTPS using Caddy
Point your domain A-record to your server IP. Install Caddy: [Caddy install guide](https://caddyserver.com/docs/install#debian-ubuntu-raspbian)
Use Caddy to make your LNbits install accessible over clearnet with a domain and https cert.
```sh
Point your domain at the IP of the server you're running LNbits on, by making an `A` record.
Install Caddy on the server
https://caddyserver.com/docs/install#debian-ubuntu-raspbian
```
sudo caddy stop
```
Create a Caddyfile
```
sudo nano Caddyfile
```
Add:
Assuming your LNbits is running on port `5000` add:
```
yourdomain.com {
handle /api/v1/payments/sse* {
reverse_proxy 0.0.0.0:5000 {
header_up X-Forwarded-Host yourdomain.com
transport http {
keepalive off
compression off
}
}
}
reverse_proxy 0.0.0.0:5000 {
header_up X-Forwarded-Host yourdomain.com
}
}
```
Save (Ctrl+X) and start:
Save and exit `CTRL + x`
```sh
```
sudo caddy start
```
## Apache2 reverse proxy over HTTPS
## Running behind an Apache2 reverse proxy over HTTPS
Install Apache2 and enable Apache2 mods:
```sh
apt-get install apache2 certbot
a2enmod headers ssl proxy proxy_http
a2enmod headers ssl proxy proxy-http
```
Create a SSL certificate with LetsEncrypt:
```sh
certbot certonly --webroot --agree-tos --non-interactive --webroot-path /var/www/html -d lnbits.org
```
Create `/etc/apache2/sites-enabled/lnbits.conf`:
Create an Apache2 vhost at: `/etc/apache2/sites-enabled/lnbits.conf`:
```sh
cat <<EOF > /etc/apache2/sites-enabled/lnbits.conf
@@ -774,20 +411,27 @@ cat <<EOF > /etc/apache2/sites-enabled/lnbits.conf
EOF
```
Restart:
Restart Apache2:
```sh
service apache2 restart
service restart apache2
```
## Nginx reverse proxy over HTTPS
## Running behind an Nginx reverse proxy over HTTPS
Install nginx:
```sh
apt-get install nginx certbot
```
Create a SSL certificate with LetsEncrypt:
```sh
certbot certonly --nginx --agree-tos -d lnbits.org
```
Create `/etc/nginx/sites-enabled/lnbits.org`:
Create an nginx vhost at `/etc/nginx/sites-enabled/lnbits.org`:
```sh
cat <<EOF > /etc/nginx/sites-enabled/lnbits.org
@@ -821,22 +465,23 @@ server {
EOF
```
Restart:
Restart nginx:
```sh
service nginx restart
service restart nginx
```
---
## Using https without reverse proxy
## HTTPS without a reverse proxy (self-signed)
The most common way of using LNbits via https is to use a reverse proxy such as Caddy, nginx, or ngriok. However, you can also run LNbits via https without additional software. This is useful for development purposes or if you want to use LNbits in your local network.
Create a self-signed cert (useful for local/dev). Browsers wont trust it by default.
We have to create a self-signed certificate using `mkcert`. Note that this certificate is not "trusted" by most browsers but that's fine (since you know that you have created it) and encryption is always better than clear text.
### Install mkcert
#### Install mkcert
- Install instructions: [mkcert README](https://github.com/FiloSottile/mkcert)
- Ubuntu example:
You can find the install instructions for `mkcert` [here](https://github.com/FiloSottile/mkcert).
Install mkcert on Ubuntu:
```sh
sudo apt install libnss3-tools
@@ -845,47 +490,61 @@ chmod +x mkcert-v*-linux-amd64
sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert
```
### Create certificate
#### Create certificate
**OpenSSL**
To create a certificate, first `cd` into your LNbits folder and execute the following command on Linux:
```sh
openssl req -new -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out cert.pem -keyout key.pem
```
**mkcert** (alternative)
This will create two new files (`key.pem` and `cert.pem `).
Alternatively, you can use mkcert ([more info](https://kifarunix.com/how-to-create-self-signed-ssl-certificate-with-mkcert-on-ubuntu-18-04/)):
```sh
# include your local IP (e.g., 192.x.x.x) if needed
# add your local IP (192.x.x.x) as well if you want to use it in your local network
mkcert localhost 127.0.0.1 ::1
```
**Run with certs**
You can then pass the certificate files to uvicorn when you start LNbits:
```sh
poetry run uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./key.pem --ssl-certfile ./cert.pem
```
## LNbits on Umbrel behind Tor
## LNbits running on Umbrel behind Tor
See this community [guide](https://community.getumbrel.com/t/guide-lnbits-without-tor/604).
If you want to run LNbits on your Umbrel but want it to be reached through clearnet, _Uxellodunum_ made an extensive [guide](https://community.getumbrel.com/t/guide-lnbits-without-tor/604) on how to do it.
## FreeBSD notes
## Docker installation
Issue with secp256k1 0.14.0 on FreeBSD (thanks @GitKalle):
To install using docker you first need to build the docker image as:
1. Install `py311-secp256k1` with `pkg install py311-secp256k1`.
2. Change version in `pyproject.toml` from `0.14.0` to `0.13.2`.
3. Rewrite `poetry.lock` with `poetry lock`.
4. Follow install instructions with Poetry.
```
git clone https://github.com/lnbits/lnbits.git
cd lnbits
docker build -t lnbits/lnbits .
```
---
You can launch the docker in a different directory, but make sure to copy `.env.example` from lnbits there
## Powered by LNbits
```
cp <lnbits_repo>/.env.example .env
```
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable.
and change the configuration in `.env` as required.
If you like this project [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visiting our [Shop](https://shop.lnbits.com)
Then create the data directory
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/) [![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login) [![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/) [![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/)
```
mkdir data
```
Then the image can be run as:
```
docker run --detach --publish 5000:5000 --name lnbits -e "LNBITS_BACKEND_WALLET_CLASS='FakeWallet'" --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits
```
Finally you can access your lnbits on your machine at port 5000.
-139
View File
@@ -1,139 +0,0 @@
# Generic OIDC Authentication Configuration
This document explains how to configure generic OIDC authentication for LNbits, which allows integration with various OIDC-compliant authentication providers such as Zitadel, Authentik, and others.
## Overview
The generic OIDC provider (`oidc`) complements the existing Keycloak provider and allows you to integrate any OIDC-compliant authentication service. You can customize the login button with your own organization name and icon.
## Configuration
Add the following environment variables to your `.env` file or system environment:
### Required Settings
```bash
# Enable OIDC authentication
LNBITS_AUTH_ALLOWED_METHODS=oidc-auth
# OIDC Discovery URL (well-known endpoint)
LNBITS_OIDC_DISCOVERY_URL=https://your-oidc-provider-domain/.well-known/openid-configuration
# Client credentials from your OIDC provider
LNBITS_OIDC_CLIENT_ID=your-client-id
LNBITS_OIDC_CLIENT_SECRET=your-client-secret
```
### Optional Settings - Customize the Login Button
You can customize how the OIDC login button appears to your users:
```bash
# Custom organization name (displayed on the login button)
# Example: "Login via Zitadel" or "Login via Authentik"
LNBITS_OIDC_CLIENT_CUSTOM_ORG="Zitadel"
# Custom icon URL (displayed on the login button)
# Can be a full URL or a path to a local image
LNBITS_OIDC_CLIENT_CUSTOM_ICON=https://zitadel.com/favicon.svg
```
If not set, the button will display "Login via OIDC" with a generic lock icon.
## Zitadel Configuration Example
For Zitadel, configure as follows:
1. Create a new application in Zitadel
2. Choose "Web" application type
3. Configure the redirect URI: `https://your-lnbits-domain/api/v1/auth/oidc/token`
4. Save the Client ID and Client Secret
5. Use these environment variables:
```bash
LNBITS_AUTH_ALLOWED_METHODS=oidc-auth
LNBITS_OIDC_DISCOVERY_URL=https://your-oidc-provider-domain/.well-known/openid-configuration
LNBITS_OIDC_CLIENT_ID=your-zitadel-client-id
LNBITS_OIDC_CLIENT_SECRET=your-zitadel-client-secret
# Customize the button to show "Login via Zitadel" with Zitadel's logo
LNBITS_OIDC_CLIENT_CUSTOM_ORG="Zitadel"
LNBITS_OIDC_CLIENT_CUSTOM_ICON="https://zitadel.com/favicon.svg"
```
**Result**: The login page will display a button with the text "Login via Zitadel" and the Zitadel logo.
## Authentik Configuration Example
For Authentik:
1. Create a new OAuth2/OpenID Provider
2. Set the redirect URI: `https://your-lnbits-domain/api/v1/auth/oidc/token`
3. Configure scopes: `openid`, `email`, `profile`
4. Get the Client ID and Client Secret
```bash
LNBITS_AUTH_ALLOWED_METHODS=oidc-auth
LNBITS_OIDC_DISCOVERY_URL=https://authentik.yourdomain.com/application/o/your-app/.well-known/openid-configuration
LNBITS_OIDC_CLIENT_ID=your-authentik-client-id
LNBITS_OIDC_CLIENT_SECRET=your-authentik-client-secret
LNBITS_OIDC_CLIENT_CUSTOM_ORG="Authentik"
```
## Multiple Auth Methods
You can enable multiple authentication methods simultaneously:
```bash
LNBITS_AUTH_ALLOWED_METHODS=username-password,oidc-auth,keycloak-auth
```
## Discovery Endpoint Requirements
Your OIDC provider must expose a standard discovery endpoint (`.well-known/openid-configuration`) that includes:
- `authorization_endpoint`
- `token_endpoint`
- `userinfo_endpoint`
- `jwks_uri` (JSON Web Key Set)
The OIDC implementation will automatically fetch these endpoints from the discovery URL.
## User Mapping
The OIDC provider maps user information from the OIDC userinfo endpoint:
- `sub` → User ID
- `email` → Email address
- `given_name` → First name
- `family_name` → Last name
- `name` or `preferred_username` → Display name
- `picture` → Profile picture URL
## Troubleshooting
### Authentication fails
1. Verify the discovery URL is accessible
2. Check that Client ID and Client Secret are correct
3. Ensure redirect URI in your OIDC provider matches: `https://your-lnbits-domain/api/v1/auth/oidc/token`
4. Check LNbits logs for detailed error messages
### User info not populated
Some OIDC providers may use different claim names. If user information is not correctly populated, check your provider's userinfo endpoint response format and adjust the provider class if needed.
## Security Considerations
- Always use HTTPS in production
- Keep client secrets secure and never commit them to version control
- Use environment variables or secure configuration management
- Regularly rotate client secrets
- Review OIDC provider's security best practices
## Implementation Details
The OIDC provider is implemented in `lnbits/core/models/sso/oidc.py` and extends the `fastapi_sso` library's `SSOBase` class. It uses the standard OpenID Connect flow with:
- Scopes: `openid`, `email`, `profile`
- Response type: `code` (authorization code flow)
- Discovery document for automatic endpoint resolution
-132
View File
@@ -1,132 +0,0 @@
---
layout: default
title: Super user
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
# LNbits Super User (SU)
**Table of Contents**
- [What is the Super User?](#what-is-the-super-user)
- [When is the Super User created?](#when-is-the-super-user-created)
- [Disabeling the Admin UI](#disabeling-the-admin-ui)
- [Super User identity and storage](#super-user-identity-and-storage)
- [Security model since v1](#security-model-since-v1)
- [Admin vs Super User](#admin-vs-super-user)
- [Operational guidance](#operational-guidance)
- [Additional guides](#additional-guides)
<details>
<summary><strong>TLDR</strong></summary>
- **No Admin UI → No Super User.** The Super User (SU) exists only when `LNBITS_ADMIN_UI=true`.
- **Why SU exists:** SU can do a few high impact actions regular admins cannot, like **changing the funding source**, **restarting the server from the UI**, and **crediting or debiting accounts**.
- **Login changes since v1:** Logging in by **user ID** for SU and admins is **disabled**. On first visit after enabling the Admin UI you will be prompted to set a **username and password** for the SU.
- **Trust model:** Admins and the SU share about **99 percent of the same powers**, but the SU is the one trusted with funding source control and cannot be demoted by regular admins.
</details>
## What is the Super User?
The **Super User** is the owner-operator account of an LNbits instance. Think of it as your “break glass” operator with a few capabilities that are intentionally reserved for the person ultimately responsible for the server and the funding rails.
The SU is created alongside the [Admin UI](./admin_ui.md) and is meant to keep enviroment operations pleasant in the UI while keeping the most sensitive knobs in trusted hands.
**Key SU capabilities**
- **Change the funding source** for the instance
- **Restart the LNbits server** from the web UI
- **Credit or debit accounts** for operational corrections
> Note
> These are separated from regular admin tasks on purpose. It helps maintain least privilege and reduces the chance of accidental or malicious changes.
## Admin vs Super User
| Capability | Admin | Super User |
| ------------------------ | ---------- | ---------- |
| View Admin UI | If enabled | If enabled |
| Change funding source | — | ✓ |
| Credit or debit accounts | — | ✓ |
| Restart server from UI | — | ✓ |
| Manage users and wallets | ✓ | ✓ |
| Instance-level settings | ✓ | ✓ |
| Manage notifications | ✓ | ✓ |
| Exchange rates | ✓ | ✓ |
| View all Payments | ✓ | ✓ |
**Why both roles?**
In many teams the person running the server prefers to **delegate day-to-day admin work** while keeping funding and final authority safe. Admins can do almost everything; the SU retains the last few high risk powers.
## When is the Super User created?
- The SU is created **only** when you enable the Admin UI: `LNBITS_ADMIN_UI=true`.
- If the Admin UI is **disabled**, there is **no SU** and all SU-only UI is hidden.
## Disabeling the Admin UI
> [!IMPORTANT]
> Read the [Admin UI guide](./admin_ui.md) before Disabeling. You are turning on a management surface; do it deliberately.
Set the environment variable in your deployment:
```bash
# .env
LNBITS_ADMIN_UI=false
```
## Super User identity and storage
LNbits stores the **Super User ID** at:
```
/lnbits/data/.super_user
```
- Back this up along with the rest of `/lnbits/data` as part of your secure backup routine.
- **Changing who is the SU** can only be done by someone with **CLI access to the host OS** where LNbits runs. **Regular admins cannot revoke or replace the SU in the Admin UI.**
## Security model since v1
- **User-ID logins are disabled** for SU and admin roles.
- **Credentialed login is required:** set a **username and password** for the SU at first run of the Admin UI.
- **SU secrecy:** Regular users and admins **cannot discover the SU user ID** through normal UI flows.
## Operational guidance
These are practical tips for running a safe and friendly instance.
- It is normal to **delegate admin** duties to trusted people. Admins have about **99 percent** of SU powers for day-to-day work.
- Keep the **SU** reserved for the person legally or operationally responsible for the **funding source**.
- Use admin roles for regular day-to-day management and keep the SU for reserved SU tasks only.
## Additional guides
- **[Admin UI](./admin_ui.md)** — Manage server settings in the browser instead of editing `.env` or using the CLI for routine tasks.
- **[User Roles](./user_roles.md)** — Overview of roles and what they can do.
- **[Funding sources](./funding-sources-table.md)** — Available options and how to enable and configure them.
- **[Install LNBits](./installation.md)** — Choose your prefared way to install LNBits.
## Powered by LNbits
LNbits empowers everyone with modular, open source tools for building Bitcoin-based systems — fast, free, and extendable.
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
[![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/)
-101
View File
@@ -1,101 +0,0 @@
---
layout: default
title: User Roles
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
# LNbits Roles: A Quick Overview
### Understand **who can do what** in seconds: `Super User`, `Admin`, and `Regular User`.
**Jump to:**
[Roles at a Glance](#roles-at-a-glance) •
[Super User](#super-user--master-control) •
[Admin](#admin--day-to-day-manager) •
[Regular User](#regular-user--everyday-use) •
[Best Practices](#best-practices) •
[Additional Guides](#additional-guides)
---
## Roles at a Glance
| Capability | **Super User** (owner) | **Admin** (manager) | **Regular User** (end user) |
| -------------------------------- | :--------------------: | :-----------------: | :-------------------------: |
| Change **funding source** | ✅ | ❌ | ❌ |
| Credit/Debit any wallet | ✅ | ❌ | ❌ |
| Manage Admins & Users | ✅ | ✅ | ❌ |
| Enable/disable extensions | ✅ | ✅ | ❌ |
| Use wallets & allowed extensions | ✅ | ✅ | ✅ |
> **Plain talk:** **Super User** = Owner • **Admin** = Trusted manager • **Regular User** = End user
## Role Snapshots
### Super User — Master Control
For initial setup and rare, high-impact changes.
- Configure **server-level settings** (e.g., funding source).
- **Credit/Debit** any wallet.
- Create and manage initial **Admin(s)**.
> **Sign-in:** username + password (v1+). The old query-string login is retired.
### Admin — Day-to-Day Manager
For running the service without touching the most sensitive knobs.
- Manage **Users**, **Admins**, and **Extensions** in the Admin UI.
- Adjust security-related settings (e.g., `rate_limiter`, `ip_blocker`).
- Handle operations settings (e.g., `service_fee`, `invoice_expiry`).
- Build brand design in **Site Customization**.
- Update user accounts.
**Typical tasks:** onboarding users, enabling extensions, tidying wallets, reviewing activity.
> **Sign-in:** username + password (v1+). The old query-string login is retired.
### Regular User — Everyday Use
For using LNbits, not administering it.
- Access **personal wallets** and **allowed extensions**.
- No server/admin privileges.
**Typical tasks:** receive and send payments, use enabled extensions.
## Best Practices
- **Minimize risk:** Reserve **Super User** for rare, sensitive actions (funding source, debit/credit). Use **Admin** for daily operations.
- **Keep access tidy:** Review your Admin list occasionally; remove unused accounts.
- **Change management:** Test risky changes (like funding) in a staging setup first.
## Additional Guides
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
- **[Super User](./super_user.md)** — Deep dive on responsibilities and safe usage patterns.
- **[Funding sources](./funding-sources-table.md)** — Whats available and how to enable/configure each.
## Powered by LNbits
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems—fast, free, and extendable.
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
[![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/)
+91 -341
View File
@@ -4,394 +4,144 @@ title: Backend wallets
nav_order: 3
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
# Backend wallets
**LNbits is modular**: You can switch the funding source (backend wallet) **without changing anything else** in your setup. Keep your extensions, apps, users, and config as-is — just point LNbits to a different backend via environment variables.
LNbits can run on top of many Lightning Network funding sources with more being added regularly.
**What stays the same when you switch backends**
A backend wallet can be configured using the following LNbits environment variables:
- Your LNbits setup and extensions
- Your API keys and endpoints
- Your server and deployment setup
### CoreLightning
A backend wallet is selected and configured entirely through LNbits environment variables. See the options and variables below, and compare them here: [Funding-Source-Table.md](funding-sources-table.md)
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
- `CORELIGHTNING_RPC`: /file/path/lightning-rpc
> [!NOTE]
> **Terminology:** “Backend Wallet” and “Funding Source” mean the same thing — the wallet or service that funds your LNbits.
### CoreLightning REST
## Funding Sources
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningRestWallet**
- `CORELIGHTNING_REST_URL`: http://127.0.0.1:8185/
- `CORELIGHTNING_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
- `CORELIGHTNING_REST_CERT`: /home/lightning/clnrest/tls.cert
## Funding Sources
### Spark (Core Lightning)
| | | |
| ----------------------------------------------- | ------------------------------------- | ------------------------------------------------- |
| [CLNRest (runes)](#clnrest-runes) | [LND (REST)](#lnd-rest) | [OpenNode](#opennode) |
| [CoreLightning](#corelightning) | [LND (gRPC)](#lnd-grpc) | [Blink](#blink) |
| [CoreLightning REST](#corelightning-rest) | [LNbits](#lnbits) | [Alby](#alby) |
| [Spark (Core Lightning)](#spark-core-lightning) | [LNPay](#lnpay) | [Boltz](#boltz) |
| [Spark L2](#spark-l2) | [ZBD](#zbd) | [Phoenixd](#phoenixd) |
| [Cliche Wallet](#cliche-wallet) | | |
| [Breez SDK](#breez-sdk) | [Breez Liquid SDK](#breez-liquid-sdk) | [Nostr Wallet Connect](#nostr-wallet-connect-nwc) |
| [Strike](#strike) | [Eclair (ACINQ)](#eclair-acinq) | [LN.tips](#lntips) |
| [Fake Wallet](#fake-wallet) | | |
- `LNBITS_BACKEND_WALLET_CLASS`: **SparkWallet**
- `SPARK_URL`: http://10.147.17.230:9737/rpc
- `SPARK_TOKEN`: secret_access_key
---
### LND (REST)
<a id="clnrest-runes"></a>
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
- `LND_REST_CERT`: /file/path/tls.cert
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
### CLNRest (using [runes](https://docs.corelightning.org/reference/lightning-createrune))
or
[Core Lightning REST API docs](https://docs.corelightning.org/docs/rest)
Should also work with the [Rust version of CLNRest](https://github.com/daywalker90/clnrest-rs)
- `LND_REST_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
**Environment variables**
### LND (gRPC)
- `LNBITS_BACKEND_WALLET_CLASS`: `CLNRestWallet`
- `CLNREST_URL`: `https://127.0.0.1:3010`
- `CLNREST_CA`: `/home/lightningd/.lightning/bitcoin/ca.pem` (or the content of the file)
- `CLNREST_CERT`: `/home/lightningd/.lightning/bitcoin/server.pem` (or the content of the file)
- `CLNREST_LAST_PAY_INDEX`: `lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max'`
- `CLNREST_NODEID`: `lightning-cli getinfo | jq -r .id` (only required for v23.08)
- `LNBITS_BACKEND_WALLET_CLASS`: **LndWallet**
- `LND_GRPC_ENDPOINT`: ip_address
- `LND_GRPC_PORT`: port
- `LND_GRPC_CERT`: /file/path/tls.cert
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon or Base64/Hex
**Create runes (copy/paste)**
You can also use an AES-encrypted macaroon (more info) instead by using
```bash
# Read-only: funds, pays, invoices, info, summary, and invoice listener
lightning-cli createrune \
restrictions='[["method=listfunds","method=listpays","method=listinvoices","method=getinfo","method=summary","method=waitanyinvoice"]]' \
| jq -r .rune
```
- `LND_GRPC_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
```bash
# Invoice: max 1,000,001 msat, label must start with "LNbits", 60 req/min
lightning-cli createrune \
restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' \
| jq -r .rune
```
To encrypt your macaroon, run `poetry run python lnbits/wallets/macaroon/macaroon.py`.
```bash
# Pay: bolt11 amount < 1001 (msat), label must start with "LNbits", 1 req/min
lightning-cli createrune \
restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' \
| jq -r .rune
```
### LNbits
```bash
# Renepay: invstring amount < 1001 (msat), label must start with "LNbits", 1 req/min
lightning-cli createrune \
restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' \
| jq -r .rune
```
- `LNBITS_BACKEND_WALLET_CLASS`: **LNbitsWallet**
- `LNBITS_ENDPOINT`: e.g. https://lnbits.com
- `LNBITS_KEY`: lnbitsAdminKey
Set the resulting values into:
### LNPay
- `CLNREST_READONLY_RUNE`
- `CLNREST_INVOICE_RUNE`
- `CLNREST_PAY_RUNE`
- `CLNREST_RENEPAY_RUNE`
For the invoice listener to work you have a publicly accessible URL in your LNbits and must set up [LNPay webhooks](https://dashboard.lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the "Wallet Receive" event and no secret. For example, `https://mylnbits/wallet/webhook` will be the Endpoint Url that gets notified about the payment.
## CoreLightning
- `LNBITS_BACKEND_WALLET_CLASS`: **LNPayWallet**
- `LNPAY_API_ENDPOINT`: https://api.lnpay.co/v1/
- `LNPAY_API_KEY`: sak_apiKey
- `LNPAY_WALLET_KEY`: waka_apiKey
**Required env vars**
### OpenNode
- `LNBITS_BACKEND_WALLET_CLASS`: `CoreLightningWallet`
- `CORELIGHTNING_RPC`: `/file/path/lightning-rpc`
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary.
## CoreLightning REST
- `LNBITS_BACKEND_WALLET_CLASS`: **OpenNodeWallet**
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
- `OPENNODE_KEY`: opennodeAdminApiKey
Old REST interface using [RTL c-lightning-REST](https://github.com/Ride-The-Lightning/c-lightning-REST)
### Blink
**Required env vars**
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate a Blink API key after logging in or creating a new Blink account at: https://dashboard.blink.sv. For more info visit: https://dev.blink.sv/api/auth#create-an-api-key```
- `LNBITS_BACKEND_WALLET_CLASS`: `CoreLightningRestWallet`
- `CORELIGHTNING_REST_URL`: `http://127.0.0.1:8185/`
- `CORELIGHTNING_REST_MACAROON`: `/file/path/admin.macaroon` or Base64/Hex
- `CORELIGHTNING_REST_CERT`: `/home/lightning/clnrest/tls.cert`
- `LNBITS_BACKEND_WALLET_CLASS`: **BlinkWallet**
- `BLINK_API_ENDPOINT`: https://api.blink.sv/graphql
- `BLINK_WS_ENDPOINT`: wss://ws.blink.sv/graphql
- `BLINK_TOKEN`: BlinkToken
## Spark (Core Lightning)
### Alby
**Required env vars**
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an alby access token here: https://getalby.com/developer/access_tokens/new
- `LNBITS_BACKEND_WALLET_CLASS`: `SparkWallet`
- `SPARK_URL`: `http://10.147.17.230:9737/rpc`
- `SPARK_TOKEN`: `secret_access_key`
- `LNBITS_BACKEND_WALLET_CLASS`: **AlbyWallet**
- `ALBY_API_ENDPOINT`: https://api.getalby.com/
- `ALBY_ACCESS_TOKEN`: AlbyAccessToken
## Spark L2
### Boltz
Self-custodial funding source using the [Spark L2](https://docs.spark.money/start/overview) network. Requires a Node.js [sidecar](https://github.com/lnbits/spark_sidecar) that bridges lnbits talking to Spark. Works in addition with any Spark-compatible seed (Wallet of Satoshi, BuhoGO, BlitzWallet).
If the sidecar is started with a `mnemonic` then that mnemonic will be used. Otherwhise if a mnemonic is set for the `Spark L2` LNbits funding source then that mnemonic will be used.
This funding source connects to a running [boltz-client](https://docs.boltz.exchange/v/boltz-client) and handles all lightning payments through submarine swaps on the liquid network.
You can configure the daemon to run in standalone mode by `standalone = True` in the config file or using the cli flag (`boltzd --standalone`).
Once running, you can create a liquid wallet using `boltzcli wallet create lnbits lbtc`.
### Optional tuning
- `LNBITS_BACKEND_WALLET_CLASS`: **BoltzWallet**
- `BOLTZ_CLIENT_ENDPOINT`: 127.0.0.1:9002
- `BOLTZ_CLIENT_MACAROON`: /home/bob/.boltz/macaroons/admin.macaroon or Base64/Hex
- `BOLTZ_CLIENT_CERT`: /home/bob/.boltz/tls.cert or Base64/Hex
- `BOLTZ_CLIENT_WALLET`: lnbits
- `SPARK_L2_PAY_WAIT_MS`: `4000` _(payment timeout in ms)_
- `SPARK_L2_PAY_POLL_MS`: `500` _(polling interval in ms)_
- `SPARK_L2_STREAM_KEEPALIVE_MS`: `15000` _(SSE keepalive in ms)_
### ZBD
### Example: run the sidecar
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an ZBD API Key here: https://zbd.dev/docs/dashboard/projects/api
```bash
git clone https://github.com/lnbits/spark_sidecar.git
cd spark_sidecar
npm install
- `LNBITS_BACKEND_WALLET_CLASS`: **ZBDWallet**
- `ZBD_API_ENDPOINT`: https://api.zebedee.io/v0/
- `ZBD_API_KEY`: ZBDApiKey
SPARK_MNEMONIC="bottom bottom bottom bottom bottom bottom bottom bottom bottom bottom bottom bottom" \
SPARK_NETWORK=MAINNET \
SPARK_SIDECAR_PORT=8765 \
SPARK_PAY_WAIT_MS=20000 \
node server.mjs
```
### Phoenixd
For testing, you can generate a 12-word mnemonic at https://iancoleman.io/bip39/. Store it securely — it controls your funds. Then select Spark (L2) as the funding source in LNbits.
For the invoice to work you must have a publicly accessible URL in your LNbits. You can get a phoenixd API key from the install
~/.phoenix/phoenix.conf, also see the documentation for phoenixd.
## LND (REST)
- `LNBITS_BACKEND_WALLET_CLASS`: **PhoenixdWallet**
- `PHOENIXD_API_ENDPOINT`: http://localhost:9740/
- `PHOENIXD_API_PASSWORD`: PhoenixdApiPassword
**Required env vars**
### Breez SDK
- `LNBITS_BACKEND_WALLET_CLASS`: `LndRestWallet`
- `LND_REST_ENDPOINT`: `http://10.147.17.230:8080/`
- `LND_REST_CERT`: `/file/path/tls.cert`
- `LND_REST_MACAROON`: `/file/path/admin.macaroon` or Base64/Hex
A Greenlight invite code or Greenlight partner certificate/key can be used to register a new node with Greenlight. If the Greenlight node already exists, neither are required.
or:
- `LNBITS_BACKEND_WALLET_CLASS`: **BreezSdkWallet**
- `BREEZ_API_KEY`: ...
- `BREEZ_GREENLIGHT_SEED`: ...
- `BREEZ_GREENLIGHT_INVITE_CODE`: ...
- `BREEZ_GREENLIGHT_DEVICE_KEY`: /path/to/breezsdk/device.pem or Base64/Hex
- `BREEZ_GREENLIGHT_DEVICE_CERT`: /path/to/breezsdk/device.crt or Base64/Hex
- `LND_REST_MACAROON_ENCRYPTED`: `eNcRyPtEdMaCaRoOn`
### Cliche Wallet
## LND (gRPC)
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
**Required env vars**
### Nostr Wallet Connect (NWC)
- `LNBITS_BACKEND_WALLET_CLASS`: `LndWallet`
- `LND_GRPC_ENDPOINT`: `ip_address`
- `LND_GRPC_PORT`: `port`
- `LND_GRPC_CERT`: `/file/path/tls.cert`
- `LND_GRPC_MACAROON`: `/file/path/admin.macaroon` or Base64/Hex
To use NWC as funding source in LNbits you'll need a pairing URL (also known as pairing secret) from a NWC service provider. You can find a list of providers [here](https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets).
You can also use an AES-encrypted macaroon instead:
You can configure Nostr Wallet Connect in the admin ui or using the following environment variables:
- `LND_GRPC_MACAROON_ENCRYPTED`: `eNcRyPtEdMaCaRoOn`
To encrypt your macaroon:
```bash
uv run lnbits-cli encrypt macaroon
```
## LNbits
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `LNbitsWallet`
- `LNBITS_ENDPOINT`: for example `https://lnbits.com`
- `LNBITS_KEY`: `lnbitsAdminKey`
## LNPay
For the invoice listener to work you must have a publicly accessible URL in your LNbits and set up [LNPay webhooks](https://dashboard.lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the event **Wallet Receive** and no secret. Example: [https://mylnbits/wallet/webhook](`https://mylnbits/wallet/webhook).
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `LNPayWallet`
- `LNPAY_API_ENDPOINT`: `https://api.lnpay.co/v1/`
- `LNPAY_API_KEY`: `sak_apiKey`
- `LNPAY_WALLET_KEY`: `waka_apiKey`
## OpenNode
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook configuration required.
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `OpenNodeWallet`
- `OPENNODE_API_ENDPOINT`: `https://api.opennode.com/`
- `OPENNODE_KEY`: `opennodeAdminApiKey`
## Blink
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook configuration required.
You can generate a Blink API key at [https://dashboard.blink.sv](https://dashboard.blink.sv). More info: [https://dev.blink.sv/api/auth#create-an-api-key](https://dev.blink.sv/api/auth#create-an-api-key)
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `BlinkWallet`
- `BLINK_API_ENDPOINT`: `https://api.blink.sv/graphql`
- `BLINK_WS_ENDPOINT`: `wss://ws.blink.sv/graphql`
- `BLINK_TOKEN`: `BlinkToken`
## Alby
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook configuration required.
Generate an Alby access token here: [https://getalby.com/developer/access_tokens/new](https://getalby.com/developer/access_tokens/new)
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `AlbyWallet`
- `ALBY_API_ENDPOINT`: `https://api.getalby.com/`
- `ALBY_ACCESS_TOKEN`: `AlbyAccessToken`
## Boltz
This connects to a running [boltz-client](https://docs.boltz.exchange/v/boltz-client) and handles Lightning payments through submarine swaps on the Liquid network.
You can run the daemon in standalone mode via `standalone = True` in the config or `boltzd --standalone`. Create a Liquid wallet with:
```bash
boltzcli wallet create lnbits lbtc
```
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `BoltzWallet`
- `BOLTZ_CLIENT_ENDPOINT`: `127.0.0.1:9002`
- `BOLTZ_CLIENT_MACAROON`: `/home/bob/.boltz/macaroons/admin.macaroon` or Base64/Hex
- `BOLTZ_CLIENT_CERT`: `/home/bob/.boltz/tls.cert` or Base64/Hex
- `BOLTZ_CLIENT_WALLET`: `lnbits`
## ZBD
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook configuration required.
Generate a ZBD API key here: [https://zbd.dev/docs/dashboard/projects/api](https://zbd.dev/docs/dashboard/projects/api)
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `ZBDWallet`
- `ZBD_API_ENDPOINT`: `https://api.zebedee.io/v0/`
- `ZBD_API_KEY`: `ZBDApiKey`
## Phoenixd
For the invoice to work you must have a publicly accessible URL in your LNbits.
You can get a phoenixd API key from `~/.phoenix/phoenix.conf`. See the phoenixd documentation for details.
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `PhoenixdWallet`
- `PHOENIXD_API_ENDPOINT`: `http://localhost:9740/`
- `PHOENIXD_API_PASSWORD`: `PhoenixdApiPassword`
## Eclair (ACINQ)
<a id="eclair-acinq"></a>
Connect to an existing Eclair node so your backend handles invoices and payments.
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `EclairWallet`
- `ECLAIR_URL`: `http://127.0.0.1:8283`
- `ECLAIR_PASSWORD`: `eclairpw`
## Fake Wallet
<a id="fake-wallet"></a>
A testing-only backend that mints accounting units inside LNbits accounting (no real sats).
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `FakeWallet`
- `FAKE_SECRET`: `ToTheMoon1`
- `FAKE_UNIT`: `sats`
## LN.tips
<a id="lntips"></a>
As the initinal LN.tips bot is no longer active the code still exists and is widly used. Connect one of custodial services as your backend to create and pay Lightning invoices through their API or selfhost this service and run it as funding source.
Resources: https://github.com/massmux/SatsMobiBot
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `LNTipsWallet`
- `LNTIPS_API_ENDPOINT`: `https://ln.tips`
- `LNTIPS_API_KEY`: `LNTIPS_ADMIN_KEY`
## Breez SDK
A Greenlight invite code or Greenlight partner certificate/key can register a new node with Greenlight. If the Greenlight node already exists, neither is required.
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `BreezSdkWallet`
- `BREEZ_API_KEY`: `...`
- `BREEZ_GREENLIGHT_SEED`: `...`
- `BREEZ_GREENLIGHT_INVITE_CODE`: `...`
- `BREEZ_GREENLIGHT_DEVICE_KEY`: `/path/to/breezsdk/device.pem` or Base64/Hex
- `BREEZ_GREENLIGHT_DEVICE_CERT`: `/path/to/breezsdk/device.crt` or Base64/Hex
## Breez Liquid SDK
This uses the [Breez SDK - Liquid](https://sdk-doc-liquid.breez.technology/) to manage Lightning payments via submarine swaps on the Liquid network. Provide a mnemonic seed phrase (for example, generate one with a Liquid wallet like [Blockstream Green](https://blockstream.com/green/)) and set it in the environment or admin UI.
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `BreezLiquidSdkWallet`
- `BREEZ_LIQUID_SEED`: `...`
Fees apply for each submarine swap. You may need to increase the reserve fee under **Settings → Funding** or via:
- `LNBITS_RESERVE_FEE_MIN`: `...`
- `LNBITS_RESERVE_FEE_PERCENT`: `...`
## Cliche Wallet
**Required env vars**
- `CLICHE_ENDPOINT`: `ws://127.0.0.1:12000`
## Nostr Wallet Connect (NWC)
To use NWC as a funding source you need a pairing URL (pairing secret) from an NWC provider. See providers here:
[https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets](https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets)
Configure in the admin UI or via env vars:
- `LNBITS_BACKEND_WALLET_CLASS`: `NWCWallet`
- `NWC_PAIRING_URL`: `nostr+walletconnect://...your...pairing...secret...`
<a id="strike"></a>
## Strike (alpha)
Custodial provider integrated via **Strike OAuth Connect** (OAuth 2.0 / OIDC). Authenticate a Strike user in your app, then call Strike APIs on the users behalf once scopes are granted. Requires a Strike business account, registered OAuth client, minimal scopes, and login/logout redirect URLs.
Get more info here [https://docs.strike.me/strike-oauth-connect/](https://docs.strike.me/strike-oauth-connect/)
**Integration endpoints**
- `STRIKE_API_ENDPOINT`: `https://api.strike.me/v1`
- `STRIKE_API_KEY`: `YOUR_STRIKE_API_KEY`
---
## Additional Guides
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
- **[User Roles](./user_roles.md)** — Quick Overview of existing Roles in LNBits.
- **[Funding sources](./funding-sources-table.md)** — Whats available and how to enable/configure each.
## Powered by LNbits
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable.
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
[![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/)
- `LNBITS_BACKEND_WALLET_CLASS`: **NWCWallet**
- `NWC_PAIRING_URL`: **nostr+walletconnect://...your...pairing...secret...**
Generated
+61 -76
View File
@@ -1,39 +1,15 @@
{
"nodes": {
"build-system-pkgs": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"pyproject-nix": [
"pyproject-nix"
],
"uv2nix": "uv2nix"
},
"locked": {
"lastModified": 1755484659,
"narHash": "sha256-2FfbqsaHVQd12XFFUAinIMAuGO3853LONmva1gT3vKw=",
"owner": "pyproject-nix",
"repo": "build-system-pkgs",
"rev": "9778e87c2361810ff15e287ca5895c9da4a0e900",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "build-system-pkgs",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
@@ -42,49 +18,71 @@
"type": "github"
}
},
"nix-github-actions": {
"inputs": {
"nixpkgs": [
"poetry2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1703863825,
"narHash": "sha256-rXwqjtwiGKJheXB43ybM8NwWB8rO2dSRrEqes0S7F5Y=",
"owner": "nix-community",
"repo": "nix-github-actions",
"rev": "5163432afc817cf8bd1f031418d1869e4c9d5547",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nix-github-actions",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1751274312,
"narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=",
"owner": "NixOS",
"lastModified": 1723938990,
"narHash": "sha256-9tUadhnZQbWIiYVXH8ncfGXGvkNq3Hag4RCBEMUk7MI=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674",
"rev": "c42fcfbdfeae23e68fc520f9182dde9f38ad1890",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-24.11",
"owner": "nixos",
"ref": "nixos-24.05",
"repo": "nixpkgs",
"type": "github"
}
},
"pyproject-nix": {
"poetry2nix": {
"inputs": {
"flake-utils": "flake-utils",
"nix-github-actions": "nix-github-actions",
"nixpkgs": [
"nixpkgs"
]
],
"systems": "systems_2",
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1754923840,
"narHash": "sha256-QSKpYg+Ts9HYF155ltlj40iBex39c05cpOF8gjoE2EM=",
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"rev": "023cd4be230eacae52635be09eef100c37ef78da",
"lastModified": 1724134185,
"narHash": "sha256-nDqpGjz7cq3ThdC98BPe1ANCNlsJds/LLZ3/MdIXjA0=",
"owner": "nix-community",
"repo": "poetry2nix",
"rev": "5ee730a8752264e463c0eaf06cc060fd07f6dae9",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"owner": "nix-community",
"repo": "poetry2nix",
"type": "github"
}
},
"root": {
"inputs": {
"build-system-pkgs": "build-system-pkgs",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"pyproject-nix": "pyproject-nix",
"uv2nix": "uv2nix_2"
"poetry2nix": "poetry2nix"
}
},
"systems": {
@@ -102,51 +100,38 @@
"type": "github"
}
},
"uv2nix": {
"inputs": {
"nixpkgs": [
"build-system-pkgs",
"nixpkgs"
],
"pyproject-nix": [
"build-system-pkgs",
"pyproject-nix"
]
},
"systems_2": {
"locked": {
"lastModified": 1755210905,
"narHash": "sha256-WnoFEk79ysjL85TNP7bvImzhxvQw9B6uNtnLd4oJntw=",
"owner": "pyproject-nix",
"repo": "uv2nix",
"rev": "87bcba013ef304bbfd67c8e8a257aee634ed5a4c",
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "uv2nix",
"type": "github"
"id": "systems",
"type": "indirect"
}
},
"uv2nix_2": {
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"poetry2nix",
"nixpkgs"
],
"pyproject-nix": [
"pyproject-nix"
]
},
"locked": {
"lastModified": 1755485731,
"narHash": "sha256-k8kxwVs8Oze6q/jAaRa3RvZbb50I/K0b5uptlsh0HXI=",
"owner": "pyproject-nix",
"repo": "uv2nix",
"rev": "bebbd80bf56110fcd20b425589814af28f1939eb",
"lastModified": 1719749022,
"narHash": "sha256-ddPKHcqaKCIFSFc/cvxS14goUhCOAwsM1PbMr0ZtHMg=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "8df5ff62195d4e67e2264df0b7f5e8c9995fd0bd",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "uv2nix",
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
+50 -142
View File
@@ -1,151 +1,59 @@
{
description = "LNbits, free and open-source Lightning wallet and accounts system (uv2nix)";
description = "LNbits, free and open-source Lightning wallet and accounts system";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
flake-utils.url = "github:numtide/flake-utils";
pyproject-nix.url = "github:pyproject-nix/pyproject.nix";
uv2nix.url = "github:pyproject-nix/uv2nix";
build-system-pkgs.url = "github:pyproject-nix/build-system-pkgs";
pyproject-nix.inputs.nixpkgs.follows = "nixpkgs";
uv2nix.inputs.nixpkgs.follows = "nixpkgs";
build-system-pkgs.inputs.nixpkgs.follows = "nixpkgs";
uv2nix.inputs.pyproject-nix.follows = "pyproject-nix";
build-system-pkgs.inputs.pyproject-nix.follows = "pyproject-nix";
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05";
poetry2nix = {
url = "github:nix-community/poetry2nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, flake-utils, uv2nix, pyproject-nix, build-system-pkgs, ... }:
flake-utils.lib.eachSystem [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]
(system:
let
pkgs = import nixpkgs { inherit system; };
lib = pkgs.lib;
python = pkgs.python312;
# Read uv.lock / pyproject via uv2nix
workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./.; };
# Prefer wheels when available
uvLockedOverlay = workspace.mkPyprojectOverlay { sourcePreference = "wheel"; };
# Helper for extending lists safely (works if a is null)
plus = a: b: lib.unique (((if a == null then [] else a)) ++ b);
# Extra build inputs for troublesome sdists
myOverrides = (final: prev: {
# embit needs setuptools at build time
embit = prev.embit.overrideAttrs (old: {
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ];
});
# http-ece (pywebpush dep) needs setuptools
"http-ece" = prev."http-ece".overrideAttrs (old: {
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ];
});
# pyqrcode needs setuptools
pyqrcode = prev.pyqrcode.overrideAttrs (old: {
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ];
});
# tlv8 needs setuptools
tlv8 = prev.tlv8.overrideAttrs (old: {
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ];
});
# secp256k1 Python binding:
# - setuptools, pkg-config
# - cffi + pycparser
# - system libsecp256k1 for headers/libs
secp256k1 = prev.secp256k1.overrideAttrs (old: {
nativeBuildInputs = plus (old.nativeBuildInputs or []) [
prev.setuptools
pkgs.pkg-config
prev.cffi
prev.pycparser
];
buildInputs = plus (old.buildInputs or []) [ pkgs.secp256k1 ];
propagatedBuildInputs = plus (old.propagatedBuildInputs or []) [ prev.cffi prev.pycparser ];
env = (old.env or { }) // { PKG_CONFIG = "${pkgs.pkg-config}/bin/pkg-config"; };
});
# pynostr uses setuptools-scm for versioning
pynostr = prev.pynostr.overrideAttrs (old: {
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools-scm ];
});
outputs = { self, nixpkgs, poetry2nix }@inputs:
let
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forSystems = systems: f:
nixpkgs.lib.genAttrs systems
(system: f system (import nixpkgs { inherit system; overlays = [ poetry2nix.overlays.default self.overlays.default ]; }));
forAllSystems = forSystems supportedSystems;
projectName = "lnbits";
in
{
overlays = {
default = final: prev: {
${projectName} = self.packages.${prev.stdenv.hostPlatform.system}.${projectName};
};
};
packages = forAllSystems (system: pkgs: {
default = self.packages.${system}.${projectName};
${projectName} = pkgs.poetry2nix.mkPoetryApplication {
projectDir = ./.;
meta.rev = self.dirtyRev or self.rev;
meta.mainProgram = projectName;
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
protobuf = prev.protobuf.override { preferWheel = true; };
ruff = prev.ruff.override { preferWheel = true; };
wallycore = prev.wallycore.override { preferWheel = true; };
});
# Compose Python package set honoring uv.lock
pythonSet =
(pkgs.callPackage pyproject-nix.build.packages { inherit python; })
.overrideScope (lib.composeManyExtensions [
build-system-pkgs.overlays.default
uvLockedOverlay
myOverrides
]);
projectName = "lnbits";
# Build a venv from the locked spec (this installs the resolved wheels)
runtimeVenv = pythonSet.mkVirtualEnv "${projectName}-env" workspace.deps.default;
# Wrapper so `nix run` behaves like `uv run` (use local source tree for templates/static/extensions)
lnbitsApp = pkgs.writeShellApplication {
name = "lnbits";
text = ''
export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
export REQUESTS_CA_BUNDLE=$SSL_CERT_FILE
export PYTHONPATH="$PWD:${PYTHONPATH:-}"
exec ${runtimeVenv}/bin/lnbits "$@"
'';
};
lnbitsCliApp = pkgs.writeShellApplication {
name = "lnbits-cli";
text = ''
export PYTHONPATH="$PWD:${PYTHONPATH:-}"
exec ${runtimeVenv}/bin/lnbits-cli "$@"
'';
};
});
nixosModules = {
default = { pkgs, lib, config, ... }: {
imports = [ "${./nix/modules/${projectName}-service.nix}" ];
nixpkgs.overlays = [ self.overlays.default ];
};
};
checks = forAllSystems (system: pkgs:
let
vmTests = import ./nix/tests {
makeTest = (import (nixpkgs + "/nixos/lib/testing-python.nix") { inherit system; }).makeTest;
inherit inputs pkgs;
};
in
pkgs.lib.optionalAttrs pkgs.stdenv.isLinux vmTests # vmTests can only be ran on Linux, so append them only if on Linux.
//
{
# nix build → produces the venv in ./result
packages.default = runtimeVenv;
packages.${projectName} = runtimeVenv;
# nix run . → launches via wrapper that imports from source tree
apps.default = { type = "app"; program = "${lnbitsApp}/bin/lnbits"; };
apps.${projectName} = self.apps.${system}.default;
apps."${projectName}-cli" = { type = "app"; program = "${lnbitsCliApp}/bin/lnbits-cli"; };
# dev shell with locked deps + tools
devShells.default = pkgs.mkShell {
packages = [
runtimeVenv
pkgs.uv
pkgs.ruff
pkgs.black
pkgs.mypy
pkgs.pre-commit
pkgs.openapi-generator-cli
];
};
overlays.default = final: prev: {
${projectName} = self.packages.${final.stdenv.hostPlatform.system}.${projectName};
replaceVars = prev.replaceVars or (path: vars: prev.substituteAll ({ src = path; } // vars));
};
# System-specific nixos modules to avoid circular dependency
nixosModules.default = { pkgs, lib, config, ... }: {
imports = [ "${./nix/modules/lnbits-service.nix}" ];
nixpkgs.overlays = [ self.overlays.${system}.default ];
};
checks =
import ./nix/tests { inherit pkgs; flake = self; };
});
# Other checks here...
}
);
};
}
+47 -68
View File
@@ -1,75 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
#!/bin/bash
# --- Config you might tweak ---
REPO_URL="https://github.com/lnbits/lnbits.git"
BRANCH="main"
APP_DIR="${PWD}/lnbits"
HOST="${HOST:-0.0.0.0}"
PORT="${PORT:-5000}"
ADMIN_UI="${LNBITS_ADMIN_UI:-true}"
# -------------------------------
# Check install has not already run
if [ ! -d lnbits/data ]; then
export DEBIAN_FRONTEND=noninteractive
# Update package list and install prerequisites non-interactively
sudo apt update -y
sudo apt install -y software-properties-common
# Add the deadsnakes PPA repository non-interactively
sudo add-apt-repository -y ppa:deadsnakes/ppa
# Install Python 3.9 and distutils non-interactively
sudo apt install -y python3.9 python3.9-distutils
# Ensure basic tooling
if ! command -v curl >/dev/null 2>&1 || ! command -v git >/dev/null 2>&1; then
sudo apt-get update -y
sudo apt-get install -y curl git
# Install Poetry
curl -sSL https://install.python-poetry.org | python3.9 -
# Add Poetry to PATH for the current session
export PATH="/home/$USER/.local/bin:$PATH"
if [ ! -d lnbits/wallets ]; then
# Clone the LNbits repository
git clone https://github.com/lnbits/lnbits.git
if [ $? -ne 0 ]; then
echo "Failed to clone the repository ... FAIL"
exit 1
fi
# Ensure we are in the lnbits directory
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
fi
git checkout main
# Make data folder
mkdir data
# Copy the .env.example to .env
cp .env.example .env
elif [ ! -d lnbits/wallets ]; then
# cd into lnbits
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
fi
# System build deps and secp headers
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update -y
sudo apt-get install -y \
pkg-config \
build-essential \
libsecp256k1-dev \
automake \
autoconf \
libtool \
m4
fi
# Install the dependencies using Poetry
poetry env use python3.9
poetry install --only main
# Install uv (if missing)
if ! command -v uv >/dev/null 2>&1; then
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
fi
# Ensure PATH for current session
if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
export PATH="$HOME/.local/bin:$PATH"
fi
# Set environment variables for LNbits
export LNBITS_ADMIN_UI=true
export HOST=0.0.0.0
# Clone or reuse repo
if [[ ! -d "$APP_DIR/.git" ]]; then
git clone "$REPO_URL" "$APP_DIR"
fi
cd "$APP_DIR"
git fetch --all --prune
git checkout "$BRANCH"
git pull --ff-only || true
# First-run setup
mkdir -p data
[[ -f .env ]] || cp .env.example .env || true
# Prefer system libsecp256k1 (avoid autotools path)
export SECP_BUNDLED=0
# Sync dependencies with Python 3.12
uv sync --python 3.12 --all-extras --no-dev
# Environment
export LNBITS_ADMIN_UI="$ADMIN_UI"
export HOST="$HOST"
export PORT="$PORT"
# Open firewall (optional)
if command -v ufw >/dev/null 2>&1; then
sudo ufw allow "$PORT"/tcp || true
fi
# Run LNbits with Python 3.12 via uv
exec uv run --python 3.12 lnbits
# Run LNbits
poetry run lnbits
-23
View File
@@ -1,23 +0,0 @@
from .core.services import create_invoice, pay_invoice
from .decorators import (
check_account_exists,
check_admin,
check_super_user,
check_user_exists,
require_admin_key,
require_invoice_key,
)
from .exceptions import InvoiceError, PaymentError
__all__ = [
"InvoiceError",
"PaymentError",
"check_account_exists",
"check_admin",
"check_super_user",
"check_user_exists",
"create_invoice",
"pay_invoice",
"require_admin_key",
"require_invoice_key",
]
+66 -137
View File
@@ -4,10 +4,9 @@ import importlib
import os
import shutil
import sys
import time
from collections.abc import Callable
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Callable, List, Optional
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -15,32 +14,19 @@ from fastapi.staticfiles import StaticFiles
from loguru import logger
from slowapi import Limiter
from slowapi.util import get_remote_address
from starlette.middleware.gzip import GZipMiddleware
from starlette.middleware.sessions import SessionMiddleware
from lnbits.core.crud import (
get_db_version,
get_dbversions,
get_installed_extensions,
update_installed_extension_state,
)
from lnbits.core.crud.extensions import create_installed_extension
from lnbits.core.helpers import migrate_extension_database
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
from lnbits.core.services.notifications import enqueue_admin_notification
from lnbits.core.services.payments import check_pending_payments
from lnbits.core.tasks import (
audit_queue,
collect_exchange_rates_data,
purge_audit_data,
run_by_the_minute_tasks,
wait_for_audit_data,
from lnbits.core.tasks import ( # watchdog_task
killswitch_task,
wait_for_paid_invoices,
wait_notification_messages,
)
from lnbits.exceptions import register_exception_handlers
from lnbits.helpers import version_parse
from lnbits.llms_txt import create_llms_txt_route
from lnbits.settings import settings
from lnbits.tasks import (
cancel_all_tasks,
@@ -58,36 +44,41 @@ from lnbits.wallets import get_funding_source, set_funding_source
from .commands import migrate_databases
from .core import init_core_routers
from .core.db import core_app_extra
from .core.models.extensions import Extension, ExtensionMeta, InstallableExtension
from .core.services import check_admin_settings, check_webpush_settings
from .core.views.extension_api import add_installed_extension
from .extension_manager import (
Extension,
InstallableExtension,
get_valid_extensions,
version_parse,
)
from .middleware import (
AuditMiddleware,
CustomGZipMiddleware,
ExtensionsRedirectMiddleware,
InstalledExtensionMiddleware,
add_first_install_middleware,
add_ip_block_middleware,
add_profiler_middleware,
add_ratelimit_middleware,
)
from .tasks import internal_invoice_listener, invoice_listener, run_interval
from .requestvars import g
from .tasks import (
check_pending_payments,
create_task,
internal_invoice_listener,
invoice_listener,
)
async def startup(app: FastAPI):
logger.info(f"Starting LNbits Version: {settings.version}")
start = time.perf_counter()
settings.lnbits_running = True
# wait till migration is done
await migrate_databases()
# setup admin settings
await check_admin_settings()
core_app_extra.register_new_ratelimiter()
await check_webpush_settings()
# check extensions after restart
if not settings.lnbits_extensions_deactivate_all:
await check_and_register_extensions(app)
log_server_info()
# initialize WALLET
@@ -103,33 +94,12 @@ async def startup(app: FastAPI):
# register core routes
init_core_routers(app)
# register llms.txt endpoint for AI agents
create_llms_txt_route(app)
# initialize tasks
register_async_tasks()
enqueue_admin_notification(
NotificationType.server_start_stop,
{
"message": "LNbits server started.",
"up_time": settings.lnbits_server_up_time,
},
)
end = time.perf_counter()
logger.success(f"LNbits started in {end - start:.2f} seconds.")
register_async_tasks(app)
async def shutdown():
logger.warning("LNbits shutting down...")
enqueue_admin_notification(
NotificationType.server_start_stop,
{
"message": "LNbits server shutting down...",
"up_time": settings.lnbits_server_up_time,
},
)
settings.lnbits_running = False
# shutdown event
@@ -169,20 +139,19 @@ def create_app() -> FastAPI:
core_app_extra.register_new_ratelimiter = register_new_ratelimiter(app)
# register static files
app.mount("/static", StaticFiles(directory=Path("lnbits", "static")), name="static")
Path(settings.lnbits_data_folder, "images").mkdir(parents=True, exist_ok=True)
app.mount(
"/library",
StaticFiles(directory=Path(settings.lnbits_data_folder, "images")),
name="library",
)
static_path = Path("lnbits", "static")
static = StaticFiles(directory=static_path)
app.mount("/static", static, name="static")
g().base_url = f"http://{settings.host}:{settings.port}"
app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
)
app.add_middleware(GZipMiddleware, minimum_size=1000)
app.add_middleware(AuditMiddleware, audit_queue=audit_queue)
app.add_middleware(
CustomGZipMiddleware, minimum_size=1000, exclude_paths=["/api/v1/payments/sse"]
)
# required for SSO login
app.add_middleware(SessionMiddleware, secret_key=settings.auth_secret_key)
@@ -201,9 +170,6 @@ def create_app() -> FastAPI:
register_exception_handlers(app)
if settings.profiler:
add_profiler_middleware(app)
return app
@@ -263,7 +229,7 @@ async def check_installed_extensions(app: FastAPI):
re-created. The 'data' directory (where the '.zip' files live) is expected to
persist state. Zips that are missing will be re-downloaded.
"""
shutil.rmtree(os.path.join("lnbits", "upgrades"), True)
installed_extensions = await build_all_installed_extensions_list(False)
for ext in installed_extensions:
@@ -277,7 +243,6 @@ async def check_installed_extensions(app: FastAPI):
)
except Exception as e:
logger.warning(e)
await deactivate_extension(ext.id)
logger.warning(
f"Failed to re-install extension: {ext.id} ({ext.installed_version})"
)
@@ -287,37 +252,18 @@ async def check_installed_extensions(app: FastAPI):
logger.info(f"{ext.id} ({ext.installed_version})")
async def build_all_installed_extensions_list( # noqa: C901
include_deactivated: bool | None = True,
) -> list[InstallableExtension]:
async def build_all_installed_extensions_list(
include_deactivated: Optional[bool] = True,
) -> List[InstallableExtension]:
"""
Returns a list of all the installed extensions plus the extensions that
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
"""
installed_extensions = await get_installed_extensions()
settings.lnbits_installed_extensions_ids = {e.id for e in installed_extensions}
for ext_dir in Path(settings.lnbits_extensions_path, "extensions").iterdir():
try:
if not ext_dir.is_dir():
continue
ext_id = ext_dir.name
if ext_id in settings.lnbits_installed_extensions_ids:
continue
ext_info = InstallableExtension.from_ext_dir(ext_id)
if not ext_info:
continue
installed_extensions.append(ext_info)
await create_installed_extension(ext_info)
current_version = await get_db_version(ext_id)
await migrate_extension_database(ext_info, current_version)
except Exception as e:
logger.warning(e)
settings.lnbits_all_extensions_ids = {e.id for e in installed_extensions}
for ext_id in settings.lnbits_extensions_default_install:
if ext_id in settings.lnbits_installed_extensions_ids:
if ext_id in settings.lnbits_all_extensions_ids:
continue
ext_releases = await InstallableExtension.get_extension_releases(ext_id)
@@ -328,13 +274,8 @@ async def build_all_installed_extensions_list( # noqa: C901
release = next((e for e in ext_releases if e.is_version_compatible), None)
if release:
ext_meta = ExtensionMeta(installed_release=release)
ext_info = InstallableExtension(
id=ext_id,
name=ext_id,
version=release.version,
icon=release.icon,
meta=ext_meta,
id=ext_id, name=ext_id, installed_release=release, icon=release.icon
)
installed_extensions.append(ext_info)
@@ -365,24 +306,21 @@ async def check_installed_extension_files(ext: InstallableExtension) -> bool:
async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
await add_installed_extension(ext)
await update_installed_extension_state(ext_id=ext.id, active=True)
extension = Extension.from_installable_ext(ext)
register_ext_routes(app, extension)
current_version = await get_db_version(ext.id)
await migrate_extension_database(ext, current_version)
current_version = (await get_dbversions()).get(ext.id, 0)
await migrate_extension_database(extension, current_version)
# mount routes for the new version
core_app_extra.register_new_ext_routes(extension)
ext.notify_upgrade(extension.upgrade_hash)
def register_custom_extensions_path():
upgrades_dir = settings.lnbits_extensions_upgrade_path
shutil.rmtree(upgrades_dir, True)
Path(upgrades_dir).mkdir(parents=True, exist_ok=True)
sys.path.append(str(upgrades_dir))
if settings.has_default_extension_path:
return
default_ext_path = os.path.join("lnbits", "extensions")
@@ -397,9 +335,8 @@ def register_custom_extensions_path():
+ f" '{settings.lnbits_extensions_path}/extensions'"
)
extensions_dir = Path(settings.lnbits_extensions_path, "extensions")
Path(extensions_dir).mkdir(parents=True, exist_ok=True)
sys.path.append(str(extensions_dir))
sys.path.append(str(Path(settings.lnbits_extensions_path, "extensions")))
sys.path.append(str(Path(settings.lnbits_extensions_path, "upgrades")))
def register_new_ext_routes(app: FastAPI) -> Callable:
@@ -425,21 +362,16 @@ def register_new_ratelimiter(app: FastAPI) -> Callable:
return register_new_ratelimiter_fn
def register_ext_tasks(ext: Extension) -> None:
"""Register extension async tasks."""
ext_module = importlib.import_module(ext.module_name)
if hasattr(ext_module, f"{ext.code}_start"):
ext_start_func = getattr(ext_module, f"{ext.code}_start")
ext_start_func()
def register_ext_routes(app: FastAPI, ext: Extension) -> None:
"""Register FastAPI routes for extension."""
ext_module = importlib.import_module(ext.module_name)
ext_route = getattr(ext_module, f"{ext.code}_ext")
if hasattr(ext_module, f"{ext.code}_start"):
ext_start_func = getattr(ext_module, f"{ext.code}_start")
ext_start_func()
if hasattr(ext_module, f"{ext.code}_static_files"):
ext_statics = getattr(ext_module, f"{ext.code}_static_files")
for s in ext_statics:
@@ -448,52 +380,49 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
)
app.mount(s["path"], StaticFiles(directory=static_dir), s["name"])
ext_redirects = (
getattr(ext_module, f"{ext.code}_redirect_paths")
if hasattr(ext_module, f"{ext.code}_redirect_paths")
else []
)
if hasattr(ext_module, f"{ext.code}_redirect_paths"):
ext_redirects = getattr(ext_module, f"{ext.code}_redirect_paths")
settings.lnbits_extensions_redirects = [
r for r in settings.lnbits_extensions_redirects if r["ext_id"] != ext.code
]
for r in ext_redirects:
r["ext_id"] = ext.code
settings.lnbits_extensions_redirects.append(r)
settings.activate_extension_paths(ext.code, ext.upgrade_hash, ext_redirects)
logger.trace(f"adding route for extension {ext_module}")
logger.trace(f"Adding route for extension {ext_module}.")
prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash != "" else ""
app.include_router(router=ext_route, prefix=prefix)
async def check_and_register_extensions(app: FastAPI) -> None:
async def check_and_register_extensions(app: FastAPI):
await check_installed_extensions(app)
for ext in await get_valid_extensions(False):
for ext in get_valid_extensions(False):
try:
register_ext_routes(app, ext)
register_ext_tasks(ext)
except Exception as exc:
logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
def register_async_tasks() -> None:
def register_async_tasks(app: FastAPI):
create_permanent_task(wait_for_audit_data)
create_permanent_task(wait_notification_messages)
# check extensions after restart
if not settings.lnbits_extensions_deactivate_all:
create_task(check_and_register_extensions(app))
create_permanent_task(
run_interval(
settings.lnbits_funding_source_pending_interval_seconds,
check_pending_payments,
)
)
create_permanent_task(check_pending_payments)
create_permanent_task(invoice_listener)
create_permanent_task(internal_invoice_listener)
create_permanent_task(cache.invalidate_forever)
# core invoice listener
invoice_queue: asyncio.Queue = asyncio.Queue()
invoice_queue: asyncio.Queue = asyncio.Queue(5)
register_invoice_listener(invoice_queue, "core")
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
create_permanent_task(run_by_the_minute_tasks)
create_permanent_task(purge_audit_data)
create_permanent_task(collect_exchange_rates_data)
# TODO: implement watchdog properly
# create_permanent_task(watchdog_task)
create_permanent_task(killswitch_task)
# server logs for websocket
if settings.lnbits_admin_ui:
+83 -179
View File
@@ -1,11 +1,10 @@
import asyncio
import importlib
import sys
import time
from functools import wraps
from getpass import getpass
from pathlib import Path
from uuid import uuid4
from typing import List, Optional, Tuple
from urllib.parse import urlparse
import click
import httpx
@@ -19,30 +18,27 @@ from lnbits.core.crud import (
delete_unused_wallets,
delete_wallet_by_id,
delete_wallet_payment,
get_db_versions,
get_dbversions,
get_installed_extension,
get_installed_extensions,
get_payment,
get_payments,
remove_deleted_wallets,
update_payment,
update_payment_status,
)
from lnbits.core.helpers import is_valid_url, migrate_databases
from lnbits.core.models import Account, Payment, PaymentState
from lnbits.core.models.extensions import (
CreateExtension,
ExtensionRelease,
InstallableExtension,
)
from lnbits.core.services import check_admin_settings, create_user_account_no_ckeck
from lnbits.core.helpers import migrate_databases
from lnbits.core.models import Payment, PaymentState, User
from lnbits.core.services import check_admin_settings
from lnbits.core.views.extension_api import (
api_install_extension,
api_uninstall_extension,
)
from lnbits.extension_manager import (
CreateExtension,
ExtensionRelease,
InstallableExtension,
)
from lnbits.settings import settings
from lnbits.utils.crypto import AESCipher
from lnbits.wallets.base import Wallet
from lnbits.wallets.macaroon import load_macaroon
def coro(f):
@@ -67,13 +63,6 @@ def db():
"""
@lnbits_cli.group()
def users():
"""
Users related commands
"""
@lnbits_cli.group()
def extensions():
"""
@@ -81,21 +70,7 @@ def extensions():
"""
@lnbits_cli.group()
def encrypt():
"""
Encryption commands
"""
@lnbits_cli.group()
def decrypt():
"""
Decryption commands
"""
def get_super_user() -> str | None:
def get_super_user() -> Optional[str]:
"""Get the superuser"""
superuser_file = Path(settings.lnbits_data_folder, ".super_user")
if not superuser_file.exists() or not superuser_file.is_file():
@@ -133,7 +108,7 @@ async def delete_settings():
"""Deletes the settings"""
async with core_db.connect() as conn:
await conn.execute("DELETE from system_settings")
await conn.execute("DELETE from settings")
@db.command("migrate")
@@ -148,13 +123,13 @@ def database_migrate():
async def db_versions():
"""Show current database versions"""
async with core_db.connect() as conn:
click.echo(await get_db_versions(conn))
click.echo(await get_dbversions(conn))
@db.command("cleanup-wallets")
@click.argument("days", type=int, required=False)
@coro
async def database_cleanup_wallets(days: int | None = None):
async def database_cleanup_wallets(days: Optional[int] = None):
"""Delete all wallets that never had any transaction"""
async with core_db.connect() as conn:
delta = days or settings.cleanup_wallets_days
@@ -198,10 +173,20 @@ async def database_delete_wallet_payment(wallet: str, checking_id: str):
async def database_revert_payment(checking_id: str):
"""Mark payment as pending"""
async with core_db.connect() as conn:
payment = await get_payment(checking_id=checking_id, conn=conn)
payment.status = PaymentState.PENDING
await update_payment(payment, conn=conn)
click.echo(f"Payment '{checking_id}' marked as pending.")
await update_payment_status(
status=PaymentState.PENDING, checking_id=checking_id, conn=conn
)
@db.command("cleanup-accounts")
@click.argument("days", type=int, required=False)
@coro
async def database_cleanup_accounts(days: Optional[int] = None):
"""Delete all accounts that have no wallets"""
async with core_db.connect() as conn:
delta = days or settings.cleanup_wallets_days
delta = delta * 24 * 60 * 60
await delete_accounts_no_wallets(delta, conn)
@db.command("check-payments")
@@ -211,10 +196,10 @@ async def database_revert_payment(checking_id: str):
@click.option("-v", "--verbose", is_flag=True, help="Detailed log.")
@coro
async def check_invalid_payments(
days: int | None = None,
limit: int | None = None,
wallet: str | None = None,
verbose: bool | None = False,
days: Optional[int] = None,
limit: Optional[int] = None,
wallet: Optional[str] = None,
verbose: Optional[bool] = False,
):
"""Check payments that are settled in the DB but pending on the Funding Source"""
await check_admin_settings()
@@ -247,7 +232,7 @@ async def check_invalid_payments(
click.echo("Funding source: " + str(funding_source))
# payments that are settled in the DB, but not at the Funding source level
invalid_payments: list[Payment] = []
invalid_payments: List[Payment] = []
invalid_wallets = {}
for db_payment in settled_db_payments:
if verbose:
@@ -284,32 +269,6 @@ async def check_invalid_payments(
click.echo(" ".join([w, str(data[0]), str(data[1] / 1000).ljust(10)]))
@users.command("new")
@click.option("-u", "--username", required=True, help="Username.")
@click.option("-p", "--password", required=True, help="Password.")
@coro
async def create_user(username: str, password: str):
"""Create a new user bypassing the system 'new_accounts_allowed' rules"""
account = Account(
id=uuid4().hex,
username=username,
)
account.hash_password(password)
user = await create_user_account_no_ckeck(account)
click.echo(f"User '{user.username}' created. Id: '{user.id}'")
@users.command("cleanup-accounts")
@click.argument("days", type=int, required=False)
@coro
async def database_cleanup_accounts(days: int | None = None):
"""Delete all accounts that have no wallets"""
async with core_db.connect() as conn:
delta = days or settings.cleanup_wallets_days
delta = delta * 24 * 60 * 60
await delete_accounts_no_wallets(delta, conn)
@extensions.command("list")
@coro
async def extensions_list():
@@ -319,9 +278,8 @@ async def extensions_list():
from lnbits.app import build_all_installed_extensions_list
for ext in await build_all_installed_extensions_list():
if not ext.meta or not ext.meta.installed_release:
raise ValueError(f"Extension {ext.id} has no installed_release")
click.echo(f" - {ext.id} ({ext.meta.installed_release.version})")
assert ext.installed_release, f"Extension {ext.id} has no installed_release"
click.echo(f" - {ext.id} ({ext.installed_release.version})")
@extensions.command("update")
@@ -351,13 +309,13 @@ async def extensions_list():
help="Admin user ID (must have permissions to install extensions).",
)
@coro
async def extensions_update( # noqa: C901
extension: str | None = None,
all_extensions: bool | None = False,
repo_index: str | None = None,
source_repo: str | None = None,
url: str | None = None,
admin_user: str | None = None,
async def extensions_update(
extension: Optional[str] = None,
all_extensions: Optional[bool] = False,
repo_index: Optional[str] = None,
source_repo: Optional[str] = None,
url: Optional[str] = None,
admin_user: Optional[str] = None,
):
"""
Update extension to the latest version.
@@ -370,24 +328,19 @@ async def extensions_update( # noqa: C901
if extension and all_extensions:
click.echo("Only one of extension ID or the '--all' flag must be specified")
return
if url and not is_valid_url(url):
if url and not _is_url(url):
click.echo(f"Invalid '--url' option value: {url}")
return
if not await _can_run_operation(url):
return
upgrades_dir = settings.lnbits_extensions_upgrade_path
Path(upgrades_dir).mkdir(parents=True, exist_ok=True)
sys.path.append(str(upgrades_dir))
if extension:
await update_extension(extension, repo_index, source_repo, url, admin_user)
return
click.echo("Updating all extensions:")
click.echo("Updating all extensions...")
installed_extensions = await get_installed_extensions()
click.echo(f" {[e.id for e in installed_extensions]}")
updated_extensions = []
for e in installed_extensions:
try:
@@ -442,14 +395,14 @@ async def extensions_update( # noqa: C901
@coro
async def extensions_install(
extension: str,
repo_index: str | None = None,
source_repo: str | None = None,
url: str | None = None,
admin_user: str | None = None,
repo_index: Optional[str] = None,
source_repo: Optional[str] = None,
url: Optional[str] = None,
admin_user: Optional[str] = None,
):
"""Install a extension"""
click.echo(f"Installing {extension}... {repo_index}")
if url and not is_valid_url(url):
if url and not _is_url(url):
click.echo(f"Invalid '--url' option value: {url}")
return
@@ -472,12 +425,12 @@ async def extensions_install(
)
@coro
async def extensions_uninstall(
extension: str, url: str | None = None, admin_user: str | None = None
extension: str, url: Optional[str] = None, admin_user: Optional[str] = None
):
"""Uninstall a extension"""
click.echo(f"Uninstalling '{extension}'...")
if url and not is_valid_url(url):
if url and not _is_url(url):
click.echo(f"Invalid '--url' option value: {url}")
return
@@ -494,62 +447,6 @@ async def extensions_uninstall(
return False, str(ex)
@encrypt.command("macaroon")
def encrypt_macaroon():
"""Encrypts a macaroon (LND wallets)"""
_macaroon = getpass("Enter macaroon: ")
try:
macaroon = load_macaroon(_macaroon)
except Exception as ex:
click.echo(f"Error loading macaroon: {ex}")
return
key = getpass("Enter encryption key: ")
aes = AESCipher(key.encode())
try:
encrypted_macaroon = aes.encrypt(bytes.fromhex(macaroon))
except Exception as ex:
click.echo(f"Error encrypting macaroon: {ex}")
return
click.echo("Encrypted macaroon: ")
click.echo(encrypted_macaroon)
@encrypt.command("aes")
@click.option("-p", "--payload", required=True, help="Payload to encrypt.")
@click.option(
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64encode."
)
def encrypt_aes(payload: str, urlsafe: bool = False):
"""AES encrypts a payload"""
key = getpass("Enter encryption key: ")
aes = AESCipher(key.encode())
try:
encrypted = aes.encrypt(payload.encode(), urlsafe=urlsafe)
except Exception as ex:
click.echo(f"Error encrypting payload: {ex}")
return
click.echo("Encrypted payload: ")
click.echo(encrypted)
@decrypt.command("aes")
@click.option("-p", "--payload", required=True, help="Payload to decrypt.")
@click.option(
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64decode."
)
def decrypt_aes(payload: str, urlsafe: bool = False):
"""AES decrypts a payload"""
key = getpass("Enter encryption key: ")
aes = AESCipher(key.encode())
try:
decrypted = aes.decrypt(payload, urlsafe=urlsafe)
except Exception as ex:
click.echo(f"Error decrypting payload: {ex}")
return
click.echo("Decrypted payload: ")
click.echo(decrypted)
def main():
"""main function"""
lnbits_cli()
@@ -561,11 +458,11 @@ if __name__ == "__main__":
async def install_extension(
extension: str,
repo_index: str | None = None,
source_repo: str | None = None,
url: str | None = None,
admin_user: str | None = None,
) -> tuple[bool, str]:
repo_index: Optional[str] = None,
source_repo: Optional[str] = None,
url: Optional[str] = None,
admin_user: Optional[str] = None,
) -> Tuple[bool, str]:
try:
release = await _select_release(extension, repo_index, source_repo)
if not release:
@@ -590,11 +487,11 @@ async def install_extension(
async def update_extension(
extension: str,
repo_index: str | None = None,
source_repo: str | None = None,
url: str | None = None,
admin_user: str | None = None,
) -> tuple[bool, str]:
repo_index: Optional[str] = None,
source_repo: Optional[str] = None,
url: Optional[str] = None,
admin_user: Optional[str] = None,
) -> Tuple[bool, str]:
try:
click.echo(f"Updating '{extension}' extension.")
installed_ext = await get_installed_extension(extension)
@@ -606,17 +503,16 @@ async def update_extension(
click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.")
if not installed_ext.meta or not installed_ext.meta.installed_release:
raise ValueError(
"Cannot find previously installed release. Please uninstall first."
)
assert (
installed_ext.installed_release
), "Cannot find previously installed release. Please uninstall first."
release = await _select_release(extension, repo_index, source_repo)
if not release:
return False, "No release selected."
if (
release.version == installed_ext.installed_version
and release.source_repo == installed_ext.meta.installed_release.source_repo
and release.source_repo == installed_ext.installed_release.source_repo
):
click.echo(f"Extension '{extension}' already up to date.")
return False, "Already up to date"
@@ -643,9 +539,9 @@ async def update_extension(
async def _select_release(
extension: str,
repo_index: str | None = None,
source_repo: str | None = None,
) -> ExtensionRelease | None:
repo_index: Optional[str] = None,
source_repo: Optional[str] = None,
) -> Optional[ExtensionRelease]:
all_releases = await InstallableExtension.get_extension_releases(extension)
if len(all_releases) == 0:
click.echo(f"No repository found for extension '{extension}'.")
@@ -705,7 +601,7 @@ def _get_latest_release_per_repo(all_releases):
async def _call_install_extension(
data: CreateExtension, url: str | None, user_id: str | None = None
data: CreateExtension, url: Optional[str], user_id: Optional[str] = None
):
if url:
user_id = user_id or get_super_user()
@@ -715,11 +611,11 @@ async def _call_install_extension(
)
resp.raise_for_status()
else:
await api_install_extension(data)
await api_install_extension(data, User(id="mock_id"))
async def _call_uninstall_extension(
extension: str, url: str | None, user_id: str | None = None
extension: str, url: Optional[str], user_id: Optional[str] = None
):
if url:
user_id = user_id or get_super_user()
@@ -729,7 +625,7 @@ async def _call_uninstall_extension(
)
resp.raise_for_status()
else:
await api_uninstall_extension(extension)
await api_uninstall_extension(extension, User(id="mock_id"))
async def _can_run_operation(url) -> bool:
@@ -755,7 +651,7 @@ async def _can_run_operation(url) -> bool:
return True
async def _is_lnbits_started(url: str | None):
async def _is_lnbits_started(url: Optional[str]):
try:
url = url or f"http://{settings.host}:{settings.port}/api/v1/health"
async with httpx.AsyncClient() as client:
@@ -763,3 +659,11 @@ async def _is_lnbits_started(url: str | None):
return True
except Exception:
return False
def _is_url(url):
try:
result = urlparse(url)
return all([result.scheme, result.netloc])
except ValueError:
return False
+2 -12
View File
@@ -3,19 +3,14 @@ from fastapi import APIRouter, FastAPI
from .db import core_app_extra, db
from .views.admin_api import admin_router
from .views.api import api_router
from .views.asset_api import asset_router
from .views.audit_api import audit_router
from .views.auth_api import auth_router
from .views.callback_api import callback_router
from .views.extension_api import extension_router
from .views.extensions_builder_api import extension_builder_router
from .views.fiat_api import fiat_router
# this compat is needed for usermanager extension
from .views.generic import generic_router
from .views.lnurl_api import lnurl_router
from .views.node_api import node_router, public_node_router, super_node_router
from .views.payment_api import payment_router
from .views.public_api import public_router
from .views.tinyurl_api import tinyurl_router
from .views.user_api import users_router
from .views.wallet_api import wallet_router
@@ -33,21 +28,16 @@ def init_core_routers(app: FastAPI):
app.include_router(admin_router)
app.include_router(node_router)
app.include_router(extension_router)
app.include_router(extension_builder_router)
app.include_router(super_node_router)
app.include_router(public_node_router)
app.include_router(public_router)
app.include_router(payment_router)
app.include_router(wallet_router)
app.include_router(api_router)
app.include_router(websocket_router)
app.include_router(callback_router)
app.include_router(tinyurl_router)
app.include_router(webpush_router)
app.include_router(users_router)
app.include_router(audit_router)
app.include_router(asset_router)
app.include_router(fiat_router)
app.include_router(lnurl_router)
__all__ = ["core_app", "core_app_extra", "db"]
+1328
View File
File diff suppressed because it is too large Load Diff
-166
View File
@@ -1,166 +0,0 @@
from .audit import create_audit_entry
from .db_versions import (
delete_dbversion,
get_db_version,
get_db_versions,
update_migration_version,
)
from .extensions import (
create_installed_extension,
create_user_extension,
delete_installed_extension,
drop_extension_db,
get_installed_extension,
get_installed_extensions,
get_installed_extensions_count,
get_user_active_extensions_ids,
get_user_extension,
get_user_extensions,
update_installed_extension,
update_installed_extension_state,
update_user_extension,
)
from .payments import (
DateTrunc,
check_internal,
create_payment,
delete_expired_invoices,
delete_wallet_payment,
get_latest_payments_by_extension,
get_payment,
get_payments,
get_payments_history,
get_payments_paginated,
get_standalone_payment,
get_wallet_payment,
is_internal_status_success,
mark_webhook_sent,
update_payment,
update_payment_checking_id,
update_payment_extra,
)
from .settings import (
create_admin_settings,
delete_admin_settings,
get_admin_settings,
get_super_settings,
reset_core_settings,
update_admin_settings,
update_super_user,
)
from .tinyurl import create_tinyurl, delete_tinyurl, get_tinyurl, get_tinyurl_by_url
from .users import (
create_account,
delete_account,
delete_accounts_no_wallets,
get_account,
get_account_by_email,
get_account_by_pubkey,
get_account_by_username,
get_account_by_username_or_email,
get_accounts,
get_accounts_count,
get_user,
get_user_access_control_lists,
get_user_from_account,
update_account,
)
from .wallets import (
create_wallet,
delete_unused_wallets,
delete_wallet,
delete_wallet_by_id,
force_delete_wallet,
get_total_balance,
get_wallet,
get_wallet_for_key,
get_wallets,
remove_deleted_wallets,
update_wallet,
)
from .webpush import (
create_webpush_subscription,
delete_webpush_subscription,
delete_webpush_subscriptions,
get_webpush_subscription,
get_webpush_subscriptions_for_user,
)
__all__ = [
"DateTrunc",
"check_internal",
"create_account",
"create_admin_settings",
"create_audit_entry",
"create_installed_extension",
"create_payment",
"create_tinyurl",
"create_user_extension",
"create_wallet",
"create_webpush_subscription",
"delete_account",
"delete_accounts_no_wallets",
"delete_admin_settings",
"delete_dbversion",
"delete_expired_invoices",
"delete_installed_extension",
"delete_tinyurl",
"delete_unused_wallets",
"delete_wallet",
"delete_wallet_by_id",
"delete_wallet_payment",
"delete_webpush_subscription",
"delete_webpush_subscriptions",
"drop_extension_db",
"force_delete_wallet",
"get_account",
"get_account_by_email",
"get_account_by_pubkey",
"get_account_by_username",
"get_account_by_username_or_email",
"get_accounts",
"get_accounts_count",
"get_admin_settings",
"get_db_version",
"get_db_versions",
"get_installed_extension",
"get_installed_extensions",
"get_installed_extensions_count",
"get_latest_payments_by_extension",
"get_payment",
"get_payments",
"get_payments_history",
"get_payments_paginated",
"get_standalone_payment",
"get_super_settings",
"get_tinyurl",
"get_tinyurl_by_url",
"get_total_balance",
"get_user",
"get_user_access_control_lists",
"get_user_active_extensions_ids",
"get_user_extension",
"get_user_extensions",
"get_user_from_account",
"get_wallet",
"get_wallet_for_key",
"get_wallet_payment",
"get_wallets",
"get_webpush_subscription",
"get_webpush_subscriptions_for_user",
"is_internal_status_success",
"mark_webhook_sent",
"remove_deleted_wallets",
"reset_core_settings",
"update_account",
"update_admin_settings",
"update_installed_extension",
"update_installed_extension_state",
"update_migration_version",
"update_payment",
"update_payment_checking_id",
"update_payment_extra",
"update_super_user",
"update_user_extension",
"update_wallet",
]
-108
View File
@@ -1,108 +0,0 @@
from lnbits.core.db import db
from lnbits.core.models.assets import Asset, AssetFilters, AssetInfo
from lnbits.db import Connection, Filters, Page
async def create_asset(
entry: Asset,
conn: Connection | None = None,
) -> None:
await (conn or db).insert("assets", entry)
async def get_user_asset_info(
user_id: str,
asset_id: str,
conn: Connection | None = None,
) -> AssetInfo | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND user_id = :user_id",
values={"asset_id": asset_id, "user_id": user_id},
model=AssetInfo,
)
async def get_asset_info(
asset_id: str, conn: Connection | None = None
) -> AssetInfo | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id",
values={"asset_id": asset_id},
model=AssetInfo,
)
async def get_user_asset(
user_id: str,
asset_id: str,
conn: Connection | None = None,
) -> Asset | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND user_id = :user_id",
values={"asset_id": asset_id, "user_id": user_id},
model=Asset,
)
async def get_public_asset(
asset_id: str,
conn: Connection | None = None,
) -> Asset | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND is_public = true",
values={"asset_id": asset_id},
model=Asset,
)
async def get_public_asset_info(
asset_id: str,
conn: Connection | None = None,
) -> AssetInfo | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND is_public = true",
values={"asset_id": asset_id},
model=AssetInfo,
)
async def update_user_asset_info(
asset: AssetInfo,
) -> AssetInfo:
await db.update("assets", asset)
return asset
async def delete_user_asset(
user_id: str, asset_id: str, conn: Connection | None = None
) -> None:
await (conn or db).execute(
query="DELETE FROM assets WHERE id = :asset_id AND user_id = :user_id",
values={"asset_id": asset_id, "user_id": user_id},
)
async def get_user_assets(
user_id: str,
filters: Filters[AssetFilters] | None = None,
conn: Connection | None = None,
) -> Page[AssetInfo]:
filters = filters or Filters()
filters.sortby = filters.sortby or "created_at"
return await (conn or db).fetch_page(
query="SELECT * FROM assets",
where=["user_id = :user_id"],
values={"user_id": user_id},
filters=filters,
model=AssetInfo,
table_name="assets",
)
async def get_user_assets_count(user_id: str) -> int:
result = await db.execute(
query="SELECT COUNT(*) as count FROM assets WHERE user_id = :user_id",
values={"user_id": user_id},
)
row = result.mappings().first()
return row.get("count", 0)
-89
View File
@@ -1,89 +0,0 @@
from lnbits.core.db import db
from lnbits.core.models import AuditEntry, AuditFilters
from lnbits.core.models.audit import AuditCountStat
from lnbits.db import Connection, Filters, Page
async def create_audit_entry(
entry: AuditEntry,
conn: Connection | None = None,
) -> None:
await (conn or db).insert("audit", entry)
async def get_audit_entries(
filters: Filters[AuditFilters] | None = None,
conn: Connection | None = None,
) -> Page[AuditEntry]:
return await (conn or db).fetch_page(
"SELECT * FROM audit",
[],
{},
filters=filters,
model=AuditEntry,
table_name="audit",
)
async def delete_expired_audit_entries(
conn: Connection | None = None,
):
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
DELETE from audit
WHERE delete_at < {db.timestamp_now}
""", # noqa: S608
)
async def get_count_stats(
field: str,
filters: Filters[AuditFilters] | None = None,
conn: Connection | None = None,
) -> list[AuditCountStat]:
if field not in ["request_method", "component", "response_code"]:
return []
if not filters:
filters = Filters()
clause = filters.where()
data = await (conn or db).fetchall(
# SQL injection vectors safety:
# - `field` is a static string, not user input
# - `clause` is generated from filters, which are validated and sanitized
query=f"""
SELECT {field} as field, count({field}) as total
FROM audit
{clause}
GROUP BY {field}
ORDER BY {field}
""", # noqa: S608
values=filters.values(),
model=AuditCountStat,
)
return data
async def get_long_duration_stats(
filters: Filters[AuditFilters] | None = None,
conn: Connection | None = None,
) -> list[AuditCountStat]:
if not filters:
filters = Filters()
clause = filters.where()
long_duration_paths = await (conn or db).fetchall(
# This query is safe from SQL injection
# The `clause` is constructed from sanitized inputs
query=f"""
SELECT path as field, max(duration) as total FROM audit
{clause}
GROUP BY path
ORDER BY total DESC
LIMIT 5
""", # noqa: S608
values=filters.values(),
model=AuditCountStat,
)
return long_duration_paths
-37
View File
@@ -1,37 +0,0 @@
from lnbits.core.db import db
from lnbits.db import Connection
from ..models import DbVersion
async def get_db_version(
ext_id: str, conn: Connection | None = None
) -> DbVersion | None:
return await (conn or db).fetchone(
"SELECT * FROM dbversions WHERE db = :ext_id",
{"ext_id": ext_id},
model=DbVersion,
)
async def get_db_versions(conn: Connection | None = None) -> list[DbVersion]:
return await (conn or db).fetchall("SELECT * FROM dbversions", model=DbVersion)
async def update_migration_version(conn, db_name, version):
await (conn or db).execute(
"""
INSERT INTO dbversions (db, version) VALUES (:db, :version)
ON CONFLICT (db) DO UPDATE SET version = :version
""",
{"db": db_name, "version": version},
)
async def delete_dbversion(*, ext_id: str, conn: Connection | None = None) -> None:
await (conn or db).execute(
"""
DELETE FROM dbversions WHERE db = :ext
""",
{"ext": ext_id},
)
-146
View File
@@ -1,146 +0,0 @@
from lnbits.core.db import db
from lnbits.core.models.extensions import (
InstallableExtension,
UserExtension,
)
from lnbits.db import Connection, Database
async def create_installed_extension(
ext: InstallableExtension,
conn: Connection | None = None,
) -> None:
await (conn or db).insert("installed_extensions", ext)
async def update_installed_extension(
ext: InstallableExtension,
conn: Connection | None = None,
) -> None:
await (conn or db).update("installed_extensions", ext)
async def update_installed_extension_state(
*, ext_id: str, active: bool, conn: Connection | None = None
) -> None:
await (conn or db).execute(
"""
UPDATE installed_extensions SET active = :active WHERE id = :ext
""",
{"ext": ext_id, "active": active},
)
async def delete_installed_extension(
*, ext_id: str, conn: Connection | None = None
) -> None:
await (conn or db).execute(
"""
DELETE from installed_extensions WHERE id = :ext
""",
{"ext": ext_id},
)
async def drop_extension_db(ext_id: str, conn: Connection | None = None) -> None:
row: dict = await (conn or db).fetchone(
"SELECT * FROM dbversions WHERE db = :id",
{"id": ext_id},
)
# Check that 'ext_id' is a valid extension id and not a malicious string
if not row:
raise Exception(f"Extension '{ext_id}' db version cannot be found")
is_file_based_db = await Database.clean_ext_db_files(ext_id)
if is_file_based_db:
return
# String formatting is required, params are not accepted for 'DROP SCHEMA'.
# The `ext_id` value is verified above.
await (conn or db).execute(
f"DROP SCHEMA IF EXISTS {ext_id} CASCADE",
)
async def get_installed_extension(
ext_id: str, conn: Connection | None = None
) -> InstallableExtension | None:
extension = await (conn or db).fetchone(
"SELECT * FROM installed_extensions WHERE id = :id",
{"id": ext_id},
InstallableExtension,
)
return extension
async def get_installed_extensions(
active: bool | None = None,
conn: Connection | None = None,
) -> list[InstallableExtension]:
query = "SELECT * FROM installed_extensions"
if active is not None:
query += " WHERE active = :active"
values = {"active": active} if active is not None else {}
all_extensions = await (conn or db).fetchall(
query,
values,
model=InstallableExtension,
)
return all_extensions
async def get_installed_extensions_count(conn: Connection | None = None) -> int:
row: dict | None = await (conn or db).fetchone(
"SELECT COUNT(*) as count FROM installed_extensions"
)
return int(row["count"]) if row else 0
async def get_user_extension(
user_id: str, extension: str, conn: Connection | None = None
) -> UserExtension | None:
return await (conn or db).fetchone(
"""
SELECT * FROM extensions
WHERE "user" = :user AND extension = :ext
""",
{"user": user_id, "ext": extension},
model=UserExtension,
)
async def get_user_extensions(
user_id: str, conn: Connection | None = None
) -> list[UserExtension]:
return await (conn or db).fetchall(
"""SELECT * FROM extensions WHERE "user" = :user""",
{"user": user_id},
model=UserExtension,
)
async def create_user_extension(
user_extension: UserExtension, conn: Connection | None = None
) -> None:
await (conn or db).insert("extensions", user_extension)
async def update_user_extension(
user_extension: UserExtension, conn: Connection | None = None
) -> None:
where = """WHERE extension = :extension AND "user" = :user"""
await (conn or db).update("extensions", user_extension, where)
async def get_user_active_extensions_ids(
user_id: str, conn: Connection | None = None
) -> list[str]:
exts = await (conn or db).fetchall(
"""
SELECT * FROM extensions WHERE "user" = :user AND active
""",
{"user": user_id},
UserExtension,
)
return [ext.extension for ext in exts]
-592
View File
@@ -1,592 +0,0 @@
from datetime import datetime, timezone
from time import time
from typing import Any
from lnbits.core.crud.wallets import get_total_balance, get_wallet, get_wallets_ids
from lnbits.core.db import db
from lnbits.core.models import PaymentState
from lnbits.db import Connection, DateTrunc, Filters, Page
from ..models import (
CreatePayment,
Payment,
PaymentCountField,
PaymentCountStat,
PaymentDailyStats,
PaymentFilters,
PaymentHistoryPoint,
PaymentsStatusCount,
PaymentWalletStats,
)
def update_payment_extra():
pass
async def get_payment(checking_id: str, conn: Connection | None = None) -> Payment:
return await (conn or db).fetchone(
"SELECT * FROM apipayments WHERE checking_id = :checking_id",
{"checking_id": checking_id},
Payment,
)
async def get_standalone_payment(
checking_id_or_hash: str,
incoming: bool | None = False,
wallet_id: str | None = None,
conn: Connection | None = None,
) -> Payment | None:
clause: str = "checking_id = :checking_id OR payment_hash = :hash"
values = {
"checking_id": checking_id_or_hash,
"hash": checking_id_or_hash,
}
if incoming:
clause = f"({clause}) AND amount > 0"
if wallet_id:
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet or not wallet.can_view_payments:
return None
values["wallet_id"] = wallet.source_wallet_id
clause = f"({clause}) AND wallet_id = :wallet_id"
row = await (conn or db).fetchone(
# This query is safe from SQL injection
# The `clause` is constructed from sanitized inputs
f"""
SELECT * FROM apipayments
WHERE {clause}
ORDER BY amount LIMIT 1
""", # noqa: S608
values,
Payment,
)
return row
async def get_wallet_payment(
wallet_id: str, payment_hash: str, conn: Connection | None = None
) -> Payment | None:
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet or not wallet.can_view_payments:
return None
payment = await (conn or db).fetchone(
"""
SELECT *
FROM apipayments
WHERE wallet_id = :wallet AND payment_hash = :hash
""",
{"wallet": wallet.source_wallet_id, "hash": payment_hash},
Payment,
)
return payment
async def get_latest_payments_by_extension(
ext_name: str, ext_id: str, limit: int = 5
) -> list[Payment]:
return await db.fetchall(
# This query is safe from SQL injection
# The limtit is an integer and not user input
f"""
SELECT * FROM apipayments
WHERE status = :status
AND extra LIKE :ext_name
AND extra LIKE :ext_id
ORDER BY time DESC LIMIT {int(limit)}
""", # noqa: S608
{
"status": f"{PaymentState.SUCCESS}",
"ext_name": f"%{ext_name}%",
"ext_id": f"%{ext_id}%",
},
Payment,
)
async def get_payments_paginated( # noqa: C901
*,
wallet_id: str | None = None,
user_id: str | None = None,
complete: bool = False,
pending: bool = False,
failed: bool = False,
outgoing: bool = False,
incoming: bool = False,
since: int | None = None,
exclude_uncheckable: bool = False,
filters: Filters[PaymentFilters] | None = None,
conn: Connection | None = None,
) -> Page[Payment]:
"""
Filters payments to be returned by:
- complete | pending | failed | outgoing | incoming.
"""
values: dict[str, Any] = {
"time": since,
}
clause: list[str] = []
if since is not None:
clause.append(f"time > {db.timestamp_placeholder('time')}")
if wallet_id:
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet or not wallet.can_view_payments:
return Page(data=[], total=0)
values["wallet_id"] = wallet.source_wallet_id
clause.append("wallet_id = :wallet_id")
elif user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
clause.append(only_user_wallets)
if complete and pending:
clause.append(
f"(status = '{PaymentState.SUCCESS}' OR status = '{PaymentState.PENDING}')"
)
elif complete:
clause.append(f"""
(
status = '{PaymentState.SUCCESS}'
OR (amount < 0 AND status = '{PaymentState.PENDING}')
)
""")
elif pending:
clause.append(f"status = '{PaymentState.PENDING}'")
elif failed:
clause.append(f"status = '{PaymentState.FAILED}'")
if outgoing and incoming:
pass
elif outgoing:
clause.append("amount < 0")
elif incoming:
clause.append("amount > 0")
else:
pass
if exclude_uncheckable: # checkable means it has a checking_id that isn't internal
clause.append("checking_id NOT LIKE 'temp_%'")
clause.append("checking_id NOT LIKE 'internal_%'")
return await (conn or db).fetch_page(
"SELECT * FROM apipayments",
clause,
values,
filters=filters,
model=Payment,
table_name="apipayments",
)
async def get_payments(
*,
wallet_id: str | None = None,
complete: bool = False,
pending: bool = False,
outgoing: bool = False,
incoming: bool = False,
since: int | None = None,
exclude_uncheckable: bool = False,
filters: Filters[PaymentFilters] | None = None,
conn: Connection | None = None,
limit: int | None = None,
offset: int | None = None,
) -> list[Payment]:
"""
Filters payments to be returned by complete | pending | outgoing | incoming.
"""
filters = filters or Filters()
filters.sortby = filters.sortby or "time"
filters.direction = filters.direction or "desc"
filters.limit = limit or filters.limit
filters.offset = offset or filters.offset
page = await get_payments_paginated(
wallet_id=wallet_id,
complete=complete,
pending=pending,
outgoing=outgoing,
incoming=incoming,
since=since,
exclude_uncheckable=exclude_uncheckable,
filters=filters,
conn=conn,
)
return page.data
async def get_payments_status_count() -> PaymentsStatusCount:
empty_page: Filters = Filters(limit=0)
in_payments = await get_payments_paginated(incoming=True, filters=empty_page)
out_payments = await get_payments_paginated(outgoing=True, filters=empty_page)
pending_payments = await get_payments_paginated(pending=True, filters=empty_page)
failed_payments = await get_payments_paginated(failed=True, filters=empty_page)
return PaymentsStatusCount(
incoming=in_payments.total,
outgoing=out_payments.total,
pending=pending_payments.total,
failed=failed_payments.total,
)
async def delete_expired_invoices(
conn: Connection | None = None,
) -> None:
# first we delete all invoices older than one month
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
DELETE FROM apipayments
WHERE status = :status AND amount > 0
AND time < {db.timestamp_placeholder("delta")}
""", # noqa: S608
{"status": f"{PaymentState.PENDING}", "delta": int(time() - 2592000)},
)
# then we delete all invoices whose expiry date is in the past
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
DELETE FROM apipayments
WHERE status = :status AND amount > 0
AND expiry < {db.timestamp_placeholder("now")}
""", # noqa: S608
{"status": f"{PaymentState.PENDING}", "now": int(time())},
)
async def create_payment(
checking_id: str,
data: CreatePayment,
status: PaymentState = PaymentState.PENDING,
conn: Connection | None = None,
) -> Payment:
# we don't allow the creation of the same invoice twice
# note: this can be removed if the db uniqueness constraints are set appropriately
previous_payment = await get_standalone_payment(checking_id, conn=conn)
if previous_payment is not None:
raise ValueError("Payment already exists")
extra = data.extra or {}
payment = Payment(
checking_id=checking_id,
status=status,
wallet_id=data.wallet_id,
payment_hash=data.payment_hash,
bolt11=data.bolt11,
amount=data.amount_msat,
memo=data.memo,
preimage=data.preimage,
expiry=data.expiry,
webhook=data.webhook,
fee=-abs(data.fee),
tag=extra.get("tag", None),
extension=data.extension,
extra=extra,
labels=data.labels or [],
external_id=data.external_id,
)
await (conn or db).insert("apipayments", payment)
return payment
async def update_payment_checking_id(
checking_id: str, new_checking_id: str, conn: Connection | None = None
) -> None:
await (conn or db).execute(
f"""
UPDATE apipayments
SET checking_id = :new_id, updated_at = {db.timestamp_placeholder("now")}
WHERE checking_id = :old_id
""", # noqa: S608
{
"new_id": new_checking_id,
"old_id": checking_id,
"now": int(time()),
},
)
async def update_payment(
payment: Payment,
new_checking_id: str | None = None,
conn: Connection | None = None,
) -> Payment:
payment.updated_at = datetime.now(timezone.utc)
await (conn or db).update(
"apipayments", payment, "WHERE checking_id = :checking_id"
)
if new_checking_id and new_checking_id != payment.checking_id:
await update_payment_checking_id(payment.checking_id, new_checking_id, conn)
payment.checking_id = new_checking_id
return payment
async def get_payments_history(
wallet_id: str | None = None,
group: DateTrunc = "day",
filters: Filters | None = None,
conn: Connection | None = None,
) -> list[PaymentHistoryPoint]:
if not filters:
filters = Filters()
date_trunc = db.datetime_grouping(group)
values: dict[str, Any] = {
"wallet_id": wallet_id,
}
# count outgoing payments if they are still pending
where = [f"""
wallet_id = :wallet_id AND (
status = '{PaymentState.SUCCESS}'
OR (amount < 0 AND status = '{PaymentState.PENDING}')
)
"""]
clause = filters.where(where)
transactions: list[dict] = await db.fetchall(
# This query is safe from SQL injection:
# - `date_trunc` is a static string, not user input
# - `clause` is generated from filters, which are validated and sanitized
f"""
SELECT {date_trunc} date,
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) income,
SUM(CASE WHEN amount < 0 THEN abs(amount) + abs(fee) ELSE 0 END) spending
FROM apipayments
{clause}
GROUP BY date
ORDER BY date DESC
""", # noqa: S608
filters.values(values),
)
if wallet_id:
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet or not wallet.can_view_payments:
return []
balance = wallet.balance_msat
values["wallet_id"] = wallet.source_wallet_id
else:
balance = await get_total_balance(conn=conn)
# since we dont know the balance at the starting point,
# we take the current balance and walk backwards
results: list[PaymentHistoryPoint] = []
for row in transactions:
results.insert(
0,
PaymentHistoryPoint(
balance=balance,
date=row.get("date", 0),
income=row.get("income", 0),
spending=row.get("spending", 0),
),
)
balance -= row.get("income", 0) - row.get("spending", 0)
return results
async def get_payment_count_stats(
field: PaymentCountField,
filters: Filters[PaymentFilters] | None = None,
user_id: str | None = None,
conn: Connection | None = None,
) -> list[PaymentCountStat]:
if not filters:
filters = Filters()
extra_stmts = []
if user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
extra_stmts.append(only_user_wallets)
clause = filters.where(extra_stmts)
data = await (conn or db).fetchall(
# SQL injection vectors safety:
# - `field` is a static string, not user input
# - `clause` is generated from filters, which are validated and sanitized
query=f"""
SELECT {field} as field, count(*) as total
FROM apipayments
{clause}
GROUP BY {field}
ORDER BY {field}
""", # noqa: S608
values=filters.values(),
model=PaymentCountStat,
)
return data
async def get_daily_stats(
filters: Filters[PaymentFilters] | None = None,
user_id: str | None = None,
conn: Connection | None = None,
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
if not filters:
filters = Filters()
in_where_stmts = ["(apipayments.status = 'success' AND apipayments.amount > 0)"]
out_where_stmts = [
"(apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)"
]
if user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
in_where_stmts.append(only_user_wallets)
out_where_stmts.append(only_user_wallets)
in_clause = filters.where(in_where_stmts)
out_clause = filters.where(out_where_stmts)
date_trunc = db.datetime_grouping("day")
query = """
SELECT {date_trunc} date,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance,
ABS(SUM(apipayments.fee)) as fee,
COUNT(*) as payments_count
FROM wallets
LEFT JOIN apipayments ON apipayments.wallet_id = wallets.id
{clause}
AND (wallets.deleted = false OR wallets.deleted is NULL)
GROUP BY date
ORDER BY date ASC
"""
data_in = await (conn or db).fetchall(
query=query.format(date_trunc=date_trunc, clause=in_clause),
values=filters.values(),
model=PaymentDailyStats,
)
data_out = await (conn or db).fetchall(
query=query.format(date_trunc=date_trunc, clause=out_clause),
values=filters.values(),
model=PaymentDailyStats,
)
return data_in, data_out
async def get_wallets_stats(
filters: Filters[PaymentFilters] | None = None,
user_id: str | None = None,
conn: Connection | None = None,
) -> list[PaymentWalletStats]:
if not filters:
filters = Filters()
where_stmts = [
"(wallets.deleted = false OR wallets.deleted is NULL)",
"""
(
(apipayments.status = 'success' AND apipayments.amount > 0)
OR (
apipayments.status IN ('success', 'pending')
AND apipayments.amount < 0
)
)
""",
]
if user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
where_stmts.append(only_user_wallets)
clauses = filters.where(where_stmts)
data = await (conn or db).fetchall(
# This query is safe from SQL injection
# The `clauses` is constructed from sanitized inputs
query=f"""
SELECT apipayments.wallet_id,
MAX(wallets.name) AS wallet_name,
MAX(wallets.user) AS user_id,
COUNT(*) as payments_count,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
FROM wallets
LEFT JOIN apipayments ON apipayments.wallet_id = wallets.id
{clauses}
GROUP BY apipayments.wallet_id
ORDER BY payments_count
""", # noqa: S608
values=filters.values(),
model=PaymentWalletStats,
)
return data
async def delete_wallet_payment(
checking_id: str, wallet_id: str, conn: Connection | None = None
) -> None:
await (conn or db).execute(
"DELETE FROM apipayments WHERE checking_id = :checking_id AND wallet = :wallet",
{"checking_id": checking_id, "wallet": wallet_id},
)
async def check_internal(
payment_hash: str, conn: Connection | None = None
) -> Payment | None:
"""
Returns the checking_id of the internal payment if it exists,
otherwise None
"""
return await (conn or db).fetchone(
"""
SELECT * FROM apipayments
WHERE payment_hash = :hash AND status = :status AND amount > 0
""",
{"status": f"{PaymentState.PENDING}", "hash": payment_hash},
Payment,
)
async def is_internal_status_success(
payment_hash: str, conn: Connection | None = None
) -> bool:
"""
Returns True if the internal payment was found and is successful,
"""
payment = await (conn or db).fetchone(
"""
SELECT * FROM apipayments
WHERE payment_hash = :payment_hash AND amount > 0
""",
{"payment_hash": payment_hash},
Payment,
)
if not payment:
return False
return payment.status == PaymentState.SUCCESS.value
async def mark_webhook_sent(payment_hash: str, status: str) -> None:
await db.execute(
"""
UPDATE apipayments SET webhook_status = :status
WHERE payment_hash = :hash
""",
{"status": status, "hash": payment_hash},
)
async def _only_user_wallets_statement(
user_id: str, conn: Connection | None = None
) -> str:
wallet_ids = await get_wallets_ids(user_id=user_id, conn=conn) or [
"no-wallets-for-user"
]
# wallet ids are safe to use in sql queries
wallet_ids_str = [f"'{w}'" for w in wallet_ids]
return f""" wallet_id IN ({", ".join(wallet_ids_str)}) """
-144
View File
@@ -1,144 +0,0 @@
import json
from typing import Any
from loguru import logger
from lnbits.core.db import db
from lnbits.db import dict_to_model
from lnbits.settings import (
AdminSettings,
EditableSettings,
FundingSourcesSettings,
SettingsField,
SuperSettings,
settings,
)
RESET_PRESERVED_SETTINGS = (
"lnbits_webpush_pubkey",
"lnbits_webpush_privkey",
*FundingSourcesSettings.__fields__,
)
async def get_super_settings() -> SuperSettings | None:
data = await get_settings_by_tag("core")
if data:
super_user = await get_settings_field("super_user")
super_user_id = super_user.value if super_user else None
settings_dict = {"super_user": super_user_id, **data}
return dict_to_model(settings_dict, SuperSettings)
return None
async def get_admin_settings(is_super_user: bool = False) -> AdminSettings | None:
sets = await get_super_settings()
if not sets:
return None
row_dict = dict(sets)
row_dict.pop("super_user")
row_dict.pop("auth_all_methods")
admin_settings = AdminSettings(
is_super_user=is_super_user,
lnbits_allowed_funding_sources=settings.lnbits_allowed_funding_sources,
**row_dict,
)
return admin_settings
async def update_admin_settings(
data: EditableSettings, tag: str | None = "core"
) -> None:
editable_settings = await get_settings_by_tag("core") or {}
editable_settings.update(data.dict(exclude_unset=True))
for key, value in editable_settings.items():
try:
await set_settings_field(key, value, tag)
except Exception as exc:
logger.warning(exc)
logger.warning(f"Failed to update settings for '{tag}.{key}'.")
async def update_super_user(super_user: str) -> SuperSettings:
await set_settings_field("super_user", super_user)
settings = await get_super_settings()
if not settings:
raise ValueError("updated super_user settings could not be retrieved")
return settings
async def delete_admin_settings(tag: str | None = "core") -> None:
await db.execute(
"DELETE FROM system_settings WHERE tag = :tag",
{"tag": tag},
)
async def reset_core_settings() -> None:
core_settings = await get_settings_by_tag("core") or {}
super_user = await get_settings_field("super_user")
await delete_admin_settings()
if super_user:
await set_settings_field("super_user", super_user.value)
for field in RESET_PRESERVED_SETTINGS:
if field in core_settings:
await set_settings_field(field, core_settings[field])
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
data = {"super_user": super_user, **new_settings}
for key, value in data.items():
await set_settings_field(key, value)
settings = await get_super_settings()
if not settings:
raise ValueError("created admin settings could not be retrieved")
return settings
async def get_settings_field(
id_: str, tag: str | None = "core"
) -> SettingsField | None:
row: dict = await db.fetchone(
"""
SELECT * FROM system_settings
WHERE id = :id AND tag = :tag
""",
{"id": id_, "tag": tag},
)
if not row:
return None
value = json.loads(row["value"]) if row["value"] else None
return SettingsField(id=row["id"], value=value, tag=row["tag"])
async def set_settings_field(id_: str, value: Any | None, tag: str | None = "core"):
value = json.dumps(value) if value is not None else None
await db.execute(
"""
INSERT INTO system_settings (id, value, tag)
VALUES (:id, :value, :tag)
ON CONFLICT (id, tag) DO UPDATE SET value = :value
""",
{"id": id_, "value": value, "tag": tag or "core"},
)
async def get_settings_by_tag(tag: str) -> dict[str, Any] | None:
rows: list[dict] = await db.fetchall(
"SELECT * FROM system_settings WHERE tag = :tag", {"tag": tag}
)
if len(rows) == 0:
return None
data: dict[str, Any] = {}
for row in rows:
try:
data[row["id"]] = json.loads(row["value"]) if row["value"] else None
except Exception as _:
logger.warning(
f"""Failed to load settings value for '{tag}.{row["id"]}'."""
)
data.pop("super_user")
return data
-40
View File
@@ -1,40 +0,0 @@
import shortuuid
from lnbits.core.db import db
from ..models import TinyURL
async def create_tinyurl(domain: str, endless: bool, wallet: str):
tinyurl_id = shortuuid.uuid()[:8]
await db.execute(
"""
INSERT INTO tiny_url (id, url, endless, wallet)
VALUES (:tinyurl, :domain, :endless, :wallet)
""",
{"tinyurl": tinyurl_id, "domain": domain, "endless": endless, "wallet": wallet},
)
return await get_tinyurl(tinyurl_id)
async def get_tinyurl(tinyurl_id: str) -> TinyURL | None:
return await db.fetchone(
"SELECT * FROM tiny_url WHERE id = :tinyurl",
{"tinyurl": tinyurl_id},
TinyURL,
)
async def get_tinyurl_by_url(url: str) -> list[TinyURL]:
return await db.fetchall(
"SELECT * FROM tiny_url WHERE url = :url",
{"url": url},
TinyURL,
)
async def delete_tinyurl(tinyurl_id: str):
await db.execute(
"DELETE FROM tiny_url WHERE id = :tinyurl",
{"tinyurl": tinyurl_id},
)
-298
View File
@@ -1,298 +0,0 @@
from datetime import datetime, timezone
from time import time
from typing import Any
from uuid import uuid4
from lnbits.core.crud.extensions import get_user_active_extensions_ids
from lnbits.core.crud.wallets import (
clear_wallet_cache,
create_wallet,
get_standalone_wallet,
get_wallets,
)
from lnbits.core.db import db
from lnbits.core.models import UserAcls
from lnbits.db import Connection, Filters, Page
from lnbits.helpers import sha256s
from lnbits.utils.cache import cache
from ..models import (
Account,
AccountFilters,
AccountOverview,
User,
)
async def create_account(
account: Account | None = None,
conn: Connection | None = None,
) -> Account:
if account:
account.validate_fields()
else:
now = datetime.now(timezone.utc)
account = Account(id=uuid4().hex, created_at=now, updated_at=now)
await (conn or db).insert("accounts", account)
return account
async def get_accounts_count(conn: Connection | None = None) -> int:
row: dict | None = await (conn or db).fetchone(
"SELECT COUNT(*) as count FROM accounts"
)
return int(row["count"]) if row else 0
async def update_account(account: Account, conn: Connection | None = None) -> Account:
account.updated_at = datetime.now(timezone.utc)
await (conn or db).update("accounts", account)
return account
async def delete_account(user_id: str, conn: Connection | None = None) -> None:
await (conn or db).execute(
"DELETE from accounts WHERE id = :user",
{"user": user_id},
)
await clear_user_id_cache(user_id)
async def get_accounts(
filters: Filters[AccountFilters] | None = None,
conn: Connection | None = None,
) -> Page[AccountOverview]:
where_clauses = []
values: dict[str, Any] = {}
filters = filters or Filters()
wallet_filter = filters.get_filter_by_field("wallet_id")
if wallet_filter and wallet_filter.values:
wallet_id_value = next(iter(wallet_filter.values.values()), None)
wallet = (
await get_standalone_wallet(wallet_id_value, deleted=None, conn=conn)
if wallet_id_value
else None
)
if not wallet:
return Page(data=[], total=0)
where_clauses.append("accounts.id = :account_id")
values = {**values, "account_id": wallet.user}
filters.remove_filter_by_field("wallet_id")
return await (conn or db).fetch_page(
"""
SELECT
accounts.id,
accounts.username,
accounts.email,
accounts.pubkey,
accounts.external_id,
accounts.activated,
SUM(COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)) as balance_msat,
SUM((
SELECT COUNT(*) FROM apipayments WHERE wallet_id = wallets.id
)) as transaction_count,
(
SELECT COUNT(*) FROM wallets WHERE wallets.user = accounts.id
) as wallet_count,
MAX((
SELECT time FROM apipayments
WHERE wallet_id = wallets.id ORDER BY time DESC LIMIT 1
)) as last_payment
FROM accounts LEFT JOIN wallets ON accounts.id = wallets.user
""",
where_clauses,
values,
filters=filters,
model=AccountOverview,
group_by=["accounts.id"],
table_name="accounts",
)
async def get_account(
user_id: str, active_only: bool = True, conn: Connection | None = None
) -> Account | None:
if len(user_id) == 0:
return None
return await (conn or db).fetchone(
"""
SELECT * FROM accounts
WHERE id = :id AND (activated = true OR activated = :activated)
""",
{"id": user_id, "activated": active_only},
Account,
)
async def delete_accounts_no_wallets(
time_delta: int,
conn: Connection | None = None,
) -> None:
delta = int(time()) - time_delta
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
DELETE FROM accounts
WHERE NOT EXISTS (
SELECT wallets.id FROM wallets WHERE wallets.user = accounts.id
) AND (
(updated_at is null AND created_at < :delta)
OR updated_at < {db.timestamp_placeholder("delta")}
)
""", # noqa: S608
{"delta": delta},
)
async def get_account_by_username(
username: str, active_only: bool = True, conn: Connection | None = None
) -> Account | None:
if len(username) == 0:
return None
return await (conn or db).fetchone(
"""
SELECT * FROM accounts
WHERE
LOWER(username) = :username
AND (activated = true OR activated = :activated)
""",
{"username": username.lower(), "activated": active_only},
Account,
)
async def get_account_by_pubkey(
pubkey: str, active_only: bool = True, conn: Connection | None = None
) -> Account | None:
return await (conn or db).fetchone(
"""
SELECT * FROM accounts
WHERE
LOWER(pubkey) = :pubkey
AND (activated = true OR activated = :activated)
""",
{"pubkey": pubkey.lower(), "activated": active_only},
Account,
)
async def get_account_by_email(
email: str, active_only: bool = True, conn: Connection | None = None
) -> Account | None:
if len(email) == 0:
return None
return await (conn or db).fetchone(
"""
SELECT * FROM accounts
WHERE
LOWER(email) = :email
AND (activated = true OR activated = :activated)
""",
{"email": email.lower(), "activated": active_only},
Account,
)
async def get_account_by_username_or_email(
username_or_email: str,
active_only: bool = True,
conn: Connection | None = None,
) -> Account | None:
return await (conn or db).fetchone(
"""
SELECT * FROM accounts
WHERE
(LOWER(email) = :value or LOWER(username) = :value)
AND (activated = true OR activated = :activated)
""",
{"value": username_or_email.lower(), "activated": active_only},
Account,
)
async def get_user(
user_id: str, active_only: bool = True, conn: Connection | None = None
) -> User | None:
async with db.reuse_conn(conn) if conn else db.connect() as conn:
account = await get_account(user_id, active_only, conn=conn)
if not account:
return None
return await get_user_from_account(account, conn=conn)
async def get_user_from_account(
account: Account, conn: Connection | None = None
) -> User | None:
async with db.reuse_conn(conn) if conn else db.connect() as conn:
extensions = await get_user_active_extensions_ids(account.id, conn=conn)
wallets = await get_wallets(account.id, deleted=False, conn=conn)
if len(wallets) == 0:
wallet = await create_wallet(user_id=account.id, conn=conn)
wallets.append(wallet)
return User(
id=account.id,
activated=account.activated,
email=account.email,
username=account.username,
pubkey=account.pubkey,
external_id=account.external_id,
extra=account.extra,
created_at=account.created_at,
updated_at=account.updated_at,
extensions=extensions,
wallets=wallets,
admin=account.is_admin,
super_user=account.is_super_user,
fiat_providers=account.fiat_providers,
has_password=account.password_hash is not None,
ui_customization=account.ui_customization or {},
)
async def update_user_access_control_list(
user_acls: UserAcls, conn: Connection | None = None
):
user_acls.updated_at = datetime.now(timezone.utc)
await (conn or db).update("accounts", user_acls)
async def get_user_access_control_lists(
user_id: str, active_only: bool = True, conn: Connection | None = None
) -> UserAcls:
user_acls = await (conn or db).fetchone(
"""
SELECT id, access_control_list FROM accounts
WHERE id = :user_id AND (activated = true OR activated = :activated)
""",
{"user_id": user_id, "activated": active_only},
UserAcls,
)
return user_acls or UserAcls(id=user_id)
async def clear_user_id_cache(user_id: str):
user = await get_user(user_id, active_only=True)
if user:
clear_user_cache(user)
def clear_user_cache(user: User):
user_cache_key: str | None = cache.pop(
f"auth:user:cache_key:{sha256s(user.id)}", None
)
if user_cache_key:
cache.pop(user_cache_key)
for wallet in user.wallets:
clear_wallet_cache(wallet)
-313
View File
@@ -1,313 +0,0 @@
from datetime import datetime, timezone
from time import time
from uuid import uuid4
from lnbits.core.db import db
from lnbits.core.models.wallets import BaseWallet, WalletsFilters, WalletType
from lnbits.db import Connection, Filters, Page
from lnbits.settings import settings
from lnbits.utils.cache import cache
from ..models import Wallet
async def create_wallet(
*,
user_id: str,
wallet_name: str | None = None,
wallet_type: WalletType = WalletType.LIGHTNING,
shared_wallet_id: str | None = None,
conn: Connection | None = None,
) -> Wallet:
wallet_id = uuid4().hex
wallet = Wallet(
id=wallet_id,
name=wallet_name or settings.lnbits_default_wallet_name,
wallet_type=wallet_type.value,
shared_wallet_id=shared_wallet_id,
user=user_id,
adminkey=uuid4().hex,
inkey=uuid4().hex,
currency=settings.lnbits_default_accounting_currency or "USD",
)
await (conn or db).insert("wallets", wallet)
return wallet
async def update_wallet(
wallet: Wallet,
conn: Connection | None = None,
) -> Wallet:
wallet.updated_at = datetime.now(timezone.utc)
await (conn or db).update("wallets", wallet)
return wallet
async def delete_wallet(
user_id: str,
wallet_id: str,
deleted: bool = True,
conn: Connection | None = None,
) -> None:
clear_wallet_id_cache(wallet_id)
now = int(time())
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE wallets
SET deleted = :deleted, updated_at = {db.timestamp_placeholder('now')}
WHERE id = :wallet AND "user" = :user
""", # noqa: S608
{"wallet": wallet_id, "user": user_id, "deleted": deleted, "now": now},
)
async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) -> None:
clear_wallet_id_cache(wallet_id)
await (conn or db).execute(
"DELETE FROM wallets WHERE id = :wallet",
{"wallet": wallet_id},
)
async def delete_wallet_by_id(
wallet_id: str, conn: Connection | None = None
) -> int | None:
clear_wallet_id_cache(wallet_id)
now = int(time())
result = await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE wallets
SET deleted = true, updated_at = {db.timestamp_placeholder('now')}
WHERE id = :wallet
""", # noqa: S608
{"wallet": wallet_id, "now": now},
)
return result.rowcount
async def remove_deleted_wallets(conn: Connection | None = None) -> None:
await (conn or db).execute("DELETE FROM wallets WHERE deleted = true")
async def delete_unused_wallets(
time_delta: int,
conn: Connection | None = None,
) -> None:
delta = int(time()) - time_delta
await (conn or db).execute(
"""
DELETE FROM wallets
WHERE (
SELECT COUNT(*) FROM apipayments WHERE wallet_id = wallets.id
) = 0 AND (
(updated_at is null AND created_at < :delta)
OR updated_at < :delta
)
""",
{"delta": delta},
)
async def get_standalone_wallet(
wallet_id: str, deleted: bool | None = False, conn: Connection | None = None
) -> Wallet | None:
query = """
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
WHERE id = :wallet
"""
if deleted is not None:
query += " AND deleted = :deleted "
return await (conn or db).fetchone(
query,
{"wallet": wallet_id, "deleted": deleted},
Wallet,
)
async def get_wallet(
wallet_id: str, deleted: bool | None = False, conn: Connection | None = None
) -> Wallet | None:
wallet = await get_standalone_wallet(wallet_id, deleted, conn)
if not wallet:
return None
if wallet.is_lightning_shared_wallet:
return await get_source_wallet(wallet, conn)
return wallet
async def get_wallets(
user_id: str,
deleted: bool | None = False,
wallet_type: WalletType | None = None,
conn: Connection | None = None,
) -> list[Wallet]:
query = """
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
WHERE "user" = :user
"""
if deleted is not None:
query += " AND deleted = :deleted "
if wallet_type is not None:
query += " AND wallet_type = :wallet_type "
wallets = await (conn or db).fetchall(
query,
{
"user": user_id,
"deleted": deleted,
"wallet_type": wallet_type.value if wallet_type else None,
},
Wallet,
)
return await get_source_wallets(wallets, conn)
async def get_wallets_paginated(
user_id: str,
deleted: bool | None = None,
filters: Filters[WalletsFilters] | None = None,
conn: Connection | None = None,
) -> Page[Wallet]:
if deleted is None:
deleted = False
where: list[str] = [""" "user" = :user AND deleted = :deleted """]
wallets = await (conn or db).fetch_page(
"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
""",
where=where,
values={"user": user_id, "deleted": deleted},
filters=filters,
model=Wallet,
table_name="wallets",
)
wallets.data = await get_source_wallets(wallets.data, conn)
return wallets
async def get_wallets_ids(
user_id: str, deleted: bool | None = False, conn: Connection | None = None
) -> list[str]:
query = """SELECT * FROM wallets WHERE "user" = :user"""
if deleted is not None:
query += " AND deleted = :deleted "
wallets = await (conn or db).fetchall(
query,
{"user": user_id, "deleted": deleted},
Wallet,
)
wallets = await get_source_wallets(wallets, conn)
return [w.source_wallet_id for w in wallets if w.can_view_payments]
async def get_wallets_count():
result = await db.execute("SELECT COUNT(*) as count FROM wallets")
row = result.mappings().first()
return row.get("count", 0)
async def get_wallet_for_key(
key: str,
conn: Connection | None = None,
) -> Wallet | None:
wallet = await (conn or db).fetchone(
"""
SELECT wallets.*, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)
AS balance_msat FROM wallets
INNER JOIN accounts ON wallets.user = accounts.id
WHERE (adminkey = :key OR inkey = :key)
AND deleted = false
AND accounts.activated = true
""",
{"key": key},
Wallet,
)
if not wallet:
return None
if wallet.is_lightning_shared_wallet:
mw = await get_source_wallet(wallet, conn)
return mw
return wallet
async def get_base_wallet_for_key(
key: str,
conn: Connection | None = None,
) -> BaseWallet | None:
wallet = await (conn or db).fetchone(
"""
SELECT wallets.id, "user", wallet_type, adminkey, inkey FROM wallets
INNER JOIN accounts ON wallets.user = accounts.id
WHERE (adminkey = :key OR inkey = :key)
AND deleted = false
AND accounts.activated = true
""",
{"key": key},
BaseWallet,
)
if not wallet:
return None
return wallet
async def get_source_wallet(
wallet: Wallet, conn: Connection | None = None
) -> Wallet | None:
if not wallet.is_lightning_shared_wallet:
return wallet
if not wallet.shared_wallet_id:
return None
shared_wallet = await get_standalone_wallet(wallet.shared_wallet_id, False, conn)
if not shared_wallet:
return None
wallet.mirror_shared_wallet(shared_wallet)
return wallet
async def get_source_wallets(
wallet: list[Wallet], conn: Connection | None = None
) -> list[Wallet]:
source_wallets = []
for w in wallet:
source_wallet = await get_source_wallet(w, conn)
if source_wallet:
source_wallets.append(source_wallet)
return source_wallets
async def get_total_balance(conn: Connection | None = None):
result = await (conn or db).execute("SELECT SUM(balance) as balance FROM balances")
row = result.mappings().first()
return row.get("balance", 0) or 0
def clear_wallet_id_cache(wallet_id: str):
cached_wallet: BaseWallet | None = cache.pop(f"auth:wallet:{wallet_id}")
if cached_wallet:
cache.pop(f"auth:x-api-key:{cached_wallet.adminkey}")
cache.pop(f"auth:x-api-key:{cached_wallet.inkey}")
def clear_wallet_cache(wallet: Wallet):
cache.pop(f"auth:wallet:{wallet.id}")
cache.pop(f"auth:x-api-key:{wallet.adminkey}")
cache.pop(f"auth:x-api-key:{wallet.inkey}")
-58
View File
@@ -1,58 +0,0 @@
from lnbits.core.db import db
from ..models import WebPushSubscription
async def get_webpush_subscription(
endpoint: str, user: str
) -> WebPushSubscription | None:
return await db.fetchone(
"""
SELECT * FROM webpush_subscriptions
WHERE endpoint = :endpoint AND "user" = :user
""",
{"endpoint": endpoint, "user": user},
WebPushSubscription,
)
async def get_webpush_subscriptions_for_user(user: str) -> list[WebPushSubscription]:
return await db.fetchall(
"""SELECT * FROM webpush_subscriptions WHERE "user" = :user""",
{"user": user},
WebPushSubscription,
)
async def create_webpush_subscription(
endpoint: str, user: str, data: str, host: str
) -> WebPushSubscription:
await db.execute(
"""
INSERT INTO webpush_subscriptions (endpoint, "user", data, host)
VALUES (:endpoint, :user, :data, :host)
""",
{"endpoint": endpoint, "user": user, "data": data, "host": host},
)
subscription = await get_webpush_subscription(endpoint, user)
if not subscription:
raise ValueError("Newly created webpush subscription couldn't be retrieved")
return subscription
async def delete_webpush_subscription(endpoint: str, user: str) -> int:
resp = await db.execute(
"""
DELETE FROM webpush_subscriptions WHERE endpoint = :endpoint AND "user" = :user
""",
{"endpoint": endpoint, "user": user},
)
return resp.rowcount
async def delete_webpush_subscriptions(endpoint: str) -> int:
resp = await db.execute(
"DELETE FROM webpush_subscriptions WHERE endpoint = :endpoint",
{"endpoint": endpoint},
)
return resp.rowcount
+83 -42
View File
@@ -1,52 +1,49 @@
import importlib
import re
from typing import Any
from urllib.parse import urlparse
from typing import Any, Optional
from uuid import UUID
import httpx
from loguru import logger
from lnbits.core import migrations as core_migrations
from lnbits.core.crud import (
get_db_versions,
get_dbversions,
get_installed_extensions,
update_migration_version,
)
from lnbits.core.db import db as core_db
from lnbits.core.models import DbVersion
from lnbits.core.models.extensions import InstallableExtension
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
from lnbits.extension_manager import (
Extension,
get_valid_extensions,
)
from lnbits.settings import settings
async def migrate_extension_database(
ext: InstallableExtension, current_version: DbVersion | None = None
):
async def migrate_extension_database(ext: Extension, current_version):
try:
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
ext_db = importlib.import_module(ext.module_name).db
except ImportError as exc:
logger.error(exc)
raise ImportError(f"Cannot import module for extension '{ext.id}'.") from exc
raise ImportError(
f"Please make sure that the extension `{ext.code}` has a migrations file."
) from exc
async with ext_db.connect() as ext_conn:
await run_migration(ext_conn, ext_migrations, ext.id, current_version)
await run_migration(ext_conn, ext_migrations, ext.code, current_version)
async def run_migration(
db: Connection,
migrations_module: Any,
db_name: str,
current_version: DbVersion | None = None,
db: Connection, migrations_module: Any, db_name: str, current_version: int
):
matcher = re.compile(r"^m(\d\d\d)_")
for key, migrate in list(migrations_module.__dict__.items()):
for key, migrate in migrations_module.__dict__.items():
match = matcher.match(key)
if match:
version = int(match.group(1))
if not current_version or version > current_version.version:
if version > current_version:
logger.debug(f"running migration {db_name}.{version}")
print(f"running migration {db_name}.{version}")
await migrate(db)
@@ -58,6 +55,68 @@ async def run_migration(
await update_migration_version(conn, db_name, version)
async def stop_extension_background_work(
ext_id: str, user: str, access_token: Optional[str] = None
):
"""
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extensions SHOULD expose a `api_stop()` function and/or a DELETE enpoint
at the root level of their API.
"""
stopped = await _stop_extension_background_work(ext_id)
if not stopped:
# fallback to REST API call
await _stop_extension_background_work_via_api(ext_id, user, access_token)
async def _stop_extension_background_work(ext_id) -> bool:
upgrade_hash = settings.extension_upgrade_hash(ext_id) or ""
ext = Extension(ext_id, True, False, upgrade_hash=upgrade_hash)
try:
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
old_module = importlib.import_module(ext.module_name)
# Extensions must expose an `{ext_id}_stop()` function at the module level
# The `api_stop()` function is for backwards compatibility (will be deprecated)
stop_fns = [f"{ext_id}_stop", "api_stop"]
stop_fn_name = next((fn for fn in stop_fns if hasattr(old_module, fn)), None)
assert stop_fn_name, "No stop function found for '{ext.module_name}'"
stop_fn = getattr(old_module, stop_fn_name)
if stop_fn:
await stop_fn()
logger.info(f"Stopped background work for extension '{ext.module_name}'.")
except Exception as ex:
logger.warning(f"Failed to stop background work for '{ext.module_name}'.")
logger.warning(ex)
return False
return True
async def _stop_extension_background_work_via_api(ext_id, user, access_token):
logger.info(
f"Stopping background work for extension '{ext_id}' using the REST API."
)
async with httpx.AsyncClient() as client:
try:
url = f"http://{settings.host}:{settings.port}/{ext_id}/api/v1?usr={user}"
headers = (
{"Authorization": "Bearer " + access_token} if access_token else None
)
resp = await client.delete(url=url, headers=headers)
resp.raise_for_status()
logger.info(f"Stopped background work for extension '{ext_id}'.")
except Exception as ex:
logger.warning(
f"Failed to stop background work for '{ext_id}' using the REST API."
)
logger.warning(ex)
def to_valid_user_id(user_id: str) -> UUID:
if len(user_id) < 32:
raise ValueError("User ID must have at least 128 bits")
@@ -93,38 +152,20 @@ async def migrate_databases():
if not exists:
await core_migrations.m000_create_migrations_table(conn)
current_versions = await get_db_versions(conn)
core_version = next(
(v for v in current_versions if v.db == "core"),
DbVersion(db="core", version=0),
)
current_versions = await get_dbversions(conn)
core_version = current_versions.get("core", 0)
await run_migration(conn, core_migrations, "core", core_version)
# here is the first place we can be sure that the
# `installed_extensions` table has been created
await load_disabled_extension_list()
for ext in await get_installed_extensions():
current_version = next(
(v for v in current_versions if v.db == ext.id),
DbVersion(db=ext.id, version=0),
)
if current_version is None:
logger.warning(
f"Extension {ext.id} has no migration version. This should not happen."
)
continue
# todo: revisit, use installed extensions
for ext in get_valid_extensions(False):
current_version = current_versions.get(ext.code, 0)
try:
await migrate_extension_database(ext, current_version)
except Exception as e:
logger.exception(f"Error migrating extension {ext.id}: {e}")
logger.exception(f"Error migrating extension {ext.code}: {e}")
logger.info("✔️ All migrations done.")
def is_valid_url(url):
try:
result = urlparse(url)
return all([result.scheme, result.netloc])
except ValueError:
return False
+156 -423
View File
@@ -1,35 +1,38 @@
import json
import datetime
from time import time
from typing import Any
from loguru import logger
from sqlalchemy.exc import OperationalError
from lnbits import bolt11
from lnbits.db import Connection
async def m000_create_migrations_table(db: Connection):
await db.execute("""
async def m000_create_migrations_table(db):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS dbversions (
db TEXT PRIMARY KEY,
version INT NOT NULL
)
""")
"""
)
async def m001_initial(db: Connection):
async def m001_initial(db):
"""
Initial LNbits tables.
"""
await db.execute("""
await db.execute(
"""
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
email TEXT,
pass TEXT
);
""")
await db.execute("""
"""
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS extensions (
"user" TEXT NOT NULL,
extension TEXT NOT NULL,
@@ -37,8 +40,10 @@ async def m001_initial(db: Connection):
UNIQUE ("user", extension)
);
""")
await db.execute("""
"""
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS wallets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
@@ -46,8 +51,10 @@ async def m001_initial(db: Connection):
adminkey TEXT NOT NULL,
inkey TEXT
);
""")
await db.execute(f"""
"""
)
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS apipayments (
payhash TEXT NOT NULL,
amount {db.big_int} NOT NULL,
@@ -58,9 +65,11 @@ async def m001_initial(db: Connection):
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
UNIQUE (wallet, payhash)
);
""")
"""
)
await db.execute("""
await db.execute(
"""
CREATE VIEW balances AS
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
SELECT wallet, SUM(amount) AS s -- incoming
@@ -74,10 +83,11 @@ async def m001_initial(db: Connection):
GROUP BY wallet
)x
GROUP BY wallet;
""")
"""
)
async def m002_add_fields_to_apipayments(db: Connection):
async def m002_add_fields_to_apipayments(db):
"""
Adding fields to apipayments for better accounting,
and renaming payhash to checking_id since that is what it really is.
@@ -90,8 +100,9 @@ async def m002_add_fields_to_apipayments(db: Connection):
await db.execute("ALTER TABLE apipayments ADD COLUMN bolt11 TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN extra TEXT")
result = await db.execute("SELECT * FROM apipayments")
rows = result.mappings().all()
import json
rows = await (await db.execute("SELECT * FROM apipayments")).fetchall()
for row in rows:
if not row["memo"] or not row["memo"].startswith("#"):
continue
@@ -102,15 +113,15 @@ async def m002_add_fields_to_apipayments(db: Connection):
new = row["memo"][len(prefix) :]
await db.execute(
"""
UPDATE apipayments SET extra = :extra, memo = :memo1
WHERE checking_id = :checking_id AND memo = :memo2
UPDATE apipayments SET extra = ?, memo = ?
WHERE checking_id = ? AND memo = ?
""",
{
"extra": json.dumps({"tag": ext}),
"memo1": new,
"checking_id": row["checking_id"],
"memo2": row["memo"],
},
(
json.dumps({"tag": ext}),
new,
row["checking_id"],
row["memo"],
),
)
break
except OperationalError:
@@ -121,7 +132,7 @@ async def m002_add_fields_to_apipayments(db: Connection):
pass
async def m003_add_invoice_webhook(db: Connection):
async def m003_add_invoice_webhook(db):
"""
Special column for webhook endpoints that can be assigned
to each different invoice.
@@ -131,13 +142,14 @@ async def m003_add_invoice_webhook(db: Connection):
await db.execute("ALTER TABLE apipayments ADD COLUMN webhook_status TEXT")
async def m004_ensure_fees_are_always_negative(db: Connection):
async def m004_ensure_fees_are_always_negative(db):
"""
Use abs() so wallet backends don't have to care about the sign of the fees.
"""
await db.execute("DROP VIEW balances")
await db.execute("""
await db.execute(
"""
CREATE VIEW balances AS
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
SELECT wallet, SUM(amount) AS s -- incoming
@@ -151,16 +163,18 @@ async def m004_ensure_fees_are_always_negative(db: Connection):
GROUP BY wallet
)x
GROUP BY wallet;
""")
"""
)
async def m005_balance_check_balance_notify(db: Connection):
async def m005_balance_check_balance_notify(db):
"""
Keep track of balanceCheck-enabled lnurl-withdrawals to be consumed by an
LNbits wallet and of balanceNotify URLs supplied by users to empty their wallets.
"""
await db.execute("""
await db.execute(
"""
CREATE TABLE IF NOT EXISTS balance_check (
wallet TEXT NOT NULL REFERENCES wallets (id),
service TEXT NOT NULL,
@@ -168,19 +182,22 @@ async def m005_balance_check_balance_notify(db: Connection):
UNIQUE(wallet, service)
);
""")
"""
)
await db.execute("""
await db.execute(
"""
CREATE TABLE IF NOT EXISTS balance_notify (
wallet TEXT NOT NULL REFERENCES wallets (id),
url TEXT NOT NULL,
UNIQUE(wallet, url)
);
""")
"""
)
async def m006_add_invoice_expiry_to_apipayments(db: Connection):
async def m006_add_invoice_expiry_to_apipayments(db):
"""
Adds invoice expiry column to apipayments.
"""
@@ -190,24 +207,24 @@ async def m006_add_invoice_expiry_to_apipayments(db: Connection):
pass
async def m007_set_invoice_expiries(db: Connection):
async def m007_set_invoice_expiries(db):
"""
Precomputes invoice expiry for existing pending incoming payments.
"""
try:
result = await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
SELECT bolt11, checking_id
FROM apipayments
WHERE pending = true
AND amount > 0
AND bolt11 IS NOT NULL
AND expiry IS NULL
AND time < {db.timestamp_now}
""" # noqa: S608
)
rows = result.mappings().all()
rows = await (
await db.execute(
f"""
SELECT bolt11, checking_id
FROM apipayments
WHERE pending = true
AND amount > 0
AND bolt11 IS NOT NULL
AND expiry IS NULL
AND time < {db.timestamp_now}
"""
)
).fetchall()
if len(rows):
logger.info(f"Migration: Checking expiry of {len(rows)} invoices")
for i, (
@@ -219,21 +236,24 @@ async def m007_set_invoice_expiries(db: Connection):
if invoice.expiry is None:
continue
expiration_date = invoice.date + invoice.expiry
expiration_date = datetime.datetime.fromtimestamp(
invoice.date + invoice.expiry
)
logger.info(
f"Migration: {i+1}/{len(rows)} setting expiry of invoice"
f" {invoice.payment_hash} to {expiration_date}"
)
await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')}
WHERE checking_id = :checking_id AND amount > 0
""", # noqa: S608
{"expiry": expiration_date, "checking_id": checking_id},
"""
UPDATE apipayments SET expiry = ?
WHERE checking_id = ? AND amount > 0
""",
(
db.datetime_to_timestamp(expiration_date),
checking_id,
),
)
except Exception as exc:
logger.debug(exc)
except Exception:
continue
except OperationalError:
# this is necessary now because it may be the case that this migration will
@@ -243,17 +263,20 @@ async def m007_set_invoice_expiries(db: Connection):
pass
async def m008_create_admin_settings_table(db: Connection):
await db.execute("""
async def m008_create_admin_settings_table(db):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS settings (
super_user TEXT,
editable_settings TEXT NOT NULL DEFAULT '{}'
);
""")
"""
)
async def m009_create_tinyurl_table(db: Connection):
await db.execute(f"""
async def m009_create_tinyurl_table(db):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS tiny_url (
id TEXT PRIMARY KEY,
url TEXT,
@@ -261,11 +284,13 @@ async def m009_create_tinyurl_table(db: Connection):
wallet TEXT,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
""")
"""
)
async def m010_create_installed_extensions_table(db: Connection):
await db.execute("""
async def m010_create_installed_extensions_table(db):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS installed_extensions (
id TEXT PRIMARY KEY,
version TEXT NOT NULL,
@@ -276,31 +301,36 @@ async def m010_create_installed_extensions_table(db: Connection):
active BOOLEAN DEFAULT false,
meta TEXT NOT NULL DEFAULT '{}'
);
""")
"""
)
async def m011_optimize_balances_view(db: Connection):
async def m011_optimize_balances_view(db):
"""
Make the calculation of the balance a single aggregation
over the payments table instead of 2.
"""
await db.execute("DROP VIEW balances")
await db.execute("""
await db.execute(
"""
CREATE VIEW balances AS
SELECT wallet, SUM(amount - abs(fee)) AS balance
FROM apipayments
WHERE (pending = false AND amount > 0) OR amount < 0
GROUP BY wallet
""")
"""
)
async def m012_add_currency_to_wallet(db: Connection):
await db.execute("""
async def m012_add_currency_to_wallet(db):
await db.execute(
"""
ALTER TABLE wallets ADD COLUMN currency TEXT
""")
"""
)
async def m013_add_deleted_to_wallets(db: Connection):
async def m013_add_deleted_to_wallets(db):
"""
Adds deleted column to wallets.
"""
@@ -312,40 +342,37 @@ async def m013_add_deleted_to_wallets(db: Connection):
pass
async def m014_set_deleted_wallets(db: Connection):
async def m014_set_deleted_wallets(db):
"""
Sets deleted column to wallets.
"""
try:
result = await db.execute("""
SELECT *
FROM wallets
WHERE user LIKE 'del:%'
AND adminkey LIKE 'del:%'
AND inkey LIKE 'del:%'
""")
rows = result.mappings().all()
rows = await (
await db.execute(
"""
SELECT *
FROM wallets
WHERE user LIKE 'del:%'
AND adminkey LIKE 'del:%'
AND inkey LIKE 'del:%'
"""
)
).fetchall()
for row in rows:
try:
user = row["user"].split(":")[1]
adminkey = row["adminkey"].split(":")[1]
inkey = row["inkey"].split(":")[1]
user = row[2].split(":")[1]
adminkey = row[3].split(":")[1]
inkey = row[4].split(":")[1]
await db.execute(
"""
UPDATE wallets SET
"user" = :user, adminkey = :adminkey, inkey = :inkey, deleted = true
WHERE id = :wallet
"user" = ?, adminkey = ?, inkey = ?, deleted = true
WHERE id = ?
""",
{
"user": user,
"adminkey": adminkey,
"inkey": inkey,
"wallet": row.get("id"),
},
(user, adminkey, inkey, row[0]),
)
except Exception as exc:
logger.debug(exc)
except Exception:
continue
except OperationalError:
# this is necessary now because it may be the case that this migration will
@@ -355,8 +382,9 @@ async def m014_set_deleted_wallets(db: Connection):
pass
async def m015_create_push_notification_subscriptions_table(db: Connection):
await db.execute(f"""
async def m015_create_push_notification_subscriptions_table(db):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS webpush_subscriptions (
endpoint TEXT NOT NULL,
"user" TEXT NOT NULL,
@@ -365,10 +393,11 @@ async def m015_create_push_notification_subscriptions_table(db: Connection):
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
PRIMARY KEY (endpoint, "user")
);
""")
"""
)
async def m016_add_username_column_to_accounts(db: Connection):
async def m016_add_username_column_to_accounts(db):
"""
Adds username column to accounts.
"""
@@ -379,7 +408,7 @@ async def m016_add_username_column_to_accounts(db: Connection):
pass
async def m017_add_timestamp_columns_to_accounts_and_wallets(db: Connection):
async def m017_add_timestamp_columns_to_accounts_and_wallets(db):
"""
Adds created_at and updated_at column to accounts and wallets.
"""
@@ -426,20 +455,18 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db: Connection):
# set all to now where they are null
now = int(time())
await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE wallets SET created_at = {db.timestamp_placeholder('now')}
UPDATE wallets SET created_at = {db.timestamp_placeholder}
WHERE created_at IS NULL
""", # noqa: S608
{"now": now},
""",
(now,),
)
await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE accounts SET created_at = {db.timestamp_placeholder('now')}
UPDATE accounts SET created_at = {db.timestamp_placeholder}
WHERE created_at IS NULL
""", # noqa: S608
{"now": now},
""",
(now,),
)
except OperationalError as exc:
@@ -447,12 +474,13 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db: Connection):
pass
async def m018_balances_view_exclude_deleted(db: Connection):
async def m018_balances_view_exclude_deleted(db):
"""
Make deleted wallets not show up in the balances view.
"""
await db.execute("DROP VIEW balances")
await db.execute("""
await db.execute(
"""
CREATE VIEW balances AS
SELECT apipayments.wallet,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
@@ -462,16 +490,18 @@ async def m018_balances_view_exclude_deleted(db: Connection):
AND ((apipayments.pending = false AND apipayments.amount > 0)
OR apipayments.amount < 0)
GROUP BY wallet
""")
"""
)
async def m019_balances_view_based_on_wallets(db: Connection):
async def m019_balances_view_based_on_wallets(db):
"""
Make deleted wallets not show up in the balances view.
Important for querying whole lnbits balances.
"""
await db.execute("DROP VIEW balances")
await db.execute("""
await db.execute(
"""
CREATE VIEW balances AS
SELECT apipayments.wallet,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
@@ -481,17 +511,18 @@ async def m019_balances_view_based_on_wallets(db: Connection):
AND ((apipayments.pending = false AND apipayments.amount > 0)
OR apipayments.amount < 0)
GROUP BY apipayments.wallet
""")
"""
)
async def m020_add_column_column_to_user_extensions(db: Connection):
async def m020_add_column_column_to_user_extensions(db):
"""
Adds extra column to user extensions.
"""
await db.execute("ALTER TABLE extensions ADD COLUMN extra TEXT")
async def m021_add_success_failed_to_apipayments(db: Connection):
async def m021_add_success_failed_to_apipayments(db):
"""
Adds success and failed columns to apipayments.
"""
@@ -500,7 +531,8 @@ async def m021_add_success_failed_to_apipayments(db: Connection):
await db.execute("UPDATE apipayments SET status = 'success' WHERE NOT pending")
await db.execute("DROP VIEW balances")
await db.execute("""
await db.execute(
"""
CREATE VIEW balances AS
SELECT apipayments.wallet,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
@@ -512,306 +544,7 @@ async def m021_add_success_failed_to_apipayments(db: Connection):
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
)
GROUP BY apipayments.wallet
""")
async def m022_add_pubkey_to_accounts(db: Connection):
"""
Adds pubkey column to accounts.
"""
try:
await db.execute("ALTER TABLE accounts ADD COLUMN pubkey TEXT")
except OperationalError:
pass
async def m023_add_column_column_to_apipayments(db: Connection):
"""
renames hash to payment_hash and drops unused index
"""
await db.execute("DROP INDEX by_hash")
await db.execute("ALTER TABLE apipayments RENAME COLUMN hash TO payment_hash")
await db.execute("ALTER TABLE apipayments RENAME COLUMN wallet TO wallet_id")
await db.execute("ALTER TABLE accounts RENAME COLUMN pass TO password_hash")
await db.execute("CREATE INDEX by_hash ON apipayments (payment_hash)")
async def m024_drop_pending(db: Connection):
await db.execute("ALTER TABLE apipayments DROP COLUMN pending")
async def m025_refresh_view(db: Connection):
await db.execute("DROP VIEW balances")
await db.execute("""
CREATE VIEW balances AS
SELECT apipayments.wallet_id,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
FROM wallets
LEFT JOIN apipayments ON apipayments.wallet_id = wallets.id
WHERE (wallets.deleted = false OR wallets.deleted is NULL)
AND (
(apipayments.status = 'success' AND apipayments.amount > 0)
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
)
GROUP BY apipayments.wallet_id
""")
async def m026_update_payment_table(db: Connection):
await db.execute("ALTER TABLE apipayments ADD COLUMN tag TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN extension TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN created_at TIMESTAMP")
await db.execute("ALTER TABLE apipayments ADD COLUMN updated_at TIMESTAMP")
async def m027_update_apipayments_data(db: Connection):
result = None
try:
result = await db.execute("SELECT * FROM apipayments LIMIT 100")
except Exception as exc:
logger.warning("Could not select, trying again after cache cleared.")
logger.debug(exc)
await db.execute("COMMIT")
offset = 0
limit = 1000
payments: list[dict[Any, Any]] = []
logger.info("Updating payments")
while len(payments) > 0 or offset == 0:
logger.info(f"Updating {offset} to {offset+limit}")
result = await db.execute(
# Limit and Offset safe from SQL injection
# since they are integers and are not user input
f"""
SELECT * FROM apipayments
ORDER BY time LIMIT {int(limit)} OFFSET {int(offset)}
""" # noqa: S608
)
payments = result.mappings().all()
logger.info(f"Payments count: {len(payments)}")
for payment in payments:
tag = None
created_at = payment.get("time")
if payment.get("extra"):
extra = json.loads(str(payment.get("extra")))
tag = extra.get("tag")
tsph = db.timestamp_placeholder("created_at")
await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE apipayments
SET tag = :tag, created_at = {tsph}, updated_at = {tsph}
WHERE checking_id = :checking_id
""", # noqa: S608
{
"tag": tag,
"created_at": created_at,
"checking_id": payment.get("checking_id"),
},
)
offset += limit
logger.info("Payments updated")
async def m028_update_settings(db: Connection):
await db.execute("""
CREATE TABLE IF NOT EXISTS system_settings (
id TEXT PRIMARY KEY,
value TEXT,
tag TEXT NOT NULL DEFAULT 'core',
UNIQUE (id, tag)
);
""")
async def _insert_key_value(id_: str, value: Any):
await db.execute(
"""
INSERT INTO system_settings (id, value, tag)
VALUES (:id, :value, :tag)
""",
{"id": id_, "value": json.dumps(value), "tag": "core"},
)
row: dict = await db.fetchone("SELECT * FROM settings")
if row:
await _insert_key_value("super_user", row["super_user"])
editable_settings = json.loads(row["editable_settings"])
for key, value in editable_settings.items():
await _insert_key_value(key, value)
await db.execute("drop table settings")
async def m029_create_audit_table(db: Connection):
await db.execute(f"""
CREATE TABLE IF NOT EXISTS audit (
component TEXT,
ip_address TEXT,
user_id TEXT,
path TEXT,
request_type TEXT,
request_method TEXT,
request_details TEXT,
response_code TEXT,
duration REAL NOT NULL,
delete_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
""")
async def m030_add_user_api_tokens_column(db: Connection):
await db.execute("""
ALTER TABLE accounts ADD COLUMN access_control_list TEXT
""")
async def m031_add_color_and_icon_to_wallets(db: Connection):
"""
Adds icon and color columns to wallets.
"""
await db.execute("ALTER TABLE wallets ADD COLUMN extra TEXT")
async def m032_add_external_id_to_accounts(db: Connection):
"""
Adds external_id column to accounts.
Used for external account linking.
"""
await db.execute("ALTER TABLE accounts ADD COLUMN external_id TEXT")
async def m033_update_payment_table(db: Connection):
await db.execute("ALTER TABLE apipayments ADD COLUMN fiat_provider TEXT")
async def m034_add_stored_paylinks_to_wallet(db: Connection):
await db.execute("""
ALTER TABLE wallets ADD COLUMN stored_paylinks TEXT
""")
async def m035_add_wallet_type_column(db: Connection):
await db.execute("""
ALTER TABLE wallets ADD COLUMN wallet_type TEXT DEFAULT 'lightning'
""")
async def m036_add_shared_wallet_column(db: Connection):
await db.execute("""
ALTER TABLE wallets ADD COLUMN shared_wallet_id TEXT
""")
async def m037_create_assets_table(db: Connection):
await db.execute(f"""
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
mime_type TEXT NOT NULL,
is_public BOOLEAN NOT NULL DEFAULT false,
name TEXT NOT NULL,
size_bytes INT NOT NULL,
thumbnail_base64 TEXT,
thumbnail {db.blob},
data {db.blob} NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
""")
async def m038_add_labels_for_payments(db: Connection):
await db.execute("""
ALTER TABLE apipayments ADD COLUMN labels TEXT
""")
async def m039_index_payments(db: Connection):
indexes = [
"wallet_id",
"checking_id",
"payment_hash",
"amount",
"fee",
"labels",
"time",
"status",
"memo",
"created_at",
"updated_at",
]
for index in indexes:
logger.debug(f"Creating index idx_payments_{index}...")
await db.execute(f"""
CREATE INDEX IF NOT EXISTS idx_payments_{index} ON apipayments ({index});
""")
async def m040_index_wallets(db: Connection):
indexes = [
"id",
"user",
"deleted",
"adminkey",
"inkey",
"wallet_type",
"created_at",
"updated_at",
]
for index in indexes:
logger.debug(f"Creating index idx_wallets_{index}...")
await db.execute(f"""
CREATE INDEX IF NOT EXISTS idx_wallets_{index} ON wallets ("{index}");
""")
async def m042_index_accounts(db: Connection):
indexes = [
"id",
"email",
"username",
"pubkey",
"external_id",
]
for index in indexes:
logger.debug(f"Creating index idx_wallets_{index}...")
await db.execute(f"""
CREATE INDEX IF NOT EXISTS idx_accounts_{index} ON accounts ("{index}");
""")
async def m043_add_ui_customization_to_accounts(db: Connection):
"""
Adds ui_customization column to accounts.
Used for server side persistence of UI customization settings.
"""
await db.execute("ALTER TABLE accounts ADD COLUMN ui_customization TEXT")
async def m044_add_activated_to_accounts(db: Connection):
"""
Adds activated column to accounts.
Used for account activation status.
"""
await db.execute("ALTER TABLE accounts ADD COLUMN activated BOOLEAN DEFAULT true")
async def m045_add_external_id_to_payments(db: Connection):
"""
Adds external_id column to apipayments.
Used for external payment references.
"""
await db.execute("ALTER TABLE apipayments ADD COLUMN external_id TEXT")
logger.debug("Creating index idx_payments_external_id...")
await db.execute("""
CREATE INDEX IF NOT EXISTS idx_payments_external_id
ON apipayments (external_id);
""")
)
# TODO: drop column in next release
# await db.execute("ALTER TABLE apipayments DROP COLUMN pending")
+431
View File
@@ -0,0 +1,431 @@
from __future__ import annotations
import datetime
import hashlib
import hmac
import json
import time
from dataclasses import dataclass
from enum import Enum
from sqlite3 import Row
from typing import Callable, Optional
from ecdsa import SECP256k1, SigningKey
from fastapi import Query
from pydantic import BaseModel, validator
from lnbits.db import FilterModel, FromRowModel
from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings
from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import (
PaymentPendingStatus,
PaymentStatus,
)
class BaseWallet(BaseModel):
id: str
name: str
adminkey: str
inkey: str
balance_msat: int
class Wallet(BaseWallet):
user: str
currency: Optional[str]
deleted: bool
created_at: Optional[int] = None
updated_at: Optional[int] = None
@property
def balance(self) -> int:
return self.balance_msat // 1000
@property
def withdrawable_balance(self) -> int:
from .services import fee_reserve
return self.balance_msat - fee_reserve(self.balance_msat)
@property
def lnurlwithdraw_full(self) -> str:
url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
try:
return lnurl_encode(url)
except Exception:
return ""
def lnurlauth_key(self, domain: str) -> SigningKey:
hashing_key = hashlib.sha256(self.id.encode()).digest()
linking_key = hmac.digest(hashing_key, domain.encode(), "sha256")
return SigningKey.from_string(
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
)
async def get_payment(self, payment_hash: str) -> Optional[Payment]:
from .crud import get_standalone_payment
return await get_standalone_payment(payment_hash)
class KeyType(Enum):
admin = 0
invoice = 1
invalid = 2
# backwards compatibility
def __eq__(self, other):
return self.value == other
@dataclass
class WalletTypeInfo:
key_type: KeyType
wallet: Wallet
class UserConfig(BaseModel):
email_verified: Optional[bool] = False
first_name: Optional[str] = None
last_name: Optional[str] = None
display_name: Optional[str] = None
picture: Optional[str] = None
# Auth provider, possible values:
# - "env": the user was created automatically by the system
# - "lnbits": the user was created via register form (username/pass or user_id only)
# - "google | github | ...": the user was created using an SSO provider
provider: Optional[str] = "lnbits" # auth provider
class Account(FromRowModel):
id: str
is_super_user: Optional[bool] = False
is_admin: Optional[bool] = False
username: Optional[str] = None
email: Optional[str] = None
balance_msat: Optional[int] = 0
transaction_count: Optional[int] = 0
wallet_count: Optional[int] = 0
last_payment: Optional[datetime.datetime] = None
class AccountFilters(FilterModel):
__search_fields__ = ["id", "email", "username"]
__sort_fields__ = [
"balance_msat",
"email",
"username",
"transaction_count",
"wallet_count",
"last_payment",
]
id: str
last_payment: Optional[datetime.datetime] = None
transaction_count: Optional[int] = None
wallet_count: Optional[int] = None
username: Optional[str] = None
email: Optional[str] = None
class User(BaseModel):
id: str
email: Optional[str] = None
username: Optional[str] = None
extensions: list[str] = []
wallets: list[Wallet] = []
admin: bool = False
super_user: bool = False
has_password: bool = False
config: Optional[UserConfig] = None
created_at: Optional[int] = None
updated_at: Optional[int] = None
@property
def wallet_ids(self) -> list[str]:
return [wallet.id for wallet in self.wallets]
def get_wallet(self, wallet_id: str) -> Optional[Wallet]:
w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
return w[0] if w else None
@classmethod
def is_extension_for_user(cls, ext: str, user: str) -> bool:
if ext not in settings.lnbits_admin_extensions:
return True
if user == settings.super_user:
return True
if user in settings.lnbits_admin_users:
return True
return False
class CreateUser(BaseModel):
email: Optional[str] = Query(default=None)
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class UpdateUser(BaseModel):
user_id: str
email: Optional[str] = Query(default=None)
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
config: Optional[UserConfig] = None
class UpdateUserPassword(BaseModel):
user_id: str
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
password_old: Optional[str] = Query(default=None, min_length=8, max_length=50)
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
class UpdateSuperuserPassword(BaseModel):
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class LoginUsr(BaseModel):
usr: str
class LoginUsernamePassword(BaseModel):
username: str
password: str
class PaymentState(str, Enum):
PENDING = "pending"
SUCCESS = "success"
FAILED = "failed"
def __str__(self) -> str:
return self.value
class Payment(FromRowModel):
status: str
# TODO should be removed in the future, backward compatibility
pending: bool
checking_id: str
amount: int
fee: int
memo: Optional[str]
time: int
bolt11: str
preimage: str
payment_hash: str
expiry: Optional[float]
extra: dict = {}
wallet_id: str
webhook: Optional[str]
webhook_status: Optional[int]
@property
def success(self) -> bool:
return self.status == PaymentState.SUCCESS.value
@property
def failed(self) -> bool:
return self.status == PaymentState.FAILED.value
@classmethod
def from_row(cls, row: Row):
return cls(
checking_id=row["checking_id"],
payment_hash=row["hash"] or "0" * 64,
bolt11=row["bolt11"] or "",
preimage=row["preimage"] or "0" * 64,
extra=json.loads(row["extra"] or "{}"),
status=row["status"],
# TODO should be removed in the future, backward compatibility
pending=row["status"] == PaymentState.PENDING.value,
amount=row["amount"],
fee=row["fee"],
memo=row["memo"],
time=row["time"],
expiry=row["expiry"],
wallet_id=row["wallet"],
webhook=row["webhook"],
webhook_status=row["webhook_status"],
)
@property
def tag(self) -> Optional[str]:
if self.extra is None:
return ""
return self.extra.get("tag")
@property
def msat(self) -> int:
return self.amount
@property
def sat(self) -> int:
return self.amount // 1000
@property
def is_in(self) -> bool:
return self.amount > 0
@property
def is_out(self) -> bool:
return self.amount < 0
@property
def is_expired(self) -> bool:
return self.expiry < time.time() if self.expiry else False
@property
def is_uncheckable(self) -> bool:
return self.checking_id.startswith("internal_")
async def check_status(self) -> PaymentStatus:
if self.is_uncheckable:
return PaymentPendingStatus()
funding_source = get_funding_source()
if self.is_out:
status = await funding_source.get_payment_status(self.checking_id)
else:
status = await funding_source.get_invoice_status(self.checking_id)
return status
class PaymentFilters(FilterModel):
__search_fields__ = ["memo", "amount"]
checking_id: str
amount: int
fee: int
memo: Optional[str]
time: datetime.datetime
bolt11: str
preimage: str
payment_hash: str
expiry: Optional[datetime.datetime]
extra: dict = {}
wallet_id: str
webhook: Optional[str]
webhook_status: Optional[int]
class PaymentHistoryPoint(BaseModel):
date: datetime.datetime
income: int
spending: int
balance: int
def _do_nothing(*_):
pass
class CoreAppExtra:
register_new_ext_routes: Callable = _do_nothing
register_new_ratelimiter: Callable
class TinyURL(BaseModel):
id: str
url: str
endless: bool
wallet: str
time: float
@classmethod
def from_row(cls, row: Row):
return cls(**dict(row))
class ConversionData(BaseModel):
from_: str = "sat"
amount: float
to: str = "usd"
class Callback(BaseModel):
callback: str
class DecodePayment(BaseModel):
data: str
filter_fields: Optional[list[str]] = []
class CreateLnurl(BaseModel):
description_hash: str
callback: str
amount: int
comment: Optional[str] = None
description: Optional[str] = None
unit: Optional[str] = None
class CreateInvoice(BaseModel):
unit: str = "sat"
internal: bool = False
out: bool = True
amount: float = Query(None, ge=0)
memo: Optional[str] = None
description_hash: Optional[str] = None
unhashed_description: Optional[str] = None
expiry: Optional[int] = None
extra: Optional[dict] = None
webhook: Optional[str] = None
bolt11: Optional[str] = None
lnurl_callback: Optional[str] = None
@validator("unit")
@classmethod
def unit_is_from_allowed_currencies(cls, v):
if v != "sat" and v not in allowed_currencies():
raise ValueError("The provided unit is not supported")
return v
class CreateTopup(BaseModel):
id: str
amount: int
class CreateLnurlAuth(BaseModel):
callback: str
class CreateWallet(BaseModel):
name: Optional[str] = None
class CreateWebPushSubscription(BaseModel):
subscription: str
class WebPushSubscription(BaseModel):
endpoint: str
user: str
data: str
host: str
timestamp: str
class BalanceDelta(BaseModel):
lnbits_balance_msats: int
node_balance_msats: int
@property
def delta_msats(self):
return self.node_balance_msats - self.lnbits_balance_msats
class SimpleStatus(BaseModel):
success: bool
message: str
-106
View File
@@ -1,106 +0,0 @@
from .audit import AuditEntry, AuditFilters
from .lnurl import CreateLnurlPayment, CreateLnurlWithdraw
from .misc import (
BalanceDelta,
Callback,
ConversionData,
CoreAppExtra,
DbVersion,
SimpleStatus,
)
from .payments import (
CancelInvoice,
CreateInvoice,
CreatePayment,
DecodePayment,
PayInvoice,
Payment,
PaymentCountField,
PaymentCountStat,
PaymentDailyStats,
PaymentExtra,
PaymentFilters,
PaymentHistoryPoint,
PaymentsStatusCount,
PaymentState,
PaymentWalletStats,
SettleInvoice,
UpdatePaymentExtra,
)
from .tinyurl import TinyURL
from .users import (
AccessTokenPayload,
Account,
AccountFilters,
AccountOverview,
CreateUser,
LoginUsernamePassword,
LoginUsr,
RegisterUser,
ResetUserPassword,
UpdateBalance,
UpdateSuperuserPassword,
UpdateUser,
UpdateUserPassword,
UpdateUserPubkey,
User,
UserAcls,
UserExtra,
)
from .wallets import CreateWallet, KeyType, Wallet, WalletInfo, WalletTypeInfo
from .webpush import CreateWebPushSubscription, WebPushSubscription
__all__ = [
"AccessTokenPayload",
"Account",
"AccountFilters",
"AccountOverview",
"AuditEntry",
"AuditFilters",
"BalanceDelta",
"Callback",
"CancelInvoice",
"ConversionData",
"CoreAppExtra",
"CreateInvoice",
"CreateLnurlPayment",
"CreateLnurlWithdraw",
"CreatePayment",
"CreateUser",
"CreateWallet",
"CreateWebPushSubscription",
"DbVersion",
"DecodePayment",
"KeyType",
"LoginUsernamePassword",
"LoginUsr",
"PayInvoice",
"Payment",
"PaymentCountField",
"PaymentCountStat",
"PaymentDailyStats",
"PaymentExtra",
"PaymentFilters",
"PaymentHistoryPoint",
"PaymentState",
"PaymentWalletStats",
"PaymentsStatusCount",
"RegisterUser",
"ResetUserPassword",
"SettleInvoice",
"SimpleStatus",
"TinyURL",
"UpdateBalance",
"UpdatePaymentExtra",
"UpdateSuperuserPassword",
"UpdateUser",
"UpdateUserPassword",
"UpdateUserPubkey",
"User",
"UserAcls",
"UserExtra",
"Wallet",
"WalletInfo",
"WalletTypeInfo",
"WebPushSubscription",
]
-37
View File
@@ -1,37 +0,0 @@
from __future__ import annotations
from datetime import datetime, timezone
from pydantic import BaseModel, Field
from lnbits.db import FilterModel
class AssetInfo(BaseModel):
id: str
mime_type: str
name: str
is_public: bool = False
size_bytes: int
thumbnail_base64: str | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Asset(AssetInfo):
user_id: str
data: bytes
class AssetUpdate(BaseModel):
name: str | None = None
is_public: bool | None = None
class AssetFilters(FilterModel):
__search_fields__ = ["name"]
__sort_fields__ = [
"created_at",
"name",
]
name: str | None = None
-61
View File
@@ -1,61 +0,0 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from pydantic import BaseModel, Field
from lnbits.db import FilterModel
from lnbits.settings import settings
class AuditEntry(BaseModel):
component: str | None = None
ip_address: str | None = None
user_id: str | None = None
path: str | None = None
request_type: str | None = None
request_method: str | None = None
request_details: str | None = None
response_code: str | None = None
duration: float
delete_at: datetime | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
def __init__(self, **data):
super().__init__(**data)
retention_days = max(0, settings.lnbits_audit_retention_days) or 365
self.delete_at = self.created_at + timedelta(days=retention_days)
class AuditFilters(FilterModel):
__search_fields__ = [
"ip_address",
"user_id",
"path",
"request_method",
"response_code",
"component",
]
__sort_fields__ = [
"created_at",
"duration",
]
ip_address: str | None = None
user_id: str | None = None
path: str | None = None
request_method: str | None = None
response_code: str | None = None
component: str | None = None
class AuditCountStat(BaseModel):
field: str = ""
total: float = 0
class AuditStats(BaseModel):
request_method: list[AuditCountStat] = []
response_code: list[AuditCountStat] = []
component: list[AuditCountStat] = []
long_duration: list[AuditCountStat] = []
-854
View File
@@ -1,854 +0,0 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import shutil
import zipfile
from asyncio.tasks import create_task
from pathlib import Path
from typing import Any
import httpx
from loguru import logger
from pydantic import BaseModel, Field
from lnbits.helpers import (
download_url,
file_hash,
is_lnbits_version_ok,
version_parse,
)
from lnbits.settings import settings
from lnbits.utils.cache import cache
class ExplicitRelease(BaseModel):
id: str
name: str
version: str
archive: str
hash: str
dependencies: list[str] = []
repo: str | None
icon: str | None
short_description: str | None
min_lnbits_version: str | None
max_lnbits_version: str | None
html_url: str | None # todo: release_url
warning: str | None
info_notification: str | None
critical_notification: str | None
details_link: str | None
paid_features: str | None
pay_link: str | None
def is_version_compatible(self):
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
class GitHubRelease(BaseModel):
id: str
organisation: str
repository: str
class Manifest(BaseModel):
extensions: list[ExplicitRelease] = []
repos: list[GitHubRelease] = []
featured: list[str] = []
categories: dict[str, list[str]] = {}
class GitHubRepoRelease(BaseModel):
name: str
tag_name: str
zipball_url: str
html_url: str
def details_link(self, source_repo: str) -> str:
return f"https://raw.githubusercontent.com/{source_repo}/{self.tag_name}/config.json"
class GitHubRepo(BaseModel):
stargazers_count: str
html_url: str
default_branch: str
class ExtensionConfig(BaseModel):
name: str
short_description: str
tile: str = ""
warning: str | None = ""
min_lnbits_version: str | None
max_lnbits_version: str | None
def is_version_compatible(self) -> bool:
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
@classmethod
async def fetch_github_release_config(
cls, org: str, repo: str, tag_name: str
) -> ExtensionConfig | None:
config_url = (
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
)
error_msg = "Cannot fetch GitHub extension config"
config = await github_api_get(config_url, error_msg)
return ExtensionConfig.parse_obj(config)
class ReleasePaymentInfo(BaseModel):
amount: int | None = None
pay_link: str | None = None
payment_hash: str | None = None
payment_request: str | None = None
class PayToEnableInfo(BaseModel):
amount: int = 0
required: bool = False
wallet: str | None = None
class UserExtensionInfo(BaseModel):
paid_to_enable: bool | None = False
payment_hash_to_enable: str | None = None
class UserExtension(BaseModel):
user: str
extension: str
active: bool
extra: UserExtensionInfo | None = None
@property
def is_paid(self) -> bool:
if not self.extra:
return False
return self.extra.paid_to_enable is True
@classmethod
def from_row(cls, data: dict) -> UserExtension:
ext = UserExtension(**data)
ext.extra = (
UserExtensionInfo(**json.loads(data["_extra"] or "{}"))
if "_extra" in data
else None
)
return ext
class Extension(BaseModel):
code: str
is_valid: bool
name: str | None = None
short_description: str | None = None
tile: str | None = None
upgrade_hash: str | None = ""
@property
def module_name(self) -> str:
if self.is_upgrade_extension:
return f"{self.code}-{self.upgrade_hash}"
if settings.has_default_extension_path:
return f"lnbits.extensions.{self.code}"
return self.code
@property
def is_upgrade_extension(self) -> bool:
return self.upgrade_hash != ""
@classmethod
def from_installable_ext(cls, ext_info: InstallableExtension) -> Extension:
return Extension(
code=ext_info.id,
is_valid=True,
name=ext_info.name,
short_description=ext_info.short_description,
tile=ext_info.icon,
upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "",
)
class ExtensionRelease(BaseModel):
name: str
version: str
archive: str
source_repo: str
is_github_release: bool = False
hash: str | None = None
min_lnbits_version: str | None = None
max_lnbits_version: str | None = None
is_version_compatible: bool | None = True
html_url: str | None = None
description: str | None = None
warning: str | None = None
repo: str | None = None
icon: str | None = None
details_link: str | None = None
paid_features: str | None = None
pay_link: str | None = None
cost_sats: int | None = None
paid_sats: int | None = 0
payment_hash: str | None = None
@property
def archive_url(self) -> str:
if not self.pay_link:
return self.archive
return (
f"{self.archive}?version=v{self.version}&payment_hash={self.payment_hash}"
)
async def check_payment_requirements(self):
if not self.pay_link:
return
payment_info = await self.fetch_release_payment_info()
self.cost_sats = payment_info.amount if payment_info else None
async def fetch_release_payment_info(
self, amount: int | None = None
) -> ReleasePaymentInfo | None:
url = f"{self.pay_link}?amount={amount}" if amount else self.pay_link
if not url:
raise ValueError("Missing URL for payment info.")
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url)
resp.raise_for_status()
return ReleasePaymentInfo(**resp.json())
except Exception as e:
logger.warning(e)
return None
@classmethod
def from_github_release(
cls, source_repo: str, r: GitHubRepoRelease
) -> ExtensionRelease:
return ExtensionRelease(
name=r.name,
description=r.name,
version=r.tag_name,
archive=r.zipball_url,
source_repo=source_repo,
is_github_release=True,
details_link=r.details_link(source_repo),
repo=f"https://github.com/{source_repo}",
html_url=r.html_url,
)
@classmethod
def from_explicit_release(
cls, source_repo: str, e: ExplicitRelease
) -> ExtensionRelease:
return ExtensionRelease(
name=e.name,
version=e.version,
archive=e.archive,
hash=e.hash,
source_repo=source_repo,
description=e.short_description,
min_lnbits_version=e.min_lnbits_version,
max_lnbits_version=e.max_lnbits_version,
is_version_compatible=e.is_version_compatible(),
warning=e.warning,
html_url=e.html_url,
details_link=e.details_link,
pay_link=e.pay_link,
paid_features=e.paid_features,
repo=e.repo,
icon=e.icon,
)
@classmethod
async def get_github_releases(cls, org: str, repo: str) -> list[ExtensionRelease]:
try:
github_releases = await cls.fetch_github_releases(org, repo)
extension_releases = [
ExtensionRelease.from_github_release(f"{org}/{repo}", r)
for r in github_releases
]
for release in extension_releases:
if not release.details_link:
continue
try:
config = await ExtensionConfig.fetch_github_release_config(
org, repo, release.version
)
except Exception as e:
logger.warning(e)
config = None
if not config:
continue
release.min_lnbits_version = config.min_lnbits_version
release.max_lnbits_version = config.max_lnbits_version
release.is_version_compatible = config.is_version_compatible()
release.icon = icon_to_github_url(f"{org}/{repo}", config.tile)
return extension_releases
except Exception as e:
logger.warning(e)
return []
@classmethod
async def fetch_github_releases(
cls, org: str, repo: str
) -> list[GitHubRepoRelease]:
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
error_msg = "Cannot fetch extension releases"
releases = await github_api_get(releases_url, error_msg)
return [GitHubRepoRelease.parse_obj(r) for r in releases]
@classmethod
async def fetch_release_details(cls, details_link: str) -> dict | None:
try:
async with httpx.AsyncClient() as client:
resp = await client.get(details_link)
resp.raise_for_status()
data = resp.json()
if "description_md" in data:
resp = await client.get(data["description_md"])
if not resp.is_error:
data["description_md"] = resp.text
return data
except Exception as e:
logger.warning(e)
return None
class ExtensionMeta(BaseModel):
installed_release: ExtensionRelease | None = None
latest_release: ExtensionRelease | None = None
pay_to_enable: PayToEnableInfo | None = None
payments: list[ReleasePaymentInfo] = []
dependencies: list[str] = []
archive: str | None = None
featured: bool = False
categories: list[str] = []
paid_features: str | None = None
has_paid_release: bool = False
has_free_release: bool = False
class InstallableExtension(BaseModel):
id: str
name: str
version: str
active: bool | None = False
short_description: str | None = None
icon: str | None = None
stars: int = 0
meta: ExtensionMeta | None = None
@property
def hash(self) -> str:
if self.meta and self.meta.installed_release:
if self.meta.installed_release.hash:
return self.meta.installed_release.hash
m = hashlib.sha256()
m.update(f"{self.meta.installed_release.archive}".encode())
return m.hexdigest()
return "not-installed"
@property
def zip_path(self) -> Path:
extensions_data_dir = Path(settings.lnbits_data_folder, "zips")
Path(extensions_data_dir).mkdir(parents=True, exist_ok=True)
return Path(extensions_data_dir, f"{self.id}.zip")
@property
def ext_dir(self) -> Path:
return Path(settings.lnbits_extensions_path, "extensions", self.id)
@property
def ext_upgrade_dir(self) -> Path:
return Path(settings.lnbits_extensions_upgrade_path, f"{self.id}-{self.hash}")
@property
def module_name(self) -> str:
if self.ext_upgrade_dir.is_dir():
return f"{self.id}-{self.hash}"
if settings.has_default_extension_path:
return f"lnbits.extensions.{self.id}"
return self.id
@property
def has_installed_version(self) -> bool:
if not self.ext_dir.is_dir():
return False
return Path(self.ext_dir, "config.json").is_file()
@property
def installed_version(self) -> str:
if self.meta and self.meta.installed_release:
return self.meta.installed_release.version
return ""
@property
def requires_payment(self) -> bool:
if not self.meta or not self.meta.pay_to_enable:
return False
return self.meta.pay_to_enable.required is True
async def download_archive(self):
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
ext_zip_file = self.zip_path
if ext_zip_file.is_file():
os.remove(ext_zip_file)
try:
if not self.meta or not self.meta.installed_release:
raise ValueError("No installed release.")
self._restore_payment_info()
await asyncio.to_thread(
download_url, self.meta.installed_release.archive_url, ext_zip_file
)
self._remember_payment_info()
except Exception as exc:
logger.warning(exc)
raise AssertionError("Cannot fetch extension archive file") from exc
archive_hash = file_hash(ext_zip_file)
if (
self.meta
and self.meta.installed_release.hash
and self.meta.installed_release.hash != archive_hash
):
# remove downloaded archive
if ext_zip_file.is_file():
os.remove(ext_zip_file)
raise AssertionError("File hash missmatch. Will not install.")
def extract_archive(self):
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
Path(settings.lnbits_extensions_upgrade_path).mkdir(parents=True, exist_ok=True)
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
shutil.rmtree(tmp_dir, True)
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
zip_ref.extractall(tmp_dir)
generated_dir_name = os.listdir(tmp_dir)[0]
shutil.rmtree(self.ext_upgrade_dir, True)
shutil.copytree(
Path(tmp_dir, generated_dir_name),
Path(self.ext_upgrade_dir),
)
shutil.rmtree(tmp_dir, True)
# Pre-packed extensions can be upgraded
# Mark the extension as installed so we know it is not the pre-packed version
with open(Path(self.ext_upgrade_dir, "config.json"), "r+") as json_file:
config_json = json.load(json_file)
self.name = config_json.get("name")
self.short_description = config_json.get("short_description")
if (
self.meta
and self.meta.installed_release
and self.meta.installed_release.is_github_release
and config_json.get("tile")
):
self.icon = icon_to_github_url(
self.meta.installed_release.source_repo, config_json.get("tile")
)
shutil.rmtree(self.ext_dir, True)
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
logger.info(f"Extension {self.name} ({self.installed_version}) extracted.")
def clean_extension_files(self):
# remove downloaded archive
if self.zip_path.is_file():
os.remove(self.zip_path)
# remove module from extensions
shutil.rmtree(self.ext_dir, True)
shutil.rmtree(self.ext_upgrade_dir, True)
def check_release_updates(self, release: ExtensionRelease | None):
self._check_latest_version(release)
self._check_payment_link(release)
def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None:
if not pay_link or not self.meta or not self.meta.payments:
return None
return next(
(p for p in self.meta.payments if p.pay_link == pay_link),
None,
)
def _check_latest_version(self, release: ExtensionRelease | None):
if not release:
return
if not release.is_version_compatible:
return
if not self.meta or not self.meta.latest_release:
meta = self.meta or ExtensionMeta()
meta.latest_release = release
self.meta = meta
return
if version_parse(self.meta.latest_release.version) < version_parse(
release.version
):
self.meta.latest_release = release
def _check_payment_link(self, release: ExtensionRelease | None):
if not release:
return
if not release.is_version_compatible:
return
if not self.meta:
self.meta = ExtensionMeta()
if release.pay_link:
self.meta.has_paid_release = True
else:
self.meta.has_free_release = True
if release.paid_features:
self.meta.paid_features = release.paid_features
def _restore_payment_info(self):
if (
not self.meta
or not self.meta.installed_release
or not self.meta.installed_release.pay_link
or not self.meta.installed_release.payment_hash
):
return
payment_info = self.find_existing_payment(self.meta.installed_release.pay_link)
if payment_info:
self.meta.installed_release.payment_hash = payment_info.payment_hash
def _remember_payment_info(self):
if (
not self.meta
or not self.meta.installed_release
or not self.meta.installed_release.pay_link
):
return
payment_info = ReleasePaymentInfo(
amount=self.meta.installed_release.cost_sats,
pay_link=self.meta.installed_release.pay_link,
payment_hash=self.meta.installed_release.payment_hash,
)
self.meta.payments = [
p for p in self.meta.payments if p.pay_link != payment_info.pay_link
]
self.meta.payments.append(payment_info)
@classmethod
async def from_github_release(
cls, github_release: GitHubRelease
) -> InstallableExtension | None:
try:
repo, latest_release, config = await cls.fetch_github_repo_info(
github_release.organisation, github_release.repository
)
source_repo = f"{github_release.organisation}/{github_release.repository}"
return InstallableExtension(
id=github_release.id,
name=config.name,
version=latest_release.tag_name,
short_description=config.short_description,
stars=int(repo.stargazers_count),
icon=icon_to_github_url(
source_repo,
config.tile,
),
meta=ExtensionMeta(
latest_release=ExtensionRelease.from_github_release(
source_repo, latest_release
),
),
)
except Exception as e:
logger.warning(e)
return None
@classmethod
def from_explicit_release(cls, e: ExplicitRelease) -> InstallableExtension:
meta = ExtensionMeta(archive=e.archive, dependencies=e.dependencies)
return InstallableExtension(
id=e.id,
name=e.name,
version=e.version,
short_description=e.short_description,
icon=e.icon,
meta=meta,
)
@classmethod
def from_ext_dir(cls, ext_id: str) -> InstallableExtension | None:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return None
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
version = config_json.get("version", "0.0")
return InstallableExtension(
id=ext_id,
name=config_json.get("name", ext_id),
active=True,
version=version,
short_description=config_json.get("short_description"),
icon=config_json.get("tile"),
meta=ExtensionMeta(
installed_release=ExtensionRelease(
name=ext_id,
version=version,
archive=f"{conf_path}",
source_repo=f"{conf_path}",
min_lnbits_version=config_json.get("min_lnbits_version"),
max_lnbits_version=config_json.get("max_lnbits_version"),
)
),
)
except Exception as e:
logger.warning(e)
return None
@classmethod
async def get_installable_extensions(
cls, post_refresh_cache: bool = False
) -> list[InstallableExtension]:
extension_list: list[InstallableExtension] = []
cache_key = "extensions:installable"
cache_value = cache.value(cache_key)
if not cache_value:
extension_list = await cls._get_installable_extensions()
cache.set(cache_key, extension_list, expiry=3600) # one hour
return extension_list
if cache_value.older_than(10 * 60) or post_refresh_cache:
# refresh cache in background if older than 10 minutes or requested
create_task(cls._refresh_installable_extensions_cache())
extension_list = cache_value.value # type: ignore
return extension_list
@classmethod
async def _refresh_installable_extensions_cache(
cls,
) -> None:
cache_key = "extensions:installable"
extension_list: list[InstallableExtension] = (
await cls._get_installable_extensions()
)
cache.set(cache_key, extension_list, expiry=3600)
@classmethod
async def _get_installable_extensions(
cls,
) -> list[InstallableExtension]:
extension_list: list[InstallableExtension] = []
for url in settings.lnbits_extensions_manifests:
try:
manifest = await cls.fetch_manifest(url)
for r in manifest.repos:
ext = await InstallableExtension.from_github_release(r)
if not ext:
continue
existing_ext = next(
(ee for ee in extension_list if ee.id == r.id), None
)
if existing_ext and ext.meta:
existing_ext.check_release_updates(ext.meta.latest_release)
continue
meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured
meta.categories = [
category
for category, ext_ids in manifest.categories.items()
if ext.id in ext_ids
]
ext.meta = meta
extension_list += [ext]
for e in manifest.extensions:
release = ExtensionRelease.from_explicit_release(url, e)
existing_ext = next(
(ee for ee in extension_list if ee.id == e.id), None
)
if existing_ext:
existing_ext.check_release_updates(release)
continue
ext = InstallableExtension.from_explicit_release(e)
ext.check_release_updates(release)
meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured
meta.categories = [
category
for category, ext_ids in manifest.categories.items()
if ext.id in ext_ids
]
ext.meta = meta
extension_list += [ext]
except Exception as e:
logger.warning(f"Manifest {url} failed with '{e!s}'")
settings.lnbits_all_extensions_ids = {e.id for e in extension_list}
return extension_list
@classmethod
async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
extension_releases: list[ExtensionRelease] = []
all_manifests = [
*settings.lnbits_extensions_manifests,
settings.lnbits_extensions_builder_manifest_url,
]
for url in all_manifests:
try:
manifest = await cls.fetch_manifest(url)
for r in manifest.repos:
if r.id != ext_id:
continue
repo_releases = await ExtensionRelease.get_github_releases(
r.organisation, r.repository
)
extension_releases += repo_releases
for e in manifest.extensions:
if e.id != ext_id:
continue
explicit_release = ExtensionRelease.from_explicit_release(url, e)
await explicit_release.check_payment_requirements()
extension_releases.append(explicit_release)
except Exception as e:
logger.warning(f"Manifest {url} failed with '{e!s}'")
return extension_releases
@classmethod
async def get_extension_release(
cls, ext_id: str, source_repo: str, archive: str, version: str
) -> ExtensionRelease | None:
all_releases: list[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(ext_id)
)
selected_release = [
r
for r in all_releases
if r.archive == archive
and r.source_repo == source_repo
and r.version == version
]
return selected_release[0] if len(selected_release) != 0 else None
@classmethod
async def fetch_github_repo_info(
cls, org: str, repository: str
) -> tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]:
repo_url = f"https://api.github.com/repos/{org}/{repository}"
error_msg = "Cannot fetch extension repo"
repo = await github_api_get(repo_url, error_msg)
github_repo = GitHubRepo.parse_obj(repo)
lates_release_url = (
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
)
error_msg = "Cannot fetch extension releases"
latest_release: Any = await github_api_get(lates_release_url, error_msg)
config_url = f"https://raw.githubusercontent.com/{org}/{repository}/{github_repo.default_branch}/config.json"
error_msg = "Cannot fetch config for extension"
config = await github_api_get(config_url, error_msg)
return (
github_repo,
GitHubRepoRelease.parse_obj(latest_release),
ExtensionConfig.parse_obj(config),
)
@classmethod
async def fetch_manifest(cls, url) -> Manifest:
error_msg = "Cannot fetch extensions manifest"
manifest = await github_api_get(url, error_msg)
return Manifest.parse_obj(manifest)
class CreateExtension(BaseModel):
ext_id: str
archive: str
source_repo: str
version: str
cost_sats: int | None = 0
payment_hash: str | None = None
class ExtensionDetailsRequest(BaseModel):
ext_id: str
source_repo: str
version: str
class ExtensionReviewsStatus(BaseModel):
tag: str
avg_rating: float
review_count: int
class CreateExtensionReview(BaseModel):
tag: str
name: str | None = Field(None)
rating: int = Field(..., ge=0, le=1000)
comment: str | None = Field(None)
class ExtensionReviewPaymentRequest(BaseModel):
payment_hash: str
payment_request: str
class ExtensionReview(BaseModel):
id: str
name: str | None = Field(default=None)
tag: str | None = Field(default=None)
rating: int = Field(default=0, ge=0, le=1000)
comment: str | None = Field(default=None)
async def github_api_get(url: str, error_msg: str | None) -> Any:
headers = {"User-Agent": settings.user_agent}
if settings.lnbits_ext_github_token:
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
async with httpx.AsyncClient(headers=headers) as client:
resp = await client.get(url)
if resp.status_code != 200:
logger.warning(f"{error_msg} ({url}): {resp.text}")
resp.raise_for_status()
return resp.json()
def icon_to_github_url(source_repo: str, path: str | None) -> str:
if not path:
return ""
_, _, *rest = path.split("/")
tail = "/".join(rest)
return f"https://github.com/{source_repo}/raw/main/{tail}"
-485
View File
@@ -1,485 +0,0 @@
from __future__ import annotations
import json
import random
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
from pydantic import BaseModel, validator
from lnbits.helpers import (
camel_to_snake,
is_camel_case,
is_snake_case,
urlsafe_short_hash,
)
class DataField(BaseModel):
name: str
type: str
label: str | None = None
hint: str | None = None
optional: bool = False
editable: bool = False
searchable: bool = False
sortable: bool = False
fields: list[DataField] = []
def normalize(self) -> None:
self.name = self.name.strip()
self.type = self.type.strip()
if self.label:
self.label = self.label.strip()
if self.hint:
self.hint = self.hint.strip()
if self.type == "json":
self.editable = False
self.searchable = False
self.sortable = False
else:
self.fields = []
for field in self.fields:
field.normalize()
def field_to_py(self) -> str:
field_name = camel_to_snake(self.name)
field_type = self.type
if self.type == "json":
field_type = "dict"
elif self.type in ["wallet", "currency", "text"]:
field_type = "str"
if self.optional:
field_type += " | None"
if self.type == "currency":
field_type += ' = "sat"'
return f"{field_name}: {field_type}"
def field_to_random_value(self) -> str:
field_name = camel_to_snake(self.name)
field_value: Any = f'"{self.type}"'
if self.type == "json":
field_value = '"{}"'
elif self.type == "wallet":
field_value = f'"{uuid.uuid4()}"'
elif self.type == "currency":
field_value = '"sat"'
elif self.type in ["str", "text"]:
field_value = f'"{field_name}_{urlsafe_short_hash()}"'
elif self.type == "int":
field_value = random.randint(1, 100) # noqa: S311
elif self.type == "float":
field_value = random.uniform(1.0, 100.0) # noqa: S311
elif self.type == "bool":
field_value = random.choice([True, False]) # noqa: S311
elif self.type == "datetime":
random_days = random.randint(-30, 30) # noqa: S311
random_date = datetime.now(timezone.utc) - timedelta(days=random_days)
field_value = f"""datetime.fromisoformat("{random_date.isoformat()}")"""
return f"{field_name} = {field_value},"
def field_to_js(self) -> str:
field_name = camel_to_snake(self.name)
default_value = "null"
if self.type == "json":
default_value = "{}"
if self.type == "currency":
default_value = '"sat"'
return f"{field_name}: {default_value}"
def field_to_ui_table_column(self) -> str:
column = {
"name": self.name,
"align": "left",
"label": self.label or self.name,
"field": self.name,
"sortable": self.sortable,
}
return json.dumps(column)
def field_to_db(self) -> str:
field_name = camel_to_snake(self.name)
field_type = self.type
if field_type == "str":
db_type = "TEXT"
elif field_type == "int":
db_type = "INT"
elif field_type == "float":
db_type = "REAL"
elif field_type == "bool":
db_type = "BOOLEAN"
elif field_type == "datetime":
db_type = "TIMESTAMP"
else:
db_type = "TEXT"
db_field = f"{field_name} {db_type}"
if not self.optional:
db_field += " NOT NULL"
if field_type == "json":
db_field += " DEFAULT '{empty_dict}'"
return db_field
def field_mock_value(self, index: int) -> Any:
if self.name == "id":
return urlsafe_short_hash()
if self.type == "int":
return index
elif self.type == "float":
return float(f"{index}.0{index * 2}")
elif self.type == "bool":
return True if index % 2 == 0 else False
elif self.type == "datetime":
return (datetime.now(timezone.utc) - timedelta(hours=index * 2)).isoformat()
elif self.type == "json":
return {"key": "value"}
elif self.type == "currency":
return "USD"
else:
return f"{self.name} {index}"
@validator("name")
def validate_name(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Field name is required.")
if not is_snake_case(v):
raise ValueError(f"Field Name must be snake_case. Found: {v}")
return v
@validator("type")
def validate_type(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Owner Data type is required")
if v not in [
"str",
"int",
"float",
"bool",
"datetime",
"json",
"wallet",
"currency",
"text",
]:
raise ValueError(
"Field Type must be one of: "
"str, int, float, bool, datetime, json, wallet, currency, text."
f" Found: {v}"
)
return v
@validator("label")
def validate_label(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(
f'Field label cannot contain double quotes ("). Value: {v}'
)
return v
@validator("hint")
def validate_hint(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(f'Field hint cannot contain double quotes ("). Value: {v}')
return v
class DataFields(BaseModel):
name: str
editable: bool = True
fields: list[DataField] = []
def __init__(self, **data):
super().__init__(**data)
self.normalize()
def normalize(self) -> None:
self.name = self.name.strip()
for field in self.fields:
field.normalize()
if all(not field.editable for field in self.fields):
self.editable = False
def get_field_by_name(self, name: str | None) -> DataField | None:
if not name:
return None
for field in self.fields:
if field.name == name:
return field
return None
@validator("name")
def validate_name(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Data fields name is required")
if not is_camel_case(v):
raise ValueError(f"Data name must be CamelCase. Found: {v}")
return v
class SettingsFields(DataFields):
enabled: bool = False
type: str = "user"
@validator("type")
def validate_type(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Settings type is required")
if v not in ["user", "admin"]:
raise ValueError("Field Type must be one of: user, admin." f" Found: {v}")
return v
class ActionFields(BaseModel):
generate_action: bool = False
generate_payment_logic: bool = False
wallet_id: str | None = None
currency: str | None = None
amount: str | None = None
amount_source: Literal["owner_data", "client_data"] | None = None
paid_flag: str | None = None
class OwnerDataFields(BaseModel):
name: str | None = None
description: str | None = None
class ClientDataFields(BaseModel):
public_inputs: list[str] = []
class PublicPageFields(BaseModel):
has_public_page: bool = False
owner_data_fields: OwnerDataFields
client_data_fields: ClientDataFields
action_fields: ActionFields
class PreviewAction(BaseModel):
is_preview_mode: bool = False
is_settings_preview: bool = False
is_owner_data_preview: bool = False
is_client_data_preview: bool = False
is_public_page_preview: bool = False
def __init__(self, **data):
super().__init__(**data)
if not self.is_preview_mode:
self.is_settings_preview = False
self.is_owner_data_preview = False
self.is_client_data_preview = False
self.is_public_page_preview = False
class ExtensionData(BaseModel):
id: str
name: str
stub_version: str | None
short_description: str | None = None
description: str | None = None
owner_data: DataFields
client_data: DataFields
settings_data: SettingsFields
public_page: PublicPageFields
preview_action: PreviewAction = PreviewAction()
def __init__(self, **data):
super().__init__(**data)
self.validate_data()
self.normalize()
def normalize(self) -> None:
self.id = self.id.strip()
self.name = self.name.strip()
if self.stub_version:
self.stub_version = self.stub_version.strip()
if self.short_description:
self.short_description = self.short_description.strip()
if self.description:
self.description = self.description.strip()
if not self.public_page.has_public_page:
self.public_page.action_fields.generate_action = False
self.public_page.action_fields.generate_payment_logic = False
if not self.public_page.action_fields.generate_action:
self.public_page.action_fields.generate_payment_logic = False
def validate_data(self) -> None:
self._validate_field_names()
self._validate_public_page_fields()
self._validate_action_fields()
def _validate_public_page_fields(self) -> None:
if not self.public_page.has_public_page:
return
public_page_name = self.public_page.owner_data_fields.name
if public_page_name:
public_page_name_field = self.owner_data.get_field_by_name(public_page_name)
if not public_page_name_field:
raise ValueError(
"Public Page Name must be one of the owner data fields."
f" Received: {public_page_name}."
)
public_page_description = self.public_page.owner_data_fields.description
if public_page_description:
public_page_description_field = self.owner_data.get_field_by_name(
public_page_description
)
if not public_page_description_field:
raise ValueError(
"Public Page Description must be one of the owner data fields."
f" Received: {public_page_description}."
)
public_page_inputs = self.public_page.client_data_fields.public_inputs
if public_page_inputs:
for input_field in public_page_inputs:
input_field_obj = self.client_data.get_field_by_name(input_field)
if not input_field_obj:
raise ValueError(
"Public Page Input fields"
" must be one of the client data fields."
f" Received: {input_field}."
)
def _validate_action_fields(self) -> None:
if not self.public_page.action_fields.generate_action:
return
if not self.public_page.action_fields.generate_payment_logic:
return
self._validate_owner_data_fields()
self._validate_client_data_fields()
def _validate_owner_data_fields(self) -> None:
wallet_id = self.public_page.action_fields.wallet_id
if wallet_id:
wallet_id_field = self.owner_data.get_field_by_name(wallet_id)
if not wallet_id_field:
raise ValueError(
"Action Wallet ID must be one of the owner data fields."
f" Received: {wallet_id}."
)
if wallet_id_field.type != "wallet":
raise ValueError(
"Action Wallet ID field type must be 'wallet'."
f" Received: {wallet_id_field.type}."
)
currency = self.public_page.action_fields.currency
if currency:
currency_field = self.owner_data.get_field_by_name(currency)
if not currency_field:
raise ValueError(
"Action Currency must be one of the owner data fields."
f" Received: {currency}."
)
if currency_field.type != "currency":
raise ValueError(
"Action Currency field type must be 'currency'."
f" Received: {currency_field.type}."
)
def _validate_field_names(self) -> None:
reserved_names = {"id", "created_at", "updated_at"}
nok = {f.name for f in self.owner_data.fields}.intersection(reserved_names)
if nok:
raise ValueError(
f"Owner Data fields cannot have reserved names: '{', '.join(nok)}.'"
)
nok = {f.name for f in self.client_data.fields}.intersection(reserved_names)
if nok:
raise ValueError(
f"Client Data fields cannot have reserved names: '{', '.join(nok)}.'"
)
nok = {f.name for f in self.settings_data.fields}.intersection(reserved_names)
if nok:
raise ValueError(
f"Settings fields cannot have reserved names: '{', '.join(nok)}.'"
)
def _validate_client_data_fields(self) -> None:
amount = self.public_page.action_fields.amount
amount_source = self.public_page.action_fields.amount_source
if amount_source and amount:
if amount_source == "owner_data":
amount_field = self.owner_data.get_field_by_name(amount)
else:
amount_field = self.client_data.get_field_by_name(amount)
if not amount_field:
raise ValueError(
"Action Amount must be one of the "
"client data or owner data fields."
f" Received: {amount}."
)
if amount_field.type not in ["int", "float"]:
raise ValueError(
"Action Amount field type must be 'int' or 'float'."
f" Received: {amount_field.type}."
)
paid_flag = self.public_page.action_fields.paid_flag
if paid_flag:
paid_flag_field = self.client_data.get_field_by_name(paid_flag)
if not paid_flag_field:
raise ValueError(
"Action Paid Flag must be one of the client data fields."
f" Received: {paid_flag}."
)
if paid_flag_field.type != "bool":
raise ValueError(
"Action Paid Flag field type must be 'bool'."
f" Received: {paid_flag_field.type}."
)
@validator("id")
def validate_id(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Extension ID is required")
if not is_snake_case(v):
raise ValueError(f"Extension Id must be snake_case. Found: {v}")
return v
@validator("name")
def validate_name(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Extension name is required")
return v
@validator("stub_version")
def validate_stub_version(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(
f'Extension stub version cannot contain double quotes ("). Value: {v}'
)
return v
@validator("short_description")
def validate_short_description(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(
f'Field short description cannot contain double quotes ("). Value: {v}'
)
return v
@validator("description")
def validate_description(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(
f'Field description cannot contain double quotes ("). Value: {v}'
)
return v
@validator("owner_data")
def validate_owner_data(cls, v: DataFields) -> DataFields:
if len(v.fields) == 0:
raise ValueError("At least one owner data field is required")
return v
@validator("client_data")
def validate_client_data(cls, v: DataFields) -> DataFields:
if len(v.fields) == 0:
raise ValueError("At least one client data field is required")
return v
-31
View File
@@ -1,31 +0,0 @@
from time import time
from lnurl import LnAddress, Lnurl, LnurlPayResponse
from pydantic import BaseModel, Field
class CreateLnurlPayment(BaseModel):
res: LnurlPayResponse | None = None
lnurl: Lnurl | LnAddress | None = None
amount: int
comment: str | None = None
unit: str | None = None
internal_memo: str | None = None
class CreateLnurlWithdraw(BaseModel):
lnurl_w: Lnurl
class LnurlScan(BaseModel):
lnurl: Lnurl | LnAddress
class StoredPayLink(BaseModel):
lnurl: str
label: str
last_used: int = Field(default_factory=lambda: int(time()))
class StoredPayLinks(BaseModel):
links: list[StoredPayLink] = []
-53
View File
@@ -1,53 +0,0 @@
from __future__ import annotations
from collections.abc import Callable
from pydantic import BaseModel
def _do_nothing(*_):
pass
class CoreAppExtra:
register_new_ext_routes: Callable = _do_nothing
register_new_ratelimiter: Callable
class ConversionData(BaseModel):
from_: str = "sat"
amount: float
to: str = "usd"
class Callback(BaseModel):
callback: str
class BalanceDelta(BaseModel):
lnbits_balance_sats: int
node_balance_sats: int
@property
def delta_sats(self) -> int:
return int(self.lnbits_balance_sats - self.node_balance_sats)
class SimpleStatus(BaseModel):
success: bool
message: str
class SimpleItem(BaseModel):
id: str
name: str
class DbVersion(BaseModel):
db: str
version: int
class Image(BaseModel):
filename: str
directory: str = "library"
-68
View File
@@ -1,68 +0,0 @@
from enum import Enum
from pydantic import BaseModel
from lnbits.core.models.users import UserNotifications
class NotificationType(Enum):
server_status = "server_status"
settings_update = "settings_update"
balance_update = "balance_update"
watchdog_check = "watchdog_check"
balance_delta = "balance_delta"
server_start_stop = "server_start_stop"
incoming_payment = "incoming_payment"
outgoing_payment = "outgoing_payment"
text_message = "text_message"
class NotificationMessage(BaseModel):
message_type: NotificationType
values: dict
user_notifications: UserNotifications | None = None
NOTIFICATION_TEMPLATES = {
"text_message": "{message}",
"server_status": """*SERVER STATUS*
*Up time*: `{up_time}`.
*Accounts*: `{accounts_count}`.
*Wallets*: `{wallets_count}`.
*In/Out payments*: `{in_payments_count}`/`{out_payments_count}`.
*Pending payments*: `{pending_payments_count}`.
*Failed payments*: `{failed_payments_count}`.
*LNbits balance*: `{lnbits_balance_sats}` sats.
*Node balance*: `{node_balance_sats}` sats.""",
"server_start_stop": """*SERVER*
{message}
*Time*: `{up_time}` seconds.
""",
"settings_update": """*SETTINGS UPDATED*
User: `{username}`.
""",
"balance_update": """*WALLET CREDIT/DEBIT*
Wallet `{wallet_name}` balance updated with `{amount}` sats.
*Current balance*: `{balance}` sats.
*Wallet ID*: `{wallet_id}`
""",
"watchdog_check": """*WATCHDOG BALANCE CHECK*
*Delta*: `{delta_sats}` sats.
*LNbits balance*: `{lnbits_balance_sats}` sats.
*Node balance*: `{node_balance_sats}` sats.
*Switching to Void Wallet*: `{switch_to_void_wallet}`.
""",
"balance_delta": """*BALANCE DELTA CHANGED*
*New delta*: `{delta_sats}` sats.
*Old delta*: `{old_delta_sats}` sats.
*LNbits balance*: `{lnbits_balance_sats}` sats.
*Node balance*: `{node_balance_sats}` sats.
""",
"outgoing_payment": """*PAYMENT SENT*
*Amount*: {fiat_value_fmt}`{amount_sats}`*sats*.
*Wallet*: `{wallet_name}` ({wallet_id}).""",
"incoming_payment": """*PAYMENT RECEIVED*
*Amount*: {fiat_value_fmt}`{amount_sats}`*sats*.
*Wallet*: `{wallet_name}` ({wallet_id}).
""",
}
-334
View File
@@ -1,334 +0,0 @@
from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from typing import Literal
from fastapi import Query
from lnurl import LnurlWithdrawResponse
from loguru import logger
from pydantic import BaseModel, Field, validator
from lnbits.db import FilterModel
from lnbits.fiat.base import (
FiatPaymentStatus,
)
from lnbits.helpers import is_valid_external_id
from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets.base import (
PaymentStatus,
)
class PaymentState(str, Enum):
PENDING = "pending"
SUCCESS = "success"
FAILED = "failed"
def __str__(self) -> str:
return self.value
class PaymentExtra(BaseModel):
comment: str | None = None
success_action: str | None = None
lnurl_response: str | None = None
class UpdatePaymentExtra(BaseModel):
payment_hash: str
extra: dict = Field(default_factory=dict)
class PayInvoice(BaseModel):
payment_request: str
description: str | None = None
max_sat: int | None = None
extra: dict | None = {}
class CreatePayment(BaseModel):
wallet_id: str
payment_hash: str
bolt11: str
amount_msat: int
memo: str
extra: dict | None = {}
extension: str | None = None
preimage: str | None = None
expiry: datetime | None = None
webhook: str | None = None
fee: int = 0
labels: list[str] | None = None
external_id: str | None = None
@validator("external_id")
def validate_external_id(cls, external_id):
return _validate_external_id(external_id)
class Payment(BaseModel):
checking_id: str
payment_hash: str
wallet_id: str
amount: int
fee: int
bolt11: str
payment_request: str | None = Field(default=None, no_database=True)
fiat_provider: str | None = None
status: str = PaymentState.PENDING
memo: str | None = None
expiry: datetime | None = None
webhook: str | None = None
webhook_status: str | None = None
preimage: str | None = None
tag: str | None = None
extension: str | None = None
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
labels: list[str] = []
extra: dict = {}
external_id: str | None = None
@validator("external_id")
def validate_external_id(cls, external_id):
return _validate_external_id(external_id)
def __init__(self, **data):
super().__init__(**data)
if "fiat_payment_request" in self.extra:
self.payment_request = self.extra["fiat_payment_request"]
else:
self.payment_request = self.bolt11
@property
def pending(self) -> bool:
return self.status == PaymentState.PENDING.value
@property
def success(self) -> bool:
return self.status == PaymentState.SUCCESS.value
@property
def failed(self) -> bool:
return self.status == PaymentState.FAILED.value
@property
def msat(self) -> int:
return self.amount
@property
def sat(self) -> int:
return self.amount // 1000
@property
def is_in(self) -> bool:
return self.amount > 0
@property
def is_out(self) -> bool:
return self.amount < 0
@property
def is_expired(self) -> bool:
return self.expiry < datetime.now(timezone.utc) if self.expiry else False
@property
def is_internal(self) -> bool:
return self.checking_id.startswith("internal_") or self.checking_id.startswith(
"fiat_"
)
# DEPRECATED: in v1.5.0, use service check_payment_status instead
async def check_status(self) -> PaymentStatus:
logger.warning("payment.check_status() is deprecated.")
from lnbits.core.services.payments import check_payment_status
return await check_payment_status(self)
# DEPRECATED: in v1.5.0, use service check_payment_status instead
async def check_fiat_status(self) -> FiatPaymentStatus:
logger.warning("payment.check_fiat_status() is deprecated.")
from lnbits.core.services.fiat_providers import check_fiat_status
return await check_fiat_status(self)
class PaymentFilters(FilterModel):
__search_fields__ = [
"memo",
"amount",
"wallet_id",
"tag",
"status",
"time",
"labels",
"external_id",
]
__sort_fields__ = [
"created_at",
"updated_at",
"amount",
"fee",
"memo",
"time",
"tag",
"external_id",
]
status: str | None
tag: str | None
checking_id: str | None
external_id: str | None
amount: int
fee: int
memo: str | None
time: datetime
preimage: str | None
payment_hash: str | None
wallet_id: str | None
labels: str | None
class PaymentDataPoint(BaseModel):
date: datetime
count: int
max_amount: int
min_amount: int
average_amount: int
total_amount: int
max_fee: int
min_fee: int
average_fee: int
total_fee: int
PaymentCountField = Literal["status", "tag", "extension", "wallet_id"]
class PaymentCountStat(BaseModel):
field: str = ""
total: float = 0
class PaymentWalletStats(BaseModel):
wallet_id: str = ""
wallet_name: str = ""
user_id: str = ""
payments_count: int
balance: float = 0
class PaymentDailyStats(BaseModel):
date: datetime
balance: float = 0
balance_in: float | None = 0
balance_out: float | None = 0
payments_count: int = 0
count_in: int | None = 0
count_out: int | None = 0
fee: float = 0
class PaymentHistoryPoint(BaseModel):
date: datetime
income: int
spending: int
balance: int
class DecodePayment(BaseModel):
data: str
filter_fields: list[str] | None = []
class CreateInvoice(BaseModel):
unit: str = "sat"
internal: bool = False
out: bool = True
amount: float = Query(None, ge=0)
memo: str | None = Query(None, max_length=640)
description_hash: str | None = None
unhashed_description: str | None = None
payment_hash: str | None = Query(
None,
description="The payment hash of the hold invoice.",
min_length=64,
max_length=64,
)
expiry: int | None = None
extra: dict | None = None
extension: str | None = None
webhook: str | None = None
bolt11: str | None = None
lnurl_withdraw: LnurlWithdrawResponse | None = None
fiat_provider: str | None = None
labels: list[str] = []
external_id: str | None = Query(default=None, max_length=256)
@validator("payment_hash")
def check_hex(cls, v):
if v:
_ = bytes.fromhex(v)
return v
@validator("unit")
@classmethod
def unit_is_from_allowed_currencies(cls, v):
if v != "sat" and v not in allowed_currencies():
raise ValueError("The provided unit is not supported")
return v
@validator("external_id")
def validate_external_id(cls, external_id):
return _validate_external_id(external_id)
class PaymentsStatusCount(BaseModel):
incoming: int = 0
outgoing: int = 0
failed: int = 0
pending: int = 0
class SettleInvoice(BaseModel):
preimage: str = Field(
...,
description="The preimage of the payment hash to settle the invoice.",
min_length=64,
max_length=64,
)
@validator("preimage")
def check_hex(cls, v):
_ = bytes.fromhex(v)
return v
class CancelInvoice(BaseModel):
payment_hash: str = Field(
...,
description="The payment hash of the invoice to cancel.",
min_length=64,
max_length=64,
)
@validator("payment_hash")
def check_hex(cls, v):
_ = bytes.fromhex(v)
return v
class UpdatePaymentLabels(BaseModel):
labels: list[str] = []
def _validate_external_id(external_id: str | None) -> str | None:
if external_id and not is_valid_external_id(external_id):
raise ValueError(
"Invalid external id. Max length is 256 characters. "
"Space and newlines are not allowed."
)
return external_id
-1
View File
@@ -1 +0,0 @@
"""SSO authentication providers for LNbits"""
-36
View File
@@ -1,36 +0,0 @@
"""Generic OIDC SSO Login Helper"""
from typing import Optional
import httpx
from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
class OidcSSO(SSOBase):
"""Class providing login via Generic OIDC OAuth (e.g., Zitadel, Authentik, etc.)"""
provider = "oidc"
scope = ["openid", "email", "profile"]
discovery_url = ""
async def openid_from_response(
self, response: dict, session: Optional["httpx.AsyncClient"] = None
) -> OpenID:
"""Return OpenID from user information provided by OIDC provider"""
return OpenID(
email=response.get("email", ""),
provider=self.provider,
id=response.get("sub"),
first_name=response.get("given_name"),
last_name=response.get("family_name"),
display_name=response.get("name") or response.get("preferred_username"),
picture=response.get("picture"),
)
async def get_discovery_document(self) -> DiscoveryDocument:
"""Get document containing handy urls"""
async with httpx.AsyncClient() as session:
response = await session.get(self.discovery_url)
content = response.json()
return content
-11
View File
@@ -1,11 +0,0 @@
from datetime import datetime, timezone
from pydantic import BaseModel, Field
class TinyURL(BaseModel):
id: str
url: str
endless: bool
wallet: str
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
-414
View File
@@ -1,414 +0,0 @@
from __future__ import annotations
from datetime import datetime, timezone
from uuid import UUID
from bcrypt import checkpw, gensalt, hashpw
from fastapi import Query
from pydantic import BaseModel, Field
from lnbits.core.models.misc import SimpleItem
from lnbits.db import FilterModel
from lnbits.helpers import (
is_valid_email_address,
is_valid_external_id,
is_valid_label,
is_valid_pubkey,
is_valid_username,
)
from lnbits.settings import settings
from .wallets import Wallet
class UserNotifications(BaseModel):
nostr_identifier: str | None = None
telegram_chat_id: str | None = None
email_address: str | None = None
excluded_wallets: list[str] = []
outgoing_payments_sats: int = 0
incoming_payments_sats: int = 0
class WalletInviteRequest(BaseModel):
request_id: str
from_user_name: str | None = None
to_wallet_id: str
to_wallet_name: str
class UserLabel(BaseModel):
name: str = Field(regex=r"([A-Za-z0-9 ._-]{1,100}$)")
description: str | None = Field(default=None, max_length=250)
color: str | None = Field(
default=None, regex=r"^#[0-9A-Fa-f]{6}$"
) # e.g., "#RRGGBB"
class UserExtra(BaseModel):
email_verified: bool | None = False
first_name: str | None = None
last_name: str | None = None
display_name: str | None = None
picture: str | None = None
# Auth provider, possible values:
# - "env": the user was created automatically by the system
# - "lnbits": the user was created via register form (username/pass or user_id only)
# - "google | github | ...": the user was created using an SSO provider
provider: str | None = "lnbits" # auth provider
# how many wallets are shown in the user interface
visible_wallet_count: int | None = 10
notifications: UserNotifications = UserNotifications()
wallet_invite_requests: list[WalletInviteRequest] = []
labels: list[UserLabel] = []
def add_wallet_invite_request(
self,
request_id: str,
to_wallet_id: str,
to_wallet_name: str,
from_user_name: str | None = None,
) -> WalletInviteRequest:
self.remove_wallet_invite_request(request_id)
invite = WalletInviteRequest(
request_id=request_id,
from_user_name=from_user_name,
to_wallet_id=to_wallet_id,
to_wallet_name=to_wallet_name,
)
self.wallet_invite_requests.append(invite)
return invite
def find_wallet_invite_request(self, request_id: str) -> WalletInviteRequest | None:
for invite in self.wallet_invite_requests:
if invite.request_id == request_id:
return invite
return None
def validate_labels(self):
seen_labels = set()
for label in self.labels:
if not label.name:
raise ValueError("Label name cannot be empty.")
# apply the same rule for labels as for usernames
if not is_valid_label(label.name):
raise ValueError(f"Invalid label name: {label.name}")
if label.name in seen_labels:
raise ValueError(f"Duplicate label name: {label.name}")
seen_labels.add(label.name)
def remove_wallet_invite_request(
self,
request_id: str,
):
self.wallet_invite_requests = [
invite
for invite in self.wallet_invite_requests
if invite.request_id != request_id
]
class EndpointAccess(BaseModel):
path: str
name: str
read: bool = False
write: bool = False
def supports_method(self, method: str) -> bool:
# all http methods
if method in ["GET", "OPTIONS", "HEAD"]:
return self.read
if method in ["POST", "PUT", "PATCH", "DELETE"]:
return self.write
return False
class AccessControlList(BaseModel):
id: str
name: str
endpoints: list[EndpointAccess] = []
token_id_list: list[SimpleItem] = []
def get_endpoint(self, path: str) -> EndpointAccess | None:
for e in self.endpoints:
if e.path == path:
return e
return None
def get_token_by_id(self, token_id: str) -> SimpleItem | None:
for t in self.token_id_list:
if t.id == token_id:
return t
return None
def delete_token_by_id(self, token_id: str):
self.token_id_list = [t for t in self.token_id_list if t.id != token_id]
class UserAcls(BaseModel):
id: str
access_control_list: list[AccessControlList] = []
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
def get_acl_by_id(self, acl_id: str) -> AccessControlList | None:
for acl in self.access_control_list:
if acl.id == acl_id:
return acl
return None
def delete_acl_by_id(self, acl_id: str):
self.access_control_list = [
acl for acl in self.access_control_list if acl.id != acl_id
]
def get_acl_by_token_id(self, token_id: str) -> AccessControlList | None:
for acl in self.access_control_list:
if acl.get_token_by_id(token_id):
return acl
return None
class AccountId(BaseModel):
id: str
@property
def is_admin_id(self) -> bool:
return settings.is_admin_user(self.id)
class Account(AccountId):
activated: bool = True
external_id: str | None = None # for external account linking
username: str | None = None
password_hash: str | None = None
pubkey: str | None = None
email: str | None = None
extra: UserExtra = UserExtra()
ui_customization: dict = Field(default_factory=dict)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
is_super_user: bool = Field(default=False, no_database=True)
is_admin: bool = Field(default=False, no_database=True)
fiat_providers: list[str] = Field(default=[], no_database=True)
def __init__(self, **data):
super().__init__(**data)
self.is_super_user = settings.is_super_user(self.id)
self.is_admin = settings.is_admin_user(self.id)
self.fiat_providers = settings.get_fiat_providers_for_user(self.id)
@property
def has_password(self) -> bool:
return self.password_hash is not None
def hash_password(self, password: str) -> str:
"""sets and returns the hashed password"""
salt = gensalt()
hashed_pw = hashpw(password.encode(), salt)
if not hashed_pw:
raise ValueError("Password hashing failed.")
self.password_hash = hashed_pw.decode()
return self.password_hash
def verify_password(self, password: str) -> bool:
"""returns True if the password matches the hash"""
if not self.password_hash:
return False
return checkpw(password.encode(), self.password_hash.encode())
def validate_fields(self):
if self.username and not is_valid_username(self.username):
raise ValueError("Invalid username.")
if self.email and not is_valid_email_address(self.email):
raise ValueError("Invalid email.")
if self.pubkey and not is_valid_pubkey(self.pubkey):
raise ValueError("Invalid pubkey.")
if self.external_id and not is_valid_external_id(self.external_id):
raise ValueError(
"Invalid external id. Max length is 256 characters. "
"Space and newlines are not allowed."
)
user_uuid4 = UUID(hex=self.id, version=4)
if user_uuid4.hex != self.id:
raise ValueError("User ID is not valid UUID4 hex string.")
self.extra.validate_labels()
class AccountOverview(Account):
activated: bool = True
transaction_count: int | None = 0
wallet_count: int | None = 0
balance_msat: int | None = 0
last_payment: datetime | None = None
class AccountFilters(FilterModel):
__search_fields__ = [
"id",
"email",
"username",
"pubkey",
"external_id",
"wallet_id",
]
__sort_fields__ = [
"id",
"email",
"username",
"pubkey",
"external_id",
"created_at",
"updated_at",
]
id: str | None = None
username: str | None = None
email: str | None = None
pubkey: str | None = None
external_id: str | None = None
wallet_id: str | None = None
class User(BaseModel):
id: str
activated: bool = True
created_at: datetime
updated_at: datetime
email: str | None = None
username: str | None = None
pubkey: str | None = None
external_id: str | None = None # for external account linking
extensions: list[str] = []
wallets: list[Wallet] = []
admin: bool = False
super_user: bool = False
fiat_providers: list[str] = []
has_password: bool = False
extra: UserExtra = UserExtra()
ui_customization: dict = Field(default_factory=dict)
@property
def wallet_ids(self) -> list[str]:
return [wallet.id for wallet in self.wallets]
def get_wallet(self, wallet_id: str) -> Wallet | None:
w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
return w[0] if w else None
@classmethod
def is_extension_for_user(cls, ext: str, user: str) -> bool:
if ext not in settings.lnbits_admin_extensions:
return True
if user == settings.super_user:
return True
if user in settings.lnbits_admin_users:
return True
return False
class RegisterUser(BaseModel):
email: str | None = Query(default=None)
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
invitation_code: str | None = Query(default=None, max_length=256)
class CreateUser(BaseModel):
id: str | None = Query(default=None)
email: str | None = Query(default=None)
username: str | None = Query(default=None, min_length=2, max_length=20)
password: str | None = Query(default=None, min_length=8, max_length=50)
password_repeat: str | None = Query(default=None, min_length=8, max_length=50)
pubkey: str = Query(default=None, max_length=64)
external_id: str = Query(default=None, max_length=256)
extensions: list[str] | None = None
extra: UserExtra | None = None
class UpdateUser(BaseModel):
user_id: str
username: str | None = Query(default=..., min_length=2, max_length=20)
extra: UserExtra | None = None
class UpdateUserPassword(BaseModel):
user_id: str
password_old: str | None = None
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
username: str = Query(default=..., min_length=2, max_length=20)
class UpdateUserPubkey(BaseModel):
user_id: str
pubkey: str = Query(default=..., max_length=64)
class ResetUserPassword(BaseModel):
reset_key: str
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class UpdateSuperuserPassword(BaseModel):
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
first_install_token: str | None = Query(None)
class LoginUsr(BaseModel):
usr: str
class LoginUsernamePassword(BaseModel):
username: str
password: str
class AccessTokenPayload(BaseModel):
sub: str
usr: str | None = None
email: str | None = None
auth_time: int | None = 0
api_token_id: str | None = None
class UpdateBalance(BaseModel):
id: str
amount: int
class ApiTokenRequest(BaseModel):
acl_id: str
token_name: str
password: str
expiration_time_minutes: int
class ApiTokenResponse(BaseModel):
id: str
api_token: str
class UpdateAccessControlList(AccessControlList):
password: str
class DeleteAccessControlList(BaseModel):
id: str
password: str
class DeleteTokenRequest(BaseModel):
id: str
acl_id: str
password: str
-249
View File
@@ -1,249 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from pydantic import BaseModel, Field
from lnbits.core.models.lnurl import StoredPayLinks
from lnbits.db import FilterModel
from lnbits.settings import settings
class WalletInfo(BaseModel):
id: str
name: str
adminkey: str
inkey: str
balance_msat: int
class WalletType(Enum):
LIGHTNING = "lightning"
LIGHTNING_SHARED = "lightning-shared"
class WalletPermission(Enum):
VIEW_PAYMENTS = "view-payments"
RECEIVE_PAYMENTS = "receive-payments"
SEND_PAYMENTS = "send-payments"
def __str__(self):
return self.value
class WalletShareStatus(Enum):
INVITE_SENT = "invite_sent"
APPROVED = "approved"
class WalletSharePermission(BaseModel):
# unique identifier for this share request
request_id: str | None = None
# username of the invited user
username: str
# ID of the wallet being shared with
shared_with_wallet_id: str | None = None
# permissions being granted
permissions: list[WalletPermission] = []
# status of the share request
status: WalletShareStatus
comment: str | None = None
def approve(
self,
permissions: list[WalletPermission] | None = None,
shared_with_wallet_id: str | None = None,
):
self.status = WalletShareStatus.APPROVED
if permissions is not None:
self.permissions = permissions
if shared_with_wallet_id is not None:
self.shared_with_wallet_id = shared_with_wallet_id
@property
def is_approved(self) -> bool:
return self.status == WalletShareStatus.APPROVED
class WalletExtra(BaseModel):
icon: str = "flash_on"
color: str = "primary"
pinned: bool = False
# What permissions this wallet grants when it's shared with other users
shared_with: list[WalletSharePermission] = []
def invite_user_to_shared_wallet(
self,
request_id: str,
request_type: WalletShareStatus,
username: str,
permissions: list[WalletPermission] | None = None,
) -> WalletSharePermission:
share = WalletSharePermission(
request_id=request_id,
username=username,
status=request_type,
permissions=permissions or [],
)
self.shared_with.append(share)
return share
def find_share_by_id(self, request_id: str) -> WalletSharePermission | None:
for share in self.shared_with:
if share.request_id == request_id:
return share
return None
def find_share_for_wallet(
self, shared_with_wallet_id: str
) -> WalletSharePermission | None:
for share in self.shared_with:
if share.shared_with_wallet_id == shared_with_wallet_id:
return share
return None
def remove_share_by_id(self, request_id: str):
self.shared_with = [
share for share in self.shared_with if share.request_id != request_id
]
class BaseWallet(BaseModel):
id: str
user: str
wallet_type: str = WalletType.LIGHTNING.value
adminkey: str
inkey: str
class Wallet(BaseWallet):
name: str
# Must be set only for shared wallets
shared_wallet_id: str | None = None
deleted: bool = False
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
currency: str | None = None
balance_msat: int = Field(default=0, no_database=True)
extra: WalletExtra = WalletExtra()
stored_paylinks: StoredPayLinks = StoredPayLinks()
# What permission this wallet has when it's a shared wallet
share_permissions: list[WalletPermission] = Field(default=[], no_database=True)
def __init__(self, **data):
super().__init__(**data)
self._validate_data()
def mirror_shared_wallet(
self,
shared_wallet: Wallet,
):
if not shared_wallet.is_lightning_wallet:
return None
self.wallet_type = WalletType.LIGHTNING_SHARED.value
self.shared_wallet_id = shared_wallet.id
self.name = shared_wallet.name
self.share_permissions = shared_wallet.get_share_permissions(self.id)
if len(self.share_permissions):
self.currency = shared_wallet.currency
self.balance_msat = shared_wallet.balance_msat
self.stored_paylinks = shared_wallet.stored_paylinks
self.extra.icon = shared_wallet.extra.icon
self.extra.color = shared_wallet.extra.color
def get_share_permissions(self, wallet_id: str) -> list[WalletPermission]:
for share in self.extra.shared_with:
if share.shared_with_wallet_id == wallet_id and share.is_approved:
return share.permissions
return []
def has_permission(self, permission: WalletPermission) -> bool:
if self.is_lightning_wallet:
return True
if self.is_lightning_shared_wallet:
return permission in self.share_permissions
return False
@property
def source_wallet_id(self) -> str:
"""For shared wallets return the original wallet ID, else return own ID."""
if self.is_lightning_shared_wallet and len(self.share_permissions):
return self.shared_wallet_id or self.id
return self.id
@property
def can_receive_payments(self) -> bool:
return self.has_permission(WalletPermission.RECEIVE_PAYMENTS)
@property
def can_send_payments(self) -> bool:
return self.has_permission(WalletPermission.SEND_PAYMENTS)
@property
def can_view_payments(self) -> bool:
return self.has_permission(WalletPermission.VIEW_PAYMENTS)
@property
def balance(self) -> int:
return int(self.balance_msat // 1000)
@property
def withdrawable_balance(self) -> int:
return self.balance_msat - settings.fee_reserve(self.balance_msat)
@property
def is_lightning_wallet(self) -> bool:
return self.wallet_type == WalletType.LIGHTNING.value
@property
def is_lightning_shared_wallet(self) -> bool:
return self.wallet_type == WalletType.LIGHTNING_SHARED.value
def _validate_data(self):
if self.is_lightning_shared_wallet:
if not self.shared_wallet_id:
raise ValueError("Shared wallet ID must be set for shared wallets.")
class CreateWallet(BaseModel):
name: str | None = None
wallet_type: WalletType = WalletType.LIGHTNING
shared_wallet_id: str | None = None
class KeyType(Enum):
admin = 0
invoice = 1
invalid = 2
# backwards compatibility
def __eq__(self, other):
return self.value == other
@dataclass
class WalletTypeInfo:
key_type: KeyType
wallet: Wallet
@dataclass
class BaseWalletTypeInfo:
key_type: KeyType
wallet: BaseWallet
class WalletsFilters(FilterModel):
__search_fields__ = ["id", "name", "currency"]
__sort_fields__ = ["id", "name", "currency", "created_at", "updated_at"]
id: str | None
name: str | None
currency: str | None

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