Compare commits
31
Commits
v1.1.0
...
v1.2.0-rc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff24847980 | ||
|
|
659ec31edd | ||
|
|
edb7d66efc | ||
|
|
efcc5e2148 | ||
|
|
55889abd88 | ||
|
|
063aa4fd06 | ||
|
|
7c0a955b30 | ||
|
|
b1a09af82c | ||
|
|
6d2f39d390 | ||
|
|
18496f0dd9 | ||
|
|
acf6c732ad | ||
|
|
b39bd257e0 | ||
|
|
9aeecd4dc0 | ||
|
|
e750165cc3 | ||
|
|
77906bc817 | ||
|
|
63e728710d | ||
|
|
27fd510142 | ||
|
|
e6de66e1b1 | ||
|
|
4071925f65 | ||
|
|
3c4d186dba | ||
|
|
56aebb9d8f | ||
|
|
beee24bd92 | ||
|
|
375b95c004 | ||
|
|
5345ccaf4e | ||
|
|
efc5233399 | ||
|
|
e4d09c6d12 | ||
|
|
7d0545dae1 | ||
|
|
00fccf513e | ||
|
|
34b8490a2d | ||
|
|
cbbba5c4c7 | ||
|
|
4a0ef7fa1a |
+13
-5
@@ -40,7 +40,7 @@ PORT=5000
|
||||
######################################
|
||||
|
||||
# which fundingsources are allowed in the admin ui
|
||||
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet"
|
||||
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet"
|
||||
|
||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||
@@ -101,10 +101,16 @@ ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
|
||||
|
||||
# BoltzWallet
|
||||
BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
|
||||
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING
|
||||
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
|
||||
# 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"
|
||||
BOLTZ_CLIENT_WALLET="lnbits"
|
||||
|
||||
# StrikeWallet
|
||||
STRIKE_API_ENDPOINT=https://api.strike.me/v1
|
||||
STRIKE_API_KEY=YOUR_STRIKE_API_KEY
|
||||
|
||||
# ZBDWallet
|
||||
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
|
||||
ZBD_API_KEY=ZBD_ACCESS_TOKEN
|
||||
@@ -163,14 +169,16 @@ GITHUB_CLIENT_SECRET=""
|
||||
KEYCLOAK_CLIENT_ID=""
|
||||
KEYCLOAK_CLIENT_SECRET=""
|
||||
KEYCLOAK_DISCOVERY_URL=""
|
||||
KEYCLOAK_CLIENT_CUSTOM_ORG=""
|
||||
KEYCLOAK_CLIENT_CUSTOM_ICON=""
|
||||
|
||||
|
||||
######################################
|
||||
|
||||
# uvicorn variable, uncomment to allow https behind a proxy
|
||||
# uvicorn variable, allow https behind a proxy
|
||||
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
||||
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
|
||||
# FORWARDED_ALLOW_IPS="*"
|
||||
FORWARDED_ALLOW_IPS="*"
|
||||
|
||||
# Server security, rate limiting ips, blocked ips, allowed ips
|
||||
LNBITS_RATE_LIMIT_NO="200"
|
||||
|
||||
@@ -5,9 +5,6 @@ inputs:
|
||||
description: "Python Version"
|
||||
required: true
|
||||
default: "3.10"
|
||||
poetry-version:
|
||||
description: "Poetry Version"
|
||||
default: "1.8.5"
|
||||
node-version:
|
||||
description: "Node Version"
|
||||
default: "20.x"
|
||||
@@ -27,10 +24,8 @@ runs:
|
||||
# cache poetry install via pip
|
||||
cache: "pip"
|
||||
|
||||
- name: Set up Poetry ${{ inputs.poetry-version }}
|
||||
- name: Set up Poetry
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ inputs.poetry-version }}
|
||||
|
||||
- name: Setup a local virtual environment (if no poetry.toml file)
|
||||
shell: bash
|
||||
|
||||
@@ -36,7 +36,6 @@ LNBITS_DATA_FOLDER="${LNBITS_DATA_FOLDER:-$LAUNCH_DIR/lnbits/database}"
|
||||
LNBITS_EXTENSIONS_PATH="${LNBITS_EXTENSIONS_PATH:-$LAUNCH_DIR/lnbits/extensions}"
|
||||
export LNBITS_DATA_FOLDER
|
||||
export LNBITS_EXTENSIONS_PATH
|
||||
export LNBITS_ADMIN_UI=true
|
||||
|
||||
# Define the LNbits URL
|
||||
URL="http://0.0.0.0:5000"
|
||||
|
||||
@@ -88,8 +88,6 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
poetry-version: ["1.5.1"]
|
||||
uses: ./.github/workflows/jmeter.yml
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
|
||||
@@ -51,3 +51,14 @@ 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: .
|
||||
file: Dockerfile.boltz
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits-boltz:${{ inputs.tag }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||
|
||||
@@ -8,11 +8,6 @@ on:
|
||||
required: true
|
||||
default: "3.10"
|
||||
type: string
|
||||
poetry-version:
|
||||
description: "Poetry Version"
|
||||
required: true
|
||||
default: "1.5.1"
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
jmeter:
|
||||
|
||||
@@ -25,6 +25,7 @@ on:
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
if: false # temporarly disable nix support until the `poetry2nix` issue is resolved
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -7,6 +7,7 @@ __pycache__
|
||||
.mypy_cache
|
||||
.vscode
|
||||
*-lock.json
|
||||
.python-version
|
||||
|
||||
*.egg
|
||||
*.egg-info
|
||||
|
||||
+5
-4
@@ -4,13 +4,14 @@ RUN apt-get clean
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
|
||||
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 - --version 1.8.5
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Only copy the files required to install the dependencies
|
||||
COPY pyproject.toml poetry.lock ./
|
||||
RUN touch README.md
|
||||
|
||||
RUN mkdir data
|
||||
|
||||
@@ -20,7 +21,7 @@ ENV POETRY_NO_INTERACTION=1 \
|
||||
POETRY_CACHE_DIR=/tmp/poetry_cache
|
||||
|
||||
ARG POETRY_INSTALL_ARGS="--only main"
|
||||
RUN poetry install ${POETRY_INSTALL_ARGS}
|
||||
RUN poetry install --no-root ${POETRY_INSTALL_ARGS}
|
||||
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
@@ -33,7 +34,7 @@ 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 -sSL https://install.python-poetry.org | python3 - --version 1.8.5
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
|
||||
ENV POETRY_NO_INTERACTION=1 \
|
||||
@@ -55,4 +56,4 @@ ENV LNBITS_HOST="0.0.0.0"
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["sh", "-c", "poetry run lnbits --port $LNBITS_PORT --host $LNBITS_HOST"]
|
||||
CMD ["sh", "-c", "poetry run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
FROM boltz/boltz-client:latest AS boltz
|
||||
|
||||
FROM lnbits/lnbits:latest
|
||||
|
||||
COPY --from=boltz /bin/boltzd /bin/boltzcli /usr/local/bin/
|
||||
RUN ls -l /usr/local/bin/boltzd
|
||||
RUN apt-get update && apt-get -y upgrade && \
|
||||
apt-get install -y netcat-openbsd
|
||||
|
||||
# Reinstall dependencies just in case (needed for CMD usage)
|
||||
ARG POETRY_INSTALL_ARGS="--only main"
|
||||
RUN poetry install ${POETRY_INSTALL_ARGS}
|
||||
|
||||
# LNbits + boltzd configuration
|
||||
ENV LNBITS_PORT="5000"
|
||||
ENV LNBITS_HOST="0.0.0.0"
|
||||
ENV LNBITS_BACKEND_WALLET_CLASS="BoltzWallet"
|
||||
ENV FUNDING_SOURCE_MAX_RETRIES=10
|
||||
ENV BOLTZ_CLIENT_ENDPOINT="127.0.0.1:9002"
|
||||
ENV BOLTZ_CLIENT_MACAROON="/root/.boltz/macaroons/admin.macaroon"
|
||||
ENV BOLTZ_CLIENT_CERT="/root/.boltz/tls.cert"
|
||||
ENV BOLTZ_CLIENT_WALLET="lnbits"
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
# Entrypoint to start boltzd and LNbits
|
||||
COPY dockerboltz.sh /dockerboltz.sh
|
||||
RUN chmod +x /dockerboltz.sh
|
||||
|
||||
CMD ["/dockerboltz.sh"]
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
boltzd --standalone --referralId lnbits &
|
||||
|
||||
# Capture boltzd PID to monitor if needed
|
||||
BOLTZ_PID=$!
|
||||
|
||||
# Wait for boltzd to start
|
||||
for i in {1..10}; do
|
||||
if nc -z localhost 9002; then
|
||||
echo "boltzd is up!"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for boltzd to start..."
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Optional: check if still not up
|
||||
if ! nc -z localhost 9002; then
|
||||
echo "boltzd did not start successfully."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Starting LNbits on $LNBITS_HOST:$LNBITS_PORT..."
|
||||
exec poetry run lnbits --port "$LNBITS_PORT" --host "$LNBITS_HOST" --forwarded-allow-ips='*'
|
||||
@@ -0,0 +1,30 @@
|
||||
# LNbits Funding Sources Comparison Table
|
||||
|
||||
LNbits can use a number of different Lightning Network funding source.
|
||||
|
||||
There may be trade-offs between the funding sources used, for example funding LNbits using Strike requires the user to KYC themselves and has some
|
||||
privacy compromises versus running your own LND node. However the technical barrier to entry of using Strike is lower than using LND.
|
||||
|
||||
The table below offers a comparison of the different Lightning Network funding sources that can be used with LNbits.
|
||||
|
||||
## LNbits Lightning Network Funding Sources Comparison Table
|
||||
|
||||
| **Funding Source** | **Custodial Type** | **KYC Required** | **Technical Knowledge Needed** | **Node Hosting Required** | **Privacy Level** | **Liquidity Management** | **Ease of Setup** | **Maintenance Effort** | **Cost Implications** | **Scalability** | **Notes** |
|
||||
| -------------------------- | ------------------ | ------------------- | ------------------------------ | ------------------------- | ----------------- | ------------------------ | ----------------- | ---------------------- | -------------------------------------------- | --------------- | ---------------------------------------------------------------- |
|
||||
| LND (gRPC) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | gRPC interface for LND; suitable for advanced integrations. |
|
||||
| CoreLightning (CLN) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Requires setting up and managing your own CLN node. |
|
||||
| Phoenixd | Self-custodial | ❌ | Medium | ❌ | Medium | Automatic | Moderate | Low | Minimal fees | Medium | Mobile wallet backend; suitable for mobile integrations. |
|
||||
| Nostr Wallet Connect (NWC) | Custodial | Depends on provider | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur fees | Medium | Connects via Nostr protocol; depends on provider's policies. |
|
||||
| Boltz | Self-custodial | ❌ | Medium | ❌ | Medium | Provider-managed | Moderate | Moderate | Minimal fees | Medium | Uses submarine swaps; connects to Boltz client. |
|
||||
| LND (REST) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for LND; suitable for web integrations. |
|
||||
| CoreLightning REST | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for CLN; suitable for web integrations. |
|
||||
| LNbits (another instance) | Custodial | Depends on host | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur hosting fees | Medium | Connects to another LNbits instance; depends on host's policies. |
|
||||
| Alby | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Browser extension wallet; suitable for web users. |
|
||||
| Breez SDK | Self-custodial | ❌ | Medium | ❌ | High | Automatic | Moderate | Low | Minimal fees | Medium | SDK for integrating Breez wallet functionalities. |
|
||||
| OpenNode | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for merchants. |
|
||||
| Blink | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; focuses on mobile integrations. |
|
||||
| ZBD | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Gaming-focused payment platform. |
|
||||
| Spark (CLN) | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Web interface for CLN; requires Spark server setup. |
|
||||
| Cliche Wallet | Self-custodial | ❌ | Medium | ❌ | Medium | Manual | Moderate | Moderate | Minimal fees | Medium | Lightweight wallet; suitable for embedded systems. |
|
||||
| Strike | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
|
||||
| LNPay | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
|
||||
@@ -18,7 +18,7 @@ Go to [releases](https://github.com/lnbits/lnbits/releases) and pull latest AppI
|
||||
sudo apt-get install libfuse2
|
||||
wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
|
||||
chmod +x LNbits-latest.AppImage
|
||||
./LNbits-latest.AppImage --host 0.0.0.0 --port 5000
|
||||
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
|
||||
```
|
||||
|
||||
LNbits will create a folder for db and extension files in the folder the AppImage runs from.
|
||||
|
||||
@@ -10,6 +10,8 @@ LNbits can run on top of many Lightning Network funding sources with more being
|
||||
|
||||
A backend wallet can be configured using the following LNbits environment variables:
|
||||
|
||||
You can [compare the LNbits compatible Lightning Network funding sources here](wallets.md).
|
||||
|
||||
### CoreLightning
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
|
||||
|
||||
@@ -6,15 +6,15 @@ if [ ! -d lnbits/data ]; then
|
||||
# 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
|
||||
|
||||
# Install Python 3.10 and distutils non-interactively
|
||||
sudo apt install -y python3.10 python3.10-distutils
|
||||
|
||||
# Install Poetry
|
||||
curl -sSL https://install.python-poetry.org | python3.9 -
|
||||
curl -sSL https://install.python-poetry.org | python3.10 -
|
||||
|
||||
# Add Poetry to PATH for the current session
|
||||
export PATH="/home/$USER/.local/bin:$PATH"
|
||||
@@ -51,4 +51,4 @@ export LNBITS_ADMIN_UI=true
|
||||
export HOST=0.0.0.0
|
||||
|
||||
# Run LNbits
|
||||
poetry run lnbits
|
||||
poetry run lnbits
|
||||
|
||||
+10
-4
@@ -518,12 +518,15 @@ def encrypt_macaroon():
|
||||
|
||||
@encrypt.command("aes")
|
||||
@click.option("-p", "--payload", required=True, help="Payload to encrypt.")
|
||||
def encrypt_aes(payload: str):
|
||||
@click.option(
|
||||
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64encode."
|
||||
)
|
||||
def encrypt_aes(payload: str, urlsafe: bool = False):
|
||||
"""AES encrypts a payload"""
|
||||
key = getpass("Enter encryption key: ")
|
||||
aes = AESCipher(key.encode())
|
||||
try:
|
||||
encrypted = aes.encrypt(payload.encode())
|
||||
encrypted = aes.encrypt(payload.encode(), urlsafe=urlsafe)
|
||||
except Exception as ex:
|
||||
click.echo(f"Error encrypting payload: {ex}")
|
||||
return
|
||||
@@ -533,12 +536,15 @@ def encrypt_aes(payload: str):
|
||||
|
||||
@decrypt.command("aes")
|
||||
@click.option("-p", "--payload", required=True, help="Payload to decrypt.")
|
||||
def decrypt_aes(payload: str):
|
||||
@click.option(
|
||||
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64decode."
|
||||
)
|
||||
def decrypt_aes(payload: str, urlsafe: bool = False):
|
||||
"""AES decrypts a payload"""
|
||||
key = getpass("Enter encryption key: ")
|
||||
aes = AESCipher(key.encode())
|
||||
try:
|
||||
decrypted = aes.decrypt(payload)
|
||||
decrypted = aes.decrypt(payload, urlsafe=urlsafe)
|
||||
except Exception as ex:
|
||||
click.echo(f"Error decrypting payload: {ex}")
|
||||
return
|
||||
|
||||
@@ -123,12 +123,8 @@ async def get_payments_paginated(
|
||||
values["wallet_id"] = wallet_id
|
||||
clause.append("wallet_id = :wallet_id")
|
||||
elif user_id:
|
||||
wallet_ids = await get_wallets_ids(user_id=user_id, conn=conn) or [
|
||||
"no-wallets-for-user"
|
||||
]
|
||||
# wallet ids are safe to use in sql queries
|
||||
wallet_ids_str = [f"'{w}'" for w in wallet_ids]
|
||||
clause.append(f""" wallet_id IN ({", ".join(wallet_ids_str)}) """)
|
||||
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
|
||||
clause.append(only_user_wallets)
|
||||
|
||||
if complete and pending:
|
||||
clause.append(
|
||||
@@ -366,12 +362,19 @@ async def get_payments_history(
|
||||
async def get_payment_count_stats(
|
||||
field: PaymentCountField,
|
||||
filters: Optional[Filters[PaymentFilters]] = None,
|
||||
user_id: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> list[PaymentCountStat]:
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
clause = filters.where()
|
||||
extra_stmts = []
|
||||
|
||||
if user_id:
|
||||
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
|
||||
extra_stmts.append(only_user_wallets)
|
||||
|
||||
clause = filters.where(extra_stmts)
|
||||
data = await (conn or db).fetchall(
|
||||
query=f"""
|
||||
SELECT {field} as field, count(*) as total
|
||||
@@ -389,18 +392,26 @@ async def get_payment_count_stats(
|
||||
|
||||
async def get_daily_stats(
|
||||
filters: Optional[Filters[PaymentFilters]] = None,
|
||||
user_id: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
in_clause = filters.where(
|
||||
["(apipayments.status = 'success' AND apipayments.amount > 0)"]
|
||||
)
|
||||
out_clause = filters.where(
|
||||
["(apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)"]
|
||||
)
|
||||
in_where_stmts = ["(apipayments.status = 'success' AND apipayments.amount > 0)"]
|
||||
out_where_stmts = [
|
||||
"(apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)"
|
||||
]
|
||||
|
||||
if user_id:
|
||||
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
|
||||
in_where_stmts.append(only_user_wallets)
|
||||
out_where_stmts.append(only_user_wallets)
|
||||
|
||||
in_clause = filters.where(in_where_stmts)
|
||||
out_clause = filters.where(out_where_stmts)
|
||||
|
||||
date_trunc = db.datetime_grouping("day")
|
||||
query = """
|
||||
SELECT {date_trunc} date,
|
||||
@@ -431,6 +442,7 @@ async def get_daily_stats(
|
||||
|
||||
async def get_wallets_stats(
|
||||
filters: Optional[Filters[PaymentFilters]] = None,
|
||||
user_id: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> list[PaymentWalletStats]:
|
||||
|
||||
@@ -449,6 +461,10 @@ async def get_wallets_stats(
|
||||
)
|
||||
""",
|
||||
]
|
||||
if user_id:
|
||||
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
|
||||
where_stmts.append(only_user_wallets)
|
||||
|
||||
clauses = filters.where(where_stmts)
|
||||
|
||||
data = await (conn or db).fetchall(
|
||||
@@ -524,3 +540,14 @@ async def mark_webhook_sent(payment_hash: str, status: str) -> None:
|
||||
""",
|
||||
{"status": status, "hash": payment_hash},
|
||||
)
|
||||
|
||||
|
||||
async def _only_user_wallets_statement(
|
||||
user_id: str, conn: Optional[Connection] = None
|
||||
) -> str:
|
||||
wallet_ids = await get_wallets_ids(user_id=user_id, conn=conn) or [
|
||||
"no-wallets-for-user"
|
||||
]
|
||||
# wallet ids are safe to use in sql queries
|
||||
wallet_ids_str = [f"'{w}'" for w in wallet_ids]
|
||||
return f""" wallet_id IN ({", ".join(wallet_ids_str)}) """
|
||||
|
||||
@@ -4,7 +4,8 @@ from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.db import Connection
|
||||
from lnbits.core.models.wallets import WalletsFilters
|
||||
from lnbits.db import Connection, Filters, Page
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..models import Wallet
|
||||
@@ -135,6 +136,29 @@ async def get_wallets(
|
||||
)
|
||||
|
||||
|
||||
async def get_wallets_paginated(
|
||||
user_id: str,
|
||||
deleted: Optional[bool] = None,
|
||||
filters: Optional[Filters[WalletsFilters]] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Page[Wallet]:
|
||||
if deleted is None:
|
||||
deleted = False
|
||||
|
||||
where: list[str] = [""" "user" = :user AND deleted = :deleted """]
|
||||
return await (conn or db).fetch_page(
|
||||
"""
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0) AS balance_msat FROM wallets
|
||||
""",
|
||||
where=where,
|
||||
values={"user": user_id, "deleted": deleted},
|
||||
filters=filters,
|
||||
model=Wallet,
|
||||
)
|
||||
|
||||
|
||||
async def get_wallets_ids(
|
||||
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
|
||||
) -> list[str]:
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
import bcrypt
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@@ -11,15 +9,6 @@ def _do_nothing(*_):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class SolveBugBcryptWarning:
|
||||
__version__: str = getattr(bcrypt, "__version__") # noqa
|
||||
|
||||
|
||||
# fix annoying warning in the logs
|
||||
setattr(bcrypt, "__about__", SolveBugBcryptWarning()) # noqa
|
||||
|
||||
|
||||
class CoreAppExtra:
|
||||
register_new_ext_routes: Callable = _do_nothing
|
||||
register_new_ratelimiter: Callable
|
||||
|
||||
@@ -27,6 +27,9 @@ class UserExtra(BaseModel):
|
||||
# - "google | github | ...": the user was created using an SSO provider
|
||||
provider: str | None = "lnbits" # auth provider
|
||||
|
||||
# how many wallets are shown in the user interface
|
||||
visible_wallet_count: int | None = 10
|
||||
|
||||
|
||||
class EndpointAccess(BaseModel):
|
||||
path: str
|
||||
|
||||
@@ -9,6 +9,7 @@ from enum import Enum
|
||||
from ecdsa import SECP256k1, SigningKey
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from lnbits.db import FilterModel
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.lnurl import encode as lnurl_encode
|
||||
from lnbits.settings import settings
|
||||
@@ -25,6 +26,7 @@ class BaseWallet(BaseModel):
|
||||
class WalletExtra(BaseModel):
|
||||
icon: str = "flash_on"
|
||||
color: str = "primary"
|
||||
pinned: bool = False
|
||||
|
||||
|
||||
class Wallet(BaseModel):
|
||||
@@ -83,3 +85,13 @@ class KeyType(Enum):
|
||||
class WalletTypeInfo:
|
||||
key_type: KeyType
|
||||
wallet: Wallet
|
||||
|
||||
|
||||
class WalletsFilters(FilterModel):
|
||||
__search_fields__ = ["id", "name", "currency"]
|
||||
|
||||
__sort_fields__ = ["id", "name", "currency", "created_at", "updated_at"]
|
||||
|
||||
id: str | None
|
||||
name: str | None
|
||||
currency: str | None
|
||||
|
||||
@@ -84,8 +84,12 @@ async def pay_invoice(
|
||||
|
||||
payment = await _pay_invoice(wallet.id, create_payment_model, conn)
|
||||
|
||||
service_fee_memo = f"""
|
||||
Service fee for payment of {abs(payment.sat)} sats.
|
||||
Wallet: '{wallet.name}' ({wallet.id})."""
|
||||
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
||||
await _credit_service_fee_wallet(payment, new_conn)
|
||||
await _credit_service_fee_wallet(payment, service_fee_memo, new_conn)
|
||||
|
||||
return payment
|
||||
|
||||
@@ -399,8 +403,9 @@ async def check_transaction_status(
|
||||
|
||||
async def get_payments_daily_stats(
|
||||
filters: Filters[PaymentFilters],
|
||||
user_id: Optional[str] = None,
|
||||
) -> list[PaymentDailyStats]:
|
||||
data_in, data_out = await get_daily_stats(filters)
|
||||
data_in, data_out = await get_daily_stats(filters, user_id=user_id)
|
||||
balance_total: float = 0
|
||||
|
||||
_none = PaymentDailyStats(date=datetime.now(timezone.utc))
|
||||
@@ -704,7 +709,7 @@ def _validate_payment_request(
|
||||
|
||||
|
||||
async def _credit_service_fee_wallet(
|
||||
payment: Payment, conn: Optional[Connection] = None
|
||||
payment: Payment, memo: str, conn: Optional[Connection] = None
|
||||
):
|
||||
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
|
||||
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
|
||||
@@ -715,7 +720,7 @@ async def _credit_service_fee_wallet(
|
||||
bolt11=payment.bolt11,
|
||||
payment_hash=payment.payment_hash,
|
||||
amount_msat=abs(service_fee_msat),
|
||||
memo="Service fee",
|
||||
memo=memo,
|
||||
)
|
||||
await create_payment(
|
||||
checking_id=f"service_fee_{payment.payment_hash}",
|
||||
|
||||
@@ -132,8 +132,8 @@
|
||||
>
|
||||
<strong class="q-my-none q-mb-sm">Keycloak Auth</strong>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-sm-12 q-pr-sm">
|
||||
<div class="row q-col-gutter-sm q-col-gutter-y-md">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.keycloak_discovery_url"
|
||||
@@ -141,7 +141,7 @@
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12 q-pr-sm">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.keycloak_client_id"
|
||||
@@ -159,6 +159,22 @@
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.keycloak_client_custom_org"
|
||||
:label="$t('auth_keycloak_custom_org_label')"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-md-8 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.keycloak_client_custom_icon"
|
||||
:label="$t('auth_keycloak_custom_icon_label')"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
|
||||
@@ -171,23 +171,26 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
|
||||
|
||||
<template v-slot:after>
|
||||
<q-form name="settings_form" id="settings_form">
|
||||
<q-tab-panels
|
||||
v-model="tab"
|
||||
animated
|
||||
swipeable
|
||||
vertical
|
||||
transition-prev="jump-up"
|
||||
transition-next="jump-up"
|
||||
>
|
||||
{% include "admin/_tab_funding.html" %} {% include
|
||||
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %}
|
||||
{% include "admin/_tab_exchange_providers.html" %} {% include
|
||||
"admin/_tab_extensions.html" %} {% include
|
||||
"admin/_tab_notifications.html" %} {% include
|
||||
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
|
||||
%}{% include "admin/_tab_audit.html"%}{% include
|
||||
"admin/_tab_library.html"%}
|
||||
</q-tab-panels>
|
||||
<q-scroll-area style="height: 100vh">
|
||||
<q-tab-panels
|
||||
v-model="tab"
|
||||
animated
|
||||
swipeable
|
||||
vertical
|
||||
scroll
|
||||
transition-prev="jump-up"
|
||||
transition-next="jump-up"
|
||||
>
|
||||
{% include "admin/_tab_funding.html" %} {% include
|
||||
"admin/_tab_users.html" %} {% include "admin/_tab_server.html"
|
||||
%} {% include "admin/_tab_exchange_providers.html" %} {% include
|
||||
"admin/_tab_extensions.html" %} {% include
|
||||
"admin/_tab_notifications.html" %} {% include
|
||||
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
|
||||
%}{% include "admin/_tab_audit.html"%}{% include
|
||||
"admin/_tab_library.html"%}
|
||||
</q-tab-panels>
|
||||
</q-scroll-area>
|
||||
</q-form>
|
||||
</template>
|
||||
</q-splitter>
|
||||
|
||||
@@ -48,6 +48,17 @@
|
||||
class="cursor-pointer q-ml-sm"
|
||||
@click="copyText(wallet.adminkey)"
|
||||
></q-icon>
|
||||
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
|
||||
<q-popup-proxy>
|
||||
<div class="q-pa-md">
|
||||
<lnbits-qrcode
|
||||
:value="wallet.adminkey"
|
||||
:options="{ width: 250 }"
|
||||
class="rounded-borders"
|
||||
></lnbits-qrcode>
|
||||
</div>
|
||||
</q-popup-proxy>
|
||||
</q-icon>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
@@ -70,6 +81,17 @@
|
||||
class="cursor-pointer q-ml-sm"
|
||||
@click="copyText(wallet.inkey)"
|
||||
></q-icon>
|
||||
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
|
||||
<q-popup-proxy>
|
||||
<div class="q-pa-md">
|
||||
<lnbits-qrcode
|
||||
:value="wallet.inkey"
|
||||
:options="{ width: 250 }"
|
||||
class="rounded-borders"
|
||||
></lnbits-qrcode>
|
||||
</div>
|
||||
</q-popup-proxy>
|
||||
</q-icon>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
@@ -335,6 +335,22 @@
|
||||
</q-btn-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('visible_wallet_count')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-input
|
||||
v-model="user.extra.visible_wallet_count"
|
||||
:label="$t('visible_wallet_count')"
|
||||
filled
|
||||
dense
|
||||
type="number"
|
||||
class="q-mb-md"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('color_scheme')"></span>
|
||||
@@ -532,6 +548,12 @@
|
||||
</q-select>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<div class="row q-mb-md q-mt-md">
|
||||
<q-btn @click="updateAccount" unelevated color="primary">
|
||||
<span v-text="$t('update_account')"></span>
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="api_acls">
|
||||
<div class="row q-mb-md">
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
<span v-text="$t('existing_account_question')"></span>
|
||||
|
||||
<span
|
||||
class="text-secondary cursor-pointer"
|
||||
class="text-secondary cursor-pointer q-ml-sm"
|
||||
@click="showLogin('username-password')"
|
||||
v-text="$t('login')"
|
||||
></span>
|
||||
|
||||
@@ -242,6 +242,23 @@
|
||||
{{ SITE_TITLE }} Wallet:
|
||||
<strong><em v-text="g.wallet.name"></em></strong>
|
||||
</div>
|
||||
<q-space></q-space>
|
||||
<div class="float-right">
|
||||
<q-btn
|
||||
@click="updateWallet({ pinned: !g.wallet.extra.pinned })"
|
||||
round
|
||||
class="float-right"
|
||||
:color="g.wallet.extra.pinned ? 'primary' : 'grey-5'"
|
||||
text-color="black"
|
||||
size="sm"
|
||||
icon="push_pin"
|
||||
style="transform: rotate(30deg)"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('pin_wallet')"></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
{% if not ajax %} {% extends "base.html" %} {% endif %}
|
||||
<!---->
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
<!---->
|
||||
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
|
||||
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-card>
|
||||
<div class="q-pa-sm q-pl-lg">
|
||||
<div class="row items-center justify-between q-gutter-xs">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
@click="showAddWalletDialog.show = true"
|
||||
:label="$t('add_wallet')"
|
||||
color="primary"
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="float-left">
|
||||
<q-input
|
||||
:label="$t('search_wallets')"
|
||||
dense
|
||||
class="float-right q-pr-xl"
|
||||
v-model="walletsTable.search"
|
||||
>
|
||||
<template v-slot:before>
|
||||
<q-icon name="search"> </q-icon>
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
v-if="walletsTable.search !== ''"
|
||||
name="close"
|
||||
@click="walletsTable.search = ''"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
</q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
<q-table
|
||||
grid
|
||||
grid-header
|
||||
flat
|
||||
bordered
|
||||
:rows="wallets"
|
||||
:columns="walletsTable.columns"
|
||||
v-model:pagination="walletsTable.pagination"
|
||||
:loading="walletsTable.loading"
|
||||
@request="getUserWallets"
|
||||
row-key="id"
|
||||
:filter="filter"
|
||||
hide-header
|
||||
>
|
||||
<template v-slot:item="props">
|
||||
<div class="q-pa-xs col-xs-12 col-sm-6 col-md-4">
|
||||
<q-card
|
||||
class="q-ma-sm cursor-pointer wallet-list-card"
|
||||
style="text-decoration: none"
|
||||
@click="goToWallet(props.row.id)"
|
||||
>
|
||||
<q-card-section>
|
||||
<div class="row items-center">
|
||||
<q-avatar
|
||||
size="lg"
|
||||
:text-color="$q.dark.isActive ? 'black' : 'grey-3'"
|
||||
:color="props.row.extra.color"
|
||||
:icon="props.row.extra.icon"
|
||||
>
|
||||
</q-avatar>
|
||||
|
||||
<div
|
||||
class="text-h6 q-pl-md ellipsis"
|
||||
class="text-bold"
|
||||
v-text="props.row.name"
|
||||
></div>
|
||||
<q-space> </q-space>
|
||||
<q-btn
|
||||
v-if="props.row.extra.pinned"
|
||||
round
|
||||
color="primary"
|
||||
text-color="black"
|
||||
size="xs"
|
||||
icon="push_pin"
|
||||
class="float-right"
|
||||
style="transform: rotate(30deg)"
|
||||
></q-btn>
|
||||
</div>
|
||||
|
||||
<div class="row items-center q-pt-sm">
|
||||
<h6 class="q-my-none ellipsis full-width">
|
||||
<strong
|
||||
v-text="formatBalance(props.row.balance_msat / 1000)"
|
||||
></strong>
|
||||
</h6>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
|
||||
<q-card-section class="text-left">
|
||||
<small>
|
||||
<strong>
|
||||
<span v-text="$t('currency')"></span>
|
||||
</strong>
|
||||
<span v-text="props.row.currency || 'sat'"></span>
|
||||
</small>
|
||||
<br />
|
||||
<small>
|
||||
<strong>
|
||||
<span v-text="$t('id')"></span>
|
||||
:
|
||||
</strong>
|
||||
<span v-text="props.row.id"></span>
|
||||
</small>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</template>
|
||||
</q-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog
|
||||
v-model="showAddWalletDialog.show"
|
||||
persistent
|
||||
@hide="showAddWalletDialog = {show: false}"
|
||||
>
|
||||
<q-card style="min-width: 350px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">
|
||||
<span v-text="$t('wallet_name')"></span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-pt-none">
|
||||
<q-input
|
||||
dense
|
||||
v-model="showAddWalletDialog.name"
|
||||
autofocus
|
||||
@keyup.enter="submitAddWallet()"
|
||||
></q-input>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right" class="text-primary">
|
||||
<q-btn flat :label="$t('cancel')" v-close-popup></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
:label="$t('add_wallet')"
|
||||
v-close-popup
|
||||
@click="submitAddWallet()"
|
||||
></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
{% endblock %}
|
||||
@@ -232,15 +232,57 @@
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-select
|
||||
<q-btn
|
||||
v-else-if="['status'].includes(col.name)"
|
||||
v-model="searchData[col.name]"
|
||||
:options="searchOptions[col.name]"
|
||||
@update:model-value="searchPaymentsBy()"
|
||||
:label="col.label"
|
||||
clearable
|
||||
style="width: 100px"
|
||||
></q-select>
|
||||
flat
|
||||
dense
|
||||
:label="$q.screen.gt.md ? 'Status' : null"
|
||||
icon="filter_alt"
|
||||
color="grey"
|
||||
class="text-capitalize"
|
||||
>
|
||||
<q-menu anchor="top right" self="top start">
|
||||
<q-item dense>
|
||||
<q-checkbox
|
||||
v-model="statusFilters.success"
|
||||
@click="handleFilterChanged"
|
||||
label="Success Payments"
|
||||
></q-checkbox>
|
||||
</q-item>
|
||||
<q-item dense>
|
||||
<q-checkbox
|
||||
v-model="statusFilters.pending"
|
||||
@click="handleFilterChanged"
|
||||
label="Pending Payments"
|
||||
></q-checkbox>
|
||||
</q-item>
|
||||
<q-item dense>
|
||||
<q-checkbox
|
||||
v-model="statusFilters.failed"
|
||||
@click="handleFilterChanged"
|
||||
label="Failed Payments"
|
||||
></q-checkbox>
|
||||
</q-item>
|
||||
<q-separator></q-separator>
|
||||
<q-item dense>
|
||||
<q-checkbox
|
||||
v-model="statusFilters.incoming"
|
||||
@click="handleFilterChanged"
|
||||
label="Incoming Payments"
|
||||
></q-checkbox>
|
||||
</q-item>
|
||||
<q-item dense>
|
||||
<q-checkbox
|
||||
v-model="statusFilters.outgoing"
|
||||
@click="handleFilterChanged"
|
||||
label="Outgoing Payments"
|
||||
></q-checkbox>
|
||||
</q-item>
|
||||
</q-menu>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('filter_payments')"></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-select
|
||||
v-else-if="['tag'].includes(col.name)"
|
||||
v-model="searchData[col.name]"
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
:label="$t('set_password')"
|
||||
v-model="activeUser.setPassword"
|
||||
>
|
||||
<q-tooltip>Toggle Admin</q-tooltip>
|
||||
<q-tooltip v-text="$t('set_password_tooltip')"></q-tooltip>
|
||||
</q-toggle>
|
||||
|
||||
<q-input
|
||||
@@ -105,11 +105,12 @@
|
||||
|
||||
<q-input
|
||||
v-model="activeUser.data.pubkey"
|
||||
:label="$t('pubkey')"
|
||||
:label="'Nostr '+ $t('pubkey')"
|
||||
filled
|
||||
dense
|
||||
class="q-mb-md"
|
||||
>
|
||||
<q-tooltip v-text="$t('nostr_pubkey_tooltip')"></q-tooltip>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-model="activeUser.data.email"
|
||||
|
||||
@@ -15,19 +15,36 @@
|
||||
</q-card>
|
||||
</div>
|
||||
<div v-else-if="activeWallet.show">
|
||||
<div class="row q-mb-lg">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
icon="arrow_back_ios"
|
||||
@click="backToUsersPage()"
|
||||
:label="$t('back')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
@click="createWalletDialog.show = true"
|
||||
:label="$t('create_new_wallet')"
|
||||
color="primary"
|
||||
class="q-ml-md"
|
||||
></q-btn>
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-card>
|
||||
<div class="q-pa-sm">
|
||||
<div class="row">
|
||||
<div class="q-pa-xs">
|
||||
<q-btn
|
||||
icon="arrow_back_ios"
|
||||
@click="backToUsersPage()"
|
||||
:label="$t('back')"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="q-pa-xs">
|
||||
<q-btn
|
||||
@click="createWalletDialog.show = true"
|
||||
:label="$t('create_new_wallet')"
|
||||
color="primary"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="q-pa-xs">
|
||||
<q-btn
|
||||
@click="deleteAllUserWallets(activeWallet.userId)"
|
||||
:label="$t('delete_all_wallets')"
|
||||
icon="delete"
|
||||
color="negative"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<q-card class="q-pa-md">
|
||||
|
||||
@@ -130,14 +130,20 @@ async def api_lnurlscan(
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
|
||||
check_callback_url(url)
|
||||
r = await client.get(url, timeout=5)
|
||||
r.raise_for_status()
|
||||
if r.is_error:
|
||||
try:
|
||||
r = await client.get(url, timeout=5)
|
||||
r.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code == 404:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Not found") from exc
|
||||
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
detail={"domain": domain, "message": "failed to get parameters"},
|
||||
)
|
||||
|
||||
detail={
|
||||
"domain": domain,
|
||||
"message": "failed to get parameters",
|
||||
},
|
||||
) from exc
|
||||
try:
|
||||
data = json.loads(r.text)
|
||||
except json.decoder.JSONDecodeError as exc:
|
||||
|
||||
@@ -216,6 +216,25 @@ async def account(
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get(
|
||||
"/wallets",
|
||||
response_class=HTMLResponse,
|
||||
description="show wallets page",
|
||||
)
|
||||
async def wallets(
|
||||
request: Request,
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"core/wallets.html",
|
||||
{
|
||||
"user": user.json(),
|
||||
"ajax": _is_ajax_request(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/service-worker.js")
|
||||
async def service_worker(request: Request):
|
||||
return template_renderer().TemplateResponse(
|
||||
@@ -402,7 +421,7 @@ async def audit_index(request: Request, user: User = Depends(check_admin)):
|
||||
|
||||
|
||||
@generic_router.get("/payments", response_class=HTMLResponse)
|
||||
async def payments_index(request: Request, user: User = Depends(check_admin)):
|
||||
async def payments_index(request: Request, user: User = Depends(check_user_exists)):
|
||||
return template_renderer().TemplateResponse(
|
||||
"payments/index.html",
|
||||
{
|
||||
|
||||
@@ -212,7 +212,10 @@ async def api_get_1ml_stats(node: Node = Depends(require_node)) -> Optional[Node
|
||||
r = await client.get(url=f"https://1ml.com/node/{node_id}/json", timeout=15)
|
||||
try:
|
||||
r.raise_for_status()
|
||||
return r.json()["noderank"]
|
||||
data = r.json()
|
||||
if "noderank" not in data:
|
||||
return None
|
||||
return data["noderank"]
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Node not found on 1ml.com"
|
||||
|
||||
@@ -44,7 +44,6 @@ from lnbits.core.services.payments import (
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
check_admin,
|
||||
check_user_exists,
|
||||
parse_filters,
|
||||
require_admin_key,
|
||||
@@ -116,30 +115,44 @@ async def api_payments_history(
|
||||
@payment_router.get(
|
||||
"/stats/count",
|
||||
name="Get payments history for all users",
|
||||
dependencies=[Depends(check_admin)],
|
||||
response_model=List[PaymentCountStat],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_counting_stats(
|
||||
count_by: PaymentCountField = Query("tag"),
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
|
||||
return await get_payment_count_stats(count_by, filters)
|
||||
if user.admin:
|
||||
# admin user can see payments from all wallets
|
||||
for_user_id = None
|
||||
else:
|
||||
# regular user can only see payments from their wallets
|
||||
for_user_id = user.id
|
||||
|
||||
return await get_payment_count_stats(count_by, filters=filters, user_id=for_user_id)
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
"/stats/wallets",
|
||||
name="Get payments history for all users",
|
||||
dependencies=[Depends(check_admin)],
|
||||
response_model=List[PaymentWalletStats],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_wallets_stats(
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
|
||||
return await get_wallets_stats(filters)
|
||||
if user.admin:
|
||||
# admin user can see payments from all wallets
|
||||
for_user_id = None
|
||||
else:
|
||||
# regular user can only see payments from their wallets
|
||||
for_user_id = user.id
|
||||
|
||||
return await get_wallets_stats(filters, user_id=for_user_id)
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
@@ -153,22 +166,13 @@ async def api_payments_daily_stats(
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
|
||||
if not user.admin:
|
||||
exc = HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Missing wallet id.",
|
||||
)
|
||||
wallet_filter = next(
|
||||
(f for f in filters.filters if f.field == "wallet_id"), None
|
||||
)
|
||||
if not wallet_filter:
|
||||
raise exc
|
||||
wallet_id = list((wallet_filter.values or {}).values())
|
||||
if len(wallet_id) == 0:
|
||||
raise exc
|
||||
if not user.get_wallet(wallet_id[0]):
|
||||
raise exc
|
||||
return await get_payments_daily_stats(filters)
|
||||
if user.admin:
|
||||
# admin user can see payments from all wallets
|
||||
for_user_id = None
|
||||
else:
|
||||
# regular user can only see payments from their wallets
|
||||
for_user_id = user.id
|
||||
return await get_payments_daily_stats(filters, user_id=for_user_id)
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
|
||||
@@ -148,7 +148,7 @@ async def api_update_user(
|
||||
async def api_users_delete_user(
|
||||
user_id: str, user: User = Depends(check_admin)
|
||||
) -> SimpleStatus:
|
||||
wallets = await get_wallets(user_id)
|
||||
wallets = await get_wallets(user_id, deleted=False)
|
||||
if len(wallets) > 0:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
@@ -257,6 +257,28 @@ async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> SimpleSta
|
||||
return SimpleStatus(success=True, message="Wallet is already active.")
|
||||
|
||||
|
||||
@users_router.delete(
|
||||
"/user/{user_id}/wallets",
|
||||
name="Delete all wallets for user",
|
||||
summary="Soft delete (only sets a flag) all user wallets.",
|
||||
)
|
||||
async def api_users_delete_all_user_wallet(user_id: str) -> SimpleStatus:
|
||||
if user_id == settings.super_user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Action not allowed.",
|
||||
)
|
||||
|
||||
wallets = await get_wallets(user_id, deleted=False)
|
||||
for wallet in wallets:
|
||||
await delete_wallet(user_id=user_id, wallet_id=wallet.id)
|
||||
|
||||
return SimpleStatus(
|
||||
success=True,
|
||||
message=f"Deleted '{len(wallets)}' wallets. ",
|
||||
)
|
||||
|
||||
|
||||
@users_router.delete(
|
||||
"/user/{user_id}/wallet/{wallet}",
|
||||
name="Delete wallet by id",
|
||||
|
||||
@@ -9,13 +9,18 @@ from fastapi import (
|
||||
HTTPException,
|
||||
)
|
||||
|
||||
from lnbits.core.crud.wallets import get_wallets_paginated
|
||||
from lnbits.core.models import CreateWallet, KeyType, User, Wallet
|
||||
from lnbits.core.models.wallets import WalletsFilters
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
check_user_exists,
|
||||
parse_filters,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import generate_filter_params_openapi
|
||||
|
||||
from ..crud import (
|
||||
create_wallet,
|
||||
@@ -38,6 +43,26 @@ async def api_wallet(key_info: WalletTypeInfo = Depends(require_invoice_key)):
|
||||
return res
|
||||
|
||||
|
||||
@wallet_router.get(
|
||||
"/paginated",
|
||||
name="Wallet List",
|
||||
summary="get paginated list of user wallets",
|
||||
response_description="list of user wallets",
|
||||
response_model=Page[Wallet],
|
||||
openapi_extra=generate_filter_params_openapi(WalletsFilters),
|
||||
)
|
||||
async def api_wallets_paginated(
|
||||
user: User = Depends(check_user_exists),
|
||||
filters: Filters = Depends(parse_filters(WalletsFilters)),
|
||||
):
|
||||
page = await get_wallets_paginated(
|
||||
user_id=user.id,
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
return page
|
||||
|
||||
|
||||
@wallet_router.put("/{new_name}")
|
||||
async def api_update_wallet_name(
|
||||
new_name: str, key_info: WalletTypeInfo = Depends(require_admin_key)
|
||||
@@ -74,6 +99,7 @@ async def api_update_wallet(
|
||||
icon: Optional[str] = Body(None),
|
||||
color: Optional[str] = Body(None),
|
||||
currency: Optional[str] = Body(None),
|
||||
pinned: Optional[bool] = Body(None),
|
||||
key_info: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Wallet:
|
||||
wallet = await get_wallet(key_info.wallet.id)
|
||||
@@ -82,6 +108,7 @@ async def api_update_wallet(
|
||||
wallet.name = name or wallet.name
|
||||
wallet.extra.icon = icon or wallet.extra.icon
|
||||
wallet.extra.color = color or wallet.extra.color
|
||||
wallet.extra.pinned = pinned if pinned is not None else wallet.extra.pinned
|
||||
wallet.currency = currency if currency is not None else wallet.currency
|
||||
await update_wallet(wallet)
|
||||
return wallet
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse, RedirectResponse, Response
|
||||
from loguru import logger
|
||||
from shortuuid import uuid
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
@@ -71,10 +72,11 @@ def register_exception_handlers(app: FastAPI):
|
||||
async def exception_handler(request: Request, exc: Exception):
|
||||
etype, _, tb = sys.exc_info()
|
||||
traceback.print_exception(etype, exc, tb)
|
||||
logger.error(f"Exception: {exc!s}")
|
||||
exception_id = uuid()
|
||||
logger.error(f"Exception ID: {exception_id}\n{exc!s}")
|
||||
return render_html_error(request, exc) or JSONResponse(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
content={"detail": str(exc)},
|
||||
content={"detail": f"Unexpected error! ID: {exception_id}"},
|
||||
)
|
||||
|
||||
@app.exception_handler(AssertionError)
|
||||
|
||||
+17
-5
@@ -11,6 +11,7 @@ import jinja2
|
||||
import jwt
|
||||
import shortuuid
|
||||
from fastapi.routing import APIRoute
|
||||
from loguru import logger
|
||||
from packaging import version
|
||||
from pydantic.schema import field_schema
|
||||
|
||||
@@ -76,6 +77,8 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
|
||||
"LNBITS_ADMIN_UI": settings.lnbits_admin_ui,
|
||||
"LNBITS_AUDIT_ENABLED": settings.lnbits_audit_enabled,
|
||||
"LNBITS_AUTH_METHODS": settings.auth_allowed_methods,
|
||||
"LNBITS_AUTH_KEYCLOAK_ORG": settings.keycloak_client_custom_org,
|
||||
"LNBITS_AUTH_KEYCLOAK_ICON": settings.keycloak_client_custom_icon,
|
||||
"LNBITS_CUSTOM_IMAGE": settings.lnbits_custom_image,
|
||||
"LNBITS_CUSTOM_BADGE": settings.lnbits_custom_badge,
|
||||
"LNBITS_CUSTOM_BADGE_COLOR": settings.lnbits_custom_badge_color,
|
||||
@@ -279,12 +282,21 @@ def is_lnbits_version_ok(
|
||||
|
||||
|
||||
def check_callback_url(url: str):
|
||||
netloc = urlparse(url).netloc
|
||||
if not settings.lnbits_callback_url_rules:
|
||||
# no rules, all urls are allowed
|
||||
return
|
||||
u = urlparse(url)
|
||||
for rule in settings.lnbits_callback_url_rules:
|
||||
if re.match(rule, netloc) is None:
|
||||
raise ValueError(
|
||||
f"Callback not allowed. URL: {url}. Netloc: {netloc}. Rule: {rule}"
|
||||
)
|
||||
try:
|
||||
if re.match(rule, f"{u.scheme}://{u.netloc}") is not None:
|
||||
return
|
||||
except re.error:
|
||||
logger.debug(f"Invalid regex rule: '{rule}'. ")
|
||||
continue
|
||||
raise ValueError(
|
||||
f"Callback not allowed. URL: {url}. Netloc: {u.netloc}. "
|
||||
f"Please check your admin settings."
|
||||
)
|
||||
|
||||
|
||||
def download_url(url, save_path):
|
||||
|
||||
+14
-1
@@ -381,7 +381,7 @@ class SecuritySettings(LNbitsSettings):
|
||||
lnbits_allowed_ips: list[str] = Field(default=[])
|
||||
lnbits_blocked_ips: list[str] = Field(default=[])
|
||||
lnbits_callback_url_rules: list[str] = Field(
|
||||
default=["^(?!\\d+\\.\\d+\\.\\d+\\.\\d+$)(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,}$"]
|
||||
default=["https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"]
|
||||
)
|
||||
|
||||
lnbits_wallet_limit_max_balance: int = Field(default=0, ge=0)
|
||||
@@ -553,7 +553,16 @@ class BoltzFundingSource(LNbitsSettings):
|
||||
boltz_client_endpoint: str | None = Field(default="127.0.0.1:9002")
|
||||
boltz_client_macaroon: str | None = Field(default=None)
|
||||
boltz_client_wallet: str | None = Field(default="lnbits")
|
||||
boltz_client_password: str = Field(default="")
|
||||
boltz_client_cert: str | None = Field(default=None)
|
||||
boltz_mnemonic: str | None = Field(default=None)
|
||||
|
||||
|
||||
class StrikeFundingSource(LNbitsSettings):
|
||||
strike_api_endpoint: str | None = Field(
|
||||
default="https://api.strike.me/v1", env="STRIKE_API_ENDPOINT"
|
||||
)
|
||||
strike_api_key: str | None = Field(default=None, env="STRIKE_API_KEY")
|
||||
|
||||
|
||||
class LightningSettings(LNbitsSettings):
|
||||
@@ -580,6 +589,7 @@ class FundingSourcesSettings(
|
||||
LnTipsFundingSource,
|
||||
NWCFundingSource,
|
||||
BreezSdkFundingSource,
|
||||
StrikeFundingSource,
|
||||
):
|
||||
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
|
||||
# How long to wait for the payment to be confirmed before returning a pending status
|
||||
@@ -659,6 +669,8 @@ class KeycloakAuthSettings(LNbitsSettings):
|
||||
keycloak_discovery_url: str = Field(default="")
|
||||
keycloak_client_id: str = Field(default="")
|
||||
keycloak_client_secret: str = Field(default="")
|
||||
keycloak_client_custom_org: str | None = Field(default=None)
|
||||
keycloak_client_custom_icon: str | None = Field(default=None)
|
||||
|
||||
|
||||
class AuditSettings(LNbitsSettings):
|
||||
@@ -863,6 +875,7 @@ class SuperUserSettings(LNbitsSettings):
|
||||
"VoidWallet",
|
||||
"ZBDWallet",
|
||||
"NWCWallet",
|
||||
"StrikeWallet",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -49,8 +49,11 @@ window.localisation.en = {
|
||||
'This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.',
|
||||
access_wallet_on_mobile: 'Mobile Access',
|
||||
wallet: 'Wallet: ',
|
||||
wallet_name: 'Wallet name',
|
||||
wallets: 'Wallets',
|
||||
add_wallet: 'Add a new wallet',
|
||||
add_wallet: 'Add wallet',
|
||||
add_new_wallet: 'Add a new wallet',
|
||||
pin_wallet: 'Pin wallet',
|
||||
delete_wallet: 'Delete wallet',
|
||||
delete_wallet_desc:
|
||||
'This whole wallet will be deleted, the funds will be UNRECOVERABLE.',
|
||||
@@ -125,6 +128,7 @@ window.localisation.en = {
|
||||
no_extensions: "You don't have any extensions installed :(",
|
||||
created: 'Created',
|
||||
search_extensions: 'Search extensions',
|
||||
search_wallets: 'Search wallets',
|
||||
extension_sources: 'Extension Sources',
|
||||
ext_sources_hint: 'Repositories from where the extensions can be downloaded',
|
||||
ext_sources_label:
|
||||
@@ -264,6 +268,7 @@ window.localisation.en = {
|
||||
notification_source_label:
|
||||
'Source URL (only use the official LNbits status source, and sources you can trust)',
|
||||
more: 'more',
|
||||
more_count: '{count} more',
|
||||
less: 'less',
|
||||
releases: 'Releases',
|
||||
watchdog: 'Watchdog',
|
||||
@@ -278,7 +283,7 @@ window.localisation.en = {
|
||||
callback_url_rules: 'Callback URL Rules',
|
||||
enter_callback_url_rule: 'Enter URL rule as regex and hit enter',
|
||||
callback_url_rule_hint:
|
||||
'Callback URLs (like LNURL one) will be validated against all of these rules. No rule means all URLs are allowed.',
|
||||
'Callback URLs (like LNURL one) will be validated against these rules. At leat one rule must match. No rule means all URLs are allowed.',
|
||||
wallet_limiter: 'Wallet Limiter',
|
||||
wallet_config: 'Wallet Config',
|
||||
wallet_charts: 'Wallet Charts',
|
||||
@@ -303,6 +308,9 @@ window.localisation.en = {
|
||||
login_with_user_id: 'Login with user ID',
|
||||
or: 'or',
|
||||
create_new_wallet: 'Create New Wallet',
|
||||
delete_all_wallets: 'Delete All Wallets',
|
||||
confirm_delete_all_wallets:
|
||||
'Are you sure you want to delete ALL wallets for this user?',
|
||||
login_to_account: 'Login to your account',
|
||||
create_account: 'Create account',
|
||||
account_settings: 'Account Settings',
|
||||
@@ -311,7 +319,7 @@ window.localisation.en = {
|
||||
signin_with_nostr: 'Continue with Nostr',
|
||||
signin_with_google: 'Sign in with Google',
|
||||
signin_with_github: 'Sign in with GitHub',
|
||||
signin_with_keycloak: 'Sign in with Keycloak',
|
||||
signin_with_custom_org: 'Sign in with {custom_org}',
|
||||
username_or_email: 'Username or Email',
|
||||
password: 'Password',
|
||||
password_config: 'Password Config',
|
||||
@@ -320,7 +328,9 @@ window.localisation.en = {
|
||||
change_password: 'Change Password',
|
||||
update_credentials: 'Update Credentials',
|
||||
update_pubkey: 'Update Public Key',
|
||||
nostr_pubkey_tooltip: "Enter this user's Nostr public key (hex value)",
|
||||
set_password: 'Set Password',
|
||||
set_password_tooltip: 'Set a password for this user',
|
||||
invalid_password: 'Password must have at least 8 characters',
|
||||
invalid_password_repeat: 'Passwords do not match',
|
||||
reset_key_generated: 'A reset key has been generated.',
|
||||
@@ -330,6 +340,7 @@ window.localisation.en = {
|
||||
username: 'Username',
|
||||
pubkey: 'Public Key',
|
||||
user_id: 'User ID',
|
||||
id: 'ID',
|
||||
email: 'Email',
|
||||
first_name: 'First Name',
|
||||
last_name: 'Last Name',
|
||||
@@ -356,6 +367,7 @@ window.localisation.en = {
|
||||
gradient_background: 'Gradient Background',
|
||||
language: 'Language',
|
||||
color_scheme: 'Color Scheme',
|
||||
visible_wallet_count: 'Visible Wallet Count',
|
||||
admin_settings: 'Admin Settings',
|
||||
extension_cost: 'This release requires a payment of minimum {cost} sats.',
|
||||
extension_paid_sats: 'You have already paid {paid_sats} sats.',
|
||||
@@ -472,6 +484,8 @@ window.localisation.en = {
|
||||
auth_keycloak_ci_hint:
|
||||
'Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/keycloak/token',
|
||||
auth_keycloak_cs_label: 'Keycloak Client Secret',
|
||||
auth_keycloak_custom_org_label: 'Keycloak Custom Organization',
|
||||
auth_keycloak_custom_icon_label: 'Keycloak Custom Icon (URL)',
|
||||
currency_settings: 'Currency Settings',
|
||||
allowed_currencies: 'Allowed Currencies',
|
||||
allowed_currencies_hint: 'Limit the number of available fiat currencies',
|
||||
|
||||
@@ -197,7 +197,8 @@ window.LNbits = {
|
||||
email: data.email,
|
||||
extensions: data.extensions,
|
||||
wallets: data.wallets,
|
||||
super_user: data.super_user
|
||||
super_user: data.super_user,
|
||||
extra: data.extra ?? {}
|
||||
}
|
||||
const mapWallet = this.wallet
|
||||
obj.wallets = obj.wallets
|
||||
@@ -205,6 +206,9 @@ window.LNbits = {
|
||||
return mapWallet(obj)
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.extra.pinned !== b.extra.pinned) {
|
||||
return a.extra.pinned ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
obj.walletOptions = obj.wallets.map(obj => {
|
||||
@@ -213,6 +217,10 @@ window.LNbits = {
|
||||
value: obj.id
|
||||
}
|
||||
})
|
||||
obj.hiddenWalletsCount = Math.max(
|
||||
0,
|
||||
data.wallets.length - data.extra.visible_wallet_count
|
||||
)
|
||||
return obj
|
||||
},
|
||||
wallet(data) {
|
||||
@@ -252,13 +260,11 @@ window.LNbits = {
|
||||
fiat_currency: data.fiat_currency
|
||||
}
|
||||
|
||||
obj.date = Quasar.date.formatDate(new Date(obj.time), window.dateFormat)
|
||||
obj.dateFrom = moment.utc(obj.date).fromNow()
|
||||
obj.expirydate = Quasar.date.formatDate(
|
||||
new Date(obj.expiry),
|
||||
window.dateFormat
|
||||
)
|
||||
obj.expirydateFrom = moment.utc(obj.expirydate).fromNow()
|
||||
obj.date = moment.utc(data.created_at).local().format(window.dateFormat)
|
||||
obj.dateFrom = moment.utc(data.created_at).fromNow()
|
||||
|
||||
obj.expirydate = moment.utc(obj.expiry).local().format(window.dateFormat)
|
||||
obj.expirydateFrom = moment.utc(obj.expiry).fromNow()
|
||||
obj.msat = obj.amount
|
||||
obj.sat = obj.msat / 1000
|
||||
obj.tag = obj.extra?.tag
|
||||
@@ -510,6 +516,11 @@ window.windowMixin = {
|
||||
}
|
||||
this.$q.localStorage.set('lnbits.walletFlip', this.walletFlip)
|
||||
},
|
||||
goToWallets() {
|
||||
this.$router.push({
|
||||
path: '/wallets'
|
||||
})
|
||||
},
|
||||
submitAddWallet() {
|
||||
if (
|
||||
this.showAddWalletDialog.name &&
|
||||
|
||||
@@ -103,14 +103,7 @@ window.app.component('lnbits-extension-list', {
|
||||
window.app.component('lnbits-manage', {
|
||||
mixins: [window.windowMixin],
|
||||
template: '#lnbits-manage',
|
||||
props: [
|
||||
'showAdmin',
|
||||
'showNode',
|
||||
'showExtensions',
|
||||
'showUsers',
|
||||
'showAudit',
|
||||
'showPayments'
|
||||
],
|
||||
props: ['showAdmin', 'showNode', 'showExtensions', 'showUsers', 'showAudit'],
|
||||
methods: {
|
||||
isActive(path) {
|
||||
return window.location.pathname === path
|
||||
@@ -210,7 +203,7 @@ window.app.component('lnbits-qrcode', {
|
||||
data() {
|
||||
return {
|
||||
custom: {
|
||||
margin: 1,
|
||||
margin: 3,
|
||||
width: 350,
|
||||
size: 350,
|
||||
logo: LNBITS_QR_LOGO
|
||||
@@ -566,7 +559,9 @@ window.app.component('username-password', {
|
||||
username: this.userName,
|
||||
password: this.password_1,
|
||||
passwordRepeat: this.password_2,
|
||||
reset_key: this.resetKey
|
||||
reset_key: this.resetKey,
|
||||
keycloakOrg: LNBITS_AUTH_KEYCLOAK_ORG || 'Keycloak',
|
||||
keycloakIcon: LNBITS_AUTH_KEYCLOAK_ICON
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -8,6 +8,10 @@ window.app.component('lnbits-funding-sources', {
|
||||
fundingSource => fundingSource[0] === item
|
||||
)
|
||||
return fundingSource ? fundingSource[1] : item
|
||||
},
|
||||
showQRValue(value) {
|
||||
this.qrValue = value
|
||||
this.showQRDialog = true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -17,7 +21,8 @@ window.app.component('lnbits-funding-sources', {
|
||||
const tmpObj = {}
|
||||
if (obj !== null) {
|
||||
for (let [k, v] of Object.entries(obj)) {
|
||||
tmpObj[k] = {label: v, value: null}
|
||||
tmpObj[k] =
|
||||
typeof v === 'string' ? {label: v, value: null} : v || {}
|
||||
}
|
||||
}
|
||||
tmp.push([key, tmpObj])
|
||||
@@ -31,6 +36,8 @@ window.app.component('lnbits-funding-sources', {
|
||||
data() {
|
||||
return {
|
||||
hideInput: true,
|
||||
showQRDialog: false,
|
||||
qrValue: '',
|
||||
rawFundingSources: [
|
||||
['VoidWallet', 'Void Wallet', null],
|
||||
[
|
||||
@@ -138,7 +145,14 @@ window.app.component('lnbits-funding-sources', {
|
||||
boltz_client_endpoint: 'Endpoint',
|
||||
boltz_client_macaroon: 'Admin Macaroon path or hex',
|
||||
boltz_client_cert: 'Certificate path or hex',
|
||||
boltz_client_wallet: 'Wallet Name'
|
||||
boltz_client_wallet: 'Wallet Name',
|
||||
boltz_client_password: 'Wallet Password (can be empty)',
|
||||
boltz_mnemonic: {
|
||||
label: 'Liquid mnemonic (copy into greenwallet)',
|
||||
readonly: true,
|
||||
copy: true,
|
||||
qrcode: true
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
@@ -197,6 +211,14 @@ window.app.component('lnbits-funding-sources', {
|
||||
breez_greenlight_device_cert: 'Greenlight Device Cert',
|
||||
breez_greenlight_invite_code: 'Greenlight Invite Code'
|
||||
}
|
||||
],
|
||||
[
|
||||
'StrikeWallet',
|
||||
'Strike (alpha)',
|
||||
{
|
||||
strike_api_endpoint: 'API Endpoint',
|
||||
strike_api_key: 'API Key'
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
@@ -193,6 +193,15 @@ const routes = [
|
||||
scripts: ['/static/js/account.js']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/wallets',
|
||||
name: 'Wallets',
|
||||
component: DynamicComponent,
|
||||
props: {
|
||||
fetchUrl: '/wallets',
|
||||
scripts: ['/static/js/wallets.js']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/node',
|
||||
name: 'Node',
|
||||
|
||||
@@ -8,9 +8,15 @@ window.PaymentsPageLogic = {
|
||||
searchData: {
|
||||
wallet_id: null,
|
||||
payment_hash: null,
|
||||
status: null,
|
||||
memo: null
|
||||
},
|
||||
statusFilters: {
|
||||
success: true,
|
||||
pending: true,
|
||||
failed: true,
|
||||
incoming: true,
|
||||
outgoing: true
|
||||
},
|
||||
chartData: {
|
||||
showPaymentStatus: true,
|
||||
showPaymentTags: true,
|
||||
@@ -131,6 +137,7 @@ window.PaymentsPageLogic = {
|
||||
if (this.searchDate.to) {
|
||||
filter['time[le]'] = this.searchDate.to + 'T23:59:59'
|
||||
}
|
||||
|
||||
this.paymentsTable.filter = filter
|
||||
|
||||
try {
|
||||
@@ -198,7 +205,38 @@ window.PaymentsPageLogic = {
|
||||
|
||||
this.fetchPayments()
|
||||
},
|
||||
handleFilterChanged() {
|
||||
const {success, pending, failed, incoming, outgoing} = this.statusFilters
|
||||
|
||||
delete this.searchData['status[ne]']
|
||||
delete this.searchData['status[eq]']
|
||||
if (success && pending && failed) {
|
||||
// do nothing, all statuses are selected
|
||||
} else if (success && pending) {
|
||||
this.searchData['status[ne]'] = 'failed'
|
||||
} else if (success && failed) {
|
||||
this.searchData['status[ne]'] = 'pending'
|
||||
} else if (failed && pending) {
|
||||
this.searchData['status[ne]'] = 'success'
|
||||
} else if (success) {
|
||||
this.searchData['status[eq]'] = 'success'
|
||||
} else if (pending) {
|
||||
this.searchData['status[eq]'] = 'pending'
|
||||
} else if (failed) {
|
||||
this.searchData['status[eq]'] = 'failed'
|
||||
}
|
||||
|
||||
delete this.searchData['amount[ge]']
|
||||
delete this.searchData['amount[le]']
|
||||
if (incoming && outgoing) {
|
||||
// do nothing
|
||||
} else if (incoming) {
|
||||
this.searchData['amount[ge]'] = '0'
|
||||
} else if (outgoing) {
|
||||
this.searchData['amount[le]'] = '0'
|
||||
}
|
||||
this.fetchPayments()
|
||||
},
|
||||
showDetailsToggle(payment) {
|
||||
this.paymentDetails = payment
|
||||
return (this.showDetails = !this.showDetails)
|
||||
|
||||
@@ -312,6 +312,23 @@ window.UsersPageLogic = {
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
})
|
||||
},
|
||||
deleteAllUserWallets(userId) {
|
||||
LNbits.utils
|
||||
.confirmDialog(this.$t('confirm_delete_all_wallets'))
|
||||
.onOk(() => {
|
||||
LNbits.api
|
||||
.request('DELETE', `/users/api/v1/user/${userId}/wallets`)
|
||||
.then(response => {
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: response.data.message,
|
||||
icon: null
|
||||
})
|
||||
this.fetchWallets(userId)
|
||||
})
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
})
|
||||
},
|
||||
copyWalletLink(walletId) {
|
||||
const url = `${window.location.origin}/wallet?usr=${this.activeWallet.userId}&wal=${walletId}`
|
||||
this.copyText(url)
|
||||
|
||||
@@ -338,9 +338,6 @@ window.WalletPageLogic = {
|
||||
'/api/v1/lnurlscan/' + this.parse.data.request,
|
||||
this.g.wallet.adminkey
|
||||
)
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
.then(response => {
|
||||
const data = response.data
|
||||
|
||||
@@ -378,6 +375,9 @@ window.WalletPageLogic = {
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
decodeQR(res) {
|
||||
this.parse.data.request = res[0].rawValue
|
||||
@@ -662,7 +662,7 @@ window.WalletPageLogic = {
|
||||
}
|
||||
}
|
||||
Quasar.Notify.create({
|
||||
message: 'Wallet and user updated.',
|
||||
message: 'Wallet updated.',
|
||||
type: 'positive',
|
||||
timeout: 3500
|
||||
})
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
window.WalletsPageLogic = {
|
||||
mixins: [window.windowMixin],
|
||||
data() {
|
||||
return {
|
||||
user: null,
|
||||
tab: 'wallets',
|
||||
wallets: [],
|
||||
showAddWalletDialog: {show: false},
|
||||
walletsTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'name',
|
||||
align: 'left',
|
||||
label: 'Name',
|
||||
field: 'name',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
align: 'center',
|
||||
label: 'Currency',
|
||||
field: 'currency',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'updated_at',
|
||||
align: 'right',
|
||||
label: 'Last Updated',
|
||||
field: 'updated_at',
|
||||
sortable: true
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
sortBy: 'updated_at',
|
||||
rowsPerPage: 12,
|
||||
page: 1,
|
||||
descending: true,
|
||||
rowsNumber: 10
|
||||
},
|
||||
search: '',
|
||||
hideEmpty: true,
|
||||
loading: false
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'walletsTable.search': {
|
||||
handler() {
|
||||
const props = {}
|
||||
if (this.walletsTable.search) {
|
||||
props['search'] = this.walletsTable.search
|
||||
}
|
||||
this.getUserWallets()
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async getUserWallets(props) {
|
||||
try {
|
||||
this.walletsTable.loading = true
|
||||
const params = LNbits.utils.prepareFilterQuery(this.walletsTable, props)
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/api/v1/wallet/paginated?${params}`,
|
||||
null
|
||||
)
|
||||
this.wallets = data.data
|
||||
this.walletsTable.pagination.rowsNumber = data.total
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
} finally {
|
||||
this.walletsTable.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
goToWallet(walletId) {
|
||||
window.location = `/wallet?wal=${walletId}`
|
||||
},
|
||||
formattedFiatAmount(amount, currency) {
|
||||
return LNbits.utils.formatCurrency(Number(amount).toFixed(2), currency)
|
||||
},
|
||||
formattedSatAmount(amount) {
|
||||
return LNbits.utils.formatMsat(amount) + ' sat'
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
await this.getUserWallets()
|
||||
}
|
||||
}
|
||||
@@ -179,12 +179,12 @@
|
||||
>
|
||||
<q-scroll-area style="height: 100%">
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-section class="cursor-pointer" @click="goToWallets()">
|
||||
<q-item-label
|
||||
:style="$q.dark.isActive ? 'color:rgba(255, 255, 255, 0.64)' : ''"
|
||||
class="q-item__label q-item__label--header q-pa-none"
|
||||
header
|
||||
v-text="$t('wallets')"
|
||||
v-text="$t('wallets') + ' (' + g.user.wallets.length + ')'"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
@@ -211,7 +211,6 @@
|
||||
:show-admin="'{{LNBITS_ADMIN_UI}}' == 'True'"
|
||||
:show-users="'{{LNBITS_ADMIN_UI}}' == 'True'"
|
||||
:show-audit="'{{LNBITS_AUDIT_ENABLED}}' == 'True'"
|
||||
:show-payments="'{{LNBITS_ADMIN_UI}}' == 'True'"
|
||||
:show-node="'{{LNBITS_NODE_UI}}' == 'True'"
|
||||
:show-extensions="'{{LNBITS_EXTENSIONS_DEACTIVATE_ALL}}' == 'False'"
|
||||
></lnbits-manage>
|
||||
@@ -248,7 +247,7 @@
|
||||
@click="showAddWalletDialog.show = true"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('add_wallet')"></span
|
||||
><span v-text="$t('add_new_wallet')"></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
<q-dialog
|
||||
@@ -258,7 +257,9 @@
|
||||
>
|
||||
<q-card style="min-width: 350px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Wallet name</div>
|
||||
<div class="text-h6">
|
||||
<span v-text="$t('wallet_name')"></span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-pt-none">
|
||||
@@ -271,10 +272,14 @@
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right" class="text-primary">
|
||||
<q-btn flat label="Cancel" v-close-popup></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
label="Add wallet"
|
||||
:label="$t('cancel')"
|
||||
v-close-popup
|
||||
></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
:label="$t('add_wallet')"
|
||||
v-close-popup
|
||||
@click="submitAddWallet()"
|
||||
></q-btn>
|
||||
@@ -285,7 +290,7 @@
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card
|
||||
v-for="wallet in g.user.wallets"
|
||||
v-for="wallet in g.user.wallets.slice(0, g.user.extra.visible_wallet_count || 10)"
|
||||
:key="wallet.id"
|
||||
clickable
|
||||
@click="selectWallet(wallet)"
|
||||
@@ -331,6 +336,29 @@
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card
|
||||
v-if="g.user.hiddenWalletsCount"
|
||||
class="wallet-list-card"
|
||||
>
|
||||
<q-card-section
|
||||
class="flex flex-center column full-height text-center"
|
||||
>
|
||||
<div>
|
||||
<q-btn
|
||||
round
|
||||
color="primary"
|
||||
icon="more_horiz"
|
||||
@click="goToWallets()"
|
||||
>
|
||||
<q-tooltip
|
||||
><span
|
||||
v-text="$t('more_count', {count: g.user.hiddenWalletsCount})"
|
||||
></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</q-scroll-area>
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
class="lnbits-drawer__q-list"
|
||||
>
|
||||
<q-item
|
||||
v-for="walletRec in g.user.wallets"
|
||||
v-for="walletRec in g.user.wallets.slice(
|
||||
0,
|
||||
g.user.extra.visible_wallet_count || 10
|
||||
)"
|
||||
:key="walletRec.id"
|
||||
clickable
|
||||
:active="g.wallet && g.wallet.id === walletRec.id"
|
||||
@@ -43,6 +46,22 @@
|
||||
<q-item-section side v-show="g.wallet && g.wallet.id === walletRec.id">
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item
|
||||
v-if="g.user.hiddenWalletsCount > 0"
|
||||
clickable
|
||||
@click="goToWallets()"
|
||||
>
|
||||
<q-item-section side>
|
||||
<q-icon name="more_horiz" color="grey-5" size="md"></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
lines="1"
|
||||
class="text-caption"
|
||||
v-text="$t('more_count', {count: g.user.hiddenWalletsCount})"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable @click="showForm = !showForm">
|
||||
<q-item-section side>
|
||||
<q-icon
|
||||
@@ -55,7 +74,7 @@
|
||||
<q-item-label
|
||||
lines="1"
|
||||
class="text-caption"
|
||||
v-text="$t('add_wallet')"
|
||||
v-text="$t('add_new_wallet')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
@@ -178,19 +197,19 @@
|
||||
<q-item-label lines="1" v-text="$t('api_watch')"></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if="showPayments" to="/payments">
|
||||
<q-item-section side>
|
||||
<q-icon
|
||||
name="query_stats"
|
||||
:color="isActive('/payments') ? 'primary' : 'grey-5'"
|
||||
size="md"
|
||||
></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label lines="1" v-text="$t('payments')"></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<q-item to="/payments">
|
||||
<q-item-section side>
|
||||
<q-icon
|
||||
name="query_stats"
|
||||
:color="isActive('/payments') ? 'primary' : 'grey-5'"
|
||||
size="md"
|
||||
></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label lines="1" v-text="$t('payments')"></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if="showExtensions" to="/extensions">
|
||||
<q-item-section side>
|
||||
<q-icon
|
||||
@@ -987,7 +1006,7 @@
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="row q-gutter-x-sm">
|
||||
<q-btn
|
||||
v-if="
|
||||
props.row.isIn && props.row.isPending && props.row.bolt11
|
||||
@@ -1122,12 +1141,41 @@
|
||||
:type="hideInput ? 'password' : 'text'"
|
||||
:label="prop.label"
|
||||
:hint="prop.hint"
|
||||
:readonly="prop.readonly || false"
|
||||
>
|
||||
<q-btn
|
||||
v-if="prop.copy"
|
||||
@click="copyText(formData[key])"
|
||||
icon="content_copy"
|
||||
class="cursor-pointer"
|
||||
color="grey"
|
||||
flat
|
||||
dense
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-if="prop.qrcode"
|
||||
@click="showQRValue(formData[key])"
|
||||
icon="qr_code"
|
||||
class="cursor-pointer"
|
||||
color="grey"
|
||||
flat
|
||||
dense
|
||||
></q-btn>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-list>
|
||||
<q-dialog v-model="showQRDialog">
|
||||
<q-card class="q-pa-md">
|
||||
<q-card-section>
|
||||
<lnbits-qrcode :value="qrValue" :size="200"></lnbits-qrcode>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Close" v-close-popup />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1185,7 +1233,7 @@
|
||||
color="primary"
|
||||
:disable="walletName == ''"
|
||||
type="submit"
|
||||
:label="$t('add_wallet')"
|
||||
:label="$t('add_new_wallet')"
|
||||
class="full-width q-mb-sm"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
@@ -1453,10 +1501,22 @@
|
||||
>
|
||||
<q-avatar size="32px" class="q-mr-md">
|
||||
<q-img
|
||||
:src="`{{ static_url_for('static', 'images/keycloak-logo.png') }}`"
|
||||
:src="
|
||||
keycloakIcon
|
||||
? keycloakIcon
|
||||
: `{{ static_url_for('static', 'images/keycloak-logo.png') }}`
|
||||
"
|
||||
></q-img>
|
||||
</q-avatar>
|
||||
<div><span v-text="$t('signin_with_keycloak')"></span></div>
|
||||
<div>
|
||||
<span
|
||||
v-text="
|
||||
$t('signin_with_custom_org', {
|
||||
custom_org: keycloakOrg
|
||||
})
|
||||
"
|
||||
></span>
|
||||
</div>
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
+15
-6
@@ -56,10 +56,11 @@ class AESCipher:
|
||||
return data + (chr(length) * length).encode()
|
||||
|
||||
def unpad(self, data: bytes) -> bytes:
|
||||
_last = data[-1]
|
||||
if isinstance(_last, int):
|
||||
return data[:-_last]
|
||||
return data[: -ord(_last)]
|
||||
padding = data[-1]
|
||||
# Ensure padding is within valid range else there is no padding
|
||||
if padding <= 0 or padding >= self.block_size:
|
||||
return data
|
||||
return data[:-padding]
|
||||
|
||||
def derive_iv_and_key(
|
||||
self, salt: bytes, output_len: int = 32 + 16
|
||||
@@ -93,9 +94,17 @@ class AESCipher:
|
||||
|
||||
try:
|
||||
decrypted_bytes = aes.decrypt(encrypted_bytes)
|
||||
return self.unpad(decrypted_bytes).decode()
|
||||
except Exception as exc:
|
||||
raise ValueError("Decryption error") from exc
|
||||
raise ValueError("Could not decrypt payload") from exc
|
||||
|
||||
unpadded = self.unpad(decrypted_bytes)
|
||||
if len(unpadded) == 0:
|
||||
raise ValueError("Unpadding resulted in empty data.")
|
||||
|
||||
try:
|
||||
return unpadded.decode()
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError("Decryption resulted in invalid UTF-8 data.") from exc
|
||||
|
||||
def encrypt(self, message: bytes, urlsafe: bool = False) -> str:
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import statistics
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
@@ -187,6 +188,54 @@ def allowed_currencies() -> list[str]:
|
||||
return list(currencies.keys())
|
||||
|
||||
|
||||
def apply_trimmed_mean_filter(
|
||||
rates: list[tuple[str, float]], threshold_percentage: float = 0.01
|
||||
) -> list[tuple[str, float]]:
|
||||
"""
|
||||
Apply trimmed mean filtering to remove outliers from exchange rates.
|
||||
|
||||
Args:
|
||||
rates: List of (provider_name, rate_value) tuples
|
||||
threshold_percentage: Percentage threshold for outlier removal (default 1%)
|
||||
|
||||
Returns:
|
||||
Filtered list of rates with outliers removed
|
||||
"""
|
||||
if len(rates) < 3:
|
||||
# Need at least 3 rates to apply filtering
|
||||
return rates
|
||||
|
||||
rates_values = [r[1] for r in rates]
|
||||
median_value = statistics.median(rates_values)
|
||||
|
||||
# Filter out values that are more than threshold_percentage away from median
|
||||
filtered_rates = []
|
||||
for rate in rates:
|
||||
provider_name, value = rate
|
||||
deviation = abs(value - median_value) / median_value
|
||||
if deviation <= threshold_percentage:
|
||||
logger.debug(
|
||||
f"Keeping {provider_name}: {value} (deviation: {deviation:.4f})"
|
||||
)
|
||||
filtered_rates.append(rate)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Removing outlier {provider_name}: {value} "
|
||||
f"(deviation: {deviation:.4f})"
|
||||
)
|
||||
|
||||
# If we still have at least 2 rates after filtering, use them
|
||||
if len(filtered_rates) >= 2:
|
||||
logger.debug(f"Filtered rates: {filtered_rates}")
|
||||
return filtered_rates
|
||||
else:
|
||||
# Fall back to median if filtering removed too many values
|
||||
logger.debug("Filtering removed too many values, using median instead")
|
||||
# Find the rate closest to median
|
||||
closest_rate = min(rates, key=lambda x: abs(x[1] - median_value))
|
||||
return [closest_rate]
|
||||
|
||||
|
||||
async def btc_rates(currency: str) -> list[tuple[str, float]]:
|
||||
if currency.upper() not in allowed_currencies():
|
||||
raise ValueError(f"Currency '{currency}' not allowed.")
|
||||
@@ -236,7 +285,9 @@ async def btc_rates(currency: str) -> list[tuple[str, float]]:
|
||||
]
|
||||
results = await asyncio.gather(*calls)
|
||||
|
||||
return [r for r in results if r is not None]
|
||||
all_rates = [r for r in results if r is not None]
|
||||
|
||||
return apply_trimmed_mean_filter(all_rates)
|
||||
|
||||
|
||||
async def btc_price(currency: str) -> float:
|
||||
|
||||
@@ -28,6 +28,7 @@ from .nwc import NWCWallet
|
||||
from .opennode import OpenNodeWallet
|
||||
from .phoenixd import PhoenixdWallet
|
||||
from .spark import SparkWallet
|
||||
from .strike import StrikeWallet
|
||||
from .void import VoidWallet
|
||||
from .zbd import ZBDWallet
|
||||
|
||||
@@ -72,6 +73,7 @@ __all__ = [
|
||||
"OpenNodeWallet",
|
||||
"PhoenixdWallet",
|
||||
"SparkWallet",
|
||||
"StrikeWallet",
|
||||
"VoidWallet",
|
||||
"ZBDWallet",
|
||||
]
|
||||
|
||||
+50
-3
@@ -63,6 +63,27 @@ class BoltzWallet(Wallet):
|
||||
self.rpc = boltzrpc_pb2_grpc.BoltzStub(channel)
|
||||
self.wallet_id: int = 0
|
||||
|
||||
# Auto-create wallet if running in Docker mode
|
||||
async def _init_boltz_wallet():
|
||||
try:
|
||||
wallet_name = settings.boltz_client_wallet or "lnbits"
|
||||
mnemonic = await self._fetch_wallet(wallet_name)
|
||||
if mnemonic:
|
||||
logger.info(
|
||||
"✅ Mnemonic found for Boltz wallet, saving to settings"
|
||||
)
|
||||
settings.boltz_mnemonic = mnemonic
|
||||
|
||||
from lnbits.core.crud.settings import set_settings_field
|
||||
|
||||
await set_settings_field("boltz_mnemonic", mnemonic)
|
||||
else:
|
||||
logger.warning("⚠️ No mnemonic returned from Boltz")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to auto-create Boltz wallet: {e}")
|
||||
|
||||
self._init_wallet_task = asyncio.create_task(_init_boltz_wallet())
|
||||
|
||||
async def status(self) -> StatusResponse:
|
||||
try:
|
||||
request = boltzrpc_pb2.GetWalletRequest(name=settings.boltz_client_wallet)
|
||||
@@ -70,9 +91,10 @@ class BoltzWallet(Wallet):
|
||||
request, metadata=self.metadata
|
||||
)
|
||||
except AioRpcError as exc:
|
||||
logger.warning(exc)
|
||||
return StatusResponse(
|
||||
exc.details()
|
||||
+ " make sure you have macaroon and certificate configured, unless your client runs without", # noqa: E501
|
||||
"make sure you have macaroon and certificate configured,"
|
||||
"unless your client runs without",
|
||||
0,
|
||||
)
|
||||
|
||||
@@ -193,7 +215,10 @@ class BoltzWallet(Wallet):
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
response: boltzrpc_pb2.GetSwapInfoResponse = await self.rpc.GetSwapInfo(
|
||||
boltzrpc_pb2.GetSwapInfoRequest(id=checking_id), metadata=self.metadata
|
||||
boltzrpc_pb2.GetSwapInfoRequest(
|
||||
payment_hash=bytes.fromhex(checking_id)
|
||||
),
|
||||
metadata=self.metadata,
|
||||
)
|
||||
swap = response.swap
|
||||
except AioRpcError:
|
||||
@@ -225,3 +250,25 @@ class BoltzWallet(Wallet):
|
||||
" 5 seconds"
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _fetch_wallet(self, wallet_name: str) -> Optional[str]:
|
||||
try:
|
||||
request = boltzrpc_pb2.GetWalletRequest(name=wallet_name)
|
||||
response = await self.rpc.GetWallet(request, metadata=self.metadata)
|
||||
logger.info(f"Wallet '{wallet_name}' already exists with ID {response.id}")
|
||||
return settings.boltz_mnemonic
|
||||
except AioRpcError as exc:
|
||||
details = exc.details() or "unknown error"
|
||||
if exc.code() != grpc.StatusCode.NOT_FOUND:
|
||||
logger.error(f"Error checking wallet existence: {details}")
|
||||
raise
|
||||
|
||||
logger.info(f"Creating new wallet '{wallet_name}'")
|
||||
params = boltzrpc_pb2.WalletParams(
|
||||
name=wallet_name,
|
||||
currency=boltzrpc_pb2.LBTC,
|
||||
password=settings.boltz_client_password,
|
||||
)
|
||||
create_request = boltzrpc_pb2.CreateWalletRequest(params=params)
|
||||
response = await self.rpc.CreateWallet(create_request, metadata=self.metadata)
|
||||
return response.mnemonic
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package boltzrpc;
|
||||
option go_package = "github.com/BoltzExchange/boltz-client/boltzrpc";
|
||||
option go_package = "github.com/BoltzExchange/boltz-client/v2/pkg/boltzrpc";
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
service Boltz {
|
||||
@@ -122,6 +122,23 @@ service Boltz {
|
||||
*/
|
||||
rpc GetWallet (GetWalletRequest) returns (Wallet);
|
||||
|
||||
/*
|
||||
Calculates the fee for an equivalent `WalletSend` request.
|
||||
If `address` is left empty, a dummy swap address will be used, allowing for a fee estimation of a swap lockup transaction.
|
||||
*/
|
||||
rpc GetWalletSendFee (WalletSendRequest) returns (WalletSendFee);
|
||||
|
||||
/*
|
||||
Returns recent transactions from a wallet.
|
||||
*/
|
||||
rpc ListWalletTransactions (ListWalletTransactionsRequest) returns (ListWalletTransactionsResponse);
|
||||
|
||||
/*
|
||||
Increase the fee of a transaction using RBF.
|
||||
The transaction has to belong to one of the clients wallets.
|
||||
*/
|
||||
rpc BumpTransaction (BumpTransactionRequest) returns (BumpTransactionResponse);
|
||||
|
||||
/*
|
||||
Returns the credentials of a wallet. The password will be required if the wallet is encrypted.
|
||||
*/
|
||||
@@ -132,6 +149,16 @@ service Boltz {
|
||||
*/
|
||||
rpc RemoveWallet (RemoveWalletRequest) returns (RemoveWalletResponse);
|
||||
|
||||
/*
|
||||
Send coins from a wallet. Only the confirmed balance can be spent.
|
||||
*/
|
||||
rpc WalletSend (WalletSendRequest) returns (WalletSendResponse);
|
||||
|
||||
/*
|
||||
Get a new address of the wallet.
|
||||
*/
|
||||
rpc WalletReceive (WalletReceiveRequest) returns (WalletReceiveResponse);
|
||||
|
||||
/*
|
||||
Gracefully stops the daemon.
|
||||
*/
|
||||
@@ -265,11 +292,12 @@ message SwapInfo {
|
||||
repeated ChannelId chan_ids = 14;
|
||||
optional string blinding_key = 15;
|
||||
int64 created_at = 16;
|
||||
optional uint64 service_fee = 17;
|
||||
optional int64 service_fee = 17;
|
||||
optional uint64 onchain_fee = 18;
|
||||
// internal wallet which was used to pay the swap
|
||||
optional uint64 wallet_id = 20;
|
||||
uint64 tenant_id = 21;
|
||||
bool is_auto = 23;
|
||||
}
|
||||
|
||||
enum SwapType {
|
||||
@@ -322,7 +350,8 @@ message ReverseSwapInfo {
|
||||
string redeem_script = 7;
|
||||
string invoice = 8;
|
||||
string claim_address = 9;
|
||||
int64 onchain_amount = 10;
|
||||
uint64 onchain_amount = 10;
|
||||
uint64 invoice_amount = 25;
|
||||
uint32 timeout_block_height = 11;
|
||||
string lockup_transaction_id = 12;
|
||||
string claim_transaction_id = 13;
|
||||
@@ -331,11 +360,12 @@ message ReverseSwapInfo {
|
||||
optional string blinding_key = 16;
|
||||
int64 created_at = 17;
|
||||
optional int64 paid_at = 23; // the time when the invoice was paid
|
||||
optional uint64 service_fee = 18;
|
||||
optional int64 service_fee = 18;
|
||||
optional uint64 onchain_fee = 19;
|
||||
optional uint64 routing_fee_msat = 20;
|
||||
bool external_pay = 21;
|
||||
uint64 tenant_id = 22;
|
||||
bool is_auto = 24;
|
||||
}
|
||||
|
||||
message BlockHeights {
|
||||
@@ -406,17 +436,44 @@ enum IncludeSwaps {
|
||||
AUTO = 2;
|
||||
}
|
||||
|
||||
message AnySwapInfo {
|
||||
string id = 1;
|
||||
SwapType type = 2;
|
||||
Pair pair = 3;
|
||||
SwapState state = 4;
|
||||
optional string error = 5;
|
||||
string status = 6;
|
||||
// The expected amount to be sent to the lockup address for submarine and chain swaps and
|
||||
// the invoice amount for reverse swaps.
|
||||
uint64 from_amount = 7;
|
||||
// `from_amount` minus the service and network fee.
|
||||
uint64 to_amount = 13;
|
||||
int64 created_at = 8;
|
||||
optional int64 service_fee = 9;
|
||||
// inclues the routing fee for reverse swaps
|
||||
optional uint64 onchain_fee = 10;
|
||||
bool is_auto = 11;
|
||||
uint64 tenant_id = 12;
|
||||
}
|
||||
|
||||
message ListSwapsRequest {
|
||||
optional Currency from = 1;
|
||||
optional Currency to = 2;
|
||||
optional SwapState state = 4;
|
||||
IncludeSwaps include = 5;
|
||||
optional uint64 limit = 6;
|
||||
optional uint64 offset = 7;
|
||||
// wether to return swaps in the shared `all_swaps` list or in the detailed lists.
|
||||
// the `limit` and `offset` are only considered when `unify` is true.
|
||||
optional bool unify = 8;
|
||||
}
|
||||
message ListSwapsResponse {
|
||||
repeated SwapInfo swaps = 1;
|
||||
repeated CombinedChannelSwapInfo channel_creations = 2;
|
||||
repeated CombinedChannelSwapInfo channel_creations = 2 [deprecated = true];
|
||||
repeated ReverseSwapInfo reverse_swaps = 3;
|
||||
repeated ChainSwapInfo chain_swaps = 4;
|
||||
// populated when `unify` is set to true in the request
|
||||
repeated AnySwapInfo all_swaps = 5;
|
||||
}
|
||||
|
||||
message GetStatsRequest {
|
||||
@@ -448,7 +505,12 @@ message ClaimSwapsResponse {
|
||||
}
|
||||
|
||||
message GetSwapInfoRequest {
|
||||
string id = 1;
|
||||
string id = 1 [deprecated = true];
|
||||
oneof identifier {
|
||||
string swap_id = 2;
|
||||
// Only implemented for submarine swaps
|
||||
bytes payment_hash = 3;
|
||||
}
|
||||
}
|
||||
message GetSwapInfoResponse {
|
||||
SwapInfo swap = 1;
|
||||
@@ -471,22 +533,29 @@ message DepositResponse {
|
||||
}
|
||||
|
||||
message CreateSwapRequest {
|
||||
// amount of sats to be received on lightning.
|
||||
// related: `invoice` field
|
||||
uint64 amount = 1;
|
||||
Pair pair = 2;
|
||||
// not yet supported
|
||||
// repeated string chan_ids = 3;
|
||||
|
||||
// the daemon will pay the swap using the onchain wallet specified in the `wallet` field or any wallet otherwise.
|
||||
// the daemon will pay the swap using the onchain wallet specified in the `wallet` field
|
||||
// or the first internal wallet with the correct currency otherwise.
|
||||
bool send_from_internal = 4;
|
||||
// address where the coins should go if the swap fails. Refunds will go to any of the daemons wallets otherwise.
|
||||
optional string refund_address = 5;
|
||||
// wallet to pay swap from. only used if `send_from_internal` is set to true
|
||||
optional uint64 wallet_id = 6;
|
||||
// invoice to use for the swap. if not set, the daemon will get a new invoice from the lightning node
|
||||
// bolt11 invoice, lnurl, or lnaddress to use for the swap.
|
||||
// required in standalone mode.
|
||||
// when connected to a lightning node, a new invoice for `amount` sats will be fetched
|
||||
// the `amount` field has to be populated in case of a lnurl and lnaddress
|
||||
optional string invoice = 7;
|
||||
// Boltz does not accept 0-conf for Liquid transactions with a fee of 0.01 sat/vByte;
|
||||
// when `zero_conf` is enabled, a fee of 0.1 sat/vByte will be used for Liquid lockup transactions
|
||||
optional bool zero_conf = 8;
|
||||
optional bool zero_conf = 8 [deprecated = true];
|
||||
// Fee rate to use when sending from internal wallet
|
||||
optional double sat_per_vbyte = 9;
|
||||
// Rates to accept for the swap. Queries latest from boltz otherwise
|
||||
// The recommended way to use this is to pass a user approved value from a previous `GetPairInfo` call
|
||||
optional PairInfo accepted_pair = 10;
|
||||
}
|
||||
message CreateSwapResponse {
|
||||
string id = 1;
|
||||
@@ -530,6 +599,16 @@ message CreateReverseSwapRequest {
|
||||
optional bool external_pay = 8;
|
||||
// Description of the invoice which will be created for the swap
|
||||
optional string description = 9;
|
||||
// Description hash of the invoice which will be created for the swap. Takes precedence over `description`
|
||||
optional bytes description_hash = 10;
|
||||
// Expiry of the reverse swap invoice in seconds
|
||||
optional uint64 invoice_expiry = 11;
|
||||
// Rates to accept for the swap. Queries latest from boltz otherwise
|
||||
// The recommended way to use this is to pass a user approved value from a previous `GetPairInfo` call
|
||||
optional PairInfo accepted_pair = 12;
|
||||
|
||||
// The routing fee limit for paying the lightning invoice in ppm (parts per million)
|
||||
optional uint64 routing_fee_limit_ppm = 13;
|
||||
}
|
||||
message CreateReverseSwapResponse {
|
||||
string id = 1;
|
||||
@@ -545,7 +624,8 @@ message CreateReverseSwapResponse {
|
||||
|
||||
message CreateChainSwapRequest {
|
||||
// Amount of satoshis to swap. It is the amount expected to be sent to the lockup address.
|
||||
uint64 amount = 1;
|
||||
// If left empty, any amount within the limits will be accepted.
|
||||
optional uint64 amount = 1;
|
||||
Pair pair = 2;
|
||||
// Address where funds will be swept to if the swap succeeds
|
||||
optional string to_address = 3;
|
||||
@@ -561,9 +641,12 @@ message CreateChainSwapRequest {
|
||||
optional bool accept_zero_conf = 7;
|
||||
// If set, the daemon will not pay the swap from an internal wallet.
|
||||
optional bool external_pay = 8;
|
||||
// Boltz does not accept 0-conf for Liquid transactions with a fee of 0.01 sat/vByte;
|
||||
// when `lockup_zero_conf` is enabled, a fee of 0.1 sat/vByte will be used for Liquid lockup transactions
|
||||
optional bool lockup_zero_conf = 9;
|
||||
optional bool lockup_zero_conf = 9 [deprecated = true];
|
||||
// Fee rate to use when sending from internal wallet
|
||||
optional double sat_per_vbyte = 10;
|
||||
// Rates to accept for the swap. Queries latest from boltz otherwise
|
||||
// The recommended way to use this is to pass a user approved value from a previous `GetPairInfo` call
|
||||
optional PairInfo accepted_pair = 11;
|
||||
}
|
||||
|
||||
message ChainSwapInfo {
|
||||
@@ -574,7 +657,7 @@ message ChainSwapInfo {
|
||||
string status = 5;
|
||||
string preimage = 6;
|
||||
bool is_auto = 8;
|
||||
optional uint64 service_fee = 9;
|
||||
optional int64 service_fee = 9;
|
||||
double service_fee_percent = 10;
|
||||
optional uint64 onchain_fee = 11;
|
||||
int64 created_at = 12;
|
||||
@@ -614,9 +697,9 @@ message LightningChannel {
|
||||
}
|
||||
|
||||
message SwapStats {
|
||||
uint64 total_fees = 1;
|
||||
int64 total_fees = 1;
|
||||
uint64 total_amount = 2;
|
||||
uint64 avg_fees = 3;
|
||||
int64 avg_fees = 3;
|
||||
uint64 avg_amount = 4;
|
||||
uint64 count = 5;
|
||||
uint64 success_count = 6;
|
||||
@@ -687,6 +770,75 @@ message GetWalletRequest {
|
||||
optional uint64 id = 2;
|
||||
}
|
||||
|
||||
message WalletSendFee {
|
||||
// amount of sats which would be sent
|
||||
uint64 amount = 1;
|
||||
uint64 fee = 2;
|
||||
// the fee rate used for the estimation in sat/vbyte
|
||||
double fee_rate = 3;
|
||||
}
|
||||
|
||||
message ListWalletTransactionsRequest {
|
||||
uint64 id = 1;
|
||||
optional bool exclude_swap_related = 2;
|
||||
optional uint64 limit = 3;
|
||||
optional uint64 offset = 4;
|
||||
}
|
||||
|
||||
enum TransactionType {
|
||||
UNKNOWN = 0;
|
||||
LOCKUP = 1;
|
||||
REFUND = 2;
|
||||
CLAIM = 3;
|
||||
CONSOLIDATION = 4;
|
||||
}
|
||||
|
||||
message WalletTransaction {
|
||||
string id = 1;
|
||||
// balance change of the wallet in satoshis.
|
||||
// its the sum of all output values minus the sum of all input values which are controlled by the wallet.
|
||||
// positive values indicate incoming transactions, negative values outgoing transactions
|
||||
int64 balance_change = 2;
|
||||
int64 timestamp = 3;
|
||||
repeated TransactionOutput outputs = 4;
|
||||
uint32 block_height = 6;
|
||||
// additional informations about the tx (type, related swaps etc.)
|
||||
repeated TransactionInfo infos = 7;
|
||||
}
|
||||
|
||||
message BumpTransactionRequest {
|
||||
oneof previous {
|
||||
// Id of the transaction to bump. The transaction has to belong to one of the clients wallets
|
||||
string tx_id = 1;
|
||||
// Depending on the state of the swap, the lockup, refund or claim transaction will be bumped
|
||||
string swap_id = 2;
|
||||
}
|
||||
// Fee rate for the new transaction. if not specified, the daemon will query the fee rate from the configured provider
|
||||
// and bump the fee by at least 1 sat/vbyte.
|
||||
optional double sat_per_vbyte = 3;
|
||||
}
|
||||
|
||||
message BumpTransactionResponse {
|
||||
string tx_id = 1;
|
||||
}
|
||||
|
||||
message TransactionInfo {
|
||||
// will be populated for LOCKUP, REFUND and CLAIM
|
||||
optional string swap_id = 1;
|
||||
TransactionType type = 2;
|
||||
}
|
||||
|
||||
message TransactionOutput {
|
||||
string address = 1;
|
||||
uint64 amount = 2;
|
||||
// wether the address is controlled by the wallet
|
||||
bool is_our_address = 3;
|
||||
}
|
||||
|
||||
message ListWalletTransactionsResponse {
|
||||
repeated WalletTransaction transactions = 1;
|
||||
}
|
||||
|
||||
message GetWalletCredentialsRequest {
|
||||
uint64 id = 1;
|
||||
optional string password = 2;
|
||||
@@ -696,6 +848,31 @@ message RemoveWalletRequest {
|
||||
uint64 id = 1;
|
||||
}
|
||||
|
||||
message WalletSendRequest {
|
||||
uint64 id = 1;
|
||||
string address = 2;
|
||||
// Amount of satoshis to be sent to 'address`
|
||||
uint64 amount = 3;
|
||||
// Fee rate to use for the transaction
|
||||
optional double sat_per_vbyte = 4;
|
||||
// Sends all available funds to the address. The `amount` field is ignored.
|
||||
optional bool send_all = 5;
|
||||
// whether `address` is the lockup of a swap.
|
||||
optional bool is_swap_address = 6;
|
||||
}
|
||||
|
||||
message WalletSendResponse {
|
||||
string tx_id = 1;
|
||||
}
|
||||
|
||||
message WalletReceiveRequest {
|
||||
uint64 id = 1;
|
||||
}
|
||||
|
||||
message WalletReceiveResponse {
|
||||
string address = 1;
|
||||
}
|
||||
|
||||
|
||||
message Wallet {
|
||||
uint64 id = 1;
|
||||
@@ -720,6 +897,7 @@ message Subaccount {
|
||||
Balance balance = 1;
|
||||
uint64 pointer = 2;
|
||||
string type = 3;
|
||||
repeated string descriptors = 4;
|
||||
}
|
||||
|
||||
message RemoveWalletResponse {}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,3 @@
|
||||
|
||||
wget https://raw.githubusercontent.com/BoltzExchange/boltz-client/master/boltzrpc/boltzrpc.proto -O lnbits/wallets/boltz_grpc_files/boltzrpc.proto
|
||||
poetry run python -m grpc_tools.protoc -I . --python_out=. --grpc_python_out=. --pyi_out=. lnbits/wallets/boltz_grpc_files/boltzrpc.proto
|
||||
wget https://raw.githubusercontent.com/BoltzExchange/boltz-client/refs/heads/master/pkg/boltzrpc/boltzrpc.proto -O boltz_grpc_files/boltzrpc.proto
|
||||
poetry run python -m grpc_tools.protoc -I . --python_out=. --grpc_python_out=. --pyi_out=. boltz_grpc_files/boltzrpc.proto
|
||||
|
||||
@@ -3,7 +3,9 @@ import base64
|
||||
import hashlib
|
||||
import json
|
||||
import urllib.parse
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
from collections.abc import AsyncGenerator
|
||||
from decimal import Decimal
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
@@ -70,8 +72,8 @@ class EclairWallet(Wallet):
|
||||
|
||||
if r.is_error or "total" not in data:
|
||||
return StatusResponse(f"Server error: '{r.text}'", 0)
|
||||
|
||||
return StatusResponse(None, int(data.get("total") * 100_000_000_000))
|
||||
total = round(Decimal(data.get("total")), 8) * 100_000_000_000
|
||||
return StatusResponse(balance_msat=int(total), error_message=None)
|
||||
except json.JSONDecodeError:
|
||||
return StatusResponse("Server error: 'invalid json response'", 0)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
import asyncio
|
||||
import time
|
||||
from decimal import Decimal
|
||||
from typing import Any, AsyncGenerator, Dict, Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
PaymentFailedStatus,
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
PaymentSuccessStatus,
|
||||
StatusResponse,
|
||||
Wallet,
|
||||
)
|
||||
|
||||
|
||||
class TokenBucket:
|
||||
"""
|
||||
Token bucket rate limiter for Strike API endpoints.
|
||||
"""
|
||||
|
||||
def __init__(self, rate: int, period_seconds: int):
|
||||
"""
|
||||
Initialize a token bucket.
|
||||
|
||||
Args:
|
||||
rate: Max requests allowed in the period
|
||||
period_seconds: Time period in seconds.
|
||||
"""
|
||||
self.rate = rate
|
||||
self.period = period_seconds
|
||||
self.tokens = rate
|
||||
self.last_refill = time.monotonic()
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
async def consume(self) -> None:
|
||||
"""Wait until a token is available and consume it."""
|
||||
async with self.lock:
|
||||
# Refill tokens based on elapsed time
|
||||
now = time.monotonic()
|
||||
elapsed = now - self.last_refill
|
||||
|
||||
if elapsed > 0:
|
||||
new_tokens = int((elapsed / self.period) * self.rate)
|
||||
self.tokens = min(self.rate, self.tokens + new_tokens)
|
||||
self.last_refill = now
|
||||
|
||||
# If no tokens available, calculate wait time and wait for a token
|
||||
if self.tokens < 1:
|
||||
# Calculate time needed for one token
|
||||
wait_time = (self.period / self.rate) * (1 - self.tokens)
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
# After waiting, update time and add one token
|
||||
self.last_refill = time.monotonic()
|
||||
self.tokens = 1
|
||||
|
||||
# Consume a token (will be 0 or more after consumption)
|
||||
self.tokens -= 1
|
||||
|
||||
|
||||
class StrikeWallet(Wallet):
|
||||
"""
|
||||
https://developer.strike.me/api
|
||||
A minimal LNbits wallet backend for Strike.
|
||||
"""
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# construction / teardown #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
def __init__(self):
|
||||
if not settings.strike_api_endpoint:
|
||||
raise ValueError("Missing strike_api_endpoint")
|
||||
if not settings.strike_api_key:
|
||||
raise ValueError("Missing strike_api_key")
|
||||
|
||||
super().__init__()
|
||||
|
||||
# throttle
|
||||
self._sem = asyncio.Semaphore(value=20)
|
||||
|
||||
# Rate limiters for different API endpoints
|
||||
# Invoice/payment operations: 250 requests / 1 minute
|
||||
self._invoice_limiter = TokenBucket(250, 60)
|
||||
self._payment_limiter = TokenBucket(250, 60)
|
||||
# All other operations: 1,000 requests / 10 minutes
|
||||
self._general_limiter = TokenBucket(1000, 600)
|
||||
|
||||
self.client = httpx.AsyncClient(
|
||||
base_url=self.normalize_endpoint(settings.strike_api_endpoint),
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.strike_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": settings.user_agent,
|
||||
},
|
||||
timeout=httpx.Timeout(connect=5.0, read=40.0, write=10.0, pool=None),
|
||||
transport=httpx.AsyncHTTPTransport(
|
||||
limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
|
||||
retries=0, # we handle retries ourselves
|
||||
),
|
||||
)
|
||||
|
||||
# runtime state
|
||||
self.pending_invoices: list[str] = [] # Keep it as a list
|
||||
self.pending_payments: Dict[str, str] = {}
|
||||
self.failed_payments: Dict[str, str] = {}
|
||||
|
||||
# balance cache
|
||||
self._cached_balance: Optional[int] = None
|
||||
self._cached_balance_ts: float = 0.0
|
||||
self._cache_ttl = 30 # seconds
|
||||
|
||||
async def cleanup(self):
|
||||
try:
|
||||
await self.client.aclose()
|
||||
except Exception:
|
||||
logger.warning("Error closing Strike client")
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# low-level request helpers #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
async def _req(self, method: str, path: str, /, **kw) -> httpx.Response:
|
||||
"""Make a Strike HTTP call with:
|
||||
One Strike HTTP call with
|
||||
• rate limiting based on endpoint type
|
||||
• concurrency throttle
|
||||
• exponential back-off + jitter
|
||||
• explicit retry on 429/5xx
|
||||
• latency logging
|
||||
"""
|
||||
# Apply the appropriate rate limiter based on the endpoint path.
|
||||
if path.startswith("/invoices") or path.startswith("/receive-requests"):
|
||||
await self._invoice_limiter.consume()
|
||||
elif path.startswith("/payment-quotes"):
|
||||
await self._payment_limiter.consume()
|
||||
else:
|
||||
await self._general_limiter.consume()
|
||||
|
||||
async with self._sem:
|
||||
return await self.client.request(method, path, **kw)
|
||||
|
||||
# Typed wrappers - so call-sites stay tidy.
|
||||
async def _get(self, path: str, **kw) -> httpx.Response: # GET request.
|
||||
return await self._req("GET", path, **kw)
|
||||
|
||||
async def _post(self, path: str, **kw) -> httpx.Response:
|
||||
return await self._req("POST", path, **kw)
|
||||
|
||||
async def _patch(self, path: str, **kw) -> httpx.Response:
|
||||
return await self._req("PATCH", path, **kw)
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# LNbits wallet API implementation #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
async def status(self) -> StatusResponse:
|
||||
"""
|
||||
Return wallet balance (millisatoshis) with a 30-second cache.
|
||||
"""
|
||||
now = time.time()
|
||||
if (
|
||||
self._cached_balance is not None
|
||||
and now - self._cached_balance_ts < self._cache_ttl
|
||||
):
|
||||
return StatusResponse(None, self._cached_balance)
|
||||
|
||||
try:
|
||||
r = await self._get("/balances")
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
balances = data.get("data", []) if isinstance(data, dict) else data
|
||||
btc = next((b for b in balances if b.get("currency") == "BTC"), None)
|
||||
if btc and "available" in btc:
|
||||
available_btc = Decimal(btc["available"]) # Get available BTC amount.
|
||||
msats = int(
|
||||
available_btc * Decimal(1e11)
|
||||
) # Convert BTC to millisatoshis.
|
||||
self._cached_balance = msats
|
||||
self._cached_balance_ts = now
|
||||
return StatusResponse(None, msats)
|
||||
|
||||
return StatusResponse(None, 0)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return StatusResponse("Connection error", 0)
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: bytes | None = None, # Add this parameter
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
try:
|
||||
btc_amt = (Decimal(amount) / Decimal(1e8)).quantize(
|
||||
Decimal("0.00000001")
|
||||
) # Convert amount from millisatoshis to BTC.
|
||||
payload: Dict[str, Any] = {
|
||||
"bolt11": {
|
||||
"amount": {
|
||||
"currency": "BTC",
|
||||
"amount": str(btc_amt),
|
||||
},
|
||||
"description": memo or "",
|
||||
},
|
||||
"targetCurrency": "BTC",
|
||||
}
|
||||
if description_hash:
|
||||
payload["bolt11"]["descriptionHash"] = description_hash.hex()
|
||||
|
||||
r = await self._post(
|
||||
"/receive-requests",
|
||||
json=payload,
|
||||
)
|
||||
r.raise_for_status()
|
||||
resp = r.json()
|
||||
invoice_id = resp.get("receiveRequestId")
|
||||
bolt11 = resp.get("bolt11", {}).get("invoice")
|
||||
if not invoice_id or not bolt11:
|
||||
return InvoiceResponse(
|
||||
ok=False, error_message="Invalid invoice response"
|
||||
)
|
||||
|
||||
self.pending_invoices.append(invoice_id)
|
||||
return InvoiceResponse(
|
||||
ok=True, checking_id=invoice_id, payment_request=bolt11
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.warning(e)
|
||||
msg = e.response.json().get("message", e.response.text)
|
||||
return InvoiceResponse(ok=False, error_message=f"Strike API error: {msg}")
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return InvoiceResponse(ok=False, error_message="Connection error")
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
try:
|
||||
# 1) Create a payment quote.
|
||||
q = await self._post(
|
||||
"/payment-quotes/lightning",
|
||||
json={"lnInvoice": bolt11},
|
||||
)
|
||||
q.raise_for_status()
|
||||
quote_id = q.json().get("paymentQuoteId")
|
||||
if not quote_id:
|
||||
return PaymentResponse(
|
||||
ok=False, error_message="Strike: missing payment quote Id"
|
||||
)
|
||||
|
||||
# 2) Execute the payment quote.
|
||||
e = await self._patch(f"/payment-quotes/{quote_id}/execute")
|
||||
e.raise_for_status()
|
||||
|
||||
data = e.json() if e.content else {}
|
||||
payment_id = data.get("paymentId")
|
||||
state = data.get("state", "").upper()
|
||||
|
||||
# Network fee → msat.
|
||||
fee_obj = data.get("lightningNetworkFee") or data.get("totalFee") or {}
|
||||
fee_btc = Decimal(fee_obj.get("amount", "0"))
|
||||
fee_msat = int(fee_btc * Decimal(1e11)) # millisatoshis.
|
||||
|
||||
if state in {"SUCCEEDED", "COMPLETED"}:
|
||||
preimage = data.get("preimage") or data.get("preImage")
|
||||
return PaymentResponse(
|
||||
ok=True,
|
||||
checking_id=payment_id,
|
||||
fee_msat=fee_msat,
|
||||
preimage=preimage,
|
||||
)
|
||||
|
||||
failed_states = {
|
||||
"CANCELED",
|
||||
"FAILED",
|
||||
"TIMED_OUT",
|
||||
}
|
||||
if state in failed_states:
|
||||
return PaymentResponse(
|
||||
ok=False, checking_id=payment_id, error_message=f"State: {state}"
|
||||
)
|
||||
|
||||
# Store mapping for later polling.
|
||||
if payment_id:
|
||||
# todo: this will be lost on server restart
|
||||
self.pending_payments[payment_id] = quote_id
|
||||
|
||||
# Treat all other states as pending (including unknown states).
|
||||
return PaymentResponse(ok=None, checking_id=payment_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
# Keep pending. Not sure if the payment went trough or not.
|
||||
return PaymentResponse(ok=None, error_message="Connection error")
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
r = await self._get(f"/receive-requests/{checking_id}/receives")
|
||||
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
items = data.get("items", [])
|
||||
|
||||
if not items:
|
||||
# Still pending.
|
||||
return PaymentPendingStatus()
|
||||
|
||||
for item in items:
|
||||
if item.get("state") == "COMPLETED":
|
||||
preimage = None
|
||||
lightning_data = item.get("lightning")
|
||||
if lightning_data:
|
||||
preimage = lightning_data.get(
|
||||
"preimage"
|
||||
) or lightning_data.get("preImage")
|
||||
|
||||
return PaymentSuccessStatus(fee_msat=0, preimage=preimage)
|
||||
|
||||
return PaymentPendingStatus()
|
||||
|
||||
if r.status_code == 404:
|
||||
logger.warning(f"Payment '{checking_id}' not found. Marking as failed.")
|
||||
return PaymentFailedStatus(False)
|
||||
|
||||
r.raise_for_status()
|
||||
return PaymentPendingStatus()
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.warning(
|
||||
f"HTTPStatusError in get_invoice_status for checking_id {checking_id} "
|
||||
f"on URL {e.request.url}: {e.response.status_code} - {e.response.text}"
|
||||
)
|
||||
# Default to Pending to allow retries by paid_invoices_stream.
|
||||
return PaymentPendingStatus()
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
quote_id = self.pending_payments.get(checking_id)
|
||||
|
||||
try:
|
||||
# Attempt 1: Use quote_id if available (from in-memory store)
|
||||
if quote_id:
|
||||
status = await self._get_payment_status_by_quote_id(
|
||||
checking_id, quote_id
|
||||
)
|
||||
if status:
|
||||
return status
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
logger.debug(f"Error while fetching payment by quote id {checking_id}.")
|
||||
|
||||
try:
|
||||
# Attempt 2: Fallback - Use paymentId (checking_id) directly.
|
||||
return await self._get_payment_status_by_checking_id(checking_id)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
logger.debug(f"Error while fetching payment {checking_id}.")
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
Poll Strike for invoice settlement while respecting the documented API limits.
|
||||
|
||||
Uses dynamic adjustment of polling frequency based on activity.
|
||||
"""
|
||||
min_poll, max_poll = 1, 15
|
||||
# 1,000 requests / 10 minutes = ~100 requests/minute.
|
||||
rate_limit = 100
|
||||
sleep_s = min_poll
|
||||
# Main loop for polling invoices.
|
||||
self._running = True
|
||||
|
||||
while self._running and settings.lnbits_running:
|
||||
loop_start = time.time()
|
||||
had_activity = False
|
||||
|
||||
req_budget = max(
|
||||
1, rate_limit * sleep_s // 60
|
||||
) # Calculate request budget based on sleep time.
|
||||
processed = 0
|
||||
|
||||
for inv in list(self.pending_invoices):
|
||||
if processed >= req_budget: # If request budget is exhausted.
|
||||
break
|
||||
status = await self.get_invoice_status(inv)
|
||||
processed += 1
|
||||
|
||||
if status.success or status.failed:
|
||||
self.pending_invoices.remove(inv)
|
||||
if status.success:
|
||||
had_activity = True
|
||||
yield inv
|
||||
|
||||
# Dynamic adjustment of polling frequency based on activity.
|
||||
sleep_s = (
|
||||
max(min_poll, sleep_s // 2)
|
||||
if had_activity
|
||||
else min(max_poll, sleep_s * 2)
|
||||
)
|
||||
|
||||
# Sleep to respect rate limits.
|
||||
elapsed = time.time() - loop_start
|
||||
# Ensure we respect the rate limit, even with dynamic adjustment.
|
||||
min_sleep_for_rate = processed * 60 / rate_limit - elapsed
|
||||
await asyncio.sleep(max(sleep_s, min_sleep_for_rate, 0))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# misc Strike helpers #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def get_invoices(
|
||||
self,
|
||||
filters: Optional[str] = None,
|
||||
orderby: Optional[str] = None,
|
||||
skip: Optional[int] = None,
|
||||
top: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
params: Dict[str, Any] = {}
|
||||
if filters:
|
||||
params["$filter"] = filters
|
||||
if orderby:
|
||||
params["$orderby"] = orderby
|
||||
if skip is not None:
|
||||
params["$skip"] = skip
|
||||
if top is not None:
|
||||
params["$top"] = top
|
||||
r = await self._get(
|
||||
"/invoices", params=params
|
||||
) # Get invoices from Strike API.
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception:
|
||||
logger.warning("Error in get_invoices()")
|
||||
return {"error": "unable to fetch invoices"}
|
||||
|
||||
async def _get_payment_status_by_quote_id(
|
||||
self, checking_id: str, quote_id: str
|
||||
) -> Optional[PaymentStatus]:
|
||||
resp = await self._get(f"/payment-quotes/{quote_id}")
|
||||
resp.raise_for_status()
|
||||
|
||||
data = resp.json()
|
||||
state = data.get("state", "").upper()
|
||||
preimage = data.get("preimage") or data.get("preImage")
|
||||
|
||||
fee_msat = 0
|
||||
fee_obj = data.get("lightningNetworkFee") or data.get("totalFee")
|
||||
if fee_obj and fee_obj.get("amount") and fee_obj.get("currency"):
|
||||
amount_str = fee_obj.get("amount")
|
||||
currency_str = fee_obj.get("currency").upper()
|
||||
try:
|
||||
if currency_str == "BTC":
|
||||
fee_btc_decimal = Decimal(amount_str)
|
||||
fee_msat = int(fee_btc_decimal * Decimal(1e11))
|
||||
elif currency_str == "SAT":
|
||||
fee_sat_decimal = Decimal(amount_str)
|
||||
fee_msat = int(fee_sat_decimal * 1000)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
logger.warning(
|
||||
f"Fee parse error. Quote: '{quote_id}'. "
|
||||
f"Payment '{checking_id}'."
|
||||
)
|
||||
fee_msat = 0
|
||||
|
||||
if state in {"SUCCEEDED", "COMPLETED"}:
|
||||
self.pending_payments.pop(checking_id, None)
|
||||
return PaymentSuccessStatus(fee_msat=fee_msat, preimage=preimage)
|
||||
if state == "FAILED":
|
||||
self.pending_payments.pop(checking_id, None)
|
||||
return PaymentFailedStatus()
|
||||
|
||||
return None
|
||||
|
||||
async def _get_payment_status_by_checking_id(
|
||||
self, checking_id: str
|
||||
) -> PaymentStatus:
|
||||
r_payment = await self._get(f"/payments/{checking_id}")
|
||||
|
||||
if r_payment.status_code == 200:
|
||||
data = r_payment.json()
|
||||
state = data.get("state", "").upper()
|
||||
preimage = None
|
||||
fee_msat = 0
|
||||
|
||||
if state in {"SUCCEEDED", "COMPLETED"}:
|
||||
self.pending_payments.pop(checking_id, None)
|
||||
return PaymentSuccessStatus(fee_msat=fee_msat, preimage=preimage)
|
||||
if state == "FAILED":
|
||||
self.pending_payments.pop(checking_id, None)
|
||||
return PaymentFailedStatus()
|
||||
|
||||
return PaymentPendingStatus()
|
||||
|
||||
if r_payment.status_code == 400:
|
||||
try:
|
||||
error_data = r_payment.json()
|
||||
# Check for Strike's specific validation
|
||||
# error structure for paymentId format
|
||||
if error_data.get("data", {}).get("code") == "INVALID_DATA":
|
||||
validation_errors = error_data.get("data", {}).get(
|
||||
"validationErrors", {}
|
||||
)
|
||||
if "paymentId" in validation_errors:
|
||||
for err_detail in validation_errors["paymentId"]:
|
||||
is_invalid = err_detail.get(
|
||||
"code"
|
||||
) == "INVALID_DATA" and "is not valid." in err_detail.get(
|
||||
"message", ""
|
||||
)
|
||||
if not is_invalid:
|
||||
continue
|
||||
logger.error(
|
||||
f"Payment '{checking_id}' not a valid Strike payment. "
|
||||
f"Marked as failed. Response: {r_payment.text}"
|
||||
)
|
||||
self.pending_payments.pop(checking_id, None)
|
||||
return PaymentFailedStatus()
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
|
||||
return PaymentPendingStatus()
|
||||
|
||||
if r_payment.status_code == 404:
|
||||
logger.warning(f"Payment {checking_id} not found. Marking as failed.")
|
||||
self.pending_payments.pop(checking_id, None)
|
||||
return PaymentFailedStatus()
|
||||
|
||||
logger.debug(
|
||||
f"Error fetching payment {checking_id} directly: "
|
||||
f"{r_payment.status_code} - {r_payment.text}"
|
||||
)
|
||||
return PaymentPendingStatus()
|
||||
Generated
+1479
-1144
File diff suppressed because it is too large
Load Diff
+4
-5
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "lnbits"
|
||||
version = "1.1.0"
|
||||
version = "1.2.0-rc2"
|
||||
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
||||
authors = ["Alan Bits <alan@lnbits.com>"]
|
||||
readme = "README.md"
|
||||
@@ -33,7 +33,7 @@ uvloop = "0.21.0"
|
||||
websockets = "11.0.3"
|
||||
loguru = "0.7.2"
|
||||
grpcio = "1.69.0"
|
||||
protobuf = "5.28.0"
|
||||
protobuf = "5.29.1"
|
||||
pyln-client = "24.11"
|
||||
pywebpush = "1.14.1"
|
||||
slowapi = "0.1.9"
|
||||
@@ -41,8 +41,6 @@ websocket-client = "1.8.0"
|
||||
pycryptodomex = "3.20.0"
|
||||
packaging = "24.0"
|
||||
bolt11 = "2.1.0"
|
||||
# needed for new login methods: username-password, google-auth, github-auth
|
||||
bcrypt = "4.2.0"
|
||||
pyjwt = "2.9.0"
|
||||
passlib = "1.7.4"
|
||||
itsdangerous = "2.2.0"
|
||||
@@ -56,7 +54,7 @@ environs = "9.5.0"
|
||||
# needed for scheduler extension
|
||||
python-crontab = "3.2.0"
|
||||
# needed for liquid support boltz
|
||||
wallycore = {version = "1.3.0", optional = true}
|
||||
wallycore = {version = "1.4.0", optional = true}
|
||||
# needed for breez funding source
|
||||
breez-sdk = {version = "0.6.6", optional = true}
|
||||
|
||||
@@ -64,6 +62,7 @@ jsonpath-ng = "^1.7.0"
|
||||
pynostr = "^0.6.2"
|
||||
python-multipart = "^0.0.20"
|
||||
filetype = "^1.2.0"
|
||||
nostr-sdk = "^0.42.1"
|
||||
|
||||
[tool.poetry.extras]
|
||||
breez = ["breez-sdk"]
|
||||
|
||||
+2
-2
@@ -130,7 +130,7 @@ async def from_wallet(from_user):
|
||||
wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_from")
|
||||
await update_wallet_balance(
|
||||
wallet=wallet,
|
||||
amount=999999999,
|
||||
amount=9999999,
|
||||
)
|
||||
yield wallet
|
||||
|
||||
@@ -174,7 +174,7 @@ async def to_wallet(to_user):
|
||||
wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_to")
|
||||
await update_wallet_balance(
|
||||
wallet=wallet,
|
||||
amount=999999999,
|
||||
amount=9999999,
|
||||
)
|
||||
yield wallet
|
||||
|
||||
|
||||
@@ -66,7 +66,6 @@ async def test_pay_real_invoice(
|
||||
|
||||
await asyncio.sleep(1)
|
||||
balance = await get_node_balance_sats()
|
||||
# TODO: maybe take fee into consideration?
|
||||
assert prev_balance - balance == 100
|
||||
|
||||
|
||||
|
||||
@@ -152,6 +152,10 @@ async def test_set_channel_fees(node_client):
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.skipif(
|
||||
funding_source.__class__.__name__ == "LndRestWallet",
|
||||
reason="Lndrest is slow / async with channel commands",
|
||||
)
|
||||
async def test_channel_management(node_client):
|
||||
async def get_channels():
|
||||
# lndrest is slow / async with channel commands
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
from lnbits.utils.exchange_rates import (
|
||||
apply_trimmed_mean_filter,
|
||||
)
|
||||
|
||||
|
||||
class TestApplyTrimmedMeanFilter:
|
||||
"""Test the trimmed mean filtering function"""
|
||||
|
||||
def test_trimmed_mean_filter_with_outliers(self):
|
||||
"""Test filtering removes outliers that deviate more than threshold"""
|
||||
# Mock rates with one outlier (20% deviation)
|
||||
rates = [
|
||||
("Binance", 50000.0),
|
||||
("Coinbase", 51000.0),
|
||||
("Kraken", 52000.0),
|
||||
("Outlier", 60000.0), # 20% higher than others
|
||||
]
|
||||
|
||||
result = apply_trimmed_mean_filter(rates, threshold_percentage=0.01)
|
||||
|
||||
# Should remove the outliers (binance and outlier)
|
||||
assert len(result) == 2
|
||||
assert ("Outlier", 60000.0) not in result
|
||||
assert ("Binance", 50000.0) not in result
|
||||
assert ("Coinbase", 51000.0) in result
|
||||
assert ("Kraken", 52000.0) in result
|
||||
|
||||
def test_trimmed_mean_filter_no_outliers(self):
|
||||
"""Test filtering keeps all rates when none are outliers"""
|
||||
rates = [
|
||||
("Binance", 50000.0),
|
||||
("Coinbase", 50100.0),
|
||||
("Kraken", 50200.0),
|
||||
]
|
||||
|
||||
result = apply_trimmed_mean_filter(rates, threshold_percentage=0.01)
|
||||
|
||||
# Should keep all rates
|
||||
assert len(result) == 3
|
||||
assert result == rates
|
||||
|
||||
def test_trimmed_mean_filter_insufficient_data(self):
|
||||
"""Test filtering returns original data when less than 3 rates"""
|
||||
rates = [
|
||||
("Binance", 50000.0),
|
||||
("Coinbase", 51000.0),
|
||||
]
|
||||
|
||||
result = apply_trimmed_mean_filter(rates, threshold_percentage=0.01)
|
||||
|
||||
# Should return original rates unchanged
|
||||
assert result == rates
|
||||
|
||||
def test_trimmed_mean_filter_single_rate(self):
|
||||
"""Test filtering with single rate"""
|
||||
rates = [("Binance", 50000.0)]
|
||||
|
||||
result = apply_trimmed_mean_filter(rates, threshold_percentage=0.01)
|
||||
|
||||
# Should return original rate unchanged
|
||||
assert result == rates
|
||||
|
||||
def test_trimmed_mean_filter_empty_list(self):
|
||||
"""Test filtering with empty list"""
|
||||
rates = []
|
||||
|
||||
result = apply_trimmed_mean_filter(rates, threshold_percentage=0.01)
|
||||
|
||||
# Should return empty list
|
||||
assert result == []
|
||||
|
||||
def test_trimmed_mean_filter_too_many_outliers(self):
|
||||
"""Test fallback to median when filtering removes too many values"""
|
||||
rates = [
|
||||
("Provider1", 50000.0),
|
||||
("Provider2", 60000.0), # 20% higher
|
||||
("Provider3", 40000.0), # 20% lower
|
||||
]
|
||||
|
||||
result = apply_trimmed_mean_filter(rates, threshold_percentage=0.01)
|
||||
|
||||
# Should fall back to rate closest to median (Provider1)
|
||||
assert len(result) == 1
|
||||
assert result[0] == ("Provider1", 50000.0)
|
||||
|
||||
def test_trimmed_mean_filter_different_thresholds(self):
|
||||
"""Test filtering with different threshold percentages"""
|
||||
rates = [
|
||||
("Binance", 50000.0),
|
||||
("Coinbase", 51000.0),
|
||||
("Kraken", 53000.0),
|
||||
("Outlier", 55000.0),
|
||||
]
|
||||
|
||||
# For the values, the average is 52250
|
||||
# 1% either side of the average is 51727.50 and 52772.50
|
||||
# This would result in three rates being removed (Binance, Kraken and Outlier)
|
||||
result_1pct = apply_trimmed_mean_filter(rates, threshold_percentage=0.01)
|
||||
|
||||
assert len(result_1pct) == 1
|
||||
assert ("Binance", 50000.0) not in result_1pct
|
||||
assert ("Coinbase", 51000.0) in result_1pct
|
||||
assert ("Kraken", 53000.0) not in result_1pct
|
||||
assert ("Outlier", 55000.0) not in result_1pct
|
||||
|
||||
# With 5% threshold, should keep just three
|
||||
result_5pct = apply_trimmed_mean_filter(rates, threshold_percentage=0.05)
|
||||
assert len(result_5pct) == 3
|
||||
assert ("Binance", 50000.0) in result_5pct
|
||||
assert ("Coinbase", 51000.0) in result_5pct
|
||||
assert ("Kraken", 53000.0) in result_5pct
|
||||
assert ("Outlier", 55000.0) not in result_5pct
|
||||
|
||||
def test_trimmed_mean_filter_edge_case_exact_threshold(self):
|
||||
"""Test filtering with rates exactly at the threshold"""
|
||||
rates = [
|
||||
("Binance", 50000.0),
|
||||
("Coinbase", 50500.0), # Exactly 1% higher
|
||||
]
|
||||
|
||||
result = apply_trimmed_mean_filter(rates, threshold_percentage=0.01)
|
||||
|
||||
# Should keep the rate at exactly 1% deviation
|
||||
assert len(result) == 2
|
||||
assert result == rates
|
||||
@@ -0,0 +1,84 @@
|
||||
import pytest
|
||||
|
||||
from lnbits.helpers import check_callback_url
|
||||
from lnbits.settings import Settings
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
def test_check_callback_url_not_allowed(settings: Settings):
|
||||
settings.lnbits_callback_url_rules = [
|
||||
"https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"
|
||||
]
|
||||
with pytest.raises(ValueError, match="Callback not allowed. URL: xx. Netloc: ."):
|
||||
check_callback_url("xx")
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Callback not allowed. URL: http://localhost:3000/callback. "
|
||||
"Netloc: localhost:3000. Please check your admin settings.",
|
||||
):
|
||||
check_callback_url("http://localhost:3000/callback")
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Callback not allowed. URL: https://localhost:3000/callback. "
|
||||
"Netloc: localhost:3000. Please check your admin settings.",
|
||||
):
|
||||
check_callback_url("https://localhost:3000/callback")
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Callback not allowed. URL: http://192.168.2.2:3000/callback. "
|
||||
"Netloc: 192.168.2.2:3000. Please check your admin settings.",
|
||||
):
|
||||
check_callback_url("http://192.168.2.2:3000/callback")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
def test_check_callback_url_no_rules(settings: Settings):
|
||||
settings.lnbits_callback_url_rules = [
|
||||
"https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"
|
||||
]
|
||||
settings.lnbits_callback_url_rules.append(".*")
|
||||
check_callback_url("xyz")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
def test_check_callback_url_allow_all(settings: Settings):
|
||||
settings.lnbits_callback_url_rules = []
|
||||
check_callback_url("xyz")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
def test_check_callback_url_allowed(settings: Settings):
|
||||
settings.lnbits_callback_url_rules = [
|
||||
"https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"
|
||||
]
|
||||
check_callback_url("http://google.com/callback")
|
||||
check_callback_url("http://google.com:80/callback")
|
||||
check_callback_url("http://google.com:8080/callback")
|
||||
check_callback_url("https://google.com/callback")
|
||||
check_callback_url("https://google.com:443/callback")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
def test_check_callback_url_multiple_rules(settings: Settings):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Callback not allowed. URL: http://localhost:3000/callback. "
|
||||
"Netloc: localhost:3000. Please check your admin settings.",
|
||||
):
|
||||
check_callback_url("http://localhost:3000/callback")
|
||||
|
||||
settings.lnbits_callback_url_rules.append("http://localhost:3000")
|
||||
check_callback_url("http://localhost:3000/callback") # should not raise
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Callback not allowed. URL: https://localhost:3000/callback. "
|
||||
"Netloc: localhost:3000. Please check your admin settings.",
|
||||
):
|
||||
check_callback_url("https://localhost:3000/callback")
|
||||
|
||||
settings.lnbits_callback_url_rules.append("https://localhost:3000")
|
||||
check_callback_url("https://localhost:3000/callback") # should not raise
|
||||
Reference in New Issue
Block a user