Compare commits
51
Commits
v1.0.0
..
v1.2.0-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9aeecd4dc0 | ||
|
|
e750165cc3 | ||
|
|
77906bc817 | ||
|
|
63e728710d | ||
|
|
27fd510142 | ||
|
|
e6de66e1b1 | ||
|
|
4071925f65 | ||
|
|
3c4d186dba | ||
|
|
56aebb9d8f | ||
|
|
beee24bd92 | ||
|
|
375b95c004 | ||
|
|
5345ccaf4e | ||
|
|
efc5233399 | ||
|
|
e4d09c6d12 | ||
|
|
7d0545dae1 | ||
|
|
00fccf513e | ||
|
|
34b8490a2d | ||
|
|
cbbba5c4c7 | ||
|
|
4a0ef7fa1a | ||
|
|
cd8804daca | ||
|
|
39e4fa724a | ||
|
|
3b350858c7 | ||
|
|
7bea591879 | ||
|
|
f74fcea35b | ||
|
|
ceb8203353 | ||
|
|
7b29223e95 | ||
|
|
bd19d78834 | ||
|
|
8458b4d84b | ||
|
|
f92ae8bae1 | ||
|
|
3529f9152f | ||
|
|
6a9089fd98 | ||
|
|
5818c3c2ba | ||
|
|
a38de94e49 | ||
|
|
b4801ce0ab | ||
|
|
51bf344d65 | ||
|
|
32cbf16d91 | ||
|
|
c4d0540e76 | ||
|
|
f8b3644029 | ||
|
|
c2fb45d640 | ||
|
|
e339bb6181 | ||
|
|
2dee26b728 | ||
|
|
d774c7a742 | ||
|
|
6c2b312c92 | ||
|
|
56c8783d9a | ||
|
|
ffecd03c4b | ||
|
|
94d5f37723 | ||
|
|
d89bd7409d | ||
|
|
b48489ef32 | ||
|
|
e686ecda51 | ||
|
|
23b4c2cc16 | ||
|
|
1bb29cf0b1 |
+9
-3
@@ -40,7 +40,7 @@ PORT=5000
|
||||
######################################
|
||||
|
||||
# which fundingsources are allowed in the admin ui
|
||||
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet"
|
||||
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet"
|
||||
|
||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||
@@ -105,6 +105,10 @@ BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING
|
||||
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
|
||||
BOLTZ_CLIENT_WALLET="lnbits"
|
||||
|
||||
# StrikeWallet
|
||||
STRIKE_API_ENDPOINT=https://api.strike.me/v1
|
||||
STRIKE_API_KEY=YOUR_STRIKE_API_KEY
|
||||
|
||||
# ZBDWallet
|
||||
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
|
||||
ZBD_API_KEY=ZBD_ACCESS_TOKEN
|
||||
@@ -163,14 +167,16 @@ GITHUB_CLIENT_SECRET=""
|
||||
KEYCLOAK_CLIENT_ID=""
|
||||
KEYCLOAK_CLIENT_SECRET=""
|
||||
KEYCLOAK_DISCOVERY_URL=""
|
||||
KEYCLOAK_CLIENT_CUSTOM_ORG=""
|
||||
KEYCLOAK_CLIENT_CUSTOM_ICON=""
|
||||
|
||||
|
||||
######################################
|
||||
|
||||
# uvicorn variable, uncomment to allow https behind a proxy
|
||||
# uvicorn variable, allow https behind a proxy
|
||||
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
||||
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
|
||||
# FORWARDED_ALLOW_IPS="*"
|
||||
FORWARDED_ALLOW_IPS="*"
|
||||
|
||||
# Server security, rate limiting ips, blocked ips, allowed ips
|
||||
LNBITS_RATE_LIMIT_NO="200"
|
||||
|
||||
@@ -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"
|
||||
|
||||
+5
-3
@@ -19,7 +19,8 @@ ENV POETRY_NO_INTERACTION=1 \
|
||||
POETRY_VIRTUALENVS_CREATE=1 \
|
||||
POETRY_CACHE_DIR=/tmp/poetry_cache
|
||||
|
||||
RUN poetry install --only main
|
||||
ARG POETRY_INSTALL_ARGS="--only main"
|
||||
RUN poetry install ${POETRY_INSTALL_ARGS}
|
||||
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
@@ -46,11 +47,12 @@ WORKDIR /app
|
||||
COPY . .
|
||||
COPY --from=builder /app/.venv .venv
|
||||
|
||||
RUN poetry install --only main
|
||||
ARG POETRY_INSTALL_ARGS="--only main"
|
||||
RUN poetry install ${POETRY_INSTALL_ARGS}
|
||||
|
||||
ENV LNBITS_PORT="5000"
|
||||
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 @@
|
||||
# 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.
|
||||
@@ -78,11 +78,24 @@ poetry run lnbits-cli --help
|
||||
|
||||
```sh
|
||||
cd lnbits
|
||||
# Stop LNbits with `ctrl + x`
|
||||
git pull
|
||||
# Keep your poetry install up to date, this can be done with `poetry self update`
|
||||
# Stop LNbits with `ctrl + x` or with service manager
|
||||
# sudo systemctl stop lnbits
|
||||
|
||||
# Update LNbits
|
||||
git pull --rebase
|
||||
|
||||
# Check your poetry version with
|
||||
poetry env list
|
||||
# If version is less 3.12, update it by running
|
||||
poetry env use python3.12
|
||||
poetry env remove python3.9
|
||||
poetry env list
|
||||
|
||||
# Run install and start LNbits with
|
||||
poetry install --only main
|
||||
# Start LNbits with `poetry run lnbits`
|
||||
poetry run lnbits
|
||||
|
||||
# use LNbits admin UI Extensions page function "Update All" do get extensions onto proper level
|
||||
```
|
||||
|
||||
## Option 2: Install script (on Debian/Ubuntu)
|
||||
@@ -137,7 +150,7 @@ SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/ln
|
||||
|
||||
## Option 4: Docker
|
||||
|
||||
use latest version from docker hub
|
||||
Use latest version from Docker Hub.
|
||||
|
||||
```sh
|
||||
docker pull lnbits/lnbits
|
||||
@@ -146,7 +159,7 @@ mkdir data
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
||||
```
|
||||
|
||||
build the image yourself
|
||||
Build the image yourself.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
@@ -157,6 +170,12 @@ mkdir data
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
|
||||
```
|
||||
|
||||
You can optionally override the arguments that are passed to `poetry install` during the build process by setting the Docker build argument named `POETRY_INSTALL_ARGS`. For example, to enable the Breez funding source, build the Docker image with the command:
|
||||
|
||||
```sh
|
||||
docker build --build-arg POETRY_INSTALL_ARGS="-E breez" -t lnbits/lnbits .
|
||||
```
|
||||
|
||||
## Option 5: Fly.io
|
||||
|
||||
Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use.
|
||||
@@ -564,3 +583,12 @@ docker run --detach --publish 5000:5000 --name lnbits -e "LNBITS_BACKEND_WALLET_
|
||||
```
|
||||
|
||||
Finally you can access your lnbits on your machine at port 5000.
|
||||
|
||||
### FreeBSD notes
|
||||
|
||||
Currently there is an issue with secp256k1 0.14.0 on FreeBSD. Thanks to @GitKalle
|
||||
|
||||
1. Install package `py311-secp256k1` with `pkg install py311-secp256k1`
|
||||
2. Change version in `pyproject.toml` from 0.14.0 to 0.13.2
|
||||
3. Rewrite `poetry.lock` file with command `poetry lock`
|
||||
4. Follow install instruction with Poetry
|
||||
|
||||
@@ -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**
|
||||
@@ -51,7 +53,7 @@ You can also use an AES-encrypted macaroon (more info) instead by using
|
||||
|
||||
- `LND_GRPC_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
|
||||
|
||||
To encrypt your macaroon, run `poetry run python lnbits/wallets/macaroon/macaroon.py`.
|
||||
To encrypt your macaroon, run `poetry run lnbits-cli encrypt macaroon`.
|
||||
|
||||
### LNbits
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import importlib
|
||||
import sys
|
||||
import time
|
||||
from functools import wraps
|
||||
from getpass import getpass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
@@ -40,7 +41,9 @@ from lnbits.core.views.extension_api import (
|
||||
api_uninstall_extension,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import AESCipher
|
||||
from lnbits.wallets.base import Wallet
|
||||
from lnbits.wallets.macaroon import load_macaroon
|
||||
|
||||
|
||||
def coro(f):
|
||||
@@ -79,6 +82,20 @@ def extensions():
|
||||
"""
|
||||
|
||||
|
||||
@lnbits_cli.group()
|
||||
def encrypt():
|
||||
"""
|
||||
Encryption commands
|
||||
"""
|
||||
|
||||
|
||||
@lnbits_cli.group()
|
||||
def decrypt():
|
||||
"""
|
||||
Decryption commands
|
||||
"""
|
||||
|
||||
|
||||
def get_super_user() -> Optional[str]:
|
||||
"""Get the superuser"""
|
||||
superuser_file = Path(settings.lnbits_data_folder, ".super_user")
|
||||
@@ -479,6 +496,62 @@ async def extensions_uninstall(
|
||||
return False, str(ex)
|
||||
|
||||
|
||||
@encrypt.command("macaroon")
|
||||
def encrypt_macaroon():
|
||||
"""Encrypts a macaroon (LND wallets)"""
|
||||
_macaroon = getpass("Enter macaroon: ")
|
||||
try:
|
||||
macaroon = load_macaroon(_macaroon)
|
||||
except Exception as ex:
|
||||
click.echo(f"Error loading macaroon: {ex}")
|
||||
return
|
||||
key = getpass("Enter encryption key: ")
|
||||
aes = AESCipher(key.encode())
|
||||
try:
|
||||
encrypted_macaroon = aes.encrypt(bytes.fromhex(macaroon))
|
||||
except Exception as ex:
|
||||
click.echo(f"Error encrypting macaroon: {ex}")
|
||||
return
|
||||
click.echo("Encrypted macaroon: ")
|
||||
click.echo(encrypted_macaroon)
|
||||
|
||||
|
||||
@encrypt.command("aes")
|
||||
@click.option("-p", "--payload", required=True, help="Payload to encrypt.")
|
||||
@click.option(
|
||||
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64encode."
|
||||
)
|
||||
def encrypt_aes(payload: str, urlsafe: bool = False):
|
||||
"""AES encrypts a payload"""
|
||||
key = getpass("Enter encryption key: ")
|
||||
aes = AESCipher(key.encode())
|
||||
try:
|
||||
encrypted = aes.encrypt(payload.encode(), urlsafe=urlsafe)
|
||||
except Exception as ex:
|
||||
click.echo(f"Error encrypting payload: {ex}")
|
||||
return
|
||||
click.echo("Encrypted payload: ")
|
||||
click.echo(encrypted)
|
||||
|
||||
|
||||
@decrypt.command("aes")
|
||||
@click.option("-p", "--payload", required=True, help="Payload to decrypt.")
|
||||
@click.option(
|
||||
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64decode."
|
||||
)
|
||||
def decrypt_aes(payload: str, urlsafe: bool = False):
|
||||
"""AES decrypts a payload"""
|
||||
key = getpass("Enter encryption key: ")
|
||||
aes = AESCipher(key.encode())
|
||||
try:
|
||||
decrypted = aes.decrypt(payload, urlsafe=urlsafe)
|
||||
except Exception as ex:
|
||||
click.echo(f"Error decrypting payload: {ex}")
|
||||
return
|
||||
click.echo("Decrypted payload: ")
|
||||
click.echo(decrypted)
|
||||
|
||||
|
||||
def main():
|
||||
"""main function"""
|
||||
lnbits_cli()
|
||||
|
||||
@@ -43,6 +43,7 @@ from .settings import (
|
||||
delete_admin_settings,
|
||||
get_admin_settings,
|
||||
get_super_settings,
|
||||
reset_core_settings,
|
||||
update_admin_settings,
|
||||
update_super_user,
|
||||
)
|
||||
@@ -129,6 +130,7 @@ __all__ = [
|
||||
"get_super_settings",
|
||||
"update_admin_settings",
|
||||
"update_super_user",
|
||||
"reset_core_settings",
|
||||
# tinyurl
|
||||
"create_tinyurl",
|
||||
"delete_tinyurl",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from time import time
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from lnbits.core.crud.wallets import get_total_balance, get_wallet
|
||||
from lnbits.core.crud.wallets import get_total_balance, get_wallet, get_wallets_ids
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import PaymentState
|
||||
from lnbits.db import Connection, DateTrunc, Filters, Page
|
||||
@@ -95,6 +95,7 @@ async def get_latest_payments_by_extension(
|
||||
async def get_payments_paginated(
|
||||
*,
|
||||
wallet_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
complete: bool = False,
|
||||
pending: bool = False,
|
||||
failed: bool = False,
|
||||
@@ -121,6 +122,9 @@ async def get_payments_paginated(
|
||||
if wallet_id:
|
||||
values["wallet_id"] = wallet_id
|
||||
clause.append("wallet_id = :wallet_id")
|
||||
elif user_id:
|
||||
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
|
||||
clause.append(only_user_wallets)
|
||||
|
||||
if complete and pending:
|
||||
clause.append(
|
||||
@@ -358,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
|
||||
@@ -381,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,
|
||||
@@ -423,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]:
|
||||
|
||||
@@ -441,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(
|
||||
@@ -508,7 +532,7 @@ async def is_internal_status_success(
|
||||
return payment.status == PaymentState.SUCCESS.value
|
||||
|
||||
|
||||
async def mark_webhook_sent(payment_hash: str, status: int) -> None:
|
||||
async def mark_webhook_sent(payment_hash: str, status: str) -> None:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET webhook_status = :status
|
||||
@@ -516,3 +540,14 @@ async def mark_webhook_sent(payment_hash: str, status: int) -> 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)}) """
|
||||
|
||||
@@ -61,7 +61,23 @@ async def update_super_user(super_user: str) -> SuperSettings:
|
||||
|
||||
|
||||
async def delete_admin_settings(tag: Optional[str] = "core") -> None:
|
||||
await db.execute("DELETE FROM settings WHERE tag = :tag", {"tag": tag})
|
||||
await db.execute(
|
||||
"DELETE FROM system_settings WHERE tag = :tag",
|
||||
{"tag": tag},
|
||||
)
|
||||
|
||||
|
||||
async def reset_core_settings() -> None:
|
||||
await db.execute(
|
||||
"""
|
||||
DELETE FROM system_settings WHERE tag = 'core'
|
||||
AND id NOT IN (
|
||||
'super_user',
|
||||
'lnbits_webpush_pubkey',
|
||||
'lnbits_webpush_privkey'
|
||||
)
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
|
||||
|
||||
@@ -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,43 @@ 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]:
|
||||
where = "AND deleted = :deleted" if deleted is not None else ""
|
||||
result: list[dict] = await (conn or db).fetchall(
|
||||
f"""
|
||||
SELECT id FROM wallets
|
||||
WHERE "user" = :user {where}
|
||||
""",
|
||||
{"user": user_id, "deleted": deleted},
|
||||
)
|
||||
return [row["id"] for row in result]
|
||||
|
||||
|
||||
async def get_wallets_count():
|
||||
result = await db.execute("SELECT COUNT(*) as count FROM wallets")
|
||||
row = result.mappings().first()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
import bcrypt
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@@ -9,6 +11,15 @@ 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
|
||||
|
||||
@@ -64,7 +64,7 @@ class Payment(BaseModel):
|
||||
memo: str | None = None
|
||||
expiry: datetime | None = None
|
||||
webhook: str | None = None
|
||||
webhook_status: int | None = None
|
||||
webhook_status: str | None = None
|
||||
preimage: str | None = None
|
||||
tag: str | None = None
|
||||
extension: str | None = None
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -205,24 +205,23 @@ async def dispatch_webhook(payment: Payment):
|
||||
logger.debug("sending webhook", payment.webhook)
|
||||
|
||||
if not payment.webhook:
|
||||
return await mark_webhook_sent(payment.payment_hash, -1)
|
||||
return await mark_webhook_sent(payment.payment_hash, "-1")
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
data = payment.dict()
|
||||
try:
|
||||
check_callback_url(payment.webhook)
|
||||
r = await client.post(payment.webhook, json=data, timeout=40)
|
||||
r = await client.post(payment.webhook, json=payment.json(), timeout=40)
|
||||
r.raise_for_status()
|
||||
await mark_webhook_sent(payment.payment_hash, r.status_code)
|
||||
await mark_webhook_sent(payment.payment_hash, str(r.status_code))
|
||||
except httpx.HTTPStatusError as exc:
|
||||
await mark_webhook_sent(payment.payment_hash, exc.response.status_code)
|
||||
await mark_webhook_sent(payment.payment_hash, str(exc.response.status_code))
|
||||
logger.warning(
|
||||
f"webhook returned a bad status_code: {exc.response.status_code} "
|
||||
f"while requesting {exc.request.url!r}."
|
||||
)
|
||||
except httpx.RequestError:
|
||||
await mark_webhook_sent(payment.payment_hash, -1)
|
||||
await mark_webhook_sent(payment.payment_hash, "-1")
|
||||
logger.warning(f"Could not send webhook to {payment.webhook}")
|
||||
|
||||
|
||||
@@ -230,18 +229,21 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
|
||||
try:
|
||||
await send_ws_payment_notification(wallet, payment)
|
||||
except Exception as e:
|
||||
logger.error("Error sending websocket payment notification", e)
|
||||
logger.error(f"Error sending websocket payment notification {e!s}")
|
||||
try:
|
||||
send_chat_payment_notification(wallet, payment)
|
||||
except Exception as e:
|
||||
logger.error("Error sending chat payment notification", e)
|
||||
logger.error(f"Error sending chat payment notification {e!s}")
|
||||
try:
|
||||
await send_payment_push_notification(wallet, payment)
|
||||
except Exception as e:
|
||||
logger.error("Error sending push payment notification", e)
|
||||
logger.error(f"Error sending push payment notification {e!s}")
|
||||
|
||||
if payment.webhook and not payment.webhook_status:
|
||||
await dispatch_webhook(payment)
|
||||
try:
|
||||
if payment.webhook and not payment.webhook_status:
|
||||
await dispatch_webhook(payment)
|
||||
except Exception as e:
|
||||
logger.error(f"Error dispatching webhook: {e!s}")
|
||||
|
||||
|
||||
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
|
||||
|
||||
@@ -44,6 +44,9 @@ from ..models import (
|
||||
)
|
||||
from .notifications import send_payment_notification
|
||||
|
||||
payment_lock = asyncio.Lock()
|
||||
wallets_payments_lock: dict[str, asyncio.Lock] = {}
|
||||
|
||||
|
||||
async def pay_invoice(
|
||||
*,
|
||||
@@ -79,12 +82,16 @@ async def pay_invoice(
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
payment = await _pay_invoice(wallet, create_payment_model, conn)
|
||||
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
|
||||
return payment
|
||||
|
||||
|
||||
async def create_invoice(
|
||||
@@ -132,29 +139,29 @@ async def create_invoice(
|
||||
status="failed",
|
||||
)
|
||||
|
||||
(
|
||||
ok,
|
||||
checking_id,
|
||||
payment_request,
|
||||
error_message,
|
||||
) = await funding_source.create_invoice(
|
||||
payment_response = await funding_source.create_invoice(
|
||||
amount=amount_sat,
|
||||
memo=invoice_memo,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=expiry or settings.lightning_invoice_expiry,
|
||||
)
|
||||
if not ok or not payment_request or not checking_id:
|
||||
if (
|
||||
not payment_response.ok
|
||||
or not payment_response.payment_request
|
||||
or not payment_response.checking_id
|
||||
):
|
||||
raise InvoiceError(
|
||||
error_message or "unexpected backend error.", status="pending"
|
||||
message=payment_response.error_message or "unexpected backend error.",
|
||||
status="pending",
|
||||
)
|
||||
|
||||
invoice = bolt11_decode(payment_request)
|
||||
invoice = bolt11_decode(payment_response.payment_request)
|
||||
|
||||
create_payment_model = CreatePayment(
|
||||
wallet_id=wallet_id,
|
||||
bolt11=payment_request,
|
||||
bolt11=payment_response.payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
preimage=payment_response.preimage,
|
||||
amount_msat=amount_sat * 1000,
|
||||
expiry=invoice.expiry_date,
|
||||
memo=memo,
|
||||
@@ -163,7 +170,7 @@ async def create_invoice(
|
||||
)
|
||||
|
||||
payment = await create_payment(
|
||||
checking_id=checking_id,
|
||||
checking_id=payment_response.checking_id,
|
||||
data=create_payment_model,
|
||||
conn=conn,
|
||||
)
|
||||
@@ -199,6 +206,7 @@ def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
|
||||
|
||||
|
||||
def fee_reserve(amount_msat: int, internal: bool = False) -> int:
|
||||
amount_msat = abs(amount_msat)
|
||||
return settings.fee_reserve(amount_msat, internal)
|
||||
|
||||
|
||||
@@ -395,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))
|
||||
@@ -409,13 +418,16 @@ async def get_payments_daily_stats(
|
||||
|
||||
data: list[PaymentDailyStats] = []
|
||||
|
||||
start_date = min(data_in[0].date, data_out[0].date)
|
||||
end_date = max(data_in[-1].date, data_out[-1].date)
|
||||
def _tz(dt: datetime) -> datetime:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
start_date = min(_tz(data_in[0].date), _tz(data_out[0].date))
|
||||
end_date = max(_tz(data_in[-1].date), _tz(data_out[-1].date))
|
||||
delta = timedelta(days=1)
|
||||
while start_date <= end_date:
|
||||
|
||||
data_in_point = next((x for x in data_in if x.date == start_date), _none)
|
||||
data_out_point = next((x for x in data_out if x.date == start_date), _none)
|
||||
data_in_point = next((x for x in data_in if _tz(x.date) == start_date), _none)
|
||||
data_out_point = next((x for x in data_out if _tz(x.date) == start_date), _none)
|
||||
|
||||
balance_total += data_in_point.balance + data_out_point.balance
|
||||
data.append(
|
||||
@@ -437,11 +449,27 @@ async def get_payments_daily_stats(
|
||||
return data
|
||||
|
||||
|
||||
async def _pay_invoice(wallet, create_payment_model, conn):
|
||||
payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
|
||||
if not payment:
|
||||
payment = await _pay_external_invoice(wallet, create_payment_model, conn)
|
||||
return payment
|
||||
async def _pay_invoice(
|
||||
wallet_id: str,
|
||||
create_payment_model: CreatePayment,
|
||||
conn: Optional[Connection] = None,
|
||||
):
|
||||
async with payment_lock:
|
||||
if wallet_id not in wallets_payments_lock:
|
||||
wallets_payments_lock[wallet_id] = asyncio.Lock()
|
||||
|
||||
async with wallets_payments_lock[wallet_id]:
|
||||
# get the wallet again to make sure we have the latest balance
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
if not wallet:
|
||||
raise PaymentError(
|
||||
f"Could not fetch wallet '{wallet_id}'.", status="failed"
|
||||
)
|
||||
|
||||
payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
|
||||
if not payment:
|
||||
payment = await _pay_external_invoice(wallet, create_payment_model, conn)
|
||||
return payment
|
||||
|
||||
|
||||
async def _pay_internal_invoice(
|
||||
@@ -476,12 +504,15 @@ async def _pay_internal_invoice(
|
||||
):
|
||||
raise PaymentError("Invalid invoice. Bolt11 changed.", status="failed")
|
||||
|
||||
fee_reserve_total_msat = fee_reserve_total(abs(amount_msat), internal=True)
|
||||
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=True)
|
||||
create_payment_model.fee = abs(fee_reserve_total_msat)
|
||||
|
||||
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
|
||||
raise PaymentError("Insufficient balance.", status="failed")
|
||||
|
||||
# release the preimage
|
||||
create_payment_model.preimage = internal_invoice.preimage
|
||||
|
||||
internal_id = f"internal_{create_payment_model.payment_hash}"
|
||||
logger.debug(f"creating temporary internal payment with id {internal_id}")
|
||||
payment = await create_payment(
|
||||
@@ -519,6 +550,8 @@ async def _pay_external_invoice(
|
||||
|
||||
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
|
||||
|
||||
if wallet.balance_msat < abs(amount_msat):
|
||||
raise PaymentError("Insufficient balance.", status="failed")
|
||||
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
|
||||
raise PaymentError(
|
||||
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
|
||||
@@ -676,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:
|
||||
@@ -687,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -59,9 +59,6 @@
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
const ALLOWED_REGISTER = {{ LNBITS_NEW_ACCOUNTS_ALLOWED | tojson }};
|
||||
</script>
|
||||
<script src="{{ static_url_for('static', 'js/index.js') }}"></script>
|
||||
{% endblock %} {% block page_container %}
|
||||
<q-page-container>
|
||||
@@ -151,7 +148,7 @@
|
||||
<username-password
|
||||
v-if="authMethod != 'user-id-only'"
|
||||
:allowed_new_users="allowedRegister"
|
||||
:auth-methods="{{ LNBITS_AUTH_METHODS }}"
|
||||
:auth-methods="LNBITS_AUTH_METHODS"
|
||||
:auth-action="authAction"
|
||||
v-model:user-name="username"
|
||||
v-model:password_1="password"
|
||||
@@ -182,12 +179,11 @@
|
||||
>
|
||||
<span v-text="$t('new_user_not_allowed')"></span>
|
||||
</p>
|
||||
|
||||
<p v-else-if="authAction === 'register'" class="q-mb-none">
|
||||
<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>
|
||||
@@ -252,7 +248,7 @@
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
{% if AD_SPACE %}
|
||||
{% if AD_SPACE_ENABLED and AD_SPACE %}
|
||||
<div class="q-pt-md full-width">
|
||||
<div class="row justify-center q-mb-xl">
|
||||
<div class="full-width text-center">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block scripts %} {{ window_vars(user, wallet) }}{% endblock %} {% block page
|
||||
%}
|
||||
<div class="row q-col-gutter-md">
|
||||
{% if HIDE_API and AD_SPACE %}
|
||||
{% if HIDE_API and AD_SPACE_ENABLED and AD_SPACE %}
|
||||
<div class="col-12 col-md-8 q-gutter-y-md">
|
||||
{% elif HIDE_API %}
|
||||
<div class="col-12 q-gutter-y-md">
|
||||
@@ -31,7 +31,7 @@
|
||||
<q-card-section style="height: 130px">
|
||||
<div class="row q-gutter-md">
|
||||
<div
|
||||
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats'"
|
||||
v-if="isSatsDenomination"
|
||||
class="col-1"
|
||||
style="max-width: 30px"
|
||||
>
|
||||
@@ -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">
|
||||
@@ -468,7 +485,7 @@
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
{% endif %} {% if AD_SPACE %}
|
||||
{% endif %} {% if AD_SPACE_ENABLED and AD_SPACE %}
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
|
||||
@@ -639,7 +656,7 @@
|
||||
>
|
||||
<span
|
||||
v-if="receive.lnurl"
|
||||
v-text="$t('withdraw_from') + receive.lnurl.domain"
|
||||
v-text="`${$t('withdraw_from')} ${receive.lnurl.domain}`"
|
||||
></span>
|
||||
<span v-else v-text="$t('create_invoice')"></span>
|
||||
</q-btn>
|
||||
@@ -961,8 +978,8 @@
|
||||
dense
|
||||
v-model.number="parse.data.amount"
|
||||
:label="$t('amount') + ' (' + parse.data.unit + ') *'"
|
||||
:mask="parse.data.unit != 'sat' ? '#.##' : '#'"
|
||||
:step="parse.data.unit != 'sat' ? '0.01' : '1'"
|
||||
:mask="parse.data.unit == 'sat' ? '#' : ''"
|
||||
:step="parse.data.unit == 'sat' ? '1': '0.01'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:min="parse.lnurlpay.minSendable / 1000"
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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">
|
||||
|
||||
@@ -29,7 +29,7 @@ from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
|
||||
from lnbits.tasks import invoice_listeners
|
||||
|
||||
from .. import core_app_extra
|
||||
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
|
||||
from ..crud import get_admin_settings, reset_core_settings, update_admin_settings
|
||||
|
||||
admin_router = APIRouter(tags=["Admin UI"], prefix="/admin")
|
||||
file_upload = File(...)
|
||||
@@ -113,7 +113,7 @@ async def api_reset_settings(field_name: str):
|
||||
@admin_router.delete("/api/v1/settings", status_code=HTTPStatus.OK)
|
||||
async def api_delete_settings(user: User = Depends(check_super_user)) -> None:
|
||||
enqueue_notification(NotificationType.settings_update, {"username": user.username})
|
||||
await delete_admin_settings()
|
||||
await reset_core_settings()
|
||||
server_restart.set()
|
||||
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ async def get_auth_user(user: User = Depends(check_user_exists)) -> User:
|
||||
async def login(data: LoginUsernamePassword) -> JSONResponse:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED, "Login by 'Username and Password' not allowed."
|
||||
HTTPStatus.FORBIDDEN, "Login by 'Username and Password' not allowed."
|
||||
)
|
||||
account = await get_account_by_username_or_email(data.username)
|
||||
if not account or not account.verify_password(data.password):
|
||||
@@ -85,9 +85,7 @@ async def login(data: LoginUsernamePassword) -> JSONResponse:
|
||||
@auth_router.post("/nostr", description="Login via Nostr")
|
||||
async def nostr_login(request: Request) -> JSONResponse:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED, "Login with Nostr Auth not allowed."
|
||||
)
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Login with Nostr Auth not allowed.")
|
||||
event = _nostr_nip98_event(request)
|
||||
account = await get_account_by_pubkey(event["pubkey"])
|
||||
if not account:
|
||||
@@ -104,7 +102,7 @@ async def nostr_login(request: Request) -> JSONResponse:
|
||||
async def login_usr(data: LoginUsr) -> JSONResponse:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED,
|
||||
HTTPStatus.FORBIDDEN,
|
||||
"Login by 'User ID' not allowed.",
|
||||
)
|
||||
account = await get_account(data.usr)
|
||||
@@ -112,7 +110,7 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User ID does not exist.")
|
||||
if account.is_admin:
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED, "Admin users cannot login with user id only."
|
||||
HTTPStatus.FORBIDDEN, "Admin users cannot login with user id only."
|
||||
)
|
||||
return _auth_success_response(account.username, account.id, account.email)
|
||||
|
||||
@@ -242,7 +240,7 @@ async def login_with_sso_provider(
|
||||
provider_sso = _new_sso(provider)
|
||||
if not provider_sso:
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED,
|
||||
HTTPStatus.FORBIDDEN,
|
||||
f"Login by '{provider}' not allowed.",
|
||||
)
|
||||
|
||||
@@ -257,7 +255,7 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons
|
||||
provider_sso = _new_sso(provider)
|
||||
if not provider_sso:
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED,
|
||||
HTTPStatus.FORBIDDEN,
|
||||
f"Login by '{provider}' not allowed.",
|
||||
)
|
||||
|
||||
@@ -285,7 +283,7 @@ async def logout() -> JSONResponse:
|
||||
async def register(data: RegisterUser) -> JSONResponse:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED,
|
||||
HTTPStatus.FORBIDDEN,
|
||||
"Register by 'Username and Password' not allowed.",
|
||||
)
|
||||
|
||||
@@ -375,7 +373,7 @@ async def update_password(
|
||||
async def reset_password(data: ResetUserPassword) -> JSONResponse:
|
||||
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED, "Auth by 'Username and Password' not allowed."
|
||||
HTTPStatus.FORBIDDEN, "Auth by 'Username and Password' not allowed."
|
||||
)
|
||||
|
||||
assert data.password == data.password_repeat, "Passwords do not match."
|
||||
@@ -449,7 +447,7 @@ async def update(
|
||||
@auth_router.put("/first_install")
|
||||
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
||||
if not settings.first_install:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "This is not your first install")
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "This is not your first install")
|
||||
account = await get_account(settings.super_user)
|
||||
if not account:
|
||||
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.")
|
||||
@@ -472,10 +470,10 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] =
|
||||
|
||||
if verified_user_id:
|
||||
if account:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Email already used.")
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Email already used.")
|
||||
account = await get_account(verified_user_id)
|
||||
if not account:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Cannot verify user email.")
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Cannot verify user email.")
|
||||
redirect_path = "/account"
|
||||
|
||||
if account:
|
||||
@@ -499,9 +497,12 @@ def _auth_success_response(
|
||||
sub=username or "", usr=user_id, email=email, auth_time=int(time())
|
||||
)
|
||||
access_token = create_access_token(data=payload.dict())
|
||||
max_age = settings.auth_token_expire_minutes * 60
|
||||
response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
|
||||
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
||||
response.set_cookie("is_lnbits_user_authorized", "true")
|
||||
response.set_cookie(
|
||||
"cookie_access_token", access_token, httponly=True, max_age=max_age
|
||||
)
|
||||
response.set_cookie("is_lnbits_user_authorized", "true", max_age=max_age)
|
||||
response.delete_cookie("is_access_token_expired")
|
||||
|
||||
return response
|
||||
@@ -521,9 +522,12 @@ def _auth_api_token_response(
|
||||
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
|
||||
payload = AccessTokenPayload(sub="" or "", email=email, auth_time=int(time()))
|
||||
access_token = create_access_token(data=payload.dict())
|
||||
max_age = settings.auth_token_expire_minutes * 60
|
||||
response = RedirectResponse(path)
|
||||
response.set_cookie("cookie_access_token", access_token, httponly=True)
|
||||
response.set_cookie("is_lnbits_user_authorized", "true")
|
||||
response.set_cookie(
|
||||
"cookie_access_token", access_token, httponly=True, max_age=max_age
|
||||
)
|
||||
response.set_cookie("is_lnbits_user_authorized", "true", max_age=max_age)
|
||||
response.delete_cookie("is_access_token_expired")
|
||||
return response
|
||||
|
||||
@@ -574,10 +578,10 @@ def _find_auth_provider_class(provider: str) -> Callable:
|
||||
def _nostr_nip98_event(request: Request) -> dict:
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Nostr Auth header missing.")
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Nostr Auth header missing.")
|
||||
scheme, token = auth_header.split()
|
||||
if scheme.lower() != "nostr":
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid Authorization scheme.")
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid Authorization scheme.")
|
||||
event = None
|
||||
try:
|
||||
event_json = base64.b64decode(token.encode("ascii"))
|
||||
|
||||
@@ -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.UNAUTHORIZED,
|
||||
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(
|
||||
@@ -264,13 +268,21 @@ async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
||||
response_description="list of payments",
|
||||
response_model=Page[Payment],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_all_payments_paginated(
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
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_paginated(
|
||||
filters=filters,
|
||||
user_id=for_user_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -314,7 +326,7 @@ async def api_payments_create(
|
||||
return await _api_payments_create_invoice(invoice_data, wallet.wallet)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+20
-7
@@ -554,9 +554,13 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
for page_filter in self.filters:
|
||||
where_stmts.append(page_filter.statement)
|
||||
if self.search and self.model and self.model.__search_fields__:
|
||||
where_stmts.append(
|
||||
f"lower(concat({', '.join(self.model.__search_fields__)})) LIKE :search"
|
||||
# Use `COALESCE` to handle `NULL` values and `||`
|
||||
# for cross-database compatible string concatenation
|
||||
_fields = self.model.__search_fields__
|
||||
search_expr = " || ".join(
|
||||
f"COALESCE(CAST({field} AS TEXT), '')" for field in _fields
|
||||
)
|
||||
where_stmts.append(f"lower({search_expr}) LIKE :search")
|
||||
|
||||
if where_stmts:
|
||||
return "WHERE " + " AND ".join(where_stmts)
|
||||
@@ -576,7 +580,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
for key, value in page_filter.values.items():
|
||||
values[key] = value
|
||||
if self.search and self.model:
|
||||
values["search"] = f"%{self.search}%"
|
||||
values["search"] = f"%{self.search.lower()}%"
|
||||
return values
|
||||
|
||||
|
||||
@@ -644,9 +648,9 @@ def model_to_dict(model: BaseModel) -> dict:
|
||||
def dict_to_submodel(model: type[TModel], value: dict | str) -> TModel | None:
|
||||
"""convert a dictionary or JSON string to a Pydantic model"""
|
||||
if isinstance(value, str):
|
||||
if value == "null":
|
||||
if value == "null" or value == "":
|
||||
return None
|
||||
_subdict = json.loads(value)
|
||||
_subdict = _safe_load_json(value)
|
||||
elif isinstance(value, dict):
|
||||
_subdict = value
|
||||
|
||||
@@ -670,7 +674,7 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
|
||||
type_ = model.__fields__[key].type_
|
||||
outertype_ = model.__fields__[key].outer_type_
|
||||
if get_origin(outertype_) is list:
|
||||
_items = json.loads(value) if isinstance(value, str) else value
|
||||
_items = _safe_load_json(value) if isinstance(value, str) else value
|
||||
_dict[key] = [
|
||||
dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v
|
||||
for v in _items
|
||||
@@ -691,7 +695,7 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
|
||||
# TODO: remove this when all sub models are migrated to Pydantic
|
||||
# NOTE: this is for type dict on BaseModel, (used in Payment class)
|
||||
if type_ is dict and value:
|
||||
_dict[key] = json.loads(value)
|
||||
_dict[key] = _safe_load_json(value)
|
||||
continue
|
||||
_dict[key] = value
|
||||
continue
|
||||
@@ -699,3 +703,12 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
|
||||
if isinstance(_model, BaseModel):
|
||||
_model.__init__(**_dict) # type: ignore
|
||||
return _model
|
||||
|
||||
|
||||
def _safe_load_json(value: str) -> dict:
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
# DB is corrupted if it gets here
|
||||
logger.error(f"Failed to decode JSON: '{value}'")
|
||||
return {}
|
||||
|
||||
@@ -102,7 +102,7 @@ class KeyChecker(SecurityBase):
|
||||
request.scope["user_id"] = wallet.user
|
||||
if self.expected_key_type is KeyType.admin and wallet.adminkey != key_value:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Invalid adminkey.",
|
||||
)
|
||||
|
||||
@@ -165,7 +165,7 @@ async def check_user_exists(
|
||||
|
||||
r.scope["user_id"] = account.id
|
||||
if not settings.is_user_allowed(account.id):
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not allowed.")
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "User not allowed.")
|
||||
|
||||
user = await get_user_from_account(account)
|
||||
if not user:
|
||||
@@ -201,7 +201,7 @@ async def access_token_payload(
|
||||
async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User:
|
||||
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED, "User not authorized. No admin privileges."
|
||||
HTTPStatus.FORBIDDEN, "User not authorized. No admin privileges."
|
||||
)
|
||||
if not user.has_password:
|
||||
raise HTTPException(
|
||||
@@ -214,7 +214,7 @@ async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User
|
||||
async def check_super_user(user: Annotated[User, Depends(check_user_exists)]) -> User:
|
||||
if user.id != settings.super_user:
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED, "User not authorized. No super user privileges."
|
||||
HTTPStatus.FORBIDDEN, "User not authorized. No super user privileges."
|
||||
)
|
||||
if not user.has_password:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -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)
|
||||
|
||||
+60
-63
@@ -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
|
||||
|
||||
@@ -64,63 +65,52 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
|
||||
t = Jinja2Templates(loader=jinja2.FileSystemLoader(folders))
|
||||
t.env.globals["static_url_for"] = static_url_for
|
||||
|
||||
if settings.lnbits_ad_space_enabled:
|
||||
t.env.globals["AD_SPACE"] = settings.lnbits_ad_space.split(",")
|
||||
t.env.globals["AD_SPACE_TITLE"] = settings.lnbits_ad_space_title
|
||||
window_settings = {
|
||||
"AD_SPACE": settings.lnbits_ad_space.split(","),
|
||||
"AD_SPACE_ENABLED": settings.lnbits_ad_space_enabled,
|
||||
"AD_SPACE_TITLE": settings.lnbits_ad_space_title,
|
||||
"EXTENSIONS": list(settings.lnbits_installed_extensions_ids),
|
||||
"HIDE_API": settings.lnbits_hide_api,
|
||||
"SITE_TITLE": settings.lnbits_site_title,
|
||||
"SITE_TAGLINE": settings.lnbits_site_tagline,
|
||||
"SITE_DESCRIPTION": settings.lnbits_site_description,
|
||||
"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,
|
||||
"LNBITS_EXTENSIONS_DEACTIVATE_ALL": settings.lnbits_extensions_deactivate_all,
|
||||
"LNBITS_NEW_ACCOUNTS_ALLOWED": settings.new_accounts_allowed,
|
||||
"LNBITS_NODE_UI": settings.lnbits_node_ui and get_node_class() is not None,
|
||||
"LNBITS_NODE_UI_AVAILABLE": get_node_class() is not None,
|
||||
"LNBITS_QR_LOGO": settings.lnbits_qr_logo,
|
||||
"LNBITS_SERVICE_FEE": settings.lnbits_service_fee,
|
||||
"LNBITS_SERVICE_FEE_MAX": settings.lnbits_service_fee_max,
|
||||
"LNBITS_SERVICE_FEE_WALLET": settings.lnbits_service_fee_wallet,
|
||||
"LNBITS_SHOW_HOME_PAGE_ELEMENTS": settings.lnbits_show_home_page_elements,
|
||||
"LNBITS_THEME_OPTIONS": settings.lnbits_theme_options,
|
||||
"LNBITS_VERSION": settings.version,
|
||||
"USE_CUSTOM_LOGO": settings.lnbits_custom_logo,
|
||||
"USE_DEFAULT_REACTION": settings.lnbits_default_reaction,
|
||||
"USE_DEFAULT_THEME": settings.lnbits_default_theme,
|
||||
"USE_DEFAULT_BORDER": settings.lnbits_default_border,
|
||||
"USE_DEFAULT_GRADIENT": settings.lnbits_default_gradient,
|
||||
"USE_DEFAULT_BGIMAGE": settings.lnbits_default_bgimage,
|
||||
"VOIDWALLET": settings.lnbits_backend_wallet_class == "VoidWallet",
|
||||
"WEBPUSH_PUBKEY": settings.lnbits_webpush_pubkey,
|
||||
"LNBITS_DENOMINATION": (
|
||||
settings.lnbits_denomination
|
||||
if settings.lnbits_denomination == "FakeWallet"
|
||||
else "sats"
|
||||
),
|
||||
}
|
||||
|
||||
t.env.globals["VOIDWALLET"] = settings.lnbits_backend_wallet_class == "VoidWallet"
|
||||
t.env.globals["HIDE_API"] = settings.lnbits_hide_api
|
||||
t.env.globals["SITE_TITLE"] = settings.lnbits_site_title
|
||||
t.env.globals["LNBITS_DENOMINATION"] = (
|
||||
settings.lnbits_denomination
|
||||
if settings.lnbits_backend_wallet_class == "FakeWallet"
|
||||
else "sats"
|
||||
)
|
||||
t.env.globals["SITE_TAGLINE"] = settings.lnbits_site_tagline
|
||||
t.env.globals["SITE_DESCRIPTION"] = settings.lnbits_site_description
|
||||
t.env.globals["LNBITS_SHOW_HOME_PAGE_ELEMENTS"] = (
|
||||
settings.lnbits_show_home_page_elements
|
||||
)
|
||||
t.env.globals["LNBITS_CUSTOM_IMAGE"] = settings.lnbits_custom_image
|
||||
t.env.globals["LNBITS_CUSTOM_BADGE"] = settings.lnbits_custom_badge
|
||||
t.env.globals["LNBITS_CUSTOM_BADGE_COLOR"] = settings.lnbits_custom_badge_color
|
||||
t.env.globals["LNBITS_THEME_OPTIONS"] = settings.lnbits_theme_options
|
||||
t.env.globals["LNBITS_QR_LOGO"] = settings.lnbits_qr_logo
|
||||
t.env.globals["LNBITS_VERSION"] = settings.version
|
||||
t.env.globals["LNBITS_NEW_ACCOUNTS_ALLOWED"] = settings.new_accounts_allowed
|
||||
t.env.globals["LNBITS_AUTH_METHODS"] = settings.auth_allowed_methods
|
||||
t.env.globals["LNBITS_ADMIN_UI"] = settings.lnbits_admin_ui
|
||||
t.env.globals["LNBITS_EXTENSIONS_DEACTIVATE_ALL"] = (
|
||||
settings.lnbits_extensions_deactivate_all
|
||||
)
|
||||
t.env.globals["LNBITS_AUDIT_ENABLED"] = settings.lnbits_audit_enabled
|
||||
|
||||
t.env.globals["LNBITS_SERVICE_FEE"] = settings.lnbits_service_fee
|
||||
t.env.globals["LNBITS_SERVICE_FEE_MAX"] = settings.lnbits_service_fee_max
|
||||
t.env.globals["LNBITS_SERVICE_FEE_WALLET"] = settings.lnbits_service_fee_wallet
|
||||
t.env.globals["LNBITS_NODE_UI"] = (
|
||||
settings.lnbits_node_ui and get_node_class() is not None
|
||||
)
|
||||
t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None
|
||||
t.env.globals["EXTENSIONS"] = list(settings.lnbits_installed_extensions_ids)
|
||||
|
||||
if settings.lnbits_custom_logo:
|
||||
t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo
|
||||
|
||||
if settings.lnbits_default_reaction:
|
||||
t.env.globals["USE_DEFAULT_REACTION"] = settings.lnbits_default_reaction
|
||||
|
||||
if settings.lnbits_default_theme:
|
||||
t.env.globals["USE_DEFAULT_THEME"] = settings.lnbits_default_theme
|
||||
|
||||
if settings.lnbits_default_border:
|
||||
t.env.globals["USE_DEFAULT_BORDER"] = settings.lnbits_default_border
|
||||
|
||||
if settings.lnbits_default_gradient:
|
||||
t.env.globals["USE_DEFAULT_GRADIENT"] = settings.lnbits_default_gradient
|
||||
|
||||
if settings.lnbits_default_bgimage:
|
||||
t.env.globals["USE_DEFAULT_BGIMAGE"] = settings.lnbits_default_bgimage
|
||||
t.env.globals["WINDOW_SETTINGS"] = window_settings
|
||||
for key, value in window_settings.items():
|
||||
t.env.globals[key] = value
|
||||
|
||||
if settings.bundle_assets:
|
||||
t.env.globals["INCLUDED_JS"] = ["bundle.min.js"]
|
||||
@@ -134,8 +124,6 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
|
||||
t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
|
||||
t.env.globals["INCLUDED_COMPONENTS"] = vendor_files["components"]
|
||||
|
||||
t.env.globals["WEBPUSH_PUBKEY"] = settings.lnbits_webpush_pubkey
|
||||
|
||||
return t
|
||||
|
||||
|
||||
@@ -294,12 +282,21 @@ def is_lnbits_version_ok(
|
||||
|
||||
|
||||
def check_callback_url(url: str):
|
||||
netloc = urlparse(url).netloc
|
||||
if not settings.lnbits_callback_url_rules:
|
||||
# no rules, all urls are allowed
|
||||
return
|
||||
u = urlparse(url)
|
||||
for rule in settings.lnbits_callback_url_rules:
|
||||
if re.match(rule, netloc) is None:
|
||||
raise ValueError(
|
||||
f"Callback not allowed. URL: {url}. Netloc: {netloc}. Rule: {rule}"
|
||||
)
|
||||
try:
|
||||
if re.match(rule, f"{u.scheme}://{u.netloc}") is not None:
|
||||
return
|
||||
except re.error:
|
||||
logger.debug(f"Invalid regex rule: '{rule}'. ")
|
||||
continue
|
||||
raise ValueError(
|
||||
f"Callback not allowed. URL: {url}. Netloc: {u.netloc}. "
|
||||
f"Please check your admin settings."
|
||||
)
|
||||
|
||||
|
||||
def download_url(url, save_path):
|
||||
|
||||
+12
-1
@@ -381,7 +381,7 @@ class SecuritySettings(LNbitsSettings):
|
||||
lnbits_allowed_ips: list[str] = Field(default=[])
|
||||
lnbits_blocked_ips: list[str] = Field(default=[])
|
||||
lnbits_callback_url_rules: list[str] = Field(
|
||||
default=["^(?!\\d+\\.\\d+\\.\\d+\\.\\d+$)(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,}$"]
|
||||
default=["https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"]
|
||||
)
|
||||
|
||||
lnbits_wallet_limit_max_balance: int = Field(default=0, ge=0)
|
||||
@@ -556,6 +556,13 @@ class BoltzFundingSource(LNbitsSettings):
|
||||
boltz_client_cert: str | None = Field(default=None)
|
||||
|
||||
|
||||
class StrikeFundingSource(LNbitsSettings):
|
||||
strike_api_endpoint: str | None = Field(
|
||||
default="https://api.strike.me/v1", env="STRIKE_API_ENDPOINT"
|
||||
)
|
||||
strike_api_key: str | None = Field(default=None, env="STRIKE_API_KEY")
|
||||
|
||||
|
||||
class LightningSettings(LNbitsSettings):
|
||||
lightning_invoice_expiry: int = Field(default=3600, gt=0)
|
||||
|
||||
@@ -580,6 +587,7 @@ class FundingSourcesSettings(
|
||||
LnTipsFundingSource,
|
||||
NWCFundingSource,
|
||||
BreezSdkFundingSource,
|
||||
StrikeFundingSource,
|
||||
):
|
||||
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
|
||||
# How long to wait for the payment to be confirmed before returning a pending status
|
||||
@@ -659,6 +667,8 @@ class KeycloakAuthSettings(LNbitsSettings):
|
||||
keycloak_discovery_url: str = Field(default="")
|
||||
keycloak_client_id: str = Field(default="")
|
||||
keycloak_client_secret: str = Field(default="")
|
||||
keycloak_client_custom_org: str | None = Field(default=None)
|
||||
keycloak_client_custom_icon: str | None = Field(default=None)
|
||||
|
||||
|
||||
class AuditSettings(LNbitsSettings):
|
||||
@@ -863,6 +873,7 @@ class SuperUserSettings(LNbitsSettings):
|
||||
"VoidWallet",
|
||||
"ZBDWallet",
|
||||
"NWCWallet",
|
||||
"StrikeWallet",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
+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',
|
||||
@@ -323,11 +331,14 @@ window.localisation.en = {
|
||||
set_password: 'Set Password',
|
||||
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.',
|
||||
reset_key_copy: 'Click OK to copy the reset URL to your clipboard.',
|
||||
login: 'Login',
|
||||
register: 'Register',
|
||||
username: 'Username',
|
||||
pubkey: 'Public Key',
|
||||
user_id: 'User ID',
|
||||
id: 'ID',
|
||||
email: 'Email',
|
||||
first_name: 'First Name',
|
||||
last_name: 'Last Name',
|
||||
@@ -354,6 +365,7 @@ window.localisation.en = {
|
||||
gradient_background: 'Gradient Background',
|
||||
language: 'Language',
|
||||
color_scheme: 'Color Scheme',
|
||||
visible_wallet_count: 'Visible Wallet Count',
|
||||
admin_settings: 'Admin Settings',
|
||||
extension_cost: 'This release requires a payment of minimum {cost} sats.',
|
||||
extension_paid_sats: 'You have already paid {paid_sats} sats.',
|
||||
@@ -444,7 +456,7 @@ window.localisation.en = {
|
||||
fee_reserve_percent: 'Fee Reserve Percent',
|
||||
fee_reserve_msats: 'Reserve fee in msats',
|
||||
reserve_fee_in_percent: 'Reserve fee in percent',
|
||||
payment_wait_time: 'Payment Wait Time',
|
||||
payment_wait_time: 'Payment Wait Time (sec)',
|
||||
payment_wait_time_desc:
|
||||
'How long to wait when making a payment before marking it as pending. Set higher values for HODL invoices, Boltz, etc.',
|
||||
server_management: 'Server Management',
|
||||
@@ -470,6 +482,8 @@ window.localisation.en = {
|
||||
auth_keycloak_ci_hint:
|
||||
'Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/keycloak/token',
|
||||
auth_keycloak_cs_label: 'Keycloak Client Secret',
|
||||
auth_keycloak_custom_org_label: 'Keycloak Custom Organization',
|
||||
auth_keycloak_custom_icon_label: 'Keycloak Custom Icon (URL)',
|
||||
currency_settings: 'Currency Settings',
|
||||
allowed_currencies: 'Allowed Currencies',
|
||||
allowed_currencies_hint: 'Limit the number of available fiat currencies',
|
||||
|
||||
@@ -100,7 +100,7 @@ window.localisation.fi = {
|
||||
payment_processing: 'Maksua käsitellään...',
|
||||
not_enough_funds: 'Varat eivät riitä!',
|
||||
search_by_tag_memo_amount: 'Etsi tunnisteella, muistiolla tai määrällä',
|
||||
invoice_waiting: 'Lasku osottaa maksamista',
|
||||
invoice_waiting: 'Lasku odottaa maksua',
|
||||
payment_received: 'Maksu vastaanotettu',
|
||||
payment_sent: 'Maksu lähetetty',
|
||||
payment_failed: 'Maksu epäonnistui',
|
||||
@@ -261,7 +261,7 @@ window.localisation.fi = {
|
||||
notification_source: 'Tiedotteiden lähde',
|
||||
notification_source_label:
|
||||
'Lähde-URL (käytä ainoastaan LNbits:iä tai muuta luotettavaa lähdettä)',
|
||||
more: 'laajenna',
|
||||
more: 'näytä lisää',
|
||||
less: 'supista',
|
||||
releases: 'Julkaisut',
|
||||
watchdog: 'Watchdog',
|
||||
@@ -321,6 +321,8 @@ window.localisation.fi = {
|
||||
set_password: 'Aseta salasana',
|
||||
invalid_password: 'Salasanassa tulee olla vähintään kahdeksan merkkiä',
|
||||
invalid_password_repeat: 'Salasanat eivät täsmää',
|
||||
reset_key_generated: 'Salasanan vaihtoavain on luotu.',
|
||||
reset_key_copy: 'Kopioi vaihto-URL leikepöydälle painamalla OK.',
|
||||
login: 'Kirjaudu',
|
||||
register: 'Rekisteröidy',
|
||||
username: 'Käyttäjänimi',
|
||||
|
||||
@@ -450,7 +450,7 @@ window.AdminPageLogic = {
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
},
|
||||
formatDate(date) {
|
||||
return moment(date * 1000).fromNow()
|
||||
return moment.utc(date * 1000).fromNow()
|
||||
},
|
||||
sendTestEmail() {
|
||||
LNbits.api
|
||||
@@ -546,10 +546,10 @@ window.AdminPageLogic = {
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message:
|
||||
'Success! Restored settings to defaults, restart required!',
|
||||
'Success! Restored settings to defaults. Restarting...',
|
||||
icon: null
|
||||
})
|
||||
this.needsRestart = true
|
||||
this.$q.localStorage.clear()
|
||||
})
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
})
|
||||
|
||||
@@ -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(obj.date).fromNow()
|
||||
obj.expirydate = Quasar.date.formatDate(
|
||||
new Date(obj.expiry),
|
||||
window.dateFormat
|
||||
)
|
||||
obj.expirydateFrom = moment(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
|
||||
@@ -495,8 +501,10 @@ window.windowMixin = {
|
||||
? this.$q.localStorage.getItem('lnbits.backgroundImage')
|
||||
: USE_DEFAULT_BGIMAGE,
|
||||
isUserAuthorized: false,
|
||||
isSatsDenomination: WINDOW_SETTINGS['LNBITS_DENOMINATION'] == 'sats',
|
||||
walletEventListeners: [],
|
||||
backgroundImage: ''
|
||||
backgroundImage: '',
|
||||
...WINDOW_SETTINGS
|
||||
}
|
||||
},
|
||||
|
||||
@@ -508,6 +516,9 @@ window.windowMixin = {
|
||||
}
|
||||
this.$q.localStorage.set('lnbits.walletFlip', this.walletFlip)
|
||||
},
|
||||
goToWallets() {
|
||||
window.location = '/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: {
|
||||
|
||||
@@ -197,6 +197,14 @@ window.app.component('lnbits-funding-sources', {
|
||||
breez_greenlight_device_cert: 'Greenlight Device Cert',
|
||||
breez_greenlight_invite_code: 'Greenlight Invite Code'
|
||||
}
|
||||
],
|
||||
[
|
||||
'StrikeWallet',
|
||||
'Strike (alpha)',
|
||||
{
|
||||
strike_api_endpoint: 'API Endpoint',
|
||||
strike_api_key: 'API Key'
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
@@ -101,8 +101,8 @@ window.app = Vue.createApp({
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.description = SITE_DESCRIPTION
|
||||
this.allowedRegister = ALLOWED_REGISTER
|
||||
this.description = this.SITE_DESCRIPTION
|
||||
this.allowedRegister = this.LNBITS_NEW_ACCOUNTS_ALLOWED
|
||||
this.authAction =
|
||||
!this.allowedRegister ||
|
||||
Quasar.LocalStorage.getItem('lnbits.disclaimerShown')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -600,7 +600,7 @@ window.app.component('lnbits-date', {
|
||||
return LNbits.utils.formatDate(this.ts)
|
||||
},
|
||||
dateFrom() {
|
||||
return moment(this.date).fromNow()
|
||||
return moment.utc(this.date).fromNow()
|
||||
}
|
||||
},
|
||||
template: `
|
||||
|
||||
@@ -148,7 +148,7 @@ window.PaymentsPageLogic = {
|
||||
if (p.extra && p.extra.tag) {
|
||||
p.tag = p.extra.tag
|
||||
}
|
||||
p.timeFrom = moment(p.created_at).fromNow()
|
||||
p.timeFrom = moment.utc(p.created_at).fromNow()
|
||||
p.outgoing = p.amount < 0
|
||||
p.amount =
|
||||
new Intl.NumberFormat(window.LOCALE).format(p.amount / 1000) +
|
||||
@@ -229,11 +229,10 @@ window.PaymentsPageLogic = {
|
||||
'GET',
|
||||
`/api/v1/payments/stats/count?${params}&count_by=status`
|
||||
)
|
||||
data.sort((a, b) => a.field - b.field)
|
||||
this.searchOptions.status = data
|
||||
.map(s => s.field)
|
||||
.sort()
|
||||
.reverse()
|
||||
|
||||
data.sort((a, b) => a.field - b.field).reverse()
|
||||
|
||||
this.searchOptions.status = data.map(s => s.field)
|
||||
this.paymentsStatusChart.data.datasets[0].data = data.map(s => s.total)
|
||||
this.paymentsStatusChart.data.labels = [...this.searchOptions.status]
|
||||
|
||||
@@ -419,11 +418,8 @@ window.PaymentsPageLogic = {
|
||||
data: [],
|
||||
backgroundColor: [
|
||||
'rgb(0, 205, 86)',
|
||||
'rgb(54, 162, 235)',
|
||||
'rgb(255, 99, 132)',
|
||||
'rgb(255, 5, 86)',
|
||||
'rgb(25, 205, 86)',
|
||||
'rgb(255, 205, 250)'
|
||||
'rgb(64, 72, 78)',
|
||||
'rgb(255, 99, 132)'
|
||||
],
|
||||
hoverOffset: 4
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ window.UsersPageLogic = {
|
||||
.then(res => {
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'A reset key has been generated. Click OK to copy the rest key to your clipboard.'
|
||||
this.$t('reset_key_generated') + ' ' + this.$t('reset_key_copy')
|
||||
)
|
||||
.onOk(() => {
|
||||
const url = window.location.origin + '?reset_key=' + res.data
|
||||
@@ -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)
|
||||
|
||||
@@ -261,7 +261,9 @@ window.WalletPageLogic = {
|
||||
this.receive.paymentReq = response.data.bolt11
|
||||
this.receive.amountMsat = response.data.amount
|
||||
this.receive.paymentHash = response.data.payment_hash
|
||||
this.readNfcTag()
|
||||
if (!this.receive.lnurl) {
|
||||
this.readNfcTag()
|
||||
}
|
||||
// TODO: lnurl_callback and lnurl_response
|
||||
// WITHDRAW
|
||||
if (response.data.lnurl_response !== null) {
|
||||
@@ -449,8 +451,8 @@ window.WalletPageLogic = {
|
||||
createdDate,
|
||||
'YYYY-MM-DDTHH:mm:ss.SSSZ'
|
||||
)
|
||||
cleanInvoice.expireDateFrom = moment(expireDate).fromNow()
|
||||
cleanInvoice.createdDateFrom = moment(createdDate).fromNow()
|
||||
cleanInvoice.expireDateFrom = moment.utc(expireDate).fromNow()
|
||||
cleanInvoice.createdDateFrom = moment.utc(createdDate).fromNow()
|
||||
|
||||
cleanInvoice.expired = false // TODO
|
||||
}
|
||||
@@ -660,7 +662,7 @@ window.WalletPageLogic = {
|
||||
}
|
||||
}
|
||||
Quasar.Notify.create({
|
||||
message: 'Wallet and user updated.',
|
||||
message: 'Wallet updated.',
|
||||
type: 'positive',
|
||||
timeout: 3500
|
||||
})
|
||||
@@ -1112,13 +1114,6 @@ window.WalletPageLogic = {
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'g.wallet.id'(newVal, oldVal) {
|
||||
try {
|
||||
this.fetchChartData()
|
||||
} catch (error) {
|
||||
console.warn(`Chart creation failed: ${error}`)
|
||||
}
|
||||
},
|
||||
'g.updatePayments'(newVal, oldVal) {
|
||||
this.parse.show = false
|
||||
if (this.receive.paymentHash === this.g.updatePaymentsHash) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -203,7 +203,8 @@ async def invoice_callback_dispatcher(checking_id: str, is_internal: bool = Fals
|
||||
if payment and payment.is_in:
|
||||
status = await payment.check_status()
|
||||
payment.fee = status.fee_msat or 0
|
||||
payment.preimage = status.preimage
|
||||
# only overwrite preimage if status.preimage provides it
|
||||
payment.preimage = status.preimage or payment.preimage
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment)
|
||||
internal = "internal" if is_internal else ""
|
||||
|
||||
+62
-38
@@ -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>
|
||||
|
||||
@@ -390,42 +418,38 @@
|
||||
</q-layout>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
const WINDOW_SETTINGS = {{ WINDOW_SETTINGS | tojson }}
|
||||
Object.keys(WINDOW_SETTINGS).forEach(key => {
|
||||
window[key] = WINDOW_SETTINGS[key]
|
||||
})
|
||||
if (LNBITS_THEME_OPTIONS && LNBITS_THEME_OPTIONS.length) {
|
||||
window.allowedThemes = LNBITS_THEME_OPTIONS.map(str => str.trim())
|
||||
}
|
||||
</script>
|
||||
{% include('components.vue') %} {% block vue_templates %}{% endblock %} {%
|
||||
for url in INCLUDED_JS %}
|
||||
<script src="{{ static_url_for('static', url) }}"></script>
|
||||
{% endfor %}
|
||||
<script type="text/javascript">
|
||||
const SITE_DESCRIPTION = {{ SITE_DESCRIPTION | tojson}}
|
||||
const themes = {{ LNBITS_THEME_OPTIONS | tojson }}
|
||||
const LNBITS_DENOMINATION = {{ LNBITS_DENOMINATION | tojson }}
|
||||
const LNBITS_VERSION = {{ LNBITS_VERSION | tojson }}
|
||||
const LNBITS_QR_LOGO = {{ LNBITS_QR_LOGO | tojson }}
|
||||
const USE_DEFAULT_REACTION = {{ USE_DEFAULT_REACTION | tojson }}
|
||||
const USE_DEFAULT_THEME = {{ USE_DEFAULT_THEME | tojson }}
|
||||
const USE_DEFAULT_BORDER = {{ USE_DEFAULT_BORDER | tojson }}
|
||||
const USE_DEFAULT_GRADIENT = {{ USE_DEFAULT_GRADIENT | lower | tojson }}
|
||||
const USE_DEFAULT_BGIMAGE = "{{ USE_DEFAULT_BGIMAGE or None | tojson }}"
|
||||
if (themes && themes.length) {
|
||||
window.allowedThemes = themes.map(str => str.trim())
|
||||
}
|
||||
window.langs = [
|
||||
{ value: 'en', label: 'English', display: '🇬🇧 EN' },
|
||||
{ value: 'de', label: 'Deutsch', display: '🇩🇪 DE' },
|
||||
{ value: 'es', label: 'Español', display: '🇪🇸 ES' },
|
||||
{ value: 'jp', label: '日本語', display: '🇯🇵 JP' },
|
||||
{ value: 'cn', label: '中文', display: '🇨🇳 CN' },
|
||||
{ value: 'fr', label: 'Français', display: '🇫🇷 FR' },
|
||||
{ value: 'it', label: 'Italiano', display: '🇮🇹 IT' },
|
||||
{ value: 'pi', label: 'Pirate', display: '🏴☠️ PI' },
|
||||
{ value: 'nl', label: 'Nederlands', display: '🇳🇱 NL' },
|
||||
{ value: 'we', label: 'Cymraeg', display: '🏴 CY' },
|
||||
{ value: 'pl', label: 'Polski', display: '🇵🇱 PL' },
|
||||
{ value: 'pt', label: 'Português', display: '🇵🇹 PT' },
|
||||
{ value: 'br', label: 'Português do Brasil', display: '🇧🇷 BR' },
|
||||
{ value: 'cs', label: 'Česky', display: '🇨🇿 CS' },
|
||||
{ value: 'sk', label: 'Slovensky', display: '🇸🇰 SK' },
|
||||
{ value: 'kr', label: '한국어', display: '🇰🇷 KR' },
|
||||
{ value: 'fi', label: 'Suomi', display: '🇫🇮 FI' }
|
||||
{value: 'en', label: 'English', display: '🇬🇧 EN'},
|
||||
{value: 'de', label: 'Deutsch', display: '🇩🇪 DE'},
|
||||
{value: 'es', label: 'Español', display: '🇪🇸 ES'},
|
||||
{value: 'jp', label: '日本語', display: '🇯🇵 JP'},
|
||||
{value: 'cn', label: '中文', display: '🇨🇳 CN'},
|
||||
{value: 'fr', label: 'Français', display: '🇫🇷 FR'},
|
||||
{value: 'it', label: 'Italiano', display: '🇮🇹 IT'},
|
||||
{value: 'pi', label: 'Pirate', display: '🏴☠️ PI'},
|
||||
{value: 'nl', label: 'Nederlands', display: '🇳🇱 NL'},
|
||||
{value: 'we', label: 'Cymraeg', display: '🏴 CY'},
|
||||
{value: 'pl', label: 'Polski', display: '🇵🇱 PL'},
|
||||
{value: 'pt', label: 'Português', display: '🇵🇹 PT'},
|
||||
{value: 'br', label: 'Português do Brasil', display: '🇧🇷 BR'},
|
||||
{value: 'cs', label: 'Česky', display: '🇨🇿 CS'},
|
||||
{value: 'sk', label: 'Slovensky', display: '🇸🇰 SK'},
|
||||
{value: 'kr', label: '한국어', display: '🇰🇷 KR'},
|
||||
{value: 'fi', label: 'Suomi', display: '🇫🇮 FI'}
|
||||
]
|
||||
window.LOCALE = 'en'
|
||||
window.dateFormat = 'YYYY-MM-DD HH:mm'
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
class="lnbits-drawer__q-list"
|
||||
>
|
||||
<q-item
|
||||
v-for="walletRec in g.user.wallets"
|
||||
v-for="walletRec in g.user.wallets.slice(
|
||||
0,
|
||||
g.user.extra.visible_wallet_count || 10
|
||||
)"
|
||||
:key="walletRec.id"
|
||||
clickable
|
||||
:active="g.wallet && g.wallet.id === walletRec.id"
|
||||
@@ -43,6 +46,22 @@
|
||||
<q-item-section side v-show="g.wallet && g.wallet.id === walletRec.id">
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item
|
||||
v-if="g.user.hiddenWalletsCount > 0"
|
||||
clickable
|
||||
@click="goToWallets()"
|
||||
>
|
||||
<q-item-section side>
|
||||
<q-icon name="more_horiz" color="grey-5" size="md"></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
lines="1"
|
||||
class="text-caption"
|
||||
v-text="$t('more_count', {count: g.user.hiddenWalletsCount})"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable @click="showForm = !showForm">
|
||||
<q-item-section side>
|
||||
<q-icon
|
||||
@@ -55,7 +74,7 @@
|
||||
<q-item-label
|
||||
lines="1"
|
||||
class="text-caption"
|
||||
v-text="$t('add_wallet')"
|
||||
v-text="$t('add_new_wallet')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
@@ -178,19 +197,19 @@
|
||||
<q-item-label lines="1" v-text="$t('api_watch')"></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if="showPayments" to="/payments">
|
||||
<q-item-section side>
|
||||
<q-icon
|
||||
name="query_stats"
|
||||
:color="isActive('/payments') ? 'primary' : 'grey-5'"
|
||||
size="md"
|
||||
></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label lines="1" v-text="$t('payments')"></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<q-item to="/payments">
|
||||
<q-item-section side>
|
||||
<q-icon
|
||||
name="query_stats"
|
||||
:color="isActive('/payments') ? 'primary' : 'grey-5'"
|
||||
size="md"
|
||||
></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label lines="1" v-text="$t('payments')"></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if="showExtensions" to="/extensions">
|
||||
<q-item-section side>
|
||||
<q-icon
|
||||
@@ -987,7 +1006,7 @@
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="row q-gutter-x-sm">
|
||||
<q-btn
|
||||
v-if="
|
||||
props.row.isIn && props.row.isPending && props.row.bolt11
|
||||
@@ -1185,7 +1204,7 @@
|
||||
color="primary"
|
||||
:disable="walletName == ''"
|
||||
type="submit"
|
||||
:label="$t('add_wallet')"
|
||||
:label="$t('add_new_wallet')"
|
||||
class="full-width q-mb-sm"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
@@ -1453,10 +1472,22 @@
|
||||
>
|
||||
<q-avatar size="32px" class="q-mr-md">
|
||||
<q-img
|
||||
:src="`{{ static_url_for('static', 'images/keycloak-logo.png') }}`"
|
||||
:src="
|
||||
keycloakIcon
|
||||
? keycloakIcon
|
||||
: `{{ static_url_for('static', 'images/keycloak-logo.png') }}`
|
||||
"
|
||||
></q-img>
|
||||
</q-avatar>
|
||||
<div><span v-text="$t('signin_with_keycloak')"></span></div>
|
||||
<div>
|
||||
<span
|
||||
v-text="
|
||||
$t('signin_with_custom_org', {
|
||||
custom_org: keycloakOrg
|
||||
})
|
||||
"
|
||||
></span>
|
||||
</div>
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
+19
-16
@@ -6,7 +6,7 @@
|
||||
>
|
||||
{% block page %}
|
||||
<div class="text-center q-pa-md flex flex-center">
|
||||
<div>
|
||||
<div v-if="statusCode">
|
||||
<div class="error-code" v-text="statusCode"></div>
|
||||
|
||||
<div class="error-message" v-text="message"></div>
|
||||
@@ -65,19 +65,15 @@
|
||||
goToExtension() {
|
||||
window.location.href = `/extensions#${this.extension}`
|
||||
},
|
||||
logOut() {
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'Do you really want to logout?'
|
||||
)
|
||||
.onOk( async () => {
|
||||
try {
|
||||
await LNbits.api.logout()
|
||||
window.location = '/'
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
})
|
||||
async logOut() {
|
||||
|
||||
try {
|
||||
await LNbits.api.logout()
|
||||
window.location = '/'
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
@@ -86,10 +82,17 @@
|
||||
if (this.message.startsWith('Extension ')) return true
|
||||
}
|
||||
},
|
||||
created() {
|
||||
async created() {
|
||||
this.err = '{{ err }}'
|
||||
this.statusCode = '{{ status_code }}' || 404
|
||||
const statusCode = '{{ status_code }}' || 404
|
||||
this.message = String({{ message | tojson }}) || 'Page not found'
|
||||
if (statusCode == 401) {
|
||||
console.warn(`Unauthorized: ${this.message}`)
|
||||
this.logOut()
|
||||
return
|
||||
}
|
||||
this.statusCode = statusCode
|
||||
|
||||
if (this.isExtension) {
|
||||
this.extension = this.message.match(/'([^']+)'/)[1]
|
||||
}
|
||||
|
||||
@@ -44,15 +44,20 @@
|
||||
</q-layout>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
const WINDOW_SETTINGS = {{ WINDOW_SETTINGS | tojson }}
|
||||
Object.keys(WINDOW_SETTINGS).forEach(key => {
|
||||
window[key] = WINDOW_SETTINGS[key]
|
||||
})
|
||||
if (LNBITS_THEME_OPTIONS && LNBITS_THEME_OPTIONS.length) {
|
||||
window.allowedThemes = LNBITS_THEME_OPTIONS.map(str => str.trim())
|
||||
}
|
||||
</script>
|
||||
{% include('components.vue') %}{% block vue_templates %}{% endblock %} {%
|
||||
for url in INCLUDED_JS %}
|
||||
<script src="{{ static_url_for('static', url) }}"></script>
|
||||
{% endfor %}
|
||||
<script>
|
||||
const LNBITS_QR_LOGO = {{ LNBITS_QR_LOGO | tojson }}
|
||||
</script>
|
||||
<!---->
|
||||
{% block scripts %}{% endblock %} {% for url in INCLUDED_COMPONENTS %}
|
||||
{% endfor %} {% block scripts %}{% endblock %} {% for url in
|
||||
INCLUDED_COMPONENTS %}
|
||||
<script src="{{ static_url_for('static', url) }}"></script>
|
||||
{% endfor %}
|
||||
</body>
|
||||
|
||||
+75
-51
@@ -1,15 +1,13 @@
|
||||
import base64
|
||||
import getpass
|
||||
from base64 import b64decode, b64encode, urlsafe_b64decode, urlsafe_b64encode
|
||||
from hashlib import md5, pbkdf2_hmac, sha256
|
||||
from typing import Union
|
||||
|
||||
from Cryptodome import Random
|
||||
from Cryptodome.Cipher import AES
|
||||
|
||||
BLOCK_SIZE = 16
|
||||
|
||||
|
||||
def random_secret_and_hash() -> tuple[str, str]:
|
||||
secret = Random.new().read(32)
|
||||
def random_secret_and_hash(length: int = 32) -> tuple[str, str]:
|
||||
secret = Random.new().read(length)
|
||||
return secret.hex(), sha256(secret).hexdigest()
|
||||
|
||||
|
||||
@@ -23,74 +21,100 @@ def fake_privkey(secret: str) -> str:
|
||||
).hex()
|
||||
|
||||
|
||||
def verify_preimage(preimage: str, payment_hash: str) -> bool:
|
||||
preimage_bytes = bytes.fromhex(preimage)
|
||||
calculated_hash = sha256(preimage_bytes).hexdigest()
|
||||
return calculated_hash == payment_hash
|
||||
|
||||
|
||||
class AESCipher:
|
||||
"""This class is compatible with crypto-js/aes.js
|
||||
"""
|
||||
AES-256-CBC encryption/decryption with salt and base64 encoding.
|
||||
:param key: The key to use for en-/decryption. It can be bytes, a hex or a string.
|
||||
|
||||
This class is compatible with crypto-js/aes.js
|
||||
Encrypt and decrypt in Javascript using:
|
||||
import AES from "crypto-js/aes.js";
|
||||
import Utf8 from "crypto-js/enc-utf8.js";
|
||||
AES.encrypt(decrypted, password).toString()
|
||||
AES.decrypt(encrypted, password).toString(Utf8);
|
||||
|
||||
import AES from "crypto-js/aes.js";
|
||||
import Utf8 from "crypto-js/enc-utf8.js";
|
||||
AES.encrypt(decrypted, password).toString()
|
||||
AES.decrypt(encrypted, password).toString(Utf8);
|
||||
"""
|
||||
|
||||
def __init__(self, key=None, description=""):
|
||||
self.key = key
|
||||
self.description = description + " "
|
||||
def __init__(self, key: Union[bytes, str], block_size: int = 16):
|
||||
self.block_size = block_size
|
||||
if isinstance(key, bytes):
|
||||
self.key = key
|
||||
return
|
||||
try:
|
||||
self.key = bytes.fromhex(key)
|
||||
except ValueError:
|
||||
pass
|
||||
self.key = key.encode()
|
||||
|
||||
def pad(self, data):
|
||||
length = BLOCK_SIZE - (len(data) % BLOCK_SIZE)
|
||||
def pad(self, data: bytes) -> bytes:
|
||||
length = self.block_size - (len(data) % self.block_size)
|
||||
return data + (chr(length) * length).encode()
|
||||
|
||||
def unpad(self, data):
|
||||
return data[: -(data[-1] if isinstance(data[-1], int) else ord(data[-1]))]
|
||||
def unpad(self, data: bytes) -> bytes:
|
||||
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]
|
||||
|
||||
@property
|
||||
def passphrase(self):
|
||||
passphrase = self.key if self.key is not None else None
|
||||
if passphrase is None:
|
||||
passphrase = getpass.getpass(f"Enter {self.description}password:")
|
||||
return passphrase
|
||||
|
||||
def bytes_to_key(self, data, salt, output=48):
|
||||
def derive_iv_and_key(
|
||||
self, salt: bytes, output_len: int = 32 + 16
|
||||
) -> tuple[bytes, bytes]:
|
||||
# extended from https://gist.github.com/gsakkis/4546068
|
||||
assert len(salt) == 8, len(salt)
|
||||
data += salt
|
||||
assert len(salt) == 8, "Salt must be 8 bytes"
|
||||
data = self.key + salt
|
||||
key = md5(data).digest()
|
||||
final_key = key
|
||||
while len(final_key) < output:
|
||||
while len(final_key) < output_len:
|
||||
key = md5(key + data).digest()
|
||||
final_key += key
|
||||
return final_key[:output]
|
||||
iv_key = final_key[:output_len]
|
||||
return iv_key[32:], iv_key[:32]
|
||||
|
||||
def decrypt(self, encrypted: str, urlsafe: bool = False) -> str:
|
||||
"""Decrypts a string using AES-256-CBC."""
|
||||
passphrase = self.passphrase
|
||||
|
||||
"""Decrypts a salted base64 encoded string using AES-256-CBC."""
|
||||
if urlsafe:
|
||||
encrypted_bytes = base64.urlsafe_b64decode(encrypted)
|
||||
decoded = urlsafe_b64decode(encrypted)
|
||||
else:
|
||||
encrypted_bytes = base64.b64decode(encrypted)
|
||||
decoded = b64decode(encrypted)
|
||||
|
||||
assert encrypted_bytes[0:8] == b"Salted__"
|
||||
salt = encrypted_bytes[8:16]
|
||||
key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16)
|
||||
key = key_iv[:32]
|
||||
iv = key_iv[32:]
|
||||
if decoded[0:8] != b"Salted__":
|
||||
raise ValueError("Invalid salt.")
|
||||
|
||||
salt = decoded[8:16]
|
||||
encrypted_bytes = decoded[16:]
|
||||
|
||||
iv, key = self.derive_iv_and_key(salt, 32 + 16)
|
||||
aes = AES.new(key, AES.MODE_CBC, iv)
|
||||
|
||||
try:
|
||||
return self.unpad(aes.decrypt(encrypted_bytes[16:])).decode()
|
||||
decrypted_bytes = aes.decrypt(encrypted_bytes)
|
||||
except Exception as 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("Wrong passphrase") from exc
|
||||
raise ValueError("Decryption resulted in invalid UTF-8 data.") from exc
|
||||
|
||||
def encrypt(self, message: bytes, urlsafe: bool = False) -> str:
|
||||
passphrase = self.passphrase
|
||||
"""
|
||||
Encrypts a string using AES-256-CBC and returns a salted base64 encoded string.
|
||||
"""
|
||||
salt = Random.new().read(8)
|
||||
key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16)
|
||||
key = key_iv[:32]
|
||||
iv = key_iv[32:]
|
||||
iv, key = self.derive_iv_and_key(salt, 32 + 16)
|
||||
aes = AES.new(key, AES.MODE_CBC, iv)
|
||||
encoded = b"Salted__" + salt + aes.encrypt(self.pad(message))
|
||||
return (
|
||||
base64.urlsafe_b64encode(encoded) if urlsafe else base64.b64encode(encoded)
|
||||
).decode()
|
||||
msg = self.pad(message)
|
||||
encrypted = aes.encrypt(msg)
|
||||
salted = b"Salted__" + salt + encrypted
|
||||
encoded = urlsafe_b64encode(salted) if urlsafe else b64encode(salted)
|
||||
return encoded.decode()
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
+21
-14
@@ -1,7 +1,7 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
@@ -72,10 +72,10 @@ class AlbyWallet(Wallet):
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
**_,
|
||||
) -> InvoiceResponse:
|
||||
# https://api.getalby.com/invoices
|
||||
data: Dict = {"amount": f"{amount}"}
|
||||
data: dict = {"amount": f"{amount}"}
|
||||
if description_hash:
|
||||
data["description_hash"] = description_hash.hex()
|
||||
elif unhashed_description:
|
||||
@@ -95,25 +95,31 @@ class AlbyWallet(Wallet):
|
||||
|
||||
if r.is_error:
|
||||
error_message = data["message"] if "message" in data else r.text
|
||||
return InvoiceResponse(False, None, None, error_message)
|
||||
return InvoiceResponse(ok=False, error_message=error_message)
|
||||
|
||||
checking_id = data["payment_hash"]
|
||||
payment_request = data["payment_request"]
|
||||
return InvoiceResponse(True, checking_id, payment_request, None)
|
||||
preimage = data.get("payment_preimage")
|
||||
return InvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=checking_id,
|
||||
payment_request=payment_request,
|
||||
preimage=preimage,
|
||||
)
|
||||
except KeyError as exc:
|
||||
logger.warning(exc)
|
||||
return InvoiceResponse(
|
||||
False, None, None, "Server error: 'missing required fields'"
|
||||
ok=False, error_message="Server error: 'missing required fields'"
|
||||
)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning(exc)
|
||||
return InvoiceResponse(
|
||||
False, None, None, "Server error: 'invalid json response'"
|
||||
ok=False, error_message="Server error: 'invalid json response'"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return InvoiceResponse(
|
||||
False, None, None, f"Unable to connect to {self.endpoint}."
|
||||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
@@ -129,30 +135,31 @@ class AlbyWallet(Wallet):
|
||||
|
||||
if r.is_error:
|
||||
error_message = data["message"] if "message" in data else r.text
|
||||
return PaymentResponse(None, None, None, None, error_message)
|
||||
return PaymentResponse(error_message=error_message)
|
||||
|
||||
checking_id = data["payment_hash"]
|
||||
# todo: confirm with bitkarrot that having the minus is fine
|
||||
# other funding sources return a positive fee value
|
||||
fee_msat = -data["fee"]
|
||||
preimage = data["payment_preimage"]
|
||||
|
||||
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
|
||||
return PaymentResponse(
|
||||
ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
|
||||
)
|
||||
except KeyError as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
None, None, None, None, "Server error: 'missing required fields'"
|
||||
error_message="Server error: 'missing required fields'"
|
||||
)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
None, None, None, None, "Server error: 'invalid json response'"
|
||||
error_message="Server error: 'invalid json response'"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info(f"Failed to pay invoice {bolt11}")
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
None, None, None, None, f"Unable to connect to {self.endpoint}."
|
||||
error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
|
||||
@@ -20,6 +20,7 @@ class InvoiceResponse(NamedTuple):
|
||||
checking_id: str | None = None # payment_hash, rpc_id
|
||||
payment_request: str | None = None
|
||||
error_message: str | None = None
|
||||
preimage: str | None = None
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
|
||||
+12
-7
@@ -135,7 +135,7 @@ class BlinkWallet(Wallet):
|
||||
)
|
||||
if len(errors) > 0:
|
||||
error_message = errors[0].get("message")
|
||||
return InvoiceResponse(False, None, None, error_message)
|
||||
return InvoiceResponse(ok=False, error_message=error_message)
|
||||
|
||||
payment_request = (
|
||||
response.get("data", {})
|
||||
@@ -150,15 +150,18 @@ class BlinkWallet(Wallet):
|
||||
.get("paymentHash", None)
|
||||
)
|
||||
|
||||
return InvoiceResponse(True, checking_id, payment_request, None)
|
||||
# TODO: add preimage to response
|
||||
return InvoiceResponse(
|
||||
ok=True, checking_id=checking_id, payment_request=payment_request
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
return InvoiceResponse(
|
||||
False, None, None, "Server error: 'invalid json response'"
|
||||
ok=False, error_message="Server error: 'invalid json response'"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return InvoiceResponse(
|
||||
False, None, None, f"Unable to connect to {self.endpoint}."
|
||||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
async def pay_invoice(
|
||||
@@ -185,19 +188,21 @@ class BlinkWallet(Wallet):
|
||||
)
|
||||
if len(errors) > 0:
|
||||
error_message = errors[0].get("message")
|
||||
return PaymentResponse(False, None, None, None, error_message)
|
||||
return PaymentResponse(ok=False, error_message=error_message)
|
||||
|
||||
checking_id = bolt11.decode(bolt11_invoice).payment_hash
|
||||
|
||||
payment_status = await self.get_payment_status(checking_id)
|
||||
fee_msat = payment_status.fee_msat
|
||||
preimage = payment_status.preimage
|
||||
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
|
||||
return PaymentResponse(
|
||||
ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info(f"Failed to pay invoice {bolt11_invoice}")
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
None, None, None, None, f"Unable to connect to {self.endpoint}."
|
||||
error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
|
||||
+12
-14
@@ -182,15 +182,16 @@ else:
|
||||
)
|
||||
)
|
||||
|
||||
# TODO: add preimage
|
||||
return InvoiceResponse(
|
||||
True,
|
||||
breez_invoice.ln_invoice.payment_hash,
|
||||
breez_invoice.ln_invoice.bolt11,
|
||||
None,
|
||||
ok=True,
|
||||
checking_id=breez_invoice.ln_invoice.payment_hash,
|
||||
payment_request=breez_invoice.ln_invoice.bolt11,
|
||||
# preimage=breez_invoice.ln_invoice.payment_preimage,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return InvoiceResponse(False, None, None, str(e))
|
||||
return InvoiceResponse(ok=False, error_message=str(e))
|
||||
|
||||
async def pay_invoice(
|
||||
self, bolt11: str, fee_limit_msat: int
|
||||
@@ -217,22 +218,19 @@ else:
|
||||
except Exception as ex:
|
||||
logger.info(ex)
|
||||
# assume that payment failed?
|
||||
return PaymentResponse(
|
||||
False, None, None, None, f"payment failed: {exc}"
|
||||
)
|
||||
return PaymentResponse(ok=False, error_message=f"payment failed: {exc}")
|
||||
|
||||
if payment.status != breez_sdk.PaymentStatus.COMPLETE:
|
||||
return PaymentResponse(False, None, None, None, "payment is pending")
|
||||
return PaymentResponse(ok=False, error_message="payment is pending")
|
||||
|
||||
# let's use the payment_hash as the checking_id
|
||||
checking_id = invoice.payment_hash
|
||||
|
||||
return PaymentResponse(
|
||||
True,
|
||||
checking_id,
|
||||
payment.fee_msat,
|
||||
payment.details.data.payment_preimage,
|
||||
None,
|
||||
ok=True,
|
||||
checking_id=checking_id,
|
||||
fee_msat=payment.fee_msat,
|
||||
preimage=payment.details.data.payment_preimage,
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
|
||||
+16
-12
@@ -54,7 +54,7 @@ class ClicheWallet(Wallet):
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
**_,
|
||||
) -> InvoiceResponse:
|
||||
if unhashed_description or description_hash:
|
||||
description_hash_str = (
|
||||
@@ -79,12 +79,13 @@ class ClicheWallet(Wallet):
|
||||
data = json.loads(r)
|
||||
checking_id = None
|
||||
payment_request = None
|
||||
error_message = None
|
||||
|
||||
if data.get("error") is not None and data["error"].get("message"):
|
||||
logger.error(data["error"]["message"])
|
||||
error_message = data["error"]["message"]
|
||||
return InvoiceResponse(False, checking_id, payment_request, error_message)
|
||||
return InvoiceResponse(
|
||||
ok=False, checking_id=checking_id, error_message=error_message
|
||||
)
|
||||
|
||||
if data.get("result") is not None:
|
||||
checking_id, payment_request = (
|
||||
@@ -92,15 +93,19 @@ class ClicheWallet(Wallet):
|
||||
data["result"]["invoice"],
|
||||
)
|
||||
else:
|
||||
return InvoiceResponse(False, None, None, "Could not get payment hash")
|
||||
return InvoiceResponse(ok=False, error_message="Could not get payment hash")
|
||||
|
||||
return InvoiceResponse(True, checking_id, payment_request, error_message)
|
||||
return InvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=checking_id,
|
||||
payment_request=payment_request,
|
||||
preimage=data["result"].get("preimage"),
|
||||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
ws = create_connection(self.endpoint)
|
||||
ws.send(f"pay-invoice --invoice {bolt11}")
|
||||
checking_id, fee_msat, preimage, error_message, payment_ok = (
|
||||
None,
|
||||
checking_id, fee_msat, preimage, payment_ok = (
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -109,8 +114,7 @@ class ClicheWallet(Wallet):
|
||||
for _ in range(2):
|
||||
r = ws.recv()
|
||||
data = json.loads(r)
|
||||
checking_id, fee_msat, preimage, error_message, payment_ok = (
|
||||
None,
|
||||
checking_id, fee_msat, preimage, payment_ok = (
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -119,7 +123,7 @@ class ClicheWallet(Wallet):
|
||||
|
||||
if data.get("error") is not None:
|
||||
error_message = data["error"].get("message")
|
||||
return PaymentResponse(False, None, None, None, error_message)
|
||||
return PaymentResponse(ok=False, error_message=error_message)
|
||||
|
||||
if data.get("method") == "payment_succeeded":
|
||||
payment_ok = True
|
||||
@@ -129,10 +133,10 @@ class ClicheWallet(Wallet):
|
||||
continue
|
||||
|
||||
if data.get("result") is None:
|
||||
return PaymentResponse(None)
|
||||
return PaymentResponse(error_message="result is None")
|
||||
|
||||
return PaymentResponse(
|
||||
payment_ok, checking_id, fee_msat, preimage, error_message
|
||||
ok=payment_ok, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import random
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any, Optional
|
||||
|
||||
from bolt11.decode import decode as bolt11_decode
|
||||
from bolt11.exceptions import Bolt11Exception
|
||||
@@ -9,6 +10,7 @@ from pyln.client import LightningRpc, RpcError
|
||||
|
||||
from lnbits.nodes.cln import CoreLightningNode
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import random_secret_and_hash
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
@@ -100,6 +102,11 @@ class CoreLightningWallet(Wallet):
|
||||
)
|
||||
if unhashed_description and not self.supports_description_hash:
|
||||
raise UnsupportedError("unhashed_description")
|
||||
|
||||
preimage = kwargs.get("preimage")
|
||||
if not preimage:
|
||||
preimage, _ = random_secret_and_hash()
|
||||
|
||||
r: dict = self.ln.invoice( # type: ignore
|
||||
amount_msat=msat,
|
||||
label=label,
|
||||
@@ -107,6 +114,7 @@ class CoreLightningWallet(Wallet):
|
||||
unhashed_description.decode() if unhashed_description else memo
|
||||
),
|
||||
exposeprivatechannels=True,
|
||||
preimage=preimage,
|
||||
deschashonly=(
|
||||
True if unhashed_description else False
|
||||
), # we can't pass None here
|
||||
@@ -115,35 +123,39 @@ class CoreLightningWallet(Wallet):
|
||||
|
||||
if r.get("code") and r.get("code") < 0: # type: ignore
|
||||
raise Exception(r.get("message"))
|
||||
|
||||
return InvoiceResponse(True, r["payment_hash"], r["bolt11"], None)
|
||||
return InvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=r["payment_hash"],
|
||||
payment_request=r["bolt11"],
|
||||
preimage=preimage,
|
||||
)
|
||||
except RpcError as exc:
|
||||
logger.warning(exc)
|
||||
error_message = f"RPC '{exc.method}' failed with '{exc.error}'."
|
||||
return InvoiceResponse(False, None, None, error_message)
|
||||
return InvoiceResponse(ok=False, error_message=error_message)
|
||||
except KeyError as exc:
|
||||
logger.warning(exc)
|
||||
return InvoiceResponse(
|
||||
False, None, None, "Server error: 'missing required fields'"
|
||||
ok=False, error_message="Server error: 'missing required fields'"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return InvoiceResponse(False, None, None, str(e))
|
||||
return InvoiceResponse(ok=False, error_message=str(e))
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
try:
|
||||
invoice = bolt11_decode(bolt11)
|
||||
except Bolt11Exception as exc:
|
||||
return PaymentResponse(False, None, None, None, str(exc))
|
||||
return PaymentResponse(ok=False, error_message=str(exc))
|
||||
|
||||
try:
|
||||
previous_payment = await self.get_payment_status(invoice.payment_hash)
|
||||
if previous_payment.paid:
|
||||
return PaymentResponse(False, None, None, None, "invoice already paid")
|
||||
return PaymentResponse(ok=False, error_message="invoice already paid")
|
||||
|
||||
if not invoice.amount_msat or invoice.amount_msat <= 0:
|
||||
return PaymentResponse(
|
||||
False, None, None, None, "CLN 0 amount invoice not supported"
|
||||
ok=False, error_message="CLN 0 amount invoice not supported"
|
||||
)
|
||||
|
||||
# maxfee overrides both maxfeepercent and exemptfee defaults (and
|
||||
@@ -170,23 +182,23 @@ class CoreLightningWallet(Wallet):
|
||||
if error_code in self.pay_failure_error_codes:
|
||||
error_message = exc.error.get("message", error_code) # type: ignore
|
||||
return PaymentResponse(
|
||||
False, None, None, None, f"Payment failed: {error_message}"
|
||||
ok=False, error_message=f"Payment failed: {error_message}"
|
||||
)
|
||||
else:
|
||||
error_message = f"Payment failed: {exc.error}"
|
||||
return PaymentResponse(None, None, None, None, error_message)
|
||||
return PaymentResponse(error_message=error_message)
|
||||
except Exception:
|
||||
error_message = f"RPC '{exc.method}' failed with '{exc.error}'."
|
||||
return PaymentResponse(None, None, None, None, error_message)
|
||||
return PaymentResponse(error_message=error_message)
|
||||
except KeyError as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
None, None, None, None, "Server error: 'missing required fields'"
|
||||
error_message="Server error: 'missing required fields'"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info(f"Failed to pay invoice {bolt11}")
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(None, None, None, None, f"Payment failed: '{exc}'.")
|
||||
return PaymentResponse(error_message=f"Payment failed: '{exc}'.")
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from bolt11 import Bolt11Exception
|
||||
@@ -9,6 +10,7 @@ from bolt11.decode import decode
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import random_secret_and_hash
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
@@ -109,7 +111,7 @@ class CoreLightningRestWallet(Wallet):
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
label = kwargs.get("label", f"lbl{random.random()}")
|
||||
data: Dict = {
|
||||
data: dict = {
|
||||
"amount": amount * 1000,
|
||||
"description": memo,
|
||||
"label": label,
|
||||
@@ -126,8 +128,10 @@ class CoreLightningRestWallet(Wallet):
|
||||
if kwargs.get("expiry"):
|
||||
data["expiry"] = kwargs["expiry"]
|
||||
|
||||
if kwargs.get("preimage"):
|
||||
data["preimage"] = kwargs["preimage"]
|
||||
preimage, _ = random_secret_and_hash()
|
||||
|
||||
# https://github.com/Ride-The-Lightning/c-lightning-REST/blob/master/controllers/invoice.js#L52C17-L52C25
|
||||
data["preimage"] = preimage
|
||||
|
||||
try:
|
||||
r = await self.client.post(
|
||||
@@ -139,41 +143,48 @@ class CoreLightningRestWallet(Wallet):
|
||||
data = r.json()
|
||||
|
||||
if len(data) == 0:
|
||||
return InvoiceResponse(False, None, None, "no data")
|
||||
return InvoiceResponse(ok=False, error_message="no data")
|
||||
|
||||
if "error" in data:
|
||||
return InvoiceResponse(
|
||||
False, None, None, f"""Server error: '{data["error"]}'"""
|
||||
ok=False, error_message=f"""Server error: '{data["error"]}'"""
|
||||
)
|
||||
|
||||
if r.is_error:
|
||||
return InvoiceResponse(False, None, None, f"Server error: '{r.text}'")
|
||||
return InvoiceResponse(
|
||||
ok=False, error_message=f"Server error: '{r.text}'"
|
||||
)
|
||||
|
||||
if "payment_hash" not in data or "bolt11" not in data:
|
||||
return InvoiceResponse(
|
||||
False, None, None, "Server error: 'missing required fields'"
|
||||
ok=False, error_message="Server error: 'missing required fields'"
|
||||
)
|
||||
|
||||
return InvoiceResponse(True, data["payment_hash"], data["bolt11"], None)
|
||||
return InvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=data["payment_hash"],
|
||||
payment_request=data["bolt11"],
|
||||
preimage=preimage,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
return InvoiceResponse(
|
||||
False, None, None, "Server error: 'invalid json response'"
|
||||
ok=False, error_message="Server error: 'invalid json response'"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return InvoiceResponse(
|
||||
False, None, None, f"Unable to connect to {self.url}."
|
||||
ok=False, error_message=f"Unable to connect to {self.url}."
|
||||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
try:
|
||||
invoice = decode(bolt11)
|
||||
except Bolt11Exception as exc:
|
||||
return PaymentResponse(False, None, None, None, str(exc))
|
||||
return PaymentResponse(ok=False, error_message=str(exc))
|
||||
|
||||
if not invoice.amount_msat or invoice.amount_msat <= 0:
|
||||
error_message = "0 amount invoices are not allowed"
|
||||
return PaymentResponse(False, None, None, None, error_message)
|
||||
return PaymentResponse(ok=False, error_message=error_message)
|
||||
try:
|
||||
r = await self.client.post(
|
||||
f"{self.url}/v1/pay",
|
||||
@@ -190,18 +201,16 @@ class CoreLightningRestWallet(Wallet):
|
||||
status = self.statuses.get(data["status"])
|
||||
if "payment_preimage" not in data:
|
||||
return PaymentResponse(
|
||||
status,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
data.get("error"),
|
||||
ok=status, error_message=data.get("error") or "unknown error"
|
||||
)
|
||||
|
||||
checking_id = data["payment_hash"]
|
||||
preimage = data["payment_preimage"]
|
||||
fee_msat = data["msatoshi_sent"] - data["msatoshi"]
|
||||
|
||||
return PaymentResponse(status, checking_id, fee_msat, preimage, None)
|
||||
return PaymentResponse(
|
||||
ok=status, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
try:
|
||||
logger.debug(exc)
|
||||
@@ -209,28 +218,26 @@ class CoreLightningRestWallet(Wallet):
|
||||
error_code = int(data["error"]["code"])
|
||||
if error_code in self.pay_failure_error_codes:
|
||||
error_message = f"Payment failed: {data['error']['message']}"
|
||||
return PaymentResponse(False, None, None, None, error_message)
|
||||
return PaymentResponse(ok=False, error_message=error_message)
|
||||
error_message = f"REST failed with {data['error']['message']}."
|
||||
return PaymentResponse(None, None, None, None, error_message)
|
||||
return PaymentResponse(error_message=error_message)
|
||||
except Exception as exc:
|
||||
error_message = f"Unable to connect to {self.url}."
|
||||
return PaymentResponse(None, None, None, None, error_message)
|
||||
return PaymentResponse(error_message=error_message)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return PaymentResponse(
|
||||
None, None, None, None, "Server error: 'invalid json response'"
|
||||
error_message="Server error: 'invalid json response'"
|
||||
)
|
||||
except KeyError as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
None, None, None, None, "Server error: 'missing required fields'"
|
||||
error_message="Server error: 'missing required fields'"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info(f"Failed to pay invoice {bolt11}")
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
None, None, None, None, f"Unable to connect to {self.url}."
|
||||
)
|
||||
return PaymentResponse(error_message=f"Unable to connect to {self.url}.")
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
r = await self.client.get(
|
||||
|
||||
+30
-19
@@ -3,13 +3,14 @@ import base64
|
||||
import hashlib
|
||||
import json
|
||||
import urllib.parse
|
||||
from typing import Any, AsyncGenerator, Dict, Optional
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from websockets.client import connect
|
||||
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import random_secret_and_hash
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
@@ -85,7 +86,7 @@ class EclairWallet(Wallet):
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
data: Dict[str, Any] = {
|
||||
data: dict[str, Any] = {
|
||||
"amountMsat": amount * 1000,
|
||||
}
|
||||
if kwargs.get("expiry"):
|
||||
@@ -99,36 +100,45 @@ class EclairWallet(Wallet):
|
||||
else:
|
||||
data["description"] = memo
|
||||
|
||||
preimage, _ = random_secret_and_hash()
|
||||
data["paymentPreimage"] = preimage
|
||||
|
||||
try:
|
||||
r = await self.client.post("/createinvoice", data=data, timeout=40)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
if len(data) == 0:
|
||||
return InvoiceResponse(False, None, None, "no data")
|
||||
return InvoiceResponse(ok=False, error_message="no data")
|
||||
|
||||
if "error" in data:
|
||||
return InvoiceResponse(
|
||||
False, None, None, f"""Server error: '{data["error"]}'"""
|
||||
ok=False, error_message=f"""Server error: '{data["error"]}'"""
|
||||
)
|
||||
|
||||
if r.is_error:
|
||||
return InvoiceResponse(False, None, None, f"Server error: '{r.text}'")
|
||||
|
||||
return InvoiceResponse(True, data["paymentHash"], data["serialized"], None)
|
||||
return InvoiceResponse(
|
||||
ok=False, error_message=f"Server error: '{r.text}'"
|
||||
)
|
||||
return InvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=data["paymentHash"],
|
||||
payment_request=data["serialized"],
|
||||
preimage=preimage,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
return InvoiceResponse(
|
||||
False, None, None, "Server error: 'invalid json response'"
|
||||
ok=False, error_message="Server error: 'invalid json response'"
|
||||
)
|
||||
except KeyError as exc:
|
||||
logger.warning(exc)
|
||||
return InvoiceResponse(
|
||||
False, None, None, "Server error: 'missing required fields'"
|
||||
ok=False, error_message="Server error: 'missing required fields'"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return InvoiceResponse(
|
||||
False, None, None, f"Unable to connect to {self.url}."
|
||||
ok=False, error_message=f"Unable to connect to {self.url}."
|
||||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
@@ -142,35 +152,36 @@ class EclairWallet(Wallet):
|
||||
data = r.json()
|
||||
|
||||
if "error" in data:
|
||||
return PaymentResponse(None, None, None, None, data["error"])
|
||||
return PaymentResponse(error_message=data["error"])
|
||||
if r.is_error:
|
||||
return PaymentResponse(None, None, None, None, r.text)
|
||||
return PaymentResponse(error_message=r.text)
|
||||
|
||||
if data["type"] == "payment-failed":
|
||||
return PaymentResponse(False, None, None, None, "payment failed")
|
||||
return PaymentResponse(ok=False, error_message="payment failed")
|
||||
|
||||
checking_id = data["paymentHash"]
|
||||
preimage = data["paymentPreimage"]
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return PaymentResponse(
|
||||
None, None, None, None, "Server error: 'invalid json response'"
|
||||
error_message="Server error: 'invalid json response'"
|
||||
)
|
||||
except KeyError:
|
||||
return PaymentResponse(
|
||||
None, None, None, None, "Server error: 'missing required fields'"
|
||||
error_message="Server error: 'missing required fields'"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info(f"Failed to pay invoice {bolt11}")
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
None, None, None, None, f"Unable to connect to {self.url}."
|
||||
)
|
||||
return PaymentResponse(error_message=f"Unable to connect to {self.url}.")
|
||||
|
||||
payment_status: PaymentStatus = await self.get_payment_status(checking_id)
|
||||
success = True if payment_status.success else None
|
||||
return PaymentResponse(
|
||||
success, checking_id, payment_status.fee_msat, preimage, None
|
||||
ok=success,
|
||||
checking_id=checking_id,
|
||||
fee_msat=payment_status.fee_msat,
|
||||
preimage=preimage,
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
|
||||
@@ -100,7 +100,7 @@ class FakeWallet(Wallet):
|
||||
ok=True,
|
||||
checking_id=payment_hash,
|
||||
payment_request=payment_request,
|
||||
# preimage=preimage.hex(),
|
||||
preimage=preimage.hex(),
|
||||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, _: int) -> PaymentResponse:
|
||||
|
||||
+20
-12
@@ -92,23 +92,28 @@ class LNbitsWallet(Wallet):
|
||||
if r.is_error or not payment_str:
|
||||
error_message = data["detail"] if "detail" in data else r.text
|
||||
return InvoiceResponse(
|
||||
False, None, None, f"Server error: '{error_message}'"
|
||||
ok=False, error_message=f"Server error: '{error_message}'"
|
||||
)
|
||||
|
||||
return InvoiceResponse(True, data["checking_id"], payment_str, None)
|
||||
return InvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=data["checking_id"],
|
||||
payment_request=payment_str,
|
||||
preimage=data.get("preimage"),
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
return InvoiceResponse(
|
||||
False, None, None, "Server error: 'invalid json response'"
|
||||
ok=False, error_message="Server error: 'invalid json response'"
|
||||
)
|
||||
except KeyError as exc:
|
||||
logger.warning(exc)
|
||||
return InvoiceResponse(
|
||||
False, None, None, "Server error: 'missing required fields'"
|
||||
ok=False, error_message="Server error: 'missing required fields'"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return InvoiceResponse(
|
||||
False, None, None, f"Unable to connect to {self.endpoint}."
|
||||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
@@ -129,7 +134,10 @@ class LNbitsWallet(Wallet):
|
||||
|
||||
success = True if payment.success else None
|
||||
return PaymentResponse(
|
||||
success, checking_id, payment.fee_msat, payment.preimage
|
||||
ok=success,
|
||||
checking_id=checking_id,
|
||||
fee_msat=payment.fee_msat,
|
||||
preimage=payment.preimage,
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as exc:
|
||||
@@ -138,25 +146,25 @@ class LNbitsWallet(Wallet):
|
||||
data = exc.response.json()
|
||||
error_message = f"Payment {data['status']}: {data['detail']}."
|
||||
if data["status"] == "failed":
|
||||
return PaymentResponse(False, None, None, None, error_message)
|
||||
return PaymentResponse(None, None, None, None, error_message)
|
||||
return PaymentResponse(ok=False, error_message=error_message)
|
||||
return PaymentResponse(error_message=error_message)
|
||||
except Exception as exc:
|
||||
error_message = f"Unable to connect to {self.endpoint}."
|
||||
return PaymentResponse(None, None, None, None, error_message)
|
||||
return PaymentResponse(error_message=error_message)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return PaymentResponse(
|
||||
None, None, None, None, "Server error: 'invalid json response'"
|
||||
error_message="Server error: 'invalid json response'"
|
||||
)
|
||||
except KeyError:
|
||||
return PaymentResponse(
|
||||
None, None, None, None, "Server error: 'missing required fields'"
|
||||
error_message="Server error: 'missing required fields'"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info(f"Failed to pay invoice {bolt11}")
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
None, None, None, None, f"Unable to connect to {self.endpoint}."
|
||||
error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
|
||||
+50
-32
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
from hashlib import sha256
|
||||
from os import environ
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
|
||||
@@ -12,10 +12,11 @@ import lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc as lnrpc
|
||||
import lnbits.wallets.lnd_grpc_files.router_pb2 as router
|
||||
import lnbits.wallets.lnd_grpc_files.router_pb2_grpc as routerrpc
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import AESCipher
|
||||
from lnbits.utils.crypto import random_secret_and_hash
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
PaymentFailedStatus,
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
@@ -71,6 +72,11 @@ class LndWallet(Wallet):
|
||||
"cannot initialize LndWallet: missing lnd_grpc_cert or lnd_cert"
|
||||
)
|
||||
|
||||
self.endpoint = self.normalize_endpoint(
|
||||
settings.lnd_grpc_endpoint, add_proto=False
|
||||
)
|
||||
self.port = int(settings.lnd_grpc_port)
|
||||
|
||||
macaroon = (
|
||||
settings.lnd_grpc_macaroon
|
||||
or settings.lnd_grpc_admin_macaroon
|
||||
@@ -79,23 +85,11 @@ class LndWallet(Wallet):
|
||||
or settings.lnd_invoice_macaroon
|
||||
)
|
||||
encrypted_macaroon = settings.lnd_grpc_macaroon_encrypted
|
||||
if encrypted_macaroon:
|
||||
macaroon = AESCipher(description="macaroon decryption").decrypt(
|
||||
encrypted_macaroon
|
||||
)
|
||||
if not macaroon:
|
||||
raise ValueError(
|
||||
"cannot initialize LndWallet: "
|
||||
"missing lnd_grpc_macaroon or lnd_grpc_admin_macaroon or "
|
||||
"lnd_admin_macaroon or lnd_grpc_invoice_macaroon or "
|
||||
"lnd_invoice_macaroon or lnd_grpc_macaroon_encrypted"
|
||||
)
|
||||
try:
|
||||
self.macaroon = load_macaroon(macaroon, encrypted_macaroon)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"cannot load macaroon for LndWallet: {exc!s}") from exc
|
||||
|
||||
self.endpoint = self.normalize_endpoint(
|
||||
settings.lnd_grpc_endpoint, add_proto=False
|
||||
)
|
||||
self.port = int(settings.lnd_grpc_port)
|
||||
self.macaroon = load_macaroon(macaroon)
|
||||
cert = open(cert_path, "rb").read()
|
||||
creds = grpc.ssl_channel_credentials(cert)
|
||||
auth_creds = grpc.metadata_call_credentials(self.metadata_callback)
|
||||
@@ -139,21 +133,38 @@ class LndWallet(Wallet):
|
||||
if description_hash:
|
||||
data["description_hash"] = description_hash
|
||||
elif unhashed_description:
|
||||
data["description_hash"] = hashlib.sha256(
|
||||
unhashed_description
|
||||
).digest() # as bytes directly
|
||||
data["description_hash"] = sha256(unhashed_description).digest()
|
||||
|
||||
preimage = kwargs.get("preimage")
|
||||
if preimage:
|
||||
payment_hash = sha256(preimage.encode()).hexdigest()
|
||||
else:
|
||||
preimage, payment_hash = random_secret_and_hash()
|
||||
|
||||
data["r_hash"] = bytes.fromhex(payment_hash)
|
||||
data["r_preimage"] = bytes.fromhex(preimage)
|
||||
try:
|
||||
req = ln.Invoice(**data)
|
||||
resp = await self.rpc.AddInvoice(req)
|
||||
# response model
|
||||
# {
|
||||
# "r_hash": <bytes>,
|
||||
# "payment_request": <string>,
|
||||
# "add_index": <uint64>,
|
||||
# "payment_addr": <bytes>,
|
||||
# }
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
error_message = str(exc)
|
||||
return InvoiceResponse(False, None, None, error_message)
|
||||
return InvoiceResponse(ok=False, error_message=str(exc))
|
||||
|
||||
checking_id = bytes_to_hex(resp.r_hash)
|
||||
payment_request = str(resp.payment_request)
|
||||
return InvoiceResponse(True, checking_id, payment_request, None)
|
||||
return InvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=checking_id,
|
||||
payment_request=payment_request,
|
||||
preimage=preimage,
|
||||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
# fee_limit_fixed = ln.FeeLimit(fixed=fee_limit_msat // 1000)
|
||||
@@ -167,7 +178,7 @@ class LndWallet(Wallet):
|
||||
resp = await self.routerpc.SendPaymentV2(req).read()
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(None, None, None, None, str(exc))
|
||||
return PaymentResponse(error_message=str(exc))
|
||||
|
||||
# PaymentStatus from https://github.com/lightningnetwork/lnd/blob/master/channeldb/payments.go#L178
|
||||
statuses = {
|
||||
@@ -195,12 +206,18 @@ class LndWallet(Wallet):
|
||||
fee_msat = -resp.htlcs[-1].route.total_fees_msat
|
||||
preimage = resp.payment_preimage
|
||||
checking_id = resp.payment_hash
|
||||
return PaymentResponse(
|
||||
ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
|
||||
)
|
||||
elif statuses[resp.status] is False:
|
||||
error_message = failure_reasons[resp.failure_reason]
|
||||
|
||||
return PaymentResponse(
|
||||
statuses[resp.status], checking_id, fee_msat, preimage, error_message
|
||||
)
|
||||
return PaymentResponse(ok=False, error_message=error_message)
|
||||
else:
|
||||
return PaymentResponse(
|
||||
ok=None,
|
||||
checking_id=checking_id,
|
||||
error_message="Payment in flight or non-existant.",
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
@@ -211,10 +228,11 @@ class LndWallet(Wallet):
|
||||
raise ValueError
|
||||
|
||||
resp = await self.rpc.LookupInvoice(ln.PaymentHash(r_hash=r_hash))
|
||||
|
||||
# todo: where is the FAILED status
|
||||
if resp.settled:
|
||||
return PaymentSuccessStatus()
|
||||
return PaymentSuccessStatus(preimage=resp.r_preimage.hex())
|
||||
|
||||
if resp.state == "CANCELED":
|
||||
return PaymentFailedStatus()
|
||||
|
||||
return PaymentPendingStatus()
|
||||
except grpc.RpcError as exc:
|
||||
|
||||
+65
-46
@@ -9,7 +9,7 @@ from loguru import logger
|
||||
|
||||
from lnbits.nodes.lndrest import LndRestNode
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import AESCipher
|
||||
from lnbits.utils.crypto import random_secret_and_hash
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
@@ -35,26 +35,6 @@ class LndRestWallet(Wallet):
|
||||
"cannot initialize LndRestWallet: missing lnd_rest_endpoint"
|
||||
)
|
||||
|
||||
macaroon = (
|
||||
settings.lnd_rest_macaroon
|
||||
or settings.lnd_admin_macaroon
|
||||
or settings.lnd_rest_admin_macaroon
|
||||
or settings.lnd_invoice_macaroon
|
||||
or settings.lnd_rest_invoice_macaroon
|
||||
)
|
||||
encrypted_macaroon = settings.lnd_rest_macaroon_encrypted
|
||||
if encrypted_macaroon:
|
||||
macaroon = AESCipher(description="macaroon decryption").decrypt(
|
||||
encrypted_macaroon
|
||||
)
|
||||
if not macaroon:
|
||||
raise ValueError(
|
||||
"cannot initialize LndRestWallet: "
|
||||
"missing lnd_rest_macaroon or lnd_admin_macaroon or "
|
||||
"lnd_rest_admin_macaroon or lnd_invoice_macaroon or "
|
||||
"lnd_rest_invoice_macaroon or lnd_rest_macaroon_encrypted"
|
||||
)
|
||||
|
||||
if not settings.lnd_rest_cert:
|
||||
logger.warning(
|
||||
"No certificate for LndRestWallet provided! "
|
||||
@@ -68,7 +48,21 @@ class LndRestWallet(Wallet):
|
||||
# even on startup
|
||||
cert = settings.lnd_rest_cert or True
|
||||
|
||||
macaroon = load_macaroon(macaroon)
|
||||
macaroon = (
|
||||
settings.lnd_rest_macaroon
|
||||
or settings.lnd_admin_macaroon
|
||||
or settings.lnd_rest_admin_macaroon
|
||||
or settings.lnd_invoice_macaroon
|
||||
or settings.lnd_rest_invoice_macaroon
|
||||
)
|
||||
encrypted_macaroon = settings.lnd_rest_macaroon_encrypted
|
||||
try:
|
||||
macaroon = load_macaroon(macaroon, encrypted_macaroon)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"cannot load macaroon for LndRestWallet: {exc!s}"
|
||||
) from exc
|
||||
|
||||
headers = {
|
||||
"Grpc-Metadata-macaroon": macaroon,
|
||||
"User-Agent": settings.user_agent,
|
||||
@@ -110,56 +104,67 @@ class LndRestWallet(Wallet):
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
data: Dict = {
|
||||
_data: Dict = {
|
||||
"value": amount,
|
||||
"private": settings.lnd_rest_route_hints,
|
||||
"memo": memo or "",
|
||||
}
|
||||
if kwargs.get("expiry"):
|
||||
data["expiry"] = kwargs["expiry"]
|
||||
_data["expiry"] = kwargs["expiry"]
|
||||
if description_hash:
|
||||
data["description_hash"] = base64.b64encode(description_hash).decode(
|
||||
_data["description_hash"] = base64.b64encode(description_hash).decode(
|
||||
"ascii"
|
||||
)
|
||||
elif unhashed_description:
|
||||
data["description_hash"] = base64.b64encode(
|
||||
_data["description_hash"] = base64.b64encode(
|
||||
hashlib.sha256(unhashed_description).digest()
|
||||
).decode("ascii")
|
||||
|
||||
preimage, _payment_hash = random_secret_and_hash()
|
||||
_data["r_hash"] = base64.b64encode(bytes.fromhex(_payment_hash)).decode()
|
||||
_data["r_preimage"] = base64.b64encode(bytes.fromhex(preimage)).decode()
|
||||
|
||||
try:
|
||||
r = await self.client.post(url="/v1/invoices", json=data)
|
||||
r = await self.client.post(url="/v1/invoices", json=_data)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
if len(data) == 0:
|
||||
return InvoiceResponse(False, None, None, "no data")
|
||||
return InvoiceResponse(ok=False, error_message="no data")
|
||||
|
||||
if "error" in data:
|
||||
return InvoiceResponse(
|
||||
False, None, None, f"""Server error: '{data["error"]}'"""
|
||||
ok=False, error_message=f"""Server error: '{data["error"]}'"""
|
||||
)
|
||||
|
||||
if r.is_error:
|
||||
return InvoiceResponse(False, None, None, f"Server error: '{r.text}'")
|
||||
return InvoiceResponse(
|
||||
ok=False, error_message=f"Server error: '{r.text}'"
|
||||
)
|
||||
|
||||
if "payment_request" not in data or "r_hash" not in data:
|
||||
return InvoiceResponse(
|
||||
False, None, None, "Server error: 'missing required fields'"
|
||||
ok=False, error_message="Server error: 'missing required fields'"
|
||||
)
|
||||
|
||||
payment_request = data["payment_request"]
|
||||
payment_hash = base64.b64decode(data["r_hash"]).hex()
|
||||
checking_id = payment_hash
|
||||
return InvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=checking_id,
|
||||
payment_request=payment_request,
|
||||
preimage=preimage,
|
||||
)
|
||||
|
||||
return InvoiceResponse(True, checking_id, payment_request, None)
|
||||
except json.JSONDecodeError:
|
||||
return InvoiceResponse(
|
||||
False, None, None, "Server error: 'invalid json response'"
|
||||
ok=False, error_message="Server error: 'invalid json response'"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return InvoiceResponse(
|
||||
False, None, None, f"Unable to connect to {self.endpoint}."
|
||||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
@@ -185,25 +190,27 @@ class LndRestWallet(Wallet):
|
||||
payment_error = data.get("payment_error")
|
||||
if payment_error:
|
||||
logger.warning(f"LndRestWallet payment_error: {payment_error}.")
|
||||
return PaymentResponse(False, None, None, None, payment_error)
|
||||
return PaymentResponse(ok=False, error_message=payment_error)
|
||||
|
||||
checking_id = base64.b64decode(data["payment_hash"]).hex()
|
||||
fee_msat = int(data["payment_route"]["total_fees_msat"])
|
||||
preimage = base64.b64decode(data["payment_preimage"]).hex()
|
||||
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
|
||||
return PaymentResponse(
|
||||
ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
|
||||
)
|
||||
except KeyError as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
None, None, None, None, "Server error: 'missing required fields'"
|
||||
error_message="Server error: 'missing required fields'"
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
return PaymentResponse(
|
||||
None, None, None, None, "Server error: 'invalid json response'"
|
||||
error_message="Server error: 'invalid json response'"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(f"LndRestWallet pay_invoice POST error: {exc}.")
|
||||
return PaymentResponse(
|
||||
None, None, None, None, f"Unable to connect to {self.endpoint}."
|
||||
error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
@@ -212,15 +219,22 @@ class LndRestWallet(Wallet):
|
||||
try:
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
if r.is_error or not data.get("settled"):
|
||||
# this must also work when checking_id is not a hex recognizable by lnd
|
||||
# it will return an error and no "settled" attribute on the object
|
||||
return PaymentPendingStatus()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting invoice status: {e}")
|
||||
return PaymentPendingStatus()
|
||||
return PaymentSuccessStatus()
|
||||
|
||||
if r.is_error or data.get("settled") is None:
|
||||
# this must also work when checking_id is not a hex recognizable by lnd
|
||||
# it will return an error and no "settled" attribute on the object
|
||||
return PaymentPendingStatus()
|
||||
|
||||
if data.get("settled") is True:
|
||||
return PaymentSuccessStatus()
|
||||
|
||||
if data.get("state") == "CANCELED":
|
||||
return PaymentFailedStatus()
|
||||
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
"""
|
||||
@@ -266,7 +280,12 @@ class LndRestWallet(Wallet):
|
||||
if payment is not None and payment.get("status"):
|
||||
return PaymentStatus(
|
||||
paid=statuses[payment["status"]],
|
||||
fee_msat=payment.get("fee_msat"),
|
||||
# API returns fee_msat as string, explicitly convert to int
|
||||
fee_msat=(
|
||||
int(payment["fee_msat"])
|
||||
if payment.get("fee_msat")
|
||||
else None
|
||||
),
|
||||
preimage=payment.get("payment_preimage"),
|
||||
)
|
||||
else:
|
||||
|
||||
+15
-18
@@ -91,20 +91,17 @@ class LNPayWallet(Wallet):
|
||||
json=data,
|
||||
timeout=60,
|
||||
)
|
||||
ok, checking_id, payment_request, error_message = (
|
||||
r.status_code == 201,
|
||||
None,
|
||||
None,
|
||||
r.text,
|
||||
)
|
||||
|
||||
if ok:
|
||||
if r.status_code == 201:
|
||||
data = r.json()
|
||||
checking_id, payment_request = data["id"], data["payment_request"]
|
||||
|
||||
self.pending_invoices.append(checking_id)
|
||||
|
||||
return InvoiceResponse(ok, checking_id, payment_request, error_message)
|
||||
self.pending_invoices.append(data["id"])
|
||||
return InvoiceResponse(
|
||||
ok=True,
|
||||
payment_request=data["payment_request"],
|
||||
)
|
||||
return InvoiceResponse(
|
||||
ok=False,
|
||||
error_message=r.text,
|
||||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
r = await self.client.post(
|
||||
@@ -116,17 +113,17 @@ class LNPayWallet(Wallet):
|
||||
try:
|
||||
data = r.json()
|
||||
except Exception:
|
||||
return PaymentResponse(
|
||||
False, None, 0, None, f"Got invalid JSON: {r.text[:200]}"
|
||||
)
|
||||
return PaymentResponse(ok=False, error_message="Got invalid JSON.")
|
||||
|
||||
if r.is_error:
|
||||
return PaymentResponse(False, None, None, None, data["message"])
|
||||
return PaymentResponse(ok=False, error_message=data["message"])
|
||||
|
||||
checking_id = data["lnTx"]["id"]
|
||||
fee_msat = 0
|
||||
preimage = data["lnTx"]["payment_preimage"]
|
||||
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
|
||||
return PaymentResponse(
|
||||
ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
return await self.get_payment_status(checking_id)
|
||||
|
||||
@@ -70,7 +70,7 @@ class LnTipsWallet(Wallet):
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
**_,
|
||||
) -> InvoiceResponse:
|
||||
data: Dict = {"amount": amount, "description_hash": "", "memo": memo or ""}
|
||||
if description_hash:
|
||||
@@ -91,11 +91,14 @@ class LnTipsWallet(Wallet):
|
||||
except Exception:
|
||||
error_message = r.text
|
||||
|
||||
return InvoiceResponse(False, None, None, error_message)
|
||||
return InvoiceResponse(ok=False, error_message=error_message)
|
||||
|
||||
data = r.json()
|
||||
return InvoiceResponse(
|
||||
True, data["payment_hash"], data["payment_request"], None
|
||||
ok=True,
|
||||
checking_id=data["payment_hash"],
|
||||
payment_request=data["payment_request"],
|
||||
preimage=data.get("preimage"),
|
||||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
@@ -105,7 +108,7 @@ class LnTipsWallet(Wallet):
|
||||
timeout=None,
|
||||
)
|
||||
if r.is_error:
|
||||
return PaymentResponse(False, None, 0, None, r.text)
|
||||
return PaymentResponse(ok=False, error_message=r.text)
|
||||
|
||||
if "error" in r.json():
|
||||
try:
|
||||
@@ -113,13 +116,15 @@ class LnTipsWallet(Wallet):
|
||||
error_message = data["error"]
|
||||
except Exception:
|
||||
error_message = r.text
|
||||
return PaymentResponse(False, None, 0, None, error_message)
|
||||
return PaymentResponse(ok=False, error_message=error_message)
|
||||
|
||||
data = r.json()["details"]
|
||||
checking_id = data["payment_hash"]
|
||||
fee_msat = -data["fee"]
|
||||
preimage = data["preimage"]
|
||||
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
|
||||
return PaymentResponse(
|
||||
ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
import base64
|
||||
|
||||
from loguru import logger
|
||||
from getpass import getpass
|
||||
from typing import Optional
|
||||
|
||||
from lnbits.utils.crypto import AESCipher
|
||||
|
||||
|
||||
def load_macaroon(macaroon: str) -> str:
|
||||
"""Returns hex version of a macaroon encoded in base64 or the file path.
|
||||
def load_macaroon(
|
||||
macaroon: Optional[str] = None,
|
||||
encrypted_macaroon: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Returns hex version of a macaroon encoded in base64 or the file path."""
|
||||
|
||||
:param macaroon: Macaroon encoded in base64 or file path.
|
||||
:type macaroon: str
|
||||
:return: Hex version of macaroon.
|
||||
:rtype: str
|
||||
"""
|
||||
if macaroon is None and encrypted_macaroon is None:
|
||||
raise ValueError("Either macaroon or encrypted_macaroon must be provided.")
|
||||
|
||||
if encrypted_macaroon:
|
||||
# if the macaroon is encrypted, decrypt it and return the hex version
|
||||
key = getpass("Enter the macaroon decryption key: ")
|
||||
aes = AESCipher(key.encode())
|
||||
return aes.decrypt(encrypted_macaroon)
|
||||
|
||||
assert macaroon, "macaroon must be set here"
|
||||
|
||||
# if the macaroon is a file path, load it and return hex version
|
||||
if macaroon.split(".")[-1] == "macaroon":
|
||||
with open(macaroon, "rb") as f:
|
||||
macaroon_bytes = f.read()
|
||||
return macaroon_bytes.hex()
|
||||
else:
|
||||
# if macaroon is a provided string
|
||||
# check if it is hex, if so, return
|
||||
try:
|
||||
bytes.fromhex(macaroon)
|
||||
return macaroon
|
||||
except ValueError:
|
||||
pass
|
||||
# convert the bas64 macaroon to hex
|
||||
try:
|
||||
macaroon = base64.b64decode(macaroon).hex()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# if macaroon is a provided string check if it is hex, if so, return
|
||||
try:
|
||||
bytes.fromhex(macaroon)
|
||||
return macaroon
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# convert the base64 macaroon to hex
|
||||
try:
|
||||
macaroon = base64.b64decode(macaroon).hex()
|
||||
return macaroon
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return macaroon
|
||||
|
||||
|
||||
# todo: move to its own (crypto.py) file
|
||||
# if this file is executed directly, ask for a macaroon and encrypt it
|
||||
if __name__ == "__main__":
|
||||
macaroon = input("Enter macaroon: ")
|
||||
macaroon = load_macaroon(macaroon)
|
||||
macaroon = AESCipher(description="encryption").encrypt(macaroon.encode())
|
||||
logger.info("Encrypted macaroon:")
|
||||
logger.info(macaroon)
|
||||
|
||||
+22
-13
@@ -132,7 +132,7 @@ class NWCWallet(Wallet):
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
**_,
|
||||
) -> InvoiceResponse:
|
||||
desc = ""
|
||||
desc_hash = None
|
||||
@@ -148,10 +148,8 @@ class NWCWallet(Wallet):
|
||||
info = await self.conn.get_info()
|
||||
if "make_invoice" not in info["supported_methods"]:
|
||||
return InvoiceResponse(
|
||||
False,
|
||||
None,
|
||||
None,
|
||||
"make_invoice is not supported by this NWC service.",
|
||||
ok=False,
|
||||
error_message="make_invoice is not supported by this NWC service.",
|
||||
)
|
||||
resp = await self.conn.call(
|
||||
"make_invoice",
|
||||
@@ -175,7 +173,9 @@ class NWCWallet(Wallet):
|
||||
"expired": False,
|
||||
}
|
||||
)
|
||||
return InvoiceResponse(True, checking_id, payment_request, None)
|
||||
return InvoiceResponse(
|
||||
ok=True, checking_id=checking_id, payment_request=payment_request
|
||||
)
|
||||
except Exception as e:
|
||||
return InvoiceResponse(ok=False, error_message=str(e))
|
||||
|
||||
@@ -203,7 +203,9 @@ class NWCWallet(Wallet):
|
||||
|
||||
if "lookup_invoice" not in info["supported_methods"]:
|
||||
# if not supported, we assume it succeeded
|
||||
return PaymentResponse(True, payment_hash, None, preimage, None)
|
||||
return PaymentResponse(
|
||||
ok=True, checking_id=payment_hash, preimage=preimage, fee_msat=0
|
||||
)
|
||||
|
||||
try:
|
||||
payment_data = await self.conn.call(
|
||||
@@ -213,15 +215,20 @@ class NWCWallet(Wallet):
|
||||
"preimage", None
|
||||
)
|
||||
if not settled:
|
||||
return PaymentResponse(None, payment_hash, None, None, None)
|
||||
return PaymentResponse(checking_id=payment_hash)
|
||||
else:
|
||||
fee_msat = payment_data.get("fees_paid", None)
|
||||
return PaymentResponse(True, payment_hash, fee_msat, preimage, None)
|
||||
return PaymentResponse(
|
||||
ok=True,
|
||||
checking_id=payment_hash,
|
||||
fee_msat=fee_msat,
|
||||
preimage=preimage,
|
||||
)
|
||||
except Exception:
|
||||
# Workaround: some nwc service providers might not store the invoice
|
||||
# right away, so this call may raise an exception.
|
||||
# We will assume the payment is pending anyway
|
||||
return PaymentResponse(None, payment_hash, None, None, None)
|
||||
return PaymentResponse(checking_id=payment_hash)
|
||||
except NWCError as e:
|
||||
logger.error("Error paying invoice: " + str(e))
|
||||
failure_codes = [
|
||||
@@ -237,13 +244,14 @@ class NWCWallet(Wallet):
|
||||
]
|
||||
failed = e.code in failure_codes
|
||||
return PaymentResponse(
|
||||
None if not failed else False,
|
||||
ok=None if not failed else False,
|
||||
error_message=e.message if failed else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error paying invoice: " + str(e))
|
||||
msg = "Error paying invoice: " + str(e)
|
||||
logger.error(msg)
|
||||
# assume pending
|
||||
return PaymentResponse(None)
|
||||
return PaymentResponse(error_message=msg)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
return await self.get_payment_status(checking_id)
|
||||
@@ -300,6 +308,7 @@ class NWCConnection:
|
||||
self.account_private_key = secp256k1.PrivateKey(bytes.fromhex(secret))
|
||||
self.account_private_key_hex = secret
|
||||
self.account_public_key = self.account_private_key.pubkey
|
||||
assert self.account_public_key
|
||||
self.account_public_key_hex = self.account_public_key.serialize().hex()[2:]
|
||||
|
||||
# Extract service key (used for encryption to identify the nwc service provider)
|
||||
|
||||
@@ -72,7 +72,7 @@ class OpenNodeWallet(Wallet):
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
**_,
|
||||
) -> InvoiceResponse:
|
||||
if description_hash or unhashed_description:
|
||||
raise UnsupportedError("description_hash")
|
||||
@@ -88,13 +88,15 @@ class OpenNodeWallet(Wallet):
|
||||
|
||||
if r.is_error:
|
||||
error_message = r.json()["message"]
|
||||
return InvoiceResponse(False, None, None, error_message)
|
||||
return InvoiceResponse(ok=False, error_message=error_message)
|
||||
|
||||
data = r.json()["data"]
|
||||
checking_id = data["id"]
|
||||
payment_request = data["lightning_invoice"]["payreq"]
|
||||
self.pending_invoices.append(checking_id)
|
||||
return InvoiceResponse(True, checking_id, payment_request, None)
|
||||
return InvoiceResponse(
|
||||
ok=True, checking_id=checking_id, payment_request=payment_request
|
||||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
r = await self.client.post(
|
||||
@@ -105,16 +107,15 @@ class OpenNodeWallet(Wallet):
|
||||
|
||||
if r.is_error:
|
||||
error_message = r.json()["message"]
|
||||
return PaymentResponse(False, None, None, None, error_message)
|
||||
return PaymentResponse(ok=False, error_message=error_message)
|
||||
|
||||
data = r.json()["data"]
|
||||
checking_id = data["id"]
|
||||
fee_msat = -data["fee"] * 1000
|
||||
|
||||
# pending
|
||||
if data["status"] != "paid":
|
||||
return PaymentResponse(None, checking_id, fee_msat, None, "payment failed")
|
||||
|
||||
return PaymentResponse(True, checking_id, fee_msat, None, None)
|
||||
return PaymentResponse(ok=None, checking_id=checking_id, fee_msat=fee_msat)
|
||||
return PaymentResponse(ok=True, checking_id=checking_id, fee_msat=fee_msat)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
r = await self.client.get(f"/v1/charge/{checking_id}")
|
||||
|
||||
+22
-12
@@ -133,25 +133,31 @@ class PhoenixdWallet(Wallet):
|
||||
if r.is_error or "paymentHash" not in data:
|
||||
error_message = data["message"]
|
||||
return InvoiceResponse(
|
||||
False, None, None, f"Server error: '{error_message}'"
|
||||
ok=False, error_message=f"Server error: '{error_message}'"
|
||||
)
|
||||
|
||||
checking_id = data["paymentHash"]
|
||||
payment_request = data["serialized"]
|
||||
return InvoiceResponse(True, checking_id, payment_request, None)
|
||||
preimage = data.get("paymentPreimage", None) # if available
|
||||
return InvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=checking_id,
|
||||
payment_request=payment_request,
|
||||
preimage=preimage,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
return InvoiceResponse(
|
||||
False, None, None, "Server error: 'invalid json response'"
|
||||
ok=False, error_message="Server error: 'invalid json response'"
|
||||
)
|
||||
except KeyError as exc:
|
||||
logger.warning(exc)
|
||||
return InvoiceResponse(
|
||||
False, None, None, "Server error: 'missing required fields'"
|
||||
ok=False, error_message="Server error: 'missing required fields'"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return InvoiceResponse(
|
||||
False, None, None, f"Unable to connect to {self.endpoint}."
|
||||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
@@ -168,31 +174,35 @@ class PhoenixdWallet(Wallet):
|
||||
data = r.json()
|
||||
|
||||
if "routingFeeSat" not in data and "reason" in data:
|
||||
return PaymentResponse(None, None, None, None, data["reason"])
|
||||
return PaymentResponse(error_message=data["reason"])
|
||||
|
||||
if r.is_error or "paymentHash" not in data:
|
||||
error_message = data["message"] if "message" in data else r.text
|
||||
return PaymentResponse(None, None, None, None, error_message)
|
||||
return PaymentResponse(error_message=error_message)
|
||||
|
||||
checking_id = data["paymentHash"]
|
||||
fee_msat = -int(data["routingFeeSat"]) * 1000
|
||||
preimage = data["paymentPreimage"]
|
||||
|
||||
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
|
||||
return PaymentResponse(
|
||||
ok=True,
|
||||
checking_id=checking_id,
|
||||
fee_msat=fee_msat,
|
||||
preimage=preimage,
|
||||
)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return PaymentResponse(
|
||||
None, None, None, None, "Server error: 'invalid json response'"
|
||||
error_message="Server error: 'invalid json response'"
|
||||
)
|
||||
except KeyError:
|
||||
return PaymentResponse(
|
||||
None, None, None, None, "Server error: 'missing required fields'"
|
||||
error_message="Server error: 'missing required fields'"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info(f"Failed to pay invoice {bolt11}")
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
None, None, None, None, f"Unable to connect to {self.endpoint}."
|
||||
error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
|
||||
+22
-13
@@ -115,8 +115,6 @@ class SparkWallet(Wallet):
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
label = f"lbs{random.random()}"
|
||||
checking_id = label
|
||||
|
||||
try:
|
||||
if description_hash:
|
||||
r = await self.invoicewithdescriptionhash(
|
||||
@@ -138,11 +136,14 @@ class SparkWallet(Wallet):
|
||||
exposeprivatechannels=True,
|
||||
expiry=kwargs.get("expiry"),
|
||||
)
|
||||
ok, payment_request, error_message = True, r["bolt11"], ""
|
||||
return InvoiceResponse(
|
||||
ok=True,
|
||||
payment_request=r["bolt11"],
|
||||
checking_id=label,
|
||||
preimage=r.get("preimage"),
|
||||
)
|
||||
except (SparkError, UnknownError) as e:
|
||||
ok, payment_request, error_message = False, None, str(e)
|
||||
|
||||
return InvoiceResponse(ok, checking_id, payment_request, error_message)
|
||||
return InvoiceResponse(ok=False, error_message=str(e))
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
try:
|
||||
@@ -152,17 +153,22 @@ class SparkWallet(Wallet):
|
||||
)
|
||||
fee_msat = -int(r["msatoshi_sent"] - r["msatoshi"])
|
||||
preimage = r["payment_preimage"]
|
||||
return PaymentResponse(True, r["payment_hash"], fee_msat, preimage, None)
|
||||
return PaymentResponse(
|
||||
ok=True,
|
||||
checking_id=r["payment_hash"],
|
||||
fee_msat=fee_msat,
|
||||
preimage=preimage,
|
||||
)
|
||||
|
||||
except (SparkError, UnknownError) as exc:
|
||||
listpays = await self.listpays(bolt11)
|
||||
if not listpays:
|
||||
return PaymentResponse(False, None, None, None, str(exc))
|
||||
return PaymentResponse(ok=False, error_message=str(exc))
|
||||
|
||||
pays = listpays["pays"]
|
||||
|
||||
if len(pays) == 0:
|
||||
return PaymentResponse(False, None, None, None, str(exc))
|
||||
return PaymentResponse(ok=False, error_message=str(exc))
|
||||
|
||||
pay = pays[0]
|
||||
payment_hash = pay["payment_hash"]
|
||||
@@ -174,10 +180,10 @@ class SparkWallet(Wallet):
|
||||
) from exc
|
||||
|
||||
if pay["status"] == "failed":
|
||||
return PaymentResponse(False, None, None, None, str(exc))
|
||||
return PaymentResponse(ok=False, error_message=str(exc))
|
||||
|
||||
if pay["status"] == "pending":
|
||||
return PaymentResponse(None, payment_hash, None, None, None)
|
||||
return PaymentResponse(ok=None, checking_id=payment_hash)
|
||||
|
||||
if pay["status"] == "complete":
|
||||
r = pay
|
||||
@@ -190,10 +196,13 @@ class SparkWallet(Wallet):
|
||||
fee_msat = -int(r["msatoshi_sent"] - r["msatoshi"])
|
||||
preimage = r["payment_preimage"]
|
||||
return PaymentResponse(
|
||||
True, r["payment_hash"], fee_msat, preimage, None
|
||||
ok=True,
|
||||
checking_id=r["payment_hash"],
|
||||
fee_msat=fee_msat,
|
||||
preimage=preimage,
|
||||
)
|
||||
else:
|
||||
return PaymentResponse(False, None, None, None, str(exc))
|
||||
return PaymentResponse(ok=False, error_message=str(exc))
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
|
||||
@@ -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()
|
||||
+19
-13
@@ -3,9 +3,9 @@ import hashlib
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
|
||||
import httpx
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from loguru import logger
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
@@ -61,7 +61,7 @@ class ZBDWallet(Wallet):
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
**_,
|
||||
) -> InvoiceResponse:
|
||||
# https://api.zebedee.io/v0/charges
|
||||
|
||||
@@ -89,21 +89,25 @@ class ZBDWallet(Wallet):
|
||||
|
||||
if r.is_error:
|
||||
error_message = r.json()["message"]
|
||||
return InvoiceResponse(False, None, None, error_message)
|
||||
return InvoiceResponse(ok=False, error_message=error_message)
|
||||
|
||||
data = r.json()["data"]
|
||||
checking_id = data["id"] # this is a zbd id
|
||||
payment_request = data["invoice"]["request"]
|
||||
return InvoiceResponse(True, checking_id, payment_request, None)
|
||||
preimage = data["invoice"].get("preimage")
|
||||
return InvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=checking_id,
|
||||
payment_request=payment_request,
|
||||
preimage=preimage,
|
||||
)
|
||||
|
||||
async def pay_invoice(
|
||||
self, bolt11_invoice: str, fee_limit_msat: int
|
||||
) -> PaymentResponse:
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
# https://api.zebedee.io/v0/payments
|
||||
r = await self.client.post(
|
||||
"payments",
|
||||
json={
|
||||
"invoice": bolt11_invoice,
|
||||
"invoice": bolt11,
|
||||
"description": "",
|
||||
"amount": "",
|
||||
"internalId": "",
|
||||
@@ -114,15 +118,17 @@ class ZBDWallet(Wallet):
|
||||
|
||||
if r.is_error:
|
||||
error_message = r.json()["message"]
|
||||
return PaymentResponse(False, None, None, None, error_message)
|
||||
return PaymentResponse(ok=False, error_message=error_message)
|
||||
|
||||
data = r.json()
|
||||
|
||||
checking_id = bolt11.decode(bolt11_invoice).payment_hash
|
||||
checking_id = bolt11_decode(bolt11).payment_hash
|
||||
fee_msat = -int(data["data"]["fee"])
|
||||
preimage = data["data"]["preimage"]
|
||||
|
||||
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
|
||||
return PaymentResponse(
|
||||
ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
r = await self.client.get(f"charges/{checking_id}")
|
||||
@@ -137,7 +143,7 @@ class ZBDWallet(Wallet):
|
||||
"expired": False,
|
||||
"completed": True,
|
||||
}
|
||||
return PaymentStatus(statuses[data.get("status")])
|
||||
return PaymentStatus(paid=statuses[data.get("status")])
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
r = await self.client.get(f"payments/{checking_id}")
|
||||
@@ -155,7 +161,7 @@ class ZBDWallet(Wallet):
|
||||
"failed": False,
|
||||
}
|
||||
|
||||
return PaymentStatus(statuses[data.get("status")], fee_msat=None, preimage=None)
|
||||
return PaymentStatus(paid=statuses[data.get("status")])
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
self.queue: asyncio.Queue = asyncio.Queue(0)
|
||||
|
||||
Generated
+36
-15
@@ -1303,13 +1303,13 @@ setuptools = "*"
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
|
||||
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
|
||||
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
|
||||
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1327,24 +1327,24 @@ cryptography = ">=2.5"
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.5"
|
||||
version = "1.0.9"
|
||||
description = "A minimal low-level HTTP client."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"},
|
||||
{file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"},
|
||||
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
|
||||
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = "*"
|
||||
h11 = ">=0.13,<0.15"
|
||||
h11 = ">=0.16"
|
||||
|
||||
[package.extras]
|
||||
asyncio = ["anyio (>=4.0,<5.0)"]
|
||||
http2 = ["h2 (>=3,<5)"]
|
||||
socks = ["socksio (==1.*)"]
|
||||
trio = ["trio (>=0.22.0,<0.26.0)"]
|
||||
trio = ["trio (>=0.22.0,<1.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
@@ -1688,6 +1688,16 @@ files = [
|
||||
{file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"},
|
||||
{file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"},
|
||||
{file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"},
|
||||
{file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"},
|
||||
{file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"},
|
||||
{file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"},
|
||||
{file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"},
|
||||
{file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"},
|
||||
{file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"},
|
||||
{file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"},
|
||||
{file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"},
|
||||
{file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"},
|
||||
{file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"},
|
||||
{file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"},
|
||||
{file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"},
|
||||
{file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"},
|
||||
@@ -2461,6 +2471,7 @@ files = [
|
||||
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
|
||||
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
|
||||
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
|
||||
{file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
|
||||
{file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
|
||||
{file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
|
||||
{file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
|
||||
@@ -2468,8 +2479,16 @@ files = [
|
||||
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
|
||||
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
|
||||
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
|
||||
{file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
|
||||
{file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
|
||||
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
|
||||
{file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
|
||||
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
|
||||
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
|
||||
@@ -2486,6 +2505,7 @@ files = [
|
||||
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
|
||||
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
|
||||
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
|
||||
{file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
|
||||
{file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
|
||||
{file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
|
||||
{file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
|
||||
@@ -2493,6 +2513,7 @@ files = [
|
||||
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
|
||||
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
|
||||
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
|
||||
{file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
|
||||
{file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
|
||||
{file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
|
||||
{file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
|
||||
@@ -3069,13 +3090,13 @@ zstd = ["zstandard (>=0.18.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.30.6"
|
||||
version = "0.34.2"
|
||||
description = "The lightning-fast ASGI server."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5"},
|
||||
{file = "uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788"},
|
||||
{file = "uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403"},
|
||||
{file = "uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -3084,7 +3105,7 @@ h11 = ">=0.8"
|
||||
typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""}
|
||||
|
||||
[package.extras]
|
||||
standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"]
|
||||
standard = ["colorama (>=0.4)", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"]
|
||||
|
||||
[[package]]
|
||||
name = "uvloop"
|
||||
@@ -3414,4 +3435,4 @@ liquid = ["wallycore"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "~3.12 | ~3.11 | ~3.10"
|
||||
content-hash = "f56154a228bfd11ca92c1818dd2b7d71ff67b218225103f4b701e6523ba499ed"
|
||||
content-hash = "602ca9d1ec774a31f3a829faba787742eee7d47c8f27cb2aeab992bd063cd185"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "lnbits"
|
||||
version = "1.0.0"
|
||||
version = "1.2.0-rc1"
|
||||
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
||||
authors = ["Alan Bits <alan@lnbits.com>"]
|
||||
readme = "README.md"
|
||||
@@ -25,7 +25,7 @@ pyqrcode = "1.2.1"
|
||||
shortuuid = "1.0.13"
|
||||
sse-starlette = "1.8.2"
|
||||
typing-extensions = "4.12.2"
|
||||
uvicorn = "0.30.6"
|
||||
uvicorn = "0.34.2"
|
||||
sqlalchemy = "1.4.54"
|
||||
aiosqlite = "0.20.0"
|
||||
asyncpg = "0.30.0"
|
||||
|
||||
@@ -7,7 +7,7 @@ from lnbits.settings import Settings
|
||||
@pytest.mark.anyio
|
||||
async def test_admin_get_settings_permission_denied(client, from_user):
|
||||
response = await client.get(f"/admin/api/v1/settings?usr={from_user.id}")
|
||||
assert response.status_code == 401
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -102,7 +102,7 @@ async def test_login_usr_not_allowed_for_admin_without_credentials(
|
||||
# Attempt to login with user ID for admin
|
||||
response = await http_client.post("/api/v1/auth/usr", json=login_data.dict())
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.status_code == 403
|
||||
assert (
|
||||
response.json().get("detail") == "Admin users cannot login with user id only."
|
||||
)
|
||||
@@ -135,7 +135,7 @@ async def test_login_usr_not_allowed(
|
||||
|
||||
response = await http_client.post("/api/v1/auth/usr", json={"usr": user_alan.id})
|
||||
|
||||
assert response.status_code == 401, "Login method not allowed."
|
||||
assert response.status_code == 403, "Login method not allowed."
|
||||
assert response.json().get("detail") == "Login by 'User ID' not allowed."
|
||||
|
||||
settings.auth_allowed_methods = AuthMethods.all()
|
||||
@@ -217,7 +217,7 @@ async def test_login_username_password_not_allowed(
|
||||
"/api/v1/auth", json={"username": user_alan.username, "password": "secret1234"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401, "Login method not allowed."
|
||||
assert response.status_code == 403, "Login method not allowed."
|
||||
assert (
|
||||
response.json().get("detail") == "Login by 'Username and Password' not allowed."
|
||||
)
|
||||
@@ -597,7 +597,7 @@ async def test_register_nostr_not_allowed(http_client: AsyncClient, settings: Se
|
||||
json={},
|
||||
)
|
||||
|
||||
assert response.status_code == 401, "User not authenticated."
|
||||
assert response.status_code == 403, "User not authenticated."
|
||||
assert response.json().get("detail") == "Login with Nostr Auth not allowed."
|
||||
|
||||
settings.auth_allowed_methods = AuthMethods.all()
|
||||
@@ -607,7 +607,7 @@ async def test_register_nostr_not_allowed(http_client: AsyncClient, settings: Se
|
||||
async def test_register_nostr_bad_header(http_client: AsyncClient):
|
||||
response = await http_client.post("/api/v1/auth/nostr")
|
||||
|
||||
assert response.status_code == 401, "Missing header."
|
||||
assert response.status_code == 400, "Missing header."
|
||||
assert response.json().get("detail") == "Nostr Auth header missing."
|
||||
|
||||
response = await http_client.post(
|
||||
@@ -615,7 +615,7 @@ async def test_register_nostr_bad_header(http_client: AsyncClient):
|
||||
headers={"Authorization": "Bearer xyz"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401, "Non nostr header."
|
||||
assert response.status_code == 400, "Non nostr header."
|
||||
assert response.json().get("detail") == "Invalid Authorization scheme."
|
||||
|
||||
response = await http_client.post(
|
||||
@@ -1028,7 +1028,7 @@ async def test_reset_username_password_not_allowed(
|
||||
)
|
||||
settings.auth_allowed_methods = AuthMethods.all()
|
||||
|
||||
assert response.status_code == 401, "Login method not allowed."
|
||||
assert response.status_code == 403, "Login method not allowed."
|
||||
assert (
|
||||
response.json().get("detail") == "Auth by 'Username and Password' not allowed."
|
||||
)
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -5,8 +5,12 @@ import pytest
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.crud import get_standalone_payment, update_payment
|
||||
from lnbits.core.crud.wallets import create_wallet, get_wallet
|
||||
from lnbits.core.models import CreateInvoice, Payment, PaymentState
|
||||
from lnbits.core.services import fee_reserve_total, get_balance_delta
|
||||
from lnbits.core.services.payments import pay_invoice, update_wallet_balance
|
||||
from lnbits.core.services.users import create_user_account
|
||||
from lnbits.exceptions import PaymentError
|
||||
from lnbits.tasks import create_task, wait_for_paid_invoices
|
||||
from lnbits.wallets import get_funding_source
|
||||
|
||||
@@ -101,6 +105,8 @@ async def test_create_real_invoice(client, adminkey_headers_from, inkey_headers_
|
||||
balance = await get_node_balance_sats()
|
||||
assert balance - prev_balance == create_invoice.amount
|
||||
|
||||
assert payment_status.get("preimage") is not None
|
||||
|
||||
# exit out of infinite loop
|
||||
raise FakeError()
|
||||
|
||||
@@ -151,6 +157,39 @@ async def test_pay_real_invoice_set_pending_and_check_state(
|
||||
assert payment.success
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.skipif(is_fake, reason="this only works in regtest")
|
||||
async def test_pay_real_invoices_in_parallel():
|
||||
user = await create_user_account()
|
||||
wallet = await create_wallet(user_id=user.id)
|
||||
|
||||
# more to cover routing feems
|
||||
await update_wallet_balance(wallet, 1100)
|
||||
|
||||
# these must be external invoices
|
||||
real_invoice_one = get_real_invoice(1000)
|
||||
real_invoice_two = get_real_invoice(1000)
|
||||
|
||||
async def pay_first():
|
||||
return await pay_invoice(
|
||||
wallet_id=wallet.id,
|
||||
payment_request=real_invoice_one["payment_request"],
|
||||
)
|
||||
|
||||
async def pay_second():
|
||||
return await pay_invoice(
|
||||
wallet_id=wallet.id,
|
||||
payment_request=real_invoice_two["payment_request"],
|
||||
)
|
||||
|
||||
with pytest.raises(PaymentError, match="Insufficient balance."):
|
||||
await asyncio.gather(pay_first(), pay_second())
|
||||
|
||||
wallet_after = await get_wallet(wallet.id)
|
||||
assert wallet_after
|
||||
assert 0 <= wallet_after.balance <= 100, "One payment should be deducted."
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.skipif(is_fake, reason="this only works in regtest")
|
||||
async def test_pay_hold_invoice_check_pending(
|
||||
|
||||
@@ -17,6 +17,8 @@ async def test_create_invoice(from_wallet):
|
||||
amount=1000,
|
||||
memo=description,
|
||||
)
|
||||
assert payment.preimage
|
||||
|
||||
invoice = decode(payment.bolt11)
|
||||
assert invoice.payment_hash == payment.payment_hash
|
||||
assert invoice.amount_msat == 1000000
|
||||
@@ -33,6 +35,8 @@ async def test_create_internal_invoice(from_wallet):
|
||||
payment = await create_invoice(
|
||||
wallet_id=from_wallet.id, amount=1000, memo=description, internal=True
|
||||
)
|
||||
assert payment.preimage
|
||||
|
||||
invoice = decode(payment.bolt11)
|
||||
assert invoice.payment_hash == payment.payment_hash
|
||||
assert invoice.amount_msat == 1000000
|
||||
|
||||
@@ -19,6 +19,7 @@ async def test_services_pay_invoice(to_wallet, real_invoice):
|
||||
assert payment
|
||||
assert payment.status == PaymentState.SUCCESS
|
||||
assert payment.memo == description
|
||||
assert payment.preimage
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import pytest
|
||||
|
||||
from lnbits.core.crud import create_wallet, get_payments, update_payment
|
||||
from lnbits.core.models import PaymentState
|
||||
from lnbits.core.services import create_user_account, update_wallet_balance
|
||||
from lnbits.core.crud import (
|
||||
create_wallet,
|
||||
get_payments,
|
||||
get_payments_paginated,
|
||||
update_payment,
|
||||
)
|
||||
from lnbits.core.models import PaymentFilters, PaymentState
|
||||
from lnbits.core.services import (
|
||||
create_invoice,
|
||||
create_user_account,
|
||||
update_wallet_balance,
|
||||
)
|
||||
from lnbits.db import Filters
|
||||
|
||||
|
||||
async def update_payments(payments):
|
||||
@@ -64,3 +74,62 @@ async def test_crud_get_payments(app):
|
||||
# both false should return failed payments
|
||||
# payments = await get_payments(wallet_id=wallet.id, complete=False, pending=False)
|
||||
# assert len(payments) == 2, "should return 2 failed payment"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_crud_search_payments():
|
||||
|
||||
user = await create_user_account()
|
||||
wallet = await create_wallet(user_id=user.id)
|
||||
filters: Filters = Filters(
|
||||
search="",
|
||||
model=PaymentFilters,
|
||||
)
|
||||
# no memo
|
||||
await create_invoice(wallet_id=wallet.id, amount=30, memo="")
|
||||
await create_invoice(wallet_id=wallet.id, amount=30, memo="Invoice A")
|
||||
filters.search = "Invoice A"
|
||||
page = await get_payments_paginated(
|
||||
wallet_id=wallet.id,
|
||||
filters=filters,
|
||||
)
|
||||
assert page.total == 1, "should return only Invoice A"
|
||||
|
||||
filters.search = "Invoice B"
|
||||
page = await get_payments_paginated(
|
||||
wallet_id=wallet.id,
|
||||
filters=filters,
|
||||
)
|
||||
assert page.total == 0, "no Invoice B yet"
|
||||
|
||||
for i in range(15):
|
||||
await create_invoice(wallet_id=wallet.id, amount=30 + i, memo="Invoice A")
|
||||
await create_invoice(wallet_id=wallet.id, amount=30 + i, memo="Invoice B")
|
||||
|
||||
filters.search = None
|
||||
page = await get_payments_paginated(
|
||||
wallet_id=wallet.id,
|
||||
filters=filters,
|
||||
)
|
||||
assert page.total == 32, "should return all payments"
|
||||
|
||||
filters.search = "Invoice A"
|
||||
page = await get_payments_paginated(
|
||||
wallet_id=wallet.id,
|
||||
filters=filters,
|
||||
)
|
||||
assert page.total == 16
|
||||
|
||||
filters.search = "Invoice B"
|
||||
page = await get_payments_paginated(
|
||||
wallet_id=wallet.id,
|
||||
filters=filters,
|
||||
)
|
||||
assert page.total == 15
|
||||
|
||||
filters.search = "Invoice"
|
||||
page = await get_payments_paginated(
|
||||
wallet_id=wallet.id,
|
||||
filters=filters,
|
||||
)
|
||||
assert page.total == 31
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import pytest
|
||||
|
||||
from lnbits.utils.crypto import AESCipher
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
[
|
||||
"normal_string",
|
||||
b"normal_bytes",
|
||||
b"hex_string".hex(),
|
||||
],
|
||||
)
|
||||
async def test_aes_encrypt_decrypt(key):
|
||||
aes = AESCipher(key)
|
||||
original_text = "Hello, World!"
|
||||
encrypted_text = aes.encrypt(original_text.encode())
|
||||
decrypted_text = aes.decrypt(encrypted_text)
|
||||
assert original_text == decrypted_text
|
||||
@@ -76,7 +76,7 @@ async def test_check_user_exists_with_user_not_allowed(user_alan: User):
|
||||
settings.lnbits_allowed_users = ["only_this_user_id"]
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await check_user_exists(request, access_token=None, usr=UUID4(user_alan.id))
|
||||
assert exc_info.value.status_code == 401
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.detail == "User not allowed."
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -8,9 +8,11 @@ from bolt11 import encode as bolt11_encode
|
||||
from bolt11.types import MilliSatoshi
|
||||
from pytest_mock.plugin import MockerFixture
|
||||
|
||||
from lnbits.core.crud import get_standalone_payment, get_wallet
|
||||
from lnbits.core.crud import create_wallet, get_standalone_payment, get_wallet
|
||||
from lnbits.core.crud.payments import get_payment, get_payments_paginated
|
||||
from lnbits.core.models import Payment, PaymentState, Wallet
|
||||
from lnbits.core.services import create_invoice, pay_invoice
|
||||
from lnbits.core.services import create_invoice, create_user_account, pay_invoice
|
||||
from lnbits.core.services.payments import update_wallet_balance
|
||||
from lnbits.exceptions import InvoiceError, PaymentError
|
||||
from lnbits.settings import Settings
|
||||
from lnbits.tasks import (
|
||||
@@ -113,6 +115,62 @@ async def test_pay_twice(to_wallet: Wallet):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pay_twice_fast():
|
||||
user = await create_user_account()
|
||||
wallet_one = await create_wallet(user_id=user.id)
|
||||
wallet_two = await create_wallet(user_id=user.id)
|
||||
|
||||
await update_wallet_balance(wallet_one, 1000)
|
||||
payment_a = await create_invoice(wallet_id=wallet_two.id, amount=1000, memo="AAA")
|
||||
payment_b = await create_invoice(wallet_id=wallet_two.id, amount=1000, memo="BBB")
|
||||
|
||||
async def pay_first():
|
||||
return await pay_invoice(
|
||||
wallet_id=wallet_one.id,
|
||||
payment_request=payment_a.bolt11,
|
||||
)
|
||||
|
||||
async def pay_second():
|
||||
return await pay_invoice(
|
||||
wallet_id=wallet_one.id,
|
||||
payment_request=payment_b.bolt11,
|
||||
)
|
||||
|
||||
with pytest.raises(PaymentError, match="Insufficient balance."):
|
||||
await asyncio.gather(pay_first(), pay_second())
|
||||
|
||||
wallet_one_after = await get_wallet(wallet_one.id)
|
||||
assert wallet_one_after
|
||||
assert wallet_one_after.balance == 0, "One payment should be deducted."
|
||||
|
||||
wallet_two_after = await get_wallet(wallet_two.id)
|
||||
assert wallet_two_after
|
||||
assert wallet_two_after.balance == 1000, "One payment received."
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pay_twice_fast_same_invoice(to_wallet: Wallet):
|
||||
payment = await create_invoice(
|
||||
wallet_id=to_wallet.id, amount=3, memo="Twice fast same invoice"
|
||||
)
|
||||
|
||||
async def pay_first():
|
||||
return await pay_invoice(
|
||||
wallet_id=to_wallet.id,
|
||||
payment_request=payment.bolt11,
|
||||
)
|
||||
|
||||
async def pay_second():
|
||||
return await pay_invoice(
|
||||
wallet_id=to_wallet.id,
|
||||
payment_request=payment.bolt11,
|
||||
)
|
||||
|
||||
with pytest.raises(PaymentError, match="Payment already paid."):
|
||||
await asyncio.gather(pay_first(), pay_second())
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fake_wallet_pay_external(
|
||||
to_wallet: Wallet, external_funding_source: FakeWallet
|
||||
@@ -179,7 +237,12 @@ async def test_notification_for_internal_payment(to_wallet: Wallet):
|
||||
invoice_queue: asyncio.Queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue, test_name)
|
||||
|
||||
payment = await create_invoice(wallet_id=to_wallet.id, amount=123, memo=test_name)
|
||||
payment = await create_invoice(
|
||||
wallet_id=to_wallet.id,
|
||||
amount=123,
|
||||
memo=test_name,
|
||||
webhook="http://test.404.lnbits.com",
|
||||
)
|
||||
await pay_invoice(
|
||||
wallet_id=to_wallet.id, payment_request=payment.bolt11, extra={"tag": "lnurlp"}
|
||||
)
|
||||
@@ -192,6 +255,9 @@ async def test_notification_for_internal_payment(to_wallet: Wallet):
|
||||
assert _payment.status == PaymentState.SUCCESS.value
|
||||
assert _payment.bolt11 == payment.bolt11
|
||||
assert _payment.amount == 123_000
|
||||
updated_payment = await get_payment(_payment.checking_id)
|
||||
assert updated_payment.webhook_status == "404"
|
||||
|
||||
break # we found our payment, success
|
||||
|
||||
|
||||
@@ -587,3 +653,55 @@ async def test_service_fee(
|
||||
assert service_fee_payment.amount == 422_400
|
||||
assert service_fee_payment.bolt11 == external_invoice.payment_request
|
||||
assert service_fee_payment.preimage is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_payments_for_user(to_wallet: Wallet):
|
||||
all_payments = await get_payments_paginated()
|
||||
total_before = all_payments.total
|
||||
|
||||
user = await create_user_account()
|
||||
wallet_one = await create_wallet(user_id=user.id, wallet_name="first wallet")
|
||||
wallet_two = await create_wallet(user_id=user.id, wallet_name="second wallet")
|
||||
|
||||
user_payments = await get_payments_paginated(user_id=user.id)
|
||||
assert user_payments.total == 0
|
||||
|
||||
payment = await create_invoice(wallet_id=wallet_one.id, amount=100, memo="one")
|
||||
user_payments = await get_payments_paginated(user_id=user.id)
|
||||
assert user_payments.total == 1
|
||||
# this will create a payment in the to_wallet that we need to count for at the end
|
||||
await pay_invoice(
|
||||
wallet_id=to_wallet.id,
|
||||
payment_request=payment.bolt11,
|
||||
)
|
||||
user_payments = await get_payments_paginated(user_id=user.id)
|
||||
assert user_payments.total == 1
|
||||
|
||||
payment = await create_invoice(wallet_id=wallet_one.id, amount=3, memo="two")
|
||||
user_payments = await get_payments_paginated(user_id=user.id)
|
||||
assert user_payments.total == 2
|
||||
|
||||
payment = await create_invoice(wallet_id=wallet_two.id, amount=3, memo="three")
|
||||
user_payments = await get_payments_paginated(user_id=user.id)
|
||||
assert user_payments.total == 3
|
||||
|
||||
await pay_invoice(
|
||||
wallet_id=wallet_one.id,
|
||||
payment_request=payment.bolt11,
|
||||
)
|
||||
user_payments = await get_payments_paginated(user_id=user.id)
|
||||
assert user_payments.total == 4
|
||||
|
||||
all_payments = await get_payments_paginated()
|
||||
total_after = all_payments.total
|
||||
|
||||
assert total_after == total_before + 5, "Total payments should be updated."
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_payments_for_non_user():
|
||||
user_payments = await get_payments_paginated(user_id="nonexistent")
|
||||
assert (
|
||||
user_payments.total == 0
|
||||
), "No payments should be found for non-existent user."
|
||||
|
||||
@@ -326,11 +326,12 @@
|
||||
"call_params": {
|
||||
"amount": 555,
|
||||
"memo": "Test Invoice",
|
||||
"label": "test-label"
|
||||
"label": "test-label",
|
||||
"preimage": "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
},
|
||||
"expect": {
|
||||
"success": true,
|
||||
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
|
||||
"checking_id": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417",
|
||||
"payment_request": "lnbc5550n1pnq9jg3sp52rvwstvjcypjsaenzdh0h30jazvzsf8aaye0julprtth9kysxtuspp5e5s3z7felv4t9zrcc6wpn7ehvjl5yzewanzl5crljdl3jgeffyhqdq2f38xy6t5wvxqzjccqpjrzjq0yzeq76ney45hmjlnlpvu0nakzy2g35hqh0dujq8ujdpr2e42pf2rrs6vqpgcsqqqqqqqqqqqqqqeqqyg9qxpqysgqwftcx89k5pp28435pgxfl2vx3ksemzxccppw2j9yjn0ngr6ed7wj8ztc0d5kmt2mvzdlcgrludhz7jncd5l5l9w820hc4clpwhtqj3gq62g66n",
|
||||
"error_message": null
|
||||
},
|
||||
@@ -355,7 +356,7 @@
|
||||
"response_type": "data",
|
||||
"response": {
|
||||
"ln_invoice": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417",
|
||||
"bolt11": "lnbc5550n1pnq9jg3sp52rvwstvjcypjsaenzdh0h30jazvzsf8aaye0julprtth9kysxtuspp5e5s3z7felv4t9zrcc6wpn7ehvjl5yzewanzl5crljdl3jgeffyhqdq2f38xy6t5wvxqzjccqpjrzjq0yzeq76ney45hmjlnlpvu0nakzy2g35hqh0dujq8ujdpr2e42pf2rrs6vqpgcsqqqqqqqqqqqqqqeqqyg9qxpqysgqwftcx89k5pp28435pgxfl2vx3ksemzxccppw2j9yjn0ngr6ed7wj8ztc0d5kmt2mvzdlcgrludhz7jncd5l5l9w820hc4clpwhtqj3gq62g66n"
|
||||
}
|
||||
}
|
||||
@@ -378,12 +379,13 @@
|
||||
"expiry": null,
|
||||
"exposeprivatechannels": true,
|
||||
"label": "test-label",
|
||||
"amount_msat": 555000
|
||||
"amount_msat": 555000,
|
||||
"preimage": "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
"response": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417",
|
||||
"bolt11": "lnbc5550n1pnq9jg3sp52rvwstvjcypjsaenzdh0h30jazvzsf8aaye0julprtth9kysxtuspp5e5s3z7felv4t9zrcc6wpn7ehvjl5yzewanzl5crljdl3jgeffyhqdq2f38xy6t5wvxqzjccqpjrzjq0yzeq76ney45hmjlnlpvu0nakzy2g35hqh0dujq8ujdpr2e42pf2rrs6vqpgcsqqqqqqqqqqqqqqeqqyg9qxpqysgqwftcx89k5pp28435pgxfl2vx3ksemzxccppw2j9yjn0ngr6ed7wj8ztc0d5kmt2mvzdlcgrludhz7jncd5l5l9w820hc4clpwhtqj3gq62g66n"
|
||||
}
|
||||
}
|
||||
@@ -402,12 +404,14 @@
|
||||
"kwargs": {
|
||||
"value": 555,
|
||||
"private": true,
|
||||
"memo": "Test Invoice"
|
||||
"memo": "Test Invoice",
|
||||
"__eval__:r_hash": "bytes.fromhex(\"c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417\")",
|
||||
"__eval__:r_preimage": "bytes.fromhex(\"0000000000000000000000000000000000000000000000000000000000000001\")"
|
||||
}
|
||||
},
|
||||
"response_type": "data",
|
||||
"response": {
|
||||
"__eval__:r_hash": "bytes.fromhex(\"e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96\")",
|
||||
"__eval__:r_hash": "bytes.fromhex(\"c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417\")",
|
||||
"payment_request": "lnbc5550n1pnq9jg3sp52rvwstvjcypjsaenzdh0h30jazvzsf8aaye0julprtth9kysxtuspp5e5s3z7felv4t9zrcc6wpn7ehvjl5yzewanzl5crljdl3jgeffyhqdq2f38xy6t5wvxqzjccqpjrzjq0yzeq76ney45hmjlnlpvu0nakzy2g35hqh0dujq8ujdpr2e42pf2rrs6vqpgcsqqqqqqqqqqqqqqeqqyg9qxpqysgqwftcx89k5pp28435pgxfl2vx3ksemzxccppw2j9yjn0ngr6ed7wj8ztc0d5kmt2mvzdlcgrludhz7jncd5l5l9w820hc4clpwhtqj3gq62g66n"
|
||||
}
|
||||
}
|
||||
@@ -422,7 +426,8 @@
|
||||
"call_params": {
|
||||
"amount": 555,
|
||||
"memo": "Test Invoice",
|
||||
"label": "test-label"
|
||||
"label": "test-label",
|
||||
"preimage": "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
},
|
||||
"expect": {
|
||||
"success": false,
|
||||
@@ -470,7 +475,8 @@
|
||||
"expiry": null,
|
||||
"exposeprivatechannels": true,
|
||||
"label": "test-label",
|
||||
"amount_msat": 555000
|
||||
"amount_msat": 555000,
|
||||
"preimage": "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
}
|
||||
},
|
||||
"response_type": "exception",
|
||||
@@ -491,7 +497,8 @@
|
||||
"expiry": null,
|
||||
"exposeprivatechannels": true,
|
||||
"label": "test-label",
|
||||
"amount_msat": 555000
|
||||
"amount_msat": 555000,
|
||||
"preimage": "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
@@ -515,7 +522,9 @@
|
||||
"kwargs": {
|
||||
"value": 555,
|
||||
"private": true,
|
||||
"memo": "Test Invoice"
|
||||
"memo": "Test Invoice",
|
||||
"__eval__:r_hash": "bytes.fromhex(\"c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417\")",
|
||||
"__eval__:r_preimage": "bytes.fromhex(\"0000000000000000000000000000000000000000000000000000000000000001\")"
|
||||
}
|
||||
},
|
||||
"response_type": "exception",
|
||||
@@ -534,7 +543,8 @@
|
||||
"call_params": {
|
||||
"amount": 555,
|
||||
"memo": "Test Invoice",
|
||||
"label": "test-label"
|
||||
"label": "test-label",
|
||||
"preimage": "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
},
|
||||
"expect": {
|
||||
"success": false,
|
||||
@@ -559,7 +569,8 @@
|
||||
"expiry": null,
|
||||
"exposeprivatechannels": true,
|
||||
"label": "test-label",
|
||||
"amount_msat": 555000
|
||||
"amount_msat": 555000,
|
||||
"preimage": "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
@@ -576,7 +587,8 @@
|
||||
"call_params": {
|
||||
"amount": 555,
|
||||
"memo": "Test Invoice",
|
||||
"label": "test-label"
|
||||
"label": "test-label",
|
||||
"preimage": "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
},
|
||||
"expect": {
|
||||
"success": false,
|
||||
@@ -601,7 +613,8 @@
|
||||
"expiry": null,
|
||||
"exposeprivatechannels": true,
|
||||
"label": "test-label",
|
||||
"amount_msat": 555000
|
||||
"amount_msat": 555000,
|
||||
"preimage": "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
}
|
||||
},
|
||||
"response_type": "exception",
|
||||
@@ -689,7 +702,7 @@
|
||||
"expect": {
|
||||
"error_message": null,
|
||||
"success": true,
|
||||
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
|
||||
"checking_id": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417",
|
||||
"fee_msat": 50,
|
||||
"preimage": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
@@ -739,7 +752,7 @@
|
||||
"response": {
|
||||
"amount_sent_msat": 21000,
|
||||
"amount_msat": 21050,
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417",
|
||||
"payment_preimage": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
}
|
||||
@@ -774,7 +787,7 @@
|
||||
}
|
||||
],
|
||||
"payment_preimage": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -806,7 +819,7 @@
|
||||
}
|
||||
],
|
||||
"payment_preimage": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1237,7 +1250,7 @@
|
||||
{
|
||||
"description": "success",
|
||||
"call_params": {
|
||||
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"checking_id": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
},
|
||||
"expect": {
|
||||
"success": true,
|
||||
@@ -1276,7 +1289,7 @@
|
||||
"request_type": "function",
|
||||
"request_data": {
|
||||
"kwargs": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
@@ -1291,7 +1304,7 @@
|
||||
"invoices": [
|
||||
{
|
||||
"status": "paid",
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1315,12 +1328,13 @@
|
||||
"request_data": {
|
||||
"klass": "lnbits.wallets.lnd_grpc_files.lightning_pb2.PaymentHash",
|
||||
"kwargs": {
|
||||
"__eval__:r_hash": "bytes.fromhex(\"e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96\")"
|
||||
"__eval__:r_hash": "bytes.fromhex(\"c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417\")"
|
||||
}
|
||||
},
|
||||
"response_type": "data",
|
||||
"response": {
|
||||
"settled": true
|
||||
"settled": true,
|
||||
"__eval__:r_preimage": "bytes.fromhex(\"0000000000000000000000000000000000000000000000000000000000000001\")"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1332,7 +1346,7 @@
|
||||
{
|
||||
"description": "pending",
|
||||
"call_params": {
|
||||
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"checking_id": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
},
|
||||
"expect": {
|
||||
"success": false,
|
||||
@@ -1364,7 +1378,7 @@
|
||||
"request_type": "function",
|
||||
"request_data": {
|
||||
"kwargs": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
@@ -1376,7 +1390,7 @@
|
||||
"request_type": "function",
|
||||
"request_data": {
|
||||
"kwargs": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
@@ -1395,7 +1409,7 @@
|
||||
"request_type": "function",
|
||||
"request_data": {
|
||||
"kwargs": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
@@ -1407,7 +1421,7 @@
|
||||
"request_type": "function",
|
||||
"request_data": {
|
||||
"kwargs": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
}
|
||||
},
|
||||
"response_type": "exception",
|
||||
@@ -1432,7 +1446,7 @@
|
||||
"request_type": "function",
|
||||
"request_data": {
|
||||
"kwargs": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
@@ -1444,7 +1458,7 @@
|
||||
"request_type": "function",
|
||||
"request_data": {
|
||||
"kwargs": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
}
|
||||
},
|
||||
"response_type": "exception",
|
||||
@@ -1463,7 +1477,7 @@
|
||||
"request_type": "function",
|
||||
"request_data": {
|
||||
"kwargs": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
@@ -1475,7 +1489,7 @@
|
||||
"request_type": "function",
|
||||
"request_data": {
|
||||
"kwargs": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
@@ -1492,7 +1506,7 @@
|
||||
"request_type": "function",
|
||||
"request_data": {
|
||||
"kwargs": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
@@ -1504,7 +1518,7 @@
|
||||
"request_type": "function",
|
||||
"request_data": {
|
||||
"kwargs": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
@@ -1528,7 +1542,7 @@
|
||||
"request_type": "function",
|
||||
"request_data": {
|
||||
"kwargs": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
@@ -1540,7 +1554,7 @@
|
||||
"request_type": "function",
|
||||
"request_data": {
|
||||
"kwargs": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
@@ -1548,7 +1562,7 @@
|
||||
"invoices": [
|
||||
{
|
||||
"status": "unpaid",
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1573,7 +1587,7 @@
|
||||
"request_data": {
|
||||
"klass": "lnbits.wallets.lnd_grpc_files.lightning_pb2.PaymentHash",
|
||||
"kwargs": {
|
||||
"__eval__:r_hash": "bytes.fromhex(\"e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96\")"
|
||||
"__eval__:r_hash": "bytes.fromhex(\"c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417\")"
|
||||
}
|
||||
},
|
||||
"response_type": "data",
|
||||
@@ -1591,7 +1605,7 @@
|
||||
"request_data": {
|
||||
"klass": "lnbits.wallets.lnd_grpc_files.lightning_pb2.PaymentHash",
|
||||
"kwargs": {
|
||||
"__eval__:r_hash": "bytes.fromhex(\"e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96\")"
|
||||
"__eval__:r_hash": "bytes.fromhex(\"c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417\")"
|
||||
}
|
||||
},
|
||||
"response_type": "exception",
|
||||
@@ -1611,7 +1625,7 @@
|
||||
"request_data": {
|
||||
"klass": "lnbits.wallets.lnd_grpc_files.lightning_pb2.PaymentHash",
|
||||
"kwargs": {
|
||||
"__eval__:r_hash": "bytes.fromhex(\"e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96\")"
|
||||
"__eval__:r_hash": "bytes.fromhex(\"c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417\")"
|
||||
}
|
||||
},
|
||||
"response_type": "exception",
|
||||
@@ -1691,7 +1705,7 @@
|
||||
"description": "failed",
|
||||
"description1": "pending should be false in the 'expect', this is a bug",
|
||||
"call_params": {
|
||||
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"checking_id": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
},
|
||||
"expect": {
|
||||
"success": false,
|
||||
@@ -1731,7 +1745,7 @@
|
||||
"request_type": "function",
|
||||
"request_data": {
|
||||
"kwargs": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
@@ -1743,7 +1757,7 @@
|
||||
"request_type": "function",
|
||||
"request_data": {
|
||||
"kwargs": {
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
}
|
||||
},
|
||||
"response_type": "json",
|
||||
@@ -1751,7 +1765,7 @@
|
||||
"invoices": [
|
||||
{
|
||||
"status": "expired",
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1823,7 +1837,7 @@
|
||||
{
|
||||
"description": "success",
|
||||
"call_params": {
|
||||
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"checking_id": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
},
|
||||
"expect": {
|
||||
"success": true,
|
||||
@@ -1872,7 +1886,7 @@
|
||||
"pays": [
|
||||
{
|
||||
"status": "complete",
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417",
|
||||
"preimage": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"amount_sent_msat": 21000,
|
||||
"amount_msat": 21050
|
||||
@@ -1900,7 +1914,7 @@
|
||||
"request_data": {
|
||||
"klass": "lnbits.wallets.lnd_grpc_files.router_pb2.TrackPaymentRequest",
|
||||
"kwargs": {
|
||||
"__eval__:payment_hash": "bytes.fromhex(\"e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96\")"
|
||||
"__eval__:payment_hash": "bytes.fromhex(\"c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417\")"
|
||||
}
|
||||
},
|
||||
"response_type": "__aiter__",
|
||||
@@ -1933,7 +1947,7 @@
|
||||
{
|
||||
"description": "success status, no payment found",
|
||||
"call_params": {
|
||||
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"checking_id": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
},
|
||||
"expect": {
|
||||
"success": true,
|
||||
@@ -1960,7 +1974,7 @@
|
||||
"request_data": {
|
||||
"klass": "lnbits.wallets.lnd_grpc_files.router_pb2.TrackPaymentRequest",
|
||||
"kwargs": {
|
||||
"__eval__:payment_hash": "bytes.fromhex(\"e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96\")"
|
||||
"__eval__:payment_hash": "bytes.fromhex(\"c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417\")"
|
||||
}
|
||||
},
|
||||
"response_type": "__aiter__",
|
||||
@@ -1980,7 +1994,7 @@
|
||||
{
|
||||
"description": "pending",
|
||||
"call_params": {
|
||||
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"checking_id": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
},
|
||||
"expect": {
|
||||
"success": false,
|
||||
@@ -2073,7 +2087,7 @@
|
||||
"pays": [
|
||||
{
|
||||
"status": "pending",
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2117,7 +2131,7 @@
|
||||
"request_data": {
|
||||
"klass": "lnbits.wallets.lnd_grpc_files.router_pb2.TrackPaymentRequest",
|
||||
"kwargs": {
|
||||
"__eval__:payment_hash": "bytes.fromhex(\"e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96\")"
|
||||
"__eval__:payment_hash": "bytes.fromhex(\"c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417\")"
|
||||
}
|
||||
},
|
||||
"response_type": "__aiter__",
|
||||
@@ -2138,7 +2152,7 @@
|
||||
"request_data": {
|
||||
"klass": "lnbits.wallets.lnd_grpc_files.router_pb2.TrackPaymentRequest",
|
||||
"kwargs": {
|
||||
"__eval__:payment_hash": "bytes.fromhex(\"e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96\")"
|
||||
"__eval__:payment_hash": "bytes.fromhex(\"c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417\")"
|
||||
}
|
||||
},
|
||||
"response_type": "__aiter__",
|
||||
@@ -2154,7 +2168,7 @@
|
||||
"request_data": {
|
||||
"klass": "lnbits.wallets.lnd_grpc_files.router_pb2.TrackPaymentRequest",
|
||||
"kwargs": {
|
||||
"__eval__:payment_hash": "bytes.fromhex(\"e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96\")"
|
||||
"__eval__:payment_hash": "bytes.fromhex(\"c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417\")"
|
||||
}
|
||||
},
|
||||
"response_type": "__aiter__",
|
||||
@@ -2170,7 +2184,7 @@
|
||||
"request_data": {
|
||||
"klass": "lnbits.wallets.lnd_grpc_files.router_pb2.TrackPaymentRequest",
|
||||
"kwargs": {
|
||||
"__eval__:payment_hash": "bytes.fromhex(\"e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96\")"
|
||||
"__eval__:payment_hash": "bytes.fromhex(\"c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417\")"
|
||||
}
|
||||
},
|
||||
"response_type": "__aiter__",
|
||||
@@ -2198,7 +2212,7 @@
|
||||
"request_data": {
|
||||
"klass": "lnbits.wallets.lnd_grpc_files.router_pb2.TrackPaymentRequest",
|
||||
"kwargs": {
|
||||
"__eval__:payment_hash": "bytes.fromhex(\"e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96\")"
|
||||
"__eval__:payment_hash": "bytes.fromhex(\"c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417\")"
|
||||
}
|
||||
},
|
||||
"response_type": "__aiter__",
|
||||
@@ -2226,7 +2240,7 @@
|
||||
"request_data": {
|
||||
"klass": "lnbits.wallets.lnd_grpc_files.router_pb2.TrackPaymentRequest",
|
||||
"kwargs": {
|
||||
"__eval__:payment_hash": "bytes.fromhex(\"e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96\")"
|
||||
"__eval__:payment_hash": "bytes.fromhex(\"c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417\")"
|
||||
}
|
||||
},
|
||||
"response_type": "__aiter__",
|
||||
@@ -2254,7 +2268,7 @@
|
||||
"request_data": {
|
||||
"klass": "lnbits.wallets.lnd_grpc_files.router_pb2.TrackPaymentRequest",
|
||||
"kwargs": {
|
||||
"__eval__:payment_hash": "bytes.fromhex(\"e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96\")"
|
||||
"__eval__:payment_hash": "bytes.fromhex(\"c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417\")"
|
||||
}
|
||||
},
|
||||
"response_type": "exception",
|
||||
@@ -2316,7 +2330,7 @@
|
||||
"description": "failed",
|
||||
"description1": "pending should be false in the 'expect', this is a bug",
|
||||
"call_params": {
|
||||
"checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"checking_id": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
},
|
||||
"expect": {
|
||||
"success": false,
|
||||
@@ -2360,7 +2374,7 @@
|
||||
"pays": [
|
||||
{
|
||||
"status": "failed",
|
||||
"payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96"
|
||||
"payment_hash": "c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2385,7 +2399,7 @@
|
||||
"request_data": {
|
||||
"klass": "lnbits.wallets.lnd_grpc_files.router_pb2.TrackPaymentRequest",
|
||||
"kwargs": {
|
||||
"__eval__:payment_hash": "bytes.fromhex(\"e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96\")"
|
||||
"__eval__:payment_hash": "bytes.fromhex(\"c386d8e8d07342f2e39e189c8e6c57bb205bb373fe4e3a6f69404a8bb767b417\")"
|
||||
}
|
||||
},
|
||||
"response_type": "__aiter__",
|
||||
|
||||
Reference in New Issue
Block a user