Compare commits

..
Author SHA1 Message Date
dni ⚡andGitHub 8272147012 chore: update to v1.3.0-rc5 (#3328) 2025-08-21 16:40:02 +02:00
dni ⚡andGitHub 244de2ae86 chore: update lnurl to v0.8.0 (#3326) 2025-08-21 16:36:11 +02:00
Tiago VasconcelosandGitHub ef371e303c feat: allow creating/update user with nostr npub1 pubkey (#3298) 2025-08-21 16:35:23 +02:00
5ba06d42d0 feat: use uv instead of poetry for CI, docker and development (#3325)
Co-authored-by: arcbtc <ben@arc.wales>
2025-08-21 16:17:19 +02:00
dni ⚡andGitHub 15984fa49b chore: update to v1.3.0-rc4 (#3323) 2025-08-19 12:10:52 +02:00
dni ⚡andGitHub 67dc096909 feat: use pydantic mypy plugin to have improved linting of models (#3311) 2025-08-19 11:37:00 +02:00
dni ⚡andGitHub 2daddf8b74 chore: update lnurl to v0.7.3 (#3320) 2025-08-19 11:34:19 +02:00
dni ⚡andGitHub ae6cf47244 feat: lud11 disposable and storeable payRequests. (#3317) 2025-08-19 11:33:57 +02:00
dni ⚡andGitHub 1488f580ff feat: frontend component for LNURLs (#3321) 2025-08-19 11:18:02 +02:00
dni ⚡andGitHub 609c949394 chore: update to v1.3.0-rc3 (#3313) 2025-08-18 10:07:29 +02:00
dni ⚡andGitHub c24d2f24cf fix: bump lnurl its not callback_url now, its just url (#3312) 2025-08-18 09:18:11 +02:00
dni ⚡andGitHub 0ba81ec719 bug: propergate LnurlErrorResponse in lnurl service and dont checks for redundant LnurlPayActionResponse (#3314) 2025-08-14 13:37:44 +02:00
dni ⚡andGitHub 5e03707be5 fix: mispelling in logs #3306 (#3310) 2025-08-13 14:40:24 +02:00
ArcandGitHub 50f1111356 Fix: fake wallet (#3309) 2025-08-13 14:39:42 +02:00
dni ⚡andGitHub 112a0f9698 fix: lnaddresses on new lnurlscan POST endpoint (#3305) 2025-08-13 08:45:36 +02:00
ArcandGitHub 75016d1a27 fix: lnurl_decode changed to url_decode (#3303) 2025-08-04 09:25:09 +02:00
Tiago VasconcelosandGitHub 5b9f244a9f fix: prevent overflow on dialog (#3295) 2025-07-31 20:06:17 +03:00
dni ⚡andGitHub 0d2ff2e663 feat: add lud17 support for wallet parse dialog (#3289) 2025-07-23 15:27:00 +02:00
Tiago VasconcelosandGitHub bdfefc2506 [Fix] Don't allow pay for expired invoice (#3290) 2025-07-23 15:25:27 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2a9f471783 build(deps): bump form-data from 4.0.0 to 4.0.4 (#3288)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-22 14:34:15 +03:00
dni ⚡andGitHub 793c7c45ed chore: update version to v1.3.0-rc2 (#3287) 2025-07-21 11:32:53 +03:00
dni ⚡andGitHub ff535ae065 feat: add get_pr_from_lnurl to lnurl services (#3286) 2025-07-21 10:28:14 +02:00
dni ⚡andGitHub 01fdc1defb fix: frontend withdraw error message (#3282) 2025-07-21 10:34:50 +03:00
dce2bfb440 [feat] User notifications backend (#3280)
Co-authored-by: Arc <33088785+arcbtc@users.noreply.github.com>
Co-authored-by: dni  <office@dnilabs.com>
2025-07-17 16:11:40 +02:00
dni ⚡andGitHub a36ab2d408 feat: update lnurl lib refactor lnurl endpoints (#3271) 2025-07-17 17:03:44 +03:00
dni ⚡andGitHub 98f732fb6f chore: update fastapi an starlette (#3277) 2025-07-17 16:41:54 +03:00
dni ⚡andGitHub 65864cb216 chore: use modern websockets instead of legacy (#3278) 2025-07-17 15:02:29 +03:00
dni ⚡andGitHub 114e7b8c3c chore: update to version v1.3.0-rc1 (#3273) 2025-07-17 14:59:12 +03:00
dni ⚡andGitHub fbfab880f3 chore: replace passlib with bcrypt (#3279) 2025-07-17 14:58:43 +03:00
dni ⚡andGitHub 133afff8ee fix: catch init fiat providers (#3281) 2025-07-17 14:58:03 +03:00
dni ⚡andGitHub 851c952c6d chore: update vue-i18n dependabot (#3276) 2025-07-17 12:23:55 +03:00
dni ⚡andGitHub 8b29148085 fix: frontend bgimagechoice can be null (#3270) 2025-07-15 12:36:39 +03:00
dni ⚡ ad48092549 chore: bundle 2025-07-15 11:16:42 +02:00
ee449ca612 chore: udate v1.2.1 fi.js (#3268)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:15:19 +02:00
Vlad Stananddni ⚡ 73f45ae859 feat: better server start logging (#3266) 2025-07-15 11:15:18 +02:00
Vlad Stananddni ⚡ 094a9fc1a1 [feat] UI for user notifications (#3267) 2025-07-15 11:15:17 +02:00
dni ⚡ 5e9919c5c5 feat: toggle holdinvoice depending on fundingsource Feature (#3262) 2025-07-15 11:15:16 +02:00
f91c933919 feat: HodlInvoices (Rebase) (#2869)
Co-authored-by: pseudozach <git@pseudozach.com>
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:15:14 +02:00
dni ⚡ 6e9f451419 feat: introduce self.features to wallets, refactor feature nodemanager (#3260) 2025-07-15 11:15:13 +02:00
dni ⚡ 256a8098c6 chore: get rid of mypy notes and typecheck more functions (#3257) 2025-07-15 11:15:12 +02:00
Arcanddni ⚡ 1a02095758 refactor: theme options, includes fix bg on firefox with dialog (#3247)
Co-authored-by: dni  <office@dnilabs.com>
2025-07-15 11:15:10 +02:00
Tiago Vasconcelosanddni ⚡ ec5b7493b3 fix: default theme settings (#3256) 2025-07-15 11:14:33 +02:00
cdc1a6dc8f feat: Add support for the new CLNRest as a funding source (#2778)
Co-authored-by: Amperstrand <amperstrand@localhost>
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:14:32 +02:00
Arcanddni ⚡ 7d573ef859 chore: tooltip for ad (#3255) 2025-07-15 11:14:30 +02:00
Vlad Stananddni ⚡ 4bd2a4fba9 chore: upgrade protobuf (#3252) 2025-07-15 11:14:29 +02:00
dni ⚡ 209c251fb6 fix: update and fixes for BreezWallet (#3246) 2025-07-15 11:14:28 +02:00
dni ⚡ 69a7ef74fd CI: fix using all optional dependecies (#3251) 2025-07-15 11:14:27 +02:00
dni ⚡andVlad Stan 35e2c4b0a7 fix: update success status (#3244)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:14:25 +02:00
dni ⚡ 2c6dce808e feat: run_interval in tasks and refactor check_pending_payments (#3245) 2025-07-15 11:14:24 +02:00
Vlad Stananddni ⚡ fa89313e6f [fix] bandit assert warnings (#3243)
Co-authored-by: dni  <office@dnilabs.com>
2025-07-15 11:14:23 +02:00
Vlad Stananddni ⚡ 76ecf113c3 [fix] bandit sql warnings (#3242) 2025-07-15 11:14:22 +02:00
Vlad Stananddni ⚡ e0749e186e [fix] Small bandit changes (#3241) 2025-07-15 11:14:20 +02:00
0529ee2835 feat: add Breez *Liquid* SDK funding source (#2681)
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:14:19 +02:00
Vlad Stananddni ⚡ 09b91f9f79 refactor: extract create_payment_request (#3240) 2025-07-15 11:14:18 +02:00
dni ⚡andVlad Stan 90ab642dcd fix: phoenixd pending payments (#3075)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:14:16 +02:00
88cf1ac853 feat: Allow custom memo on pay invoice (#3236)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:14:15 +02:00
Vlad Stananddni ⚡ 9aa2194d94 refactor: use while settings.lnbits_running instead of while True 2025-07-15 11:14:04 +02:00
dni ⚡ ba0f7a01ef chore: reduce max complexity lint (#3230) 2025-07-15 11:14:03 +02:00
Tiago Vasconcelosanddni ⚡ 5765ea0276 [Fix] Display fee in sats (#3235) 2025-07-15 11:14:02 +02:00
Arcanddni ⚡ 88b01e6079 feat: theme fix for elsal, cyber, freedom (#3259) 2025-07-11 12:39:03 +02:00
Arcanddni ⚡ b0d0901969 refactor: theme options, includes fix bg on firefox with dialog (#3247)
Co-authored-by: dni  <office@dnilabs.com>
2025-07-11 12:39:02 +02:00
dni ⚡ 6563610b6c chore: update to version 1.2.1 2025-07-11 12:38:33 +02:00
dni ⚡ c4aa0b598c chore: update to version 1.2.0 2025-07-08 10:22:22 +02:00
dni ⚡ aab76e691d chore: update to version v1.2.0-rc3 (#3231) 2025-07-08 10:17:30 +02:00
Vlad Stananddni ⚡ 3ed30be93e fix: condition for available, but not installed extension (#3232) 2025-07-08 10:17:29 +02:00
Vlad Stananddni ⚡ 37f99e7f96 doc: LNBITS_EXTENSIONS_PATH (#3229) 2025-07-08 10:17:28 +02:00
dni ⚡ a16078a6ba chore: update python packages + formatting + cleanup (#3221) 2025-07-08 10:17:27 +02:00
dni ⚡ c4c03d96a3 test: add regtest testcase for no route failure (#3189) 2025-07-08 10:17:25 +02:00
Vlad Stananddni ⚡ ab3248da15 fix: url_for should not return extension upgrades temp paths (#3227) 2025-07-08 10:17:24 +02:00
dni ⚡ b4b37cd733 fix: circular import on handle_fiat_payments (#3226) 2025-07-08 10:17:23 +02:00
dni ⚡ 26eb7a449c feat: add internal_invoice_queue_put to get rid of dynamic import (#3223) 2025-07-08 10:17:21 +02:00
Vlad Stananddni ⚡ 695e9b6471 [feat] Add stripe payments (#3184) 2025-07-08 10:17:19 +02:00
blackcoffeexbtanddni ⚡ 5c9511ccfe Label tweaks (#3224) 2025-07-08 10:17:18 +02:00
Vlad Stananddni ⚡ c7b7832a88 feat: add external id for users (#3219) 2025-07-08 10:17:16 +02:00
139 changed files with 10324 additions and 8526 deletions
+5 -17
View File
@@ -21,29 +21,17 @@ runs:
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
# cache poetry install via pip
cache: "pip"
- name: Set up Poetry
uses: abatilo/actions-poetry@v2
- name: Setup a local virtual environment (if no poetry.toml file)
shell: bash
run: |
poetry config virtualenvs.create true --local
poetry config virtualenvs.in-project true --local
- uses: actions/cache@v4
name: Define a cache for the virtual environment based on the dependencies lock file
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
path: ./.venv
key: venv-${{ hashFiles('poetry.lock') }}
enable-cache: true
python-version: ${{ inputs.python-version }}
- name: Install the project dependencies
shell: bash
run: |
poetry env use python${{ inputs.python-version }}
poetry install --all-extras
run: uv sync --locked --all-extras --dev
- name: Use Node.js ${{ inputs.node-version }}
if: ${{ (inputs.npm == 'true') }}
+4 -4
View File
@@ -20,7 +20,7 @@ jobs:
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml
with:
custom-pytest: "poetry run pytest tests/api"
custom-pytest: "uv run pytest tests/api"
python-version: ${{ matrix.python-version }}
db-url: ${{ matrix.db-url }}
secrets:
@@ -34,7 +34,7 @@ jobs:
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml
with:
custom-pytest: "poetry run pytest tests/wallets"
custom-pytest: "uv run pytest tests/wallets"
python-version: ${{ matrix.python-version }}
db-url: ${{ matrix.db-url }}
secrets:
@@ -48,7 +48,7 @@ jobs:
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml
with:
custom-pytest: "poetry run pytest tests/unit"
custom-pytest: "uv run pytest tests/unit"
python-version: ${{ matrix.python-version }}
db-url: ${{ matrix.db-url }}
secrets:
@@ -77,7 +77,7 @@ jobs:
python-version: ["3.10"]
backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
with:
custom-pytest: "poetry run pytest tests/regtest"
custom-pytest: "uv run pytest tests/regtest"
python-version: ${{ matrix.python-version }}
backend-wallet-class: ${{ matrix.backend-wallet-class }}
secrets:
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
run: |
poetry run lnbits &
uv run lnbits &
sleep 10
- name: setup java version
+2 -3
View File
@@ -14,18 +14,17 @@ on:
- 'flake.nix'
- 'flake.lock'
- 'pyproject.toml'
- 'poetry.lock'
- 'uv.lock'
- '.github/workflows/nix.yml'
pull_request:
paths:
- 'flake.nix'
- 'flake.lock'
- 'pyproject.toml'
- 'poetry.lock'
- 'uv.lock'
jobs:
nix:
if: false # temporarly disable nix support until the `poetry2nix` issue is resolved
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
+4 -4
View File
@@ -32,6 +32,10 @@ jobs:
run: |
docker build -t lnbits/lnbits .
- uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
- name: Setup Regtest
run: |
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
@@ -40,10 +44,6 @@ jobs:
./tests
sudo chmod -R a+rwx .
- uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
- name: Run pytest
uses: pavelzw/pytest-action@v2
env:
+7 -20
View File
@@ -2,26 +2,20 @@ FROM python:3.12-slim-bookworm AS builder
RUN apt-get clean
RUN apt-get update
RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
RUN apt-get install -y curl pkg-config build-essential libnss-myhostname automake
RUN curl -sSL https://install.python-poetry.org | python3 -
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:$PATH"
WORKDIR /app
# Only copy the files required to install the dependencies
COPY pyproject.toml poetry.lock ./
COPY pyproject.toml uv.lock ./
RUN touch README.md
RUN mkdir data
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
ARG POETRY_INSTALL_ARGS="--only main"
RUN poetry install --no-root ${POETRY_INSTALL_ARGS}
RUN uv sync --all-extras
FROM python:3.12-slim-bookworm
@@ -34,26 +28,19 @@ RUN apt-get update && apt-get -y upgrade && \
apt-get -y install postgresql-client-14 postgresql-client-common && \
apt-get clean all && rm -rf /var/lib/apt/lists/*
RUN curl -sSL https://install.python-poetry.org | python3 -
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:$PATH"
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH"
WORKDIR /app
COPY . .
COPY --from=builder /app/.venv .venv
ARG POETRY_INSTALL_ARGS="--only main"
RUN poetry install ${POETRY_INSTALL_ARGS}
RUN uv sync --all-extras
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 --forwarded-allow-ips='*'"]
CMD ["sh", "-c", "uv run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
+1 -2
View File
@@ -8,8 +8,7 @@ RUN apt-get update && apt-get -y upgrade && \
apt-get install -y netcat-openbsd
# Reinstall dependencies just in case (needed for CMD usage)
ARG POETRY_INSTALL_ARGS="--only main"
RUN poetry install ${POETRY_INSTALL_ARGS}
RUN uv sync --all-extras
# LNbits + boltzd configuration
ENV LNBITS_PORT="5000"
+23 -23
View File
@@ -9,34 +9,34 @@ check: mypy pyright checkblack checkruff checkprettier checkbundle
test: test-unit test-wallets test-api test-regtest
prettier:
poetry run ./node_modules/.bin/prettier --write .
uv run ./node_modules/.bin/prettier --write .
pyright:
poetry run ./node_modules/.bin/pyright
uv run ./node_modules/.bin/pyright
mypy:
poetry run mypy
uv run mypy
black:
poetry run black .
uv run black .
ruff:
poetry run ruff check . --fix
uv run ruff check . --fix
checkruff:
poetry run ruff check .
uv run ruff check .
checkprettier:
poetry run ./node_modules/.bin/prettier --check .
uv run ./node_modules/.bin/prettier --check .
checkblack:
poetry run black --check .
uv run black --check .
checkeditorconfig:
editorconfig-checker
dev:
poetry run lnbits --reload
uv run lnbits --reload
docker:
docker build -t lnbits/lnbits .
@@ -46,27 +46,27 @@ test-wallets:
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/wallets
uv run pytest tests/wallets
test-unit:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/unit
uv run pytest tests/unit
test-api:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/api
uv run pytest tests/api
test-regtest:
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/regtest
uv run pytest tests/regtest
test-migration:
LNBITS_ADMIN_UI=True \
@@ -74,18 +74,18 @@ test-migration:
HOST=0.0.0.0 \
PORT=5002 \
LNBITS_DATA_FOLDER="./tests/data" \
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
timeout 5s uv run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
HOST=0.0.0.0 \
PORT=5002 \
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
LNBITS_ADMIN_UI=False \
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
timeout 5s uv run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
poetry run python tools/conv.py
uv run python tools/conv.py
migration:
poetry run python tools/conv.py
uv run python tools/conv.py
openapi:
LNBITS_ADMIN_UI=False \
@@ -94,9 +94,9 @@ openapi:
PYTHONUNBUFFERED=1 \
HOST=0.0.0.0 \
PORT=5003 \
poetry run lnbits &
uv run lnbits &
sleep 15
curl -s http://0.0.0.0:5003/openapi.json | poetry run openapi-spec-validator --errors=all -
curl -s http://0.0.0.0:5003/openapi.json | uv run openapi-spec-validator --errors=all -
# kill -9 %1
bak:
@@ -109,7 +109,7 @@ sass:
bundle:
npm install
npm run bundle
poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
uv run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
checkbundle:
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
@@ -126,8 +126,8 @@ checkbundle:
install-pre-commit-hook:
@echo "Installing pre-commit hook to git"
@echo "Uninstall the hook with poetry run pre-commit uninstall"
poetry run pre-commit install
@echo "Uninstall the hook with uv run pre-commit uninstall"
uv run pre-commit install
pre-commit:
poetry run pre-commit run --all-files
uv run pre-commit run --all-files
+1 -1
View File
@@ -23,4 +23,4 @@ if ! nc -z localhost 9002; then
fi
echo "Starting LNbits on $LNBITS_HOST:$LNBITS_PORT..."
exec poetry run lnbits --port "$LNBITS_PORT" --host "$LNBITS_HOST" --forwarded-allow-ips='*'
exec uv run lnbits --port "$LNBITS_PORT" --host "$LNBITS_HOST" --forwarded-allow-ips='*'
+6 -6
View File
@@ -11,13 +11,13 @@ Thanks for contributing :)
# Run
Follow the [Basic installation: Option 1 (recommended): poetry](https://docs.lnbits.org/guide/installation.html#option-1-recommended-poetry)
guide to install poetry and other dependencies.
Follow the [Option 2 (recommended): UV](https://docs.lnbits.org/guide/installation.html)
guide to install uv and other dependencies.
Then you can start LNbits uvicorn server with:
```bash
poetry run lnbits
uv run lnbits
```
Or you can use the following to start uvicorn with hot reloading enabled:
@@ -25,7 +25,7 @@ Or you can use the following to start uvicorn with hot reloading enabled:
```bash
make dev
# or
poetry run lnbits --reload
uv run lnbits --reload
```
You might need the following extra dependencies on clean installation of Debian:
@@ -50,7 +50,7 @@ make install-pre-commit-hook
This project has unit tests that help prevent regressions. Before you can run the tests, you must install a few dependencies:
```bash
poetry install
uv sync --all-extras --dev
npm i
```
@@ -69,7 +69,7 @@ make format
Run mypy checks:
```bash
poetry run mypy
make mypy
```
Run everything:
+2 -6
View File
@@ -42,14 +42,10 @@ mv templates/example templates/mysuperplugin # Rename templates folder.
DO NOT ADD NEW DEPENDENCIES. Try to use the dependencies that are available in `pyproject.toml`. Getting the LNbits project to accept a new dependency is time consuming and uncertain, and may result in your extension NOT being made available to others.
If for some reason your extensions must have a new python package to work, and its nees are not met in `pyproject.toml`, you can add a new package using `poerty`:
```sh
$ poetry add <package>
```
If for some reason your extensions must have a new python package to work, and its nees are not met in `pyproject.toml`, you can add a new package using `poerty` or `uv`:
**But we need an extra step to make sure LNbits doesn't break in production.**
Dependencies need to be added to `pyproject.toml`, then tested by running on `poetry` compatibility can be tested with `nix build .#checks.x86_64-linux.vmTest`.
Dependencies need to be added to `pyproject.toml`, then tested by running on `uv` and `poetry` compatibility can be tested with `nix build .#checks.x86_64-linux.vmTest`.
## SQLite to PostgreSQL migration
+1 -1
View File
@@ -53,7 +53,7 @@ $ sudo nano .env
Now start LNbits once in the terminal window
```
$ poetry run lnbits
$ uv run lnbits
```
You can now `cat` the Super User ID:
+40 -22
View File
@@ -23,15 +23,15 @@ LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most syst
LNbits will create a folder for db and extension files in the folder the AppImage runs from.
## Option 2: Poetry (recommended for developers)
## Option 2: UV (recommended for developers)
It is recommended to use the latest version of Poetry. Make sure you have Python version `3.12` installed.
It is recommended to use the latest version of UV. Make sure you have Python version `3.12` installed.
### Install Python 3.12
## Option 2 (recommended): Poetry
## Option 2 (recommended): UV
It is recommended to use the latest version of Poetry. Make sure you have Python version 3.9 or higher installed.
It is recommended to use the latest version of UV. Make sure you have Python version 3.10 or higher installed.
### Verify Python version
@@ -39,11 +39,19 @@ It is recommended to use the latest version of Poetry. Make sure you have Python
python3 --version
```
### Install Poetry
### Install UV
```sh
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
```
### (old) Install Poetry
```sh
# If path 'export PATH="$HOME/.local/bin:$PATH"' fails, use the path echoed by the install
curl -sSL https://install.python-poetry.org | python3 - && export PATH="$HOME/.local/bin:$PATH"
curl -sSL https://install.python-poetry.org | python3 -
export PATH="$HOME/.local/bin:$PATH"
```
### install LNbits
@@ -51,9 +59,13 @@ curl -sSL https://install.python-poetry.org | python3 - && export PATH="$HOME/.l
```sh
git clone https://github.com/lnbits/lnbits.git
cd lnbits
poetry env use 3.12
git checkout main
poetry install --only main
uv sync --all-extras
# or poetry
# poetry env use 3.12
# poetry install --only main
cp .env.example .env
# Optional: to set funding source amongst other options via the env `nano .env`
```
@@ -61,8 +73,11 @@ cp .env.example .env
#### Running the server
```sh
poetry run lnbits
# To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0'
uv run lnbits
# To change port/host pass 'uv run lnbits --port 9000 --host 0.0.0.0'
# or poetry
# poetry run lnbits
# adding --debug in the start-up command above to help your troubleshooting and generate a more verbose output
# Note that you have to add the line DEBUG=true in your .env file, too.
```
@@ -71,7 +86,7 @@ poetry run lnbits
```sh
# A very useful terminal client for getting the supersuer ID, updating extensions, etc
poetry run lnbits-cli --help
uv run lnbits-cli --help
```
#### Updating the server
@@ -85,15 +100,18 @@ cd lnbits
git pull --rebase
# Check your poetry version with
poetry env list
# 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
# poetry env use python3.12
# poetry env remove python3.9
# poetry env list
# Run install and start LNbits with
poetry install --only main
poetry run lnbits
# poetry install --only main
# poetry run lnbits
uv sync --all-extras
uv run lnbits
# use LNbits admin UI Extensions page function "Update All" do get extensions onto proper level
```
@@ -108,13 +126,13 @@ chmod +x lnbits.sh &&
Now visit `0.0.0.0:5000` to make a super-user account.
`./lnbits.sh` can be used to run, but for more control `cd lnbits` and use `poetry run lnbits` (see previous option).
`./lnbits.sh` can be used to run, but for more control `cd lnbits` and use `uv run lnbits` (see previous option).
## Option 3: Nix
```sh
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
sh <(curl -L https://nixos.org/nix/install) --daemon
sh <(c&url -L https://nixos.org/nix/install) --daemon
# Enable nix-command and flakes experimental features for nix:
echo 'experimental-features = nix-command flakes' >> /etc/nix/nix.conf
@@ -332,7 +350,7 @@ LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
# START LNbits
# STOP LNbits
poetry run python tools/conv.py
uv run python tools/conv.py
# or
make migration
```
@@ -357,8 +375,8 @@ Description=LNbits
[Service]
# replace with the absolute path of your lnbits installation
WorkingDirectory=/home/lnbits/lnbits
# same here. run `which poetry` if you can't find the poetry binary
ExecStart=/home/lnbits/.local/bin/poetry run lnbits
# same here. run `which uv` if you can't find the poetry binary
ExecStart=/home/lnbits/.local/bin/uv run lnbits
# replace with the user that you're running lnbits on
User=lnbits
Restart=always
+1 -1
View File
@@ -72,7 +72,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 lnbits-cli encrypt macaroon`.
To encrypt your macaroon, run `uv run lnbits-cli encrypt macaroon`.
### LNbits
Generated
+82 -67
View File
@@ -1,15 +1,39 @@
{
"nodes": {
"build-system-pkgs": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"pyproject-nix": [
"pyproject-nix"
],
"uv2nix": "uv2nix"
},
"locked": {
"lastModified": 1755484659,
"narHash": "sha256-2FfbqsaHVQd12XFFUAinIMAuGO3853LONmva1gT3vKw=",
"owner": "pyproject-nix",
"repo": "build-system-pkgs",
"rev": "9778e87c2361810ff15e287ca5895c9da4a0e900",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "build-system-pkgs",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
@@ -18,71 +42,49 @@
"type": "github"
}
},
"nix-github-actions": {
"inputs": {
"nixpkgs": [
"poetry2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1703863825,
"narHash": "sha256-rXwqjtwiGKJheXB43ybM8NwWB8rO2dSRrEqes0S7F5Y=",
"owner": "nix-community",
"repo": "nix-github-actions",
"rev": "5163432afc817cf8bd1f031418d1869e4c9d5547",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nix-github-actions",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1735563628,
"narHash": "sha256-OnSAY7XDSx7CtDoqNh8jwVwh4xNL/2HaJxGjryLWzX8=",
"owner": "nixos",
"lastModified": 1751274312,
"narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b134951a4c9f3c995fd7be05f3243f8ecd65d798",
"rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-24.05",
"owner": "NixOS",
"ref": "nixos-24.11",
"repo": "nixpkgs",
"type": "github"
}
},
"poetry2nix": {
"pyproject-nix": {
"inputs": {
"flake-utils": "flake-utils",
"nix-github-actions": "nix-github-actions",
"nixpkgs": [
"nixpkgs"
],
"systems": "systems_2",
"treefmt-nix": "treefmt-nix"
]
},
"locked": {
"lastModified": 1724134185,
"narHash": "sha256-nDqpGjz7cq3ThdC98BPe1ANCNlsJds/LLZ3/MdIXjA0=",
"owner": "nix-community",
"repo": "poetry2nix",
"rev": "5ee730a8752264e463c0eaf06cc060fd07f6dae9",
"lastModified": 1754923840,
"narHash": "sha256-QSKpYg+Ts9HYF155ltlj40iBex39c05cpOF8gjoE2EM=",
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"rev": "023cd4be230eacae52635be09eef100c37ef78da",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "poetry2nix",
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"type": "github"
}
},
"root": {
"inputs": {
"build-system-pkgs": "build-system-pkgs",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"poetry2nix": "poetry2nix"
"pyproject-nix": "pyproject-nix",
"uv2nix": "uv2nix_2"
}
},
"systems": {
@@ -100,38 +102,51 @@
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"id": "systems",
"type": "indirect"
}
},
"treefmt-nix": {
"uv2nix": {
"inputs": {
"nixpkgs": [
"poetry2nix",
"build-system-pkgs",
"nixpkgs"
],
"pyproject-nix": [
"build-system-pkgs",
"pyproject-nix"
]
},
"locked": {
"lastModified": 1719749022,
"narHash": "sha256-ddPKHcqaKCIFSFc/cvxS14goUhCOAwsM1PbMr0ZtHMg=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "8df5ff62195d4e67e2264df0b7f5e8c9995fd0bd",
"lastModified": 1755210905,
"narHash": "sha256-WnoFEk79ysjL85TNP7bvImzhxvQw9B6uNtnLd4oJntw=",
"owner": "pyproject-nix",
"repo": "uv2nix",
"rev": "87bcba013ef304bbfd67c8e8a257aee634ed5a4c",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"owner": "pyproject-nix",
"repo": "uv2nix",
"type": "github"
}
},
"uv2nix_2": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"pyproject-nix": [
"pyproject-nix"
]
},
"locked": {
"lastModified": 1755485731,
"narHash": "sha256-k8kxwVs8Oze6q/jAaRa3RvZbb50I/K0b5uptlsh0HXI=",
"owner": "pyproject-nix",
"repo": "uv2nix",
"rev": "bebbd80bf56110fcd20b425589814af28f1939eb",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "uv2nix",
"type": "github"
}
}
+137 -58
View File
@@ -1,70 +1,149 @@
{
description = "LNbits, free and open-source Lightning wallet and accounts system";
description = "LNbits, free and open-source Lightning wallet and accounts system (uv2nix)";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05";
poetry2nix = {
url = "github:nix-community/poetry2nix";
inputs.nixpkgs.follows = "nixpkgs";
};
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
flake-utils.url = "github:numtide/flake-utils";
pyproject-nix.url = "github:pyproject-nix/pyproject.nix";
uv2nix.url = "github:pyproject-nix/uv2nix";
build-system-pkgs.url = "github:pyproject-nix/build-system-pkgs";
pyproject-nix.inputs.nixpkgs.follows = "nixpkgs";
uv2nix.inputs.nixpkgs.follows = "nixpkgs";
build-system-pkgs.inputs.nixpkgs.follows = "nixpkgs";
uv2nix.inputs.pyproject-nix.follows = "pyproject-nix";
build-system-pkgs.inputs.pyproject-nix.follows = "pyproject-nix";
};
outputs = { self, nixpkgs, poetry2nix }@inputs:
let
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forSystems = systems: f:
nixpkgs.lib.genAttrs systems
(system: f system (import nixpkgs { inherit system; overlays = [ poetry2nix.overlays.default self.overlays.default ]; }));
forAllSystems = forSystems supportedSystems;
projectName = "lnbits";
in
{
overlays = {
default = final: prev: {
${projectName} = self.packages.${prev.stdenv.hostPlatform.system}.${projectName};
};
};
packages = forAllSystems (system: pkgs: {
default = self.packages.${system}.${projectName};
${projectName} = pkgs.poetry2nix.mkPoetryApplication {
projectDir = ./.;
meta.rev = self.dirtyRev or self.rev;
meta.mainProgram = projectName;
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
coincurve = prev.coincurve.override { preferWheel = true; };
protobuf = prev.protobuf.override { preferWheel = true; };
ruff = prev.ruff.override { preferWheel = true; };
wallycore = prev.wallycore.override { preferWheel = true; };
tlv8 = prev.tlv8.overrideAttrs (old: {
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
prev.setuptools
];
outputs = { self, nixpkgs, flake-utils, uv2nix, pyproject-nix, build-system-pkgs, ... }:
flake-utils.lib.eachSystem [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]
(system:
let
pkgs = import nixpkgs { inherit system; };
lib = pkgs.lib;
python = pkgs.python312;
# Read uv.lock / pyproject via uv2nix
workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./.; };
# Prefer wheels when available
uvLockedOverlay = workspace.mkPyprojectOverlay { sourcePreference = "wheel"; };
# Helper for extending lists safely (works if a is null)
plus = a: b: lib.unique (((if a == null then [] else a)) ++ b);
# Extra build inputs for troublesome sdists
myOverrides = (final: prev: {
# embit needs setuptools at build time
embit = prev.embit.overrideAttrs (old: {
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ];
});
pynostr = prev.pynostr.overrideAttrs (old: {
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
prev.setuptools-scm
# http-ece (pywebpush dep) needs setuptools
"http-ece" = prev."http-ece".overrideAttrs (old: {
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ];
});
# pyqrcode needs setuptools
pyqrcode = prev.pyqrcode.overrideAttrs (old: {
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ];
});
# tlv8 needs setuptools
tlv8 = prev.tlv8.overrideAttrs (old: {
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ];
});
# secp256k1 Python binding:
# - setuptools, pkg-config
# - cffi + pycparser
# - system libsecp256k1 for headers/libs
secp256k1 = prev.secp256k1.overrideAttrs (old: {
nativeBuildInputs = plus (old.nativeBuildInputs or []) [
prev.setuptools
pkgs.pkg-config
prev.cffi
prev.pycparser
];
buildInputs = plus (old.buildInputs or []) [ pkgs.secp256k1 ];
propagatedBuildInputs = plus (old.propagatedBuildInputs or []) [ prev.cffi prev.pycparser ];
env = (old.env or { }) // { PKG_CONFIG = "${pkgs.pkg-config}/bin/pkg-config"; };
});
# pynostr uses setuptools-scm for versioning
pynostr = prev.pynostr.overrideAttrs (old: {
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools-scm ];
});
});
};
});
nixosModules = {
default = { pkgs, lib, config, ... }: {
imports = [ "${./nix/modules/${projectName}-service.nix}" ];
nixpkgs.overlays = [ self.overlays.default ];
};
};
checks = forAllSystems (system: pkgs:
let
vmTests = import ./nix/tests {
makeTest = (import (nixpkgs + "/nixos/lib/testing-python.nix") { inherit system; }).makeTest;
inherit inputs pkgs;
# Compose Python package set honoring uv.lock
pythonSet =
(pkgs.callPackage pyproject-nix.build.packages { inherit python; })
.overrideScope (lib.composeManyExtensions [
build-system-pkgs.overlays.default
uvLockedOverlay
myOverrides
]);
projectName = "lnbits";
# Build a venv from the locked spec (this installs the resolved wheels)
runtimeVenv = pythonSet.mkVirtualEnv "${projectName}-env" workspace.deps.default;
# Wrapper so `nix run` behaves like `uv run` (use local source tree for templates/static/extensions)
lnbitsApp = pkgs.writeShellApplication {
name = "lnbits";
text = ''
export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
export REQUESTS_CA_BUNDLE=$SSL_CERT_FILE
export PYTHONPATH="$PWD:${PYTHONPATH:-}"
exec ${runtimeVenv}/bin/lnbits "$@"
'';
};
lnbitsCliApp = pkgs.writeShellApplication {
name = "lnbits-cli";
text = ''
export PYTHONPATH="$PWD:${PYTHONPATH:-}"
exec ${runtimeVenv}/bin/lnbits-cli "$@"
'';
};
in
pkgs.lib.optionalAttrs pkgs.stdenv.isLinux vmTests # vmTests can only be ran on Linux, so append them only if on Linux.
//
{
# Other checks here...
}
);
};
# nix build → produces the venv in ./result
packages.default = runtimeVenv;
packages.${projectName} = runtimeVenv;
# nix run . → launches via wrapper that imports from source tree
apps.default = { type = "app"; program = "${lnbitsApp}/bin/lnbits"; };
apps.${projectName} = self.apps.${system}.default;
apps."${projectName}-cli" = { type = "app"; program = "${lnbitsCliApp}/bin/lnbits-cli"; };
# dev shell with locked deps + tools
devShells.default = pkgs.mkShell {
packages = [
runtimeVenv
pkgs.uv
pkgs.ruff
pkgs.black
pkgs.mypy
pkgs.pre-commit
pkgs.openapi-generator-cli
];
};
overlays.default = final: prev: {
${projectName} = self.packages.${final.stdenv.hostPlatform.system}.${projectName};
replaceVars = prev.replaceVars or (path: vars: prev.substituteAll ({ src = path; } // vars));
};
nixosModules.default = { pkgs, lib, config, ... }: {
imports = [ "${./nix/modules/lnbits-service.nix}" ];
nixpkgs.overlays = [ self.overlays.default ];
};
checks = { };
});
}
+6 -7
View File
@@ -13,10 +13,9 @@ if [ ! -d lnbits/data ]; then
# Install Python 3.10 and distutils non-interactively
sudo apt install -y python3.10 python3.10-distutils
# Install Poetry
curl -sSL https://install.python-poetry.org | python3.10 -
# Install UV
curl -LsSf https://astral.sh/uv/install.sh | sh
# Add Poetry to PATH for the current session
export PATH="/home/$USER/.local/bin:$PATH"
if [ ! -d lnbits/wallets ]; then
@@ -42,13 +41,13 @@ elif [ ! -d lnbits/wallets ]; then
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
fi
# Install the dependencies using Poetry
poetry env use python3.9
poetry install --only main
# Install the dependencies using UV
uv sync --all-extras
# Set environment variables for LNbits
export LNBITS_ADMIN_UI=true
export HOST=0.0.0.0
# Run LNbits
poetry run lnbits
uv run lnbits
+11 -6
View File
@@ -4,9 +4,10 @@ import importlib
import os
import shutil
import sys
import time
from collections.abc import Callable
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Callable, Optional
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -26,7 +27,7 @@ from lnbits.core.crud.extensions import create_installed_extension
from lnbits.core.helpers import migrate_extension_database
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
from lnbits.core.services.notifications import enqueue_notification
from lnbits.core.services.notifications import enqueue_admin_notification
from lnbits.core.services.payments import check_pending_payments
from lnbits.core.tasks import (
audit_queue,
@@ -70,8 +71,9 @@ from .tasks import internal_invoice_listener, invoice_listener, run_interval
async def startup(app: FastAPI):
logger.info(f"Starting LNbits Version: {settings.version}")
start = time.perf_counter()
settings.lnbits_running = True
# wait till migration is done
await migrate_databases()
@@ -102,7 +104,7 @@ async def startup(app: FastAPI):
# initialize tasks
register_async_tasks()
enqueue_notification(
enqueue_admin_notification(
NotificationType.server_start_stop,
{
"message": "LNbits server started.",
@@ -110,10 +112,13 @@ async def startup(app: FastAPI):
},
)
end = time.perf_counter()
logger.success(f"LNbits started in {end - start:.2f} seconds.")
async def shutdown():
logger.warning("LNbits shutting down...")
enqueue_notification(
enqueue_admin_notification(
NotificationType.server_start_stop,
{
"message": "LNbits server shutting down...",
@@ -275,7 +280,7 @@ async def check_installed_extensions(app: FastAPI):
async def build_all_installed_extensions_list( # noqa: C901
include_deactivated: Optional[bool] = True,
include_deactivated: bool | None = True,
) -> list[InstallableExtension]:
"""
Returns a list of all the installed extensions plus the extensions that
+32 -33
View File
@@ -5,7 +5,6 @@ import time
from functools import wraps
from getpass import getpass
from pathlib import Path
from typing import Optional
from uuid import uuid4
import click
@@ -96,7 +95,7 @@ def decrypt():
"""
def get_super_user() -> Optional[str]:
def get_super_user() -> str | None:
"""Get the superuser"""
superuser_file = Path(settings.lnbits_data_folder, ".super_user")
if not superuser_file.exists() or not superuser_file.is_file():
@@ -155,7 +154,7 @@ async def db_versions():
@db.command("cleanup-wallets")
@click.argument("days", type=int, required=False)
@coro
async def database_cleanup_wallets(days: Optional[int] = None):
async def database_cleanup_wallets(days: int | None = None):
"""Delete all wallets that never had any transaction"""
async with core_db.connect() as conn:
delta = days or settings.cleanup_wallets_days
@@ -212,10 +211,10 @@ async def database_revert_payment(checking_id: str):
@click.option("-v", "--verbose", is_flag=True, help="Detailed log.")
@coro
async def check_invalid_payments(
days: Optional[int] = None,
limit: Optional[int] = None,
wallet: Optional[str] = None,
verbose: Optional[bool] = False,
days: int | None = None,
limit: int | None = None,
wallet: str | None = None,
verbose: bool | None = False,
):
"""Check payments that are settled in the DB but pending on the Funding Source"""
await check_admin_settings()
@@ -303,7 +302,7 @@ async def create_user(username: str, password: str):
@users.command("cleanup-accounts")
@click.argument("days", type=int, required=False)
@coro
async def database_cleanup_accounts(days: Optional[int] = None):
async def database_cleanup_accounts(days: int | None = None):
"""Delete all accounts that have no wallets"""
async with core_db.connect() as conn:
delta = days or settings.cleanup_wallets_days
@@ -353,12 +352,12 @@ async def extensions_list():
)
@coro
async def extensions_update( # noqa: C901
extension: Optional[str] = None,
all_extensions: Optional[bool] = False,
repo_index: Optional[str] = None,
source_repo: Optional[str] = None,
url: Optional[str] = None,
admin_user: Optional[str] = None,
extension: str | None = None,
all_extensions: bool | None = False,
repo_index: str | None = None,
source_repo: str | None = None,
url: str | None = None,
admin_user: str | None = None,
):
"""
Update extension to the latest version.
@@ -443,10 +442,10 @@ async def extensions_update( # noqa: C901
@coro
async def extensions_install(
extension: str,
repo_index: Optional[str] = None,
source_repo: Optional[str] = None,
url: Optional[str] = None,
admin_user: Optional[str] = None,
repo_index: str | None = None,
source_repo: str | None = None,
url: str | None = None,
admin_user: str | None = None,
):
"""Install a extension"""
click.echo(f"Installing {extension}... {repo_index}")
@@ -473,7 +472,7 @@ async def extensions_install(
)
@coro
async def extensions_uninstall(
extension: str, url: Optional[str] = None, admin_user: Optional[str] = None
extension: str, url: str | None = None, admin_user: str | None = None
):
"""Uninstall a extension"""
click.echo(f"Uninstalling '{extension}'...")
@@ -562,10 +561,10 @@ if __name__ == "__main__":
async def install_extension(
extension: str,
repo_index: Optional[str] = None,
source_repo: Optional[str] = None,
url: Optional[str] = None,
admin_user: Optional[str] = None,
repo_index: str | None = None,
source_repo: str | None = None,
url: str | None = None,
admin_user: str | None = None,
) -> tuple[bool, str]:
try:
release = await _select_release(extension, repo_index, source_repo)
@@ -591,10 +590,10 @@ async def install_extension(
async def update_extension(
extension: str,
repo_index: Optional[str] = None,
source_repo: Optional[str] = None,
url: Optional[str] = None,
admin_user: Optional[str] = None,
repo_index: str | None = None,
source_repo: str | None = None,
url: str | None = None,
admin_user: str | None = None,
) -> tuple[bool, str]:
try:
click.echo(f"Updating '{extension}' extension.")
@@ -644,9 +643,9 @@ async def update_extension(
async def _select_release(
extension: str,
repo_index: Optional[str] = None,
source_repo: Optional[str] = None,
) -> Optional[ExtensionRelease]:
repo_index: str | None = None,
source_repo: str | None = None,
) -> ExtensionRelease | None:
all_releases = await InstallableExtension.get_extension_releases(extension)
if len(all_releases) == 0:
click.echo(f"No repository found for extension '{extension}'.")
@@ -706,7 +705,7 @@ def _get_latest_release_per_repo(all_releases):
async def _call_install_extension(
data: CreateExtension, url: Optional[str], user_id: Optional[str] = None
data: CreateExtension, url: str | None, user_id: str | None = None
):
if url:
user_id = user_id or get_super_user()
@@ -720,7 +719,7 @@ async def _call_install_extension(
async def _call_uninstall_extension(
extension: str, url: Optional[str], user_id: Optional[str] = None
extension: str, url: str | None, user_id: str | None = None
):
if url:
user_id = user_id or get_super_user()
@@ -756,7 +755,7 @@ async def _can_run_operation(url) -> bool:
return True
async def _is_lnbits_started(url: Optional[str]):
async def _is_lnbits_started(url: str | None):
try:
url = url or f"http://{settings.host}:{settings.port}/api/v1/health"
async with httpx.AsyncClient() as client:
+2
View File
@@ -11,6 +11,7 @@ from .views.fiat_api import fiat_router
# this compat is needed for usermanager extension
from .views.generic import generic_router
from .views.lnurl_api import lnurl_router
from .views.node_api import node_router, public_node_router, super_node_router
from .views.payment_api import payment_router
from .views.tinyurl_api import tinyurl_router
@@ -42,6 +43,7 @@ def init_core_routers(app: FastAPI):
app.include_router(users_router)
app.include_router(audit_router)
app.include_router(fiat_router)
app.include_router(lnurl_router)
__all__ = ["core_app", "core_app_extra", "db"]
+8 -10
View File
@@ -1,5 +1,3 @@
from typing import Optional
from lnbits.core.db import db
from lnbits.core.models import AuditEntry, AuditFilters
from lnbits.core.models.audit import AuditCountStat
@@ -8,14 +6,14 @@ from lnbits.db import Connection, Filters, Page
async def create_audit_entry(
entry: AuditEntry,
conn: Optional[Connection] = None,
conn: Connection | None = None,
) -> None:
await (conn or db).insert("audit", entry)
async def get_audit_entries(
filters: Optional[Filters[AuditFilters]] = None,
conn: Optional[Connection] = None,
filters: Filters[AuditFilters] | None = None,
conn: Connection | None = None,
) -> Page[AuditEntry]:
return await (conn or db).fetch_page(
"SELECT * from audit",
@@ -27,7 +25,7 @@ async def get_audit_entries(
async def delete_expired_audit_entries(
conn: Optional[Connection] = None,
conn: Connection | None = None,
):
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
@@ -40,8 +38,8 @@ async def delete_expired_audit_entries(
async def get_count_stats(
field: str,
filters: Optional[Filters[AuditFilters]] = None,
conn: Optional[Connection] = None,
filters: Filters[AuditFilters] | None = None,
conn: Connection | None = None,
) -> list[AuditCountStat]:
if field not in ["request_method", "component", "response_code"]:
return []
@@ -67,8 +65,8 @@ async def get_count_stats(
async def get_long_duration_stats(
filters: Optional[Filters[AuditFilters]] = None,
conn: Optional[Connection] = None,
filters: Filters[AuditFilters] | None = None,
conn: Connection | None = None,
) -> list[AuditCountStat]:
if not filters:
filters = Filters()
+4 -6
View File
@@ -1,5 +1,3 @@
from typing import Optional
from lnbits.core.db import db
from lnbits.db import Connection
@@ -7,8 +5,8 @@ from ..models import DbVersion
async def get_db_version(
ext_id: str, conn: Optional[Connection] = None
) -> Optional[DbVersion]:
ext_id: str, conn: Connection | None = None
) -> DbVersion | None:
return await (conn or db).fetchone(
"SELECT * FROM dbversions WHERE db = :ext_id",
{"ext_id": ext_id},
@@ -16,7 +14,7 @@ async def get_db_version(
)
async def get_db_versions(conn: Optional[Connection] = None) -> list[DbVersion]:
async def get_db_versions(conn: Connection | None = None) -> list[DbVersion]:
return await (conn or db).fetchall("SELECT * FROM dbversions", model=DbVersion)
@@ -30,7 +28,7 @@ async def update_migration_version(conn, db_name, version):
)
async def delete_dbversion(*, ext_id: str, conn: Optional[Connection] = None) -> None:
async def delete_dbversion(*, ext_id: str, conn: Connection | None = None) -> None:
await (conn or db).execute(
"""
DELETE FROM dbversions WHERE db = :ext
+15 -17
View File
@@ -1,5 +1,3 @@
from typing import Optional
from lnbits.core.db import db
from lnbits.core.models.extensions import (
InstallableExtension,
@@ -10,20 +8,20 @@ from lnbits.db import Connection, Database
async def create_installed_extension(
ext: InstallableExtension,
conn: Optional[Connection] = None,
conn: Connection | None = None,
) -> None:
await (conn or db).insert("installed_extensions", ext)
async def update_installed_extension(
ext: InstallableExtension,
conn: Optional[Connection] = None,
conn: Connection | None = None,
) -> None:
await (conn or db).update("installed_extensions", ext)
async def update_installed_extension_state(
*, ext_id: str, active: bool, conn: Optional[Connection] = None
*, ext_id: str, active: bool, conn: Connection | None = None
) -> None:
await (conn or db).execute(
"""
@@ -34,7 +32,7 @@ async def update_installed_extension_state(
async def delete_installed_extension(
*, ext_id: str, conn: Optional[Connection] = None
*, ext_id: str, conn: Connection | None = None
) -> None:
await (conn or db).execute(
"""
@@ -44,7 +42,7 @@ async def delete_installed_extension(
)
async def drop_extension_db(ext_id: str, conn: Optional[Connection] = None) -> None:
async def drop_extension_db(ext_id: str, conn: Connection | None = None) -> None:
row: dict = await (conn or db).fetchone(
"SELECT * FROM dbversions WHERE db = :id",
{"id": ext_id},
@@ -65,8 +63,8 @@ async def drop_extension_db(ext_id: str, conn: Optional[Connection] = None) -> N
async def get_installed_extension(
ext_id: str, conn: Optional[Connection] = None
) -> Optional[InstallableExtension]:
ext_id: str, conn: Connection | None = None
) -> InstallableExtension | None:
extension = await (conn or db).fetchone(
"SELECT * FROM installed_extensions WHERE id = :id",
{"id": ext_id},
@@ -76,8 +74,8 @@ async def get_installed_extension(
async def get_installed_extensions(
active: Optional[bool] = None,
conn: Optional[Connection] = None,
active: bool | None = None,
conn: Connection | None = None,
) -> list[InstallableExtension]:
query = "SELECT * FROM installed_extensions"
if active is not None:
@@ -93,8 +91,8 @@ async def get_installed_extensions(
async def get_user_extension(
user_id: str, extension: str, conn: Optional[Connection] = None
) -> Optional[UserExtension]:
user_id: str, extension: str, conn: Connection | None = None
) -> UserExtension | None:
return await (conn or db).fetchone(
"""
SELECT * FROM extensions
@@ -106,7 +104,7 @@ async def get_user_extension(
async def get_user_extensions(
user_id: str, conn: Optional[Connection] = None
user_id: str, conn: Connection | None = None
) -> list[UserExtension]:
return await (conn or db).fetchall(
"""SELECT * FROM extensions WHERE "user" = :user""",
@@ -116,20 +114,20 @@ async def get_user_extensions(
async def create_user_extension(
user_extension: UserExtension, conn: Optional[Connection] = None
user_extension: UserExtension, conn: Connection | None = None
) -> None:
await (conn or db).insert("extensions", user_extension)
async def update_user_extension(
user_extension: UserExtension, conn: Optional[Connection] = None
user_extension: UserExtension, conn: Connection | None = None
) -> None:
where = """WHERE extension = :extension AND "user" = :user"""
await (conn or db).update("extensions", user_extension, where)
async def get_user_active_extensions_ids(
user_id: str, conn: Optional[Connection] = None
user_id: str, conn: Connection | None = None
) -> list[str]:
exts = await (conn or db).fetchall(
"""
+45 -47
View File
@@ -1,5 +1,5 @@
from time import time
from typing import Any, Optional
from typing import Any
from lnbits.core.crud.wallets import get_total_balance, get_wallet, get_wallets_ids
from lnbits.core.db import db
@@ -19,16 +19,11 @@ from ..models import (
)
async def update_payment_extra(
payment: Payment, conn: Optional[Connection] = None
) -> Payment:
return await (conn or db).execute(
"UPDATE apipayments SET extra = :extra WHERE checking_id = :checking_id",
{"extra": payment.extra, "checking_id": payment.checking_id},
)
def update_payment_extra():
pass
async def get_payment(checking_id: str, conn: Optional[Connection] = None) -> Payment:
async def get_payment(checking_id: str, conn: Connection | None = None) -> Payment:
return await (conn or db).fetchone(
"SELECT * FROM apipayments WHERE checking_id = :checking_id",
{"checking_id": checking_id},
@@ -38,10 +33,10 @@ async def get_payment(checking_id: str, conn: Optional[Connection] = None) -> Pa
async def get_standalone_payment(
checking_id_or_hash: str,
conn: Optional[Connection] = None,
incoming: Optional[bool] = False,
wallet_id: Optional[str] = None,
) -> Optional[Payment]:
conn: Connection | None = None,
incoming: bool | None = False,
wallet_id: str | None = None,
) -> Payment | None:
clause: str = "checking_id = :checking_id OR payment_hash = :hash"
values = {
"wallet_id": wallet_id,
@@ -69,8 +64,8 @@ async def get_standalone_payment(
async def get_wallet_payment(
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> Optional[Payment]:
wallet_id: str, payment_hash: str, conn: Connection | None = None
) -> Payment | None:
payment = await (conn or db).fetchone(
"""
SELECT *
@@ -107,17 +102,17 @@ async def get_latest_payments_by_extension(
async def get_payments_paginated( # noqa: C901
*,
wallet_id: Optional[str] = None,
user_id: Optional[str] = None,
wallet_id: str | None = None,
user_id: str | None = None,
complete: bool = False,
pending: bool = False,
failed: bool = False,
outgoing: bool = False,
incoming: bool = False,
since: Optional[int] = None,
since: int | None = None,
exclude_uncheckable: bool = False,
filters: Optional[Filters[PaymentFilters]] = None,
conn: Optional[Connection] = None,
filters: Filters[PaymentFilters] | None = None,
conn: Connection | None = None,
) -> Page[Payment]:
"""
Filters payments to be returned by:
@@ -181,17 +176,17 @@ async def get_payments_paginated( # noqa: C901
async def get_payments(
*,
wallet_id: Optional[str] = None,
wallet_id: str | None = None,
complete: bool = False,
pending: bool = False,
outgoing: bool = False,
incoming: bool = False,
since: Optional[int] = None,
since: int | None = None,
exclude_uncheckable: bool = False,
filters: Optional[Filters[PaymentFilters]] = None,
conn: Optional[Connection] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
filters: Filters[PaymentFilters] | None = None,
conn: Connection | None = None,
limit: int | None = None,
offset: int | None = None,
) -> list[Payment]:
"""
Filters payments to be returned by complete | pending | outgoing | incoming.
@@ -235,7 +230,7 @@ async def get_payments_status_count() -> PaymentsStatusCount:
async def delete_expired_invoices(
conn: Optional[Connection] = None,
conn: Connection | None = None,
) -> None:
# first we delete all invoices older than one month
@@ -264,7 +259,7 @@ async def create_payment(
checking_id: str,
data: CreatePayment,
status: PaymentState = PaymentState.PENDING,
conn: Optional[Connection] = None,
conn: Connection | None = None,
) -> Payment:
# we don't allow the creation of the same invoice twice
# note: this can be removed if the db uniqueness constraints are set appropriately
@@ -295,7 +290,7 @@ async def create_payment(
async def update_payment_checking_id(
checking_id: str, new_checking_id: str, conn: Optional[Connection] = None
checking_id: str, new_checking_id: str, conn: Connection | None = None
) -> None:
await (conn or db).execute(
"UPDATE apipayments SET checking_id = :new_id WHERE checking_id = :old_id",
@@ -305,8 +300,8 @@ async def update_payment_checking_id(
async def update_payment(
payment: Payment,
new_checking_id: Optional[str] = None,
conn: Optional[Connection] = None,
new_checking_id: str | None = None,
conn: Connection | None = None,
) -> None:
await (conn or db).update(
"apipayments", payment, "WHERE checking_id = :checking_id"
@@ -316,9 +311,9 @@ async def update_payment(
async def get_payments_history(
wallet_id: Optional[str] = None,
wallet_id: str | None = None,
group: DateTrunc = "day",
filters: Optional[Filters] = None,
filters: Filters | None = None,
) -> list[PaymentHistoryPoint]:
if not filters:
filters = Filters()
@@ -381,10 +376,11 @@ 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,
filters: Filters[PaymentFilters] | None = None,
user_id: str | None = None,
conn: Connection | None = None,
) -> list[PaymentCountStat]:
if not filters:
filters = Filters()
extra_stmts = []
@@ -413,10 +409,11 @@ 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,
filters: Filters[PaymentFilters] | None = None,
user_id: str | None = None,
conn: Connection | None = None,
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
if not filters:
filters = Filters()
@@ -462,10 +459,11 @@ async def get_daily_stats(
async def get_wallets_stats(
filters: Optional[Filters[PaymentFilters]] = None,
user_id: Optional[str] = None,
conn: Optional[Connection] = None,
filters: Filters[PaymentFilters] | None = None,
user_id: str | None = None,
conn: Connection | None = None,
) -> list[PaymentWalletStats]:
if not filters:
filters = Filters()
@@ -510,7 +508,7 @@ async def get_wallets_stats(
async def delete_wallet_payment(
checking_id: str, wallet_id: str, conn: Optional[Connection] = None
checking_id: str, wallet_id: str, conn: Connection | None = None
) -> None:
await (conn or db).execute(
"DELETE FROM apipayments WHERE checking_id = :checking_id AND wallet = :wallet",
@@ -519,8 +517,8 @@ async def delete_wallet_payment(
async def check_internal(
payment_hash: str, conn: Optional[Connection] = None
) -> Optional[Payment]:
payment_hash: str, conn: Connection | None = None
) -> Payment | None:
"""
Returns the checking_id of the internal payment if it exists,
otherwise None
@@ -536,7 +534,7 @@ async def check_internal(
async def is_internal_status_success(
payment_hash: str, conn: Optional[Connection] = None
payment_hash: str, conn: Connection | None = None
) -> bool:
"""
Returns True if the internal payment was found and is successful,
@@ -565,7 +563,7 @@ async def mark_webhook_sent(payment_hash: str, status: str) -> None:
async def _only_user_wallets_statement(
user_id: str, conn: Optional[Connection] = None
user_id: str, conn: Connection | None = None
) -> str:
wallet_ids = await get_wallets_ids(user_id=user_id, conn=conn) or [
"no-wallets-for-user"
+9 -11
View File
@@ -1,5 +1,5 @@
import json
from typing import Any, Optional
from typing import Any
from loguru import logger
@@ -14,7 +14,7 @@ from lnbits.settings import (
)
async def get_super_settings() -> Optional[SuperSettings]:
async def get_super_settings() -> SuperSettings | None:
data = await get_settings_by_tag("core")
if data:
super_user = await get_settings_field("super_user")
@@ -24,7 +24,7 @@ async def get_super_settings() -> Optional[SuperSettings]:
return None
async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSettings]:
async def get_admin_settings(is_super_user: bool = False) -> AdminSettings | None:
sets = await get_super_settings()
if not sets:
return None
@@ -41,7 +41,7 @@ async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSetti
async def update_admin_settings(
data: EditableSettings, tag: Optional[str] = "core"
data: EditableSettings, tag: str | None = "core"
) -> None:
editable_settings = await get_settings_by_tag("core") or {}
editable_settings.update(data.dict(exclude_unset=True))
@@ -61,7 +61,7 @@ async def update_super_user(super_user: str) -> SuperSettings:
return settings
async def delete_admin_settings(tag: Optional[str] = "core") -> None:
async def delete_admin_settings(tag: str | None = "core") -> None:
await db.execute(
"DELETE FROM system_settings WHERE tag = :tag",
{"tag": tag},
@@ -93,8 +93,8 @@ async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSet
async def get_settings_field(
id_: str, tag: Optional[str] = "core"
) -> Optional[SettingsField]:
id_: str, tag: str | None = "core"
) -> SettingsField | None:
row: dict = await db.fetchone(
"""
@@ -108,9 +108,7 @@ async def get_settings_field(
return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"])
async def set_settings_field(
id_: str, value: Optional[Any], tag: Optional[str] = "core"
):
async def set_settings_field(id_: str, value: Any | None, tag: str | None = "core"):
value = json.dumps(value) if value is not None else None
await db.execute(
"""
@@ -122,7 +120,7 @@ async def set_settings_field(
)
async def get_settings_by_tag(tag: str) -> Optional[dict[str, Any]]:
async def get_settings_by_tag(tag: str) -> dict[str, Any] | None:
rows: list[dict] = await db.fetchall(
"SELECT * FROM system_settings WHERE tag = :tag", {"tag": tag}
)
+1 -3
View File
@@ -1,5 +1,3 @@
from typing import Optional
import shortuuid
from lnbits.core.db import db
@@ -19,7 +17,7 @@ async def create_tinyurl(domain: str, endless: bool, wallet: str):
return await get_tinyurl(tinyurl_id)
async def get_tinyurl(tinyurl_id: str) -> Optional[TinyURL]:
async def get_tinyurl(tinyurl_id: str) -> TinyURL | None:
return await db.fetchone(
"SELECT * FROM tiny_url WHERE id = :tinyurl",
{"tinyurl": tinyurl_id},
+20 -22
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone
from time import time
from typing import Any, Optional
from typing import Any
from uuid import uuid4
from lnbits.core.crud.extensions import get_user_active_extensions_ids
@@ -18,8 +18,8 @@ from ..models import (
async def create_account(
account: Optional[Account] = None,
conn: Optional[Connection] = None,
account: Account | None = None,
conn: Connection | None = None,
) -> Account:
if account:
account.validate_fields()
@@ -36,7 +36,7 @@ async def update_account(account: Account) -> Account:
return account
async def delete_account(user_id: str, conn: Optional[Connection] = None) -> None:
async def delete_account(user_id: str, conn: Connection | None = None) -> None:
await (conn or db).execute(
"DELETE from accounts WHERE id = :user",
{"user": user_id},
@@ -44,8 +44,8 @@ async def delete_account(user_id: str, conn: Optional[Connection] = None) -> Non
async def get_accounts(
filters: Optional[Filters[AccountFilters]] = None,
conn: Optional[Connection] = None,
filters: Filters[AccountFilters] | None = None,
conn: Connection | None = None,
) -> Page[AccountOverview]:
where_clauses = []
values: dict[str, Any] = {}
@@ -92,9 +92,7 @@ async def get_accounts(
)
async def get_account(
user_id: str, conn: Optional[Connection] = None
) -> Optional[Account]:
async def get_account(user_id: str, conn: Connection | None = None) -> Account | None:
if len(user_id) == 0:
return None
return await (conn or db).fetchone(
@@ -106,7 +104,7 @@ async def get_account(
async def delete_accounts_no_wallets(
time_delta: int,
conn: Optional[Connection] = None,
conn: Connection | None = None,
) -> None:
delta = int(time()) - time_delta
await (conn or db).execute(
@@ -125,8 +123,8 @@ async def delete_accounts_no_wallets(
async def get_account_by_username(
username: str, conn: Optional[Connection] = None
) -> Optional[Account]:
username: str, conn: Connection | None = None
) -> Account | None:
if len(username) == 0:
return None
return await (conn or db).fetchone(
@@ -137,8 +135,8 @@ async def get_account_by_username(
async def get_account_by_pubkey(
pubkey: str, conn: Optional[Connection] = None
) -> Optional[Account]:
pubkey: str, conn: Connection | None = None
) -> Account | None:
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey",
{"pubkey": pubkey.lower()},
@@ -147,8 +145,8 @@ async def get_account_by_pubkey(
async def get_account_by_email(
email: str, conn: Optional[Connection] = None
) -> Optional[Account]:
email: str, conn: Connection | None = None
) -> Account | None:
if len(email) == 0:
return None
return await (conn or db).fetchone(
@@ -159,8 +157,8 @@ async def get_account_by_email(
async def get_account_by_username_or_email(
username_or_email: str, conn: Optional[Connection] = None
) -> Optional[Account]:
username_or_email: str, conn: Connection | None = None
) -> Account | None:
return await (conn or db).fetchone(
"""
SELECT * FROM accounts
@@ -171,7 +169,7 @@ async def get_account_by_username_or_email(
)
async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]:
async def get_user(user_id: str, conn: Connection | None = None) -> User | None:
account = await get_account(user_id, conn)
if not account:
return None
@@ -179,8 +177,8 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
async def get_user_from_account(
account: Account, conn: Optional[Connection] = None
) -> Optional[User]:
account: Account, conn: Connection | None = None
) -> User | None:
extensions = await get_user_active_extensions_ids(account.id, conn)
wallets = await get_wallets(account.id, False, conn=conn)
return User(
@@ -207,7 +205,7 @@ async def update_user_access_control_list(user_acls: UserAcls):
async def get_user_access_control_lists(
user_id: str, conn: Optional[Connection] = None
user_id: str, conn: Connection | None = None
) -> UserAcls:
user_acls = await (conn or db).fetchone(
"SELECT id, access_control_list FROM accounts WHERE id = :id",
+20 -23
View File
@@ -1,6 +1,5 @@
from datetime import datetime, timezone
from time import time
from typing import Optional
from uuid import uuid4
from lnbits.core.db import db
@@ -14,8 +13,8 @@ from ..models import Wallet
async def create_wallet(
*,
user_id: str,
wallet_name: Optional[str] = None,
conn: Optional[Connection] = None,
wallet_name: str | None = None,
conn: Connection | None = None,
) -> Wallet:
wallet_id = uuid4().hex
wallet = Wallet(
@@ -32,8 +31,8 @@ async def create_wallet(
async def update_wallet(
wallet: Wallet,
conn: Optional[Connection] = None,
) -> Optional[Wallet]:
conn: Connection | None = None,
) -> Wallet:
wallet.updated_at = datetime.now(timezone.utc)
await (conn or db).update("wallets", wallet)
return wallet
@@ -44,7 +43,7 @@ async def delete_wallet(
user_id: str,
wallet_id: str,
deleted: bool = True,
conn: Optional[Connection] = None,
conn: Connection | None = None,
) -> None:
now = int(time())
await (conn or db).execute(
@@ -58,9 +57,7 @@ async def delete_wallet(
)
async def force_delete_wallet(
wallet_id: str, conn: Optional[Connection] = None
) -> None:
async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) -> None:
await (conn or db).execute(
"DELETE FROM wallets WHERE id = :wallet",
{"wallet": wallet_id},
@@ -68,8 +65,8 @@ async def force_delete_wallet(
async def delete_wallet_by_id(
wallet_id: str, conn: Optional[Connection] = None
) -> Optional[int]:
wallet_id: str, conn: Connection | None = None
) -> int | None:
now = int(time())
result = await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
@@ -83,13 +80,13 @@ async def delete_wallet_by_id(
return result.rowcount
async def remove_deleted_wallets(conn: Optional[Connection] = None) -> None:
async def remove_deleted_wallets(conn: Connection | None = None) -> None:
await (conn or db).execute("DELETE FROM wallets WHERE deleted = true")
async def delete_unused_wallets(
time_delta: int,
conn: Optional[Connection] = None,
conn: Connection | None = None,
) -> None:
delta = int(time()) - time_delta
await (conn or db).execute(
@@ -107,8 +104,8 @@ async def delete_unused_wallets(
async def get_wallet(
wallet_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> Optional[Wallet]:
wallet_id: str, deleted: bool | None = None, conn: Connection | None = None
) -> Wallet | None:
query = """
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
@@ -125,7 +122,7 @@ async def get_wallet(
async def get_wallets(
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
user_id: str, deleted: bool | None = None, conn: Connection | None = None
) -> list[Wallet]:
query = """
SELECT *, COALESCE((
@@ -144,9 +141,9 @@ 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,
deleted: bool | None = None,
filters: Filters[WalletsFilters] | None = None,
conn: Connection | None = None,
) -> Page[Wallet]:
if deleted is None:
deleted = False
@@ -166,7 +163,7 @@ async def get_wallets_paginated(
async def get_wallets_ids(
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
user_id: str, deleted: bool | None = None, conn: Connection | None = None
) -> list[str]:
query = """SELECT id FROM wallets WHERE "user" = :user"""
if deleted is not None:
@@ -186,8 +183,8 @@ async def get_wallets_count():
async def get_wallet_for_key(
key: str,
conn: Optional[Connection] = None,
) -> Optional[Wallet]:
conn: Connection | None = None,
) -> Wallet | None:
return await (conn or db).fetchone(
"""
SELECT *, COALESCE((
@@ -201,7 +198,7 @@ async def get_wallet_for_key(
)
async def get_total_balance(conn: Optional[Connection] = None):
async def get_total_balance(conn: Connection | None = None):
result = await (conn or db).execute("SELECT SUM(balance) as balance FROM balances")
row = result.mappings().first()
return row.get("balance", 0) or 0
+1 -3
View File
@@ -1,5 +1,3 @@
from typing import Optional
from lnbits.core.db import db
from ..models import WebPushSubscription
@@ -7,7 +5,7 @@ from ..models import WebPushSubscription
async def get_webpush_subscription(
endpoint: str, user: str
) -> Optional[WebPushSubscription]:
) -> WebPushSubscription | None:
return await db.fetchone(
"""
SELECT * FROM webpush_subscriptions
+3 -3
View File
@@ -1,6 +1,6 @@
import importlib
import re
from typing import Any, Optional
from typing import Any
from urllib.parse import urlparse
from uuid import UUID
@@ -20,7 +20,7 @@ from lnbits.settings import settings
async def migrate_extension_database(
ext: InstallableExtension, current_version: Optional[DbVersion] = None
ext: InstallableExtension, current_version: DbVersion | None = None
):
try:
@@ -38,7 +38,7 @@ async def run_migration(
db: Connection,
migrations_module: Any,
db_name: str,
current_version: Optional[DbVersion] = None,
current_version: DbVersion | None = None,
):
matcher = re.compile(r"^m(\d\d\d)_")
+8
View File
@@ -735,3 +735,11 @@ async def m032_add_external_id_to_accounts(db: Connection):
async def m033_update_payment_table(db: Connection):
await db.execute("ALTER TABLE apipayments ADD COLUMN fiat_provider TEXT")
async def m034_add_stored_paylinks_to_wallet(db: Connection):
await db.execute(
"""
ALTER TABLE wallets ADD COLUMN stored_paylinks TEXT
"""
)
+7 -4
View File
@@ -1,5 +1,5 @@
from .audit import AuditEntry, AuditFilters
from .lnurl import CreateLnurl, CreateLnurlAuth, PayLnurlWData
from .lnurl import CreateLnurlPayment, CreateLnurlWithdraw
from .misc import (
BalanceDelta,
Callback,
@@ -9,6 +9,7 @@ from .misc import (
SimpleStatus,
)
from .payments import (
CancelInvoice,
CreateInvoice,
CreatePayment,
DecodePayment,
@@ -23,6 +24,7 @@ from .payments import (
PaymentsStatusCount,
PaymentState,
PaymentWalletStats,
SettleInvoice,
)
from .tinyurl import TinyURL
from .users import (
@@ -57,11 +59,12 @@ __all__ = [
"BalanceDelta",
"BaseWallet",
"Callback",
"CancelInvoice",
"ConversionData",
"CoreAppExtra",
"CreateInvoice",
"CreateLnurl",
"CreateLnurlAuth",
"CreateLnurlPayment",
"CreateLnurlWithdraw",
"CreatePayment",
"CreateUser",
"CreateWallet",
@@ -72,7 +75,6 @@ __all__ = [
"LoginUsernamePassword",
"LoginUsr",
"PayInvoice",
"PayLnurlWData",
"Payment",
"PaymentCountField",
"PaymentCountStat",
@@ -85,6 +87,7 @@ __all__ = [
"PaymentsStatusCount",
"RegisterUser",
"ResetUserPassword",
"SettleInvoice",
"SimpleStatus",
"TinyURL",
"UpdateBalance",
+23 -13
View File
@@ -1,21 +1,31 @@
from typing import Optional
from time import time
from pydantic import BaseModel
from lnurl import LnAddress, Lnurl, LnurlPayResponse
from pydantic import BaseModel, Field
class CreateLnurl(BaseModel):
description_hash: str
callback: str
class CreateLnurlPayment(BaseModel):
res: LnurlPayResponse | None = None
lnurl: Lnurl | LnAddress | None = None
amount: int
comment: Optional[str] = None
description: Optional[str] = None
unit: Optional[str] = None
internal_memo: Optional[str] = None
comment: str | None = None
unit: str | None = None
internal_memo: str | None = None
class CreateLnurlAuth(BaseModel):
callback: str
class CreateLnurlWithdraw(BaseModel):
lnurl_w: Lnurl
class PayLnurlWData(BaseModel):
lnurl_w: str
class LnurlScan(BaseModel):
lnurl: Lnurl | LnAddress
class StoredPayLink(BaseModel):
lnurl: str
label: str
last_used: int = Field(default_factory=lambda: int(time()))
class StoredPayLinks(BaseModel):
links: list[StoredPayLink] = []
+1 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Callable
from collections.abc import Callable
from pydantic import BaseModel
+3
View File
@@ -2,6 +2,8 @@ from enum import Enum
from pydantic import BaseModel
from lnbits.core.models.users import UserNotifications
class NotificationType(Enum):
server_status = "server_status"
@@ -18,6 +20,7 @@ class NotificationType(Enum):
class NotificationMessage(BaseModel):
message_type: NotificationType
values: dict
user_notifications: UserNotifications | None = None
NOTIFICATION_TEMPLATES = {
+42 -1
View File
@@ -5,6 +5,7 @@ from enum import Enum
from typing import Literal
from fastapi import Query
from lnurl import LnurlWithdrawResponse
from pydantic import BaseModel, Field, validator
from lnbits.db import FilterModel
@@ -252,13 +253,25 @@ class CreateInvoice(BaseModel):
memo: str | None = Query(None, max_length=640)
description_hash: str | None = None
unhashed_description: str | None = None
payment_hash: str | None = Query(
None,
description="The payment hash of the hold invoice.",
min_length=64,
max_length=64,
)
expiry: int | None = None
extra: dict | None = None
webhook: str | None = None
bolt11: str | None = None
lnurl_callback: str | None = None
lnurl_withdraw: LnurlWithdrawResponse | None = None
fiat_provider: str | None = None
@validator("payment_hash")
def check_hex(cls, v):
if v:
_ = bytes.fromhex(v)
return v
@validator("unit")
@classmethod
def unit_is_from_allowed_currencies(cls, v):
@@ -272,3 +285,31 @@ class PaymentsStatusCount(BaseModel):
outgoing: int = 0
failed: int = 0
pending: int = 0
class SettleInvoice(BaseModel):
preimage: str = Field(
...,
description="The preimage of the payment hash to settle the invoice.",
min_length=64,
max_length=64,
)
@validator("preimage")
def check_hex(cls, v):
_ = bytes.fromhex(v)
return v
class CancelInvoice(BaseModel):
payment_hash: str = Field(
...,
description="The payment hash of the invoice to cancel.",
min_length=64,
max_length=64,
)
@validator("payment_hash")
def check_hex(cls, v):
_ = bytes.fromhex(v)
return v
+18 -5
View File
@@ -3,8 +3,8 @@ from __future__ import annotations
from datetime import datetime, timezone
from uuid import UUID
from bcrypt import checkpw, gensalt, hashpw
from fastapi import Query
from passlib.context import CryptContext
from pydantic import BaseModel, Field
from lnbits.core.models.misc import SimpleItem
@@ -20,6 +20,15 @@ from lnbits.settings import settings
from .wallets import Wallet
class UserNotifications(BaseModel):
nostr_identifier: str | None = None
telegram_chat_id: str | None = None
email_address: str | None = None
excluded_wallets: list[str] = []
outgoing_payments_sats: int = 0
incoming_payments_sats: int = 0
class UserExtra(BaseModel):
email_verified: bool | None = False
first_name: str | None = None
@@ -35,6 +44,8 @@ class UserExtra(BaseModel):
# how many wallets are shown in the user interface
visible_wallet_count: int | None = 10
notifications: UserNotifications = UserNotifications()
class EndpointAccess(BaseModel):
path: str
@@ -120,16 +131,18 @@ class Account(BaseModel):
def hash_password(self, password: str) -> str:
"""sets and returns the hashed password"""
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
self.password_hash = pwd_context.hash(password)
salt = gensalt()
hashed_pw = hashpw(password.encode(), salt)
if not hashed_pw:
raise ValueError("Password hashing failed.")
self.password_hash = hashed_pw.decode()
return self.password_hash
def verify_password(self, password: str) -> bool:
"""returns True if the password matches the hash"""
if not self.password_hash:
return False
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
return pwd_context.verify(password, self.password_hash)
return checkpw(password.encode(), self.password_hash.encode())
def validate_fields(self):
if self.username and not is_valid_username(self.username):
+3 -12
View File
@@ -1,17 +1,15 @@
from __future__ import annotations
import hashlib
import hmac
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from ecdsa import SECP256k1, SigningKey
from lnurl import encode as lnurl_encode
from pydantic import BaseModel, Field
from lnbits.core.models.lnurl import StoredPayLinks
from lnbits.db import FilterModel
from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings
@@ -41,6 +39,7 @@ class Wallet(BaseModel):
currency: str | None = None
balance_msat: int = Field(default=0, no_database=True)
extra: WalletExtra = WalletExtra()
stored_paylinks: StoredPayLinks = StoredPayLinks()
@property
def balance(self) -> int:
@@ -58,14 +57,6 @@ class Wallet(BaseModel):
except Exception:
return ""
def lnurlauth_key(self, domain: str) -> SigningKey:
hashing_key = hashlib.sha256(self.id.encode()).digest()
linking_key = hmac.digest(hashing_key, domain.encode(), "sha256")
return SigningKey.from_string(
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
)
class CreateWallet(BaseModel):
name: str | None = None
+9 -5
View File
@@ -2,10 +2,11 @@ from .funding_source import (
get_balance_delta,
switch_to_voidwallet,
)
from .lnurl import perform_lnurlauth, redeem_lnurl_withdraw
from .notifications import enqueue_notification, send_payment_notification
from .lnurl import fetch_lnurl_pay_request, get_pr_from_lnurl
from .notifications import enqueue_admin_notification, send_payment_notification
from .payments import (
calculate_fiat_amounts,
cancel_hold_invoice,
check_transaction_status,
check_wallet_limits,
create_fiat_invoice,
@@ -17,6 +18,7 @@ from .payments import (
get_payments_daily_stats,
pay_invoice,
service_fee,
settle_hold_invoice,
update_pending_payment,
update_pending_payments,
update_wallet_balance,
@@ -36,6 +38,7 @@ from .websockets import websocket_manager, websocket_updater
__all__ = [
"calculate_fiat_amounts",
"cancel_hold_invoice",
"check_admin_settings",
"check_transaction_status",
"check_wallet_limits",
@@ -46,16 +49,17 @@ __all__ = [
"create_user_account",
"create_user_account_no_ckeck",
"create_wallet_invoice",
"enqueue_notification",
"enqueue_admin_notification",
"fee_reserve",
"fee_reserve_total",
"fetch_lnurl_pay_request",
"get_balance_delta",
"get_payments_daily_stats",
"get_pr_from_lnurl",
"pay_invoice",
"perform_lnurlauth",
"redeem_lnurl_withdraw",
"send_payment_notification",
"service_fee",
"settle_hold_invoice",
"switch_to_voidwallet",
"update_cached_settings",
"update_pending_payment",
+3 -4
View File
@@ -1,6 +1,5 @@
import asyncio
import importlib
from typing import Optional
from loguru import logger
@@ -145,7 +144,7 @@ async def start_extension_background_work(ext_id: str) -> bool:
async def get_valid_extensions(
include_deactivated: Optional[bool] = True,
include_deactivated: bool | None = True,
) -> list[Extension]:
installed_extensions = await get_installed_extensions()
valid_extensions = [Extension.from_installable_ext(e) for e in installed_extensions]
@@ -164,8 +163,8 @@ async def get_valid_extensions(
async def get_valid_extension(
ext_id: str, include_deactivated: Optional[bool] = True
) -> Optional[Extension]:
ext_id: str, include_deactivated: bool | None = True
) -> Extension | None:
ext = await get_installed_extension(ext_id)
if not ext:
return None
+10 -6
View File
@@ -1,7 +1,6 @@
import hashlib
import hmac
import time
from typing import Optional
from loguru import logger
@@ -15,7 +14,7 @@ from lnbits.settings import settings
async def handle_fiat_payment_confirmation(
payment: Payment, conn: Optional[Connection] = None
payment: Payment, conn: Connection | None = None
):
try:
await _credit_fiat_service_fee_wallet(payment, conn=conn)
@@ -29,7 +28,7 @@ async def handle_fiat_payment_confirmation(
async def _credit_fiat_service_fee_wallet(
payment: Payment, conn: Optional[Connection] = None
payment: Payment, conn: Connection | None = None
):
fiat_provider_name = payment.fiat_provider
if not fiat_provider_name:
@@ -66,7 +65,7 @@ async def _credit_fiat_service_fee_wallet(
async def _debit_fiat_service_faucet_wallet(
payment: Payment, conn: Optional[Connection] = None
payment: Payment, conn: Connection | None = None
):
fiat_provider_name = payment.fiat_provider
if not fiat_provider_name:
@@ -129,8 +128,8 @@ async def handle_stripe_event(event: dict):
def check_stripe_signature(
payload: bytes,
sig_header: Optional[str],
secret: Optional[str],
sig_header: str | None,
secret: str | None,
tolerance_seconds=300,
):
if not sig_header:
@@ -176,6 +175,11 @@ async def test_connection(provider: str) -> SimpleStatus:
This function should be called when setting up or testing the Stripe integration.
"""
fiat_provider = await get_fiat_provider(provider)
if not fiat_provider:
return SimpleStatus(
success=False,
message=f"Fiat provider '{provider}' not found.",
)
status = await fiat_provider.status()
if status.error_message:
return SimpleStatus(
+3 -3
View File
@@ -1,7 +1,7 @@
from loguru import logger
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.notifications import enqueue_notification
from lnbits.core.services.notifications import enqueue_admin_notification
from lnbits.settings import settings
from lnbits.wallets import get_funding_source, set_funding_source
@@ -51,7 +51,7 @@ async def check_server_balance_against_node():
f"Balance delta reached: {status.delta_sats} sats."
f" Switch to void wallet: {use_voidwallet}."
)
enqueue_notification(
enqueue_admin_notification(
NotificationType.watchdog_check,
{
"delta_sats": status.delta_sats,
@@ -71,7 +71,7 @@ async def check_balance_delta_changed():
settings.latest_balance_delta_sats = status.delta_sats
return
if status.delta_sats != settings.latest_balance_delta_sats:
enqueue_notification(
enqueue_admin_notification(
NotificationType.balance_delta,
{
"delta_sats": status.delta_sats,
+127 -148
View File
@@ -1,159 +1,138 @@
import asyncio
import json
from io import BytesIO
from typing import Optional
from urllib.parse import parse_qs, urlparse
from time import time
import httpx
from fastapi import Depends
from lnurl import (
LnAddress,
Lnurl,
LnurlErrorResponse,
LnurlPayActionResponse,
LnurlPayResponse,
LnurlResponseException,
execute_pay_request,
handle,
)
from loguru import logger
from lnbits.db import Connection
from lnbits.decorators import (
WalletTypeInfo,
require_admin_key,
)
from lnbits.helpers import check_callback_url, url_for
from lnbits.lnurl import LnurlErrorResponse
from lnbits.lnurl import decode as decode_lnurl
from lnbits.core.crud import update_wallet
from lnbits.core.models import CreateLnurlPayment, Wallet
from lnbits.core.models.lnurl import StoredPayLink
from lnbits.settings import settings
from .payments import create_invoice
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
async def redeem_lnurl_withdraw(
wallet_id: str,
lnurl_request: str,
memo: Optional[str] = None,
extra: Optional[dict] = None,
wait_seconds: int = 0,
conn: Optional[Connection] = None,
) -> None:
if not lnurl_request:
return None
res = {}
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
lnurl = decode_lnurl(lnurl_request)
check_callback_url(str(lnurl))
r = await client.get(str(lnurl))
res = r.json()
try:
_, payment_request = await create_invoice(
wallet_id=wallet_id,
amount=int(res["maxWithdrawable"] / 1000),
memo=memo or res["defaultDescription"] or "",
extra=extra,
conn=conn,
async def get_pr_from_lnurl(
lnurl: str, amount_msat: int, comment: str | None = None
) -> str:
res = await handle(lnurl, user_agent=settings.user_agent, timeout=10)
if isinstance(res, LnurlErrorResponse):
raise LnurlResponseException(res.reason)
if not isinstance(res, LnurlPayResponse):
raise LnurlResponseException(
"Invalid LNURL response. Expected LnurlPayResponse."
)
except Exception:
logger.warning(
f"failed to create invoice on redeem_lnurl_withdraw "
f"from {lnurl}. params: {res}"
)
return None
if wait_seconds:
await asyncio.sleep(wait_seconds)
params = {"k1": res["k1"], "pr": payment_request}
try:
params["balanceNotify"] = url_for(
f"/withdraw/notify/{urlparse(lnurl_request).netloc}",
external=True,
wal=wallet_id,
)
except Exception as exc:
logger.debug(exc)
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
check_callback_url(res["callback"])
await client.get(res["callback"], params=params)
except Exception as exc:
logger.debug(exc)
res2 = await execute_pay_request(
res,
msat=amount_msat,
comment=comment,
user_agent=settings.user_agent,
timeout=10,
)
if isinstance(res2, LnurlErrorResponse):
raise LnurlResponseException(res2.reason)
return res2.pr
async def perform_lnurlauth(
callback: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Optional[LnurlErrorResponse]:
cb = urlparse(callback)
async def fetch_lnurl_pay_request(
data: CreateLnurlPayment, wallet: Wallet | None = None
) -> tuple[LnurlPayResponse, LnurlPayActionResponse]:
"""
Pay an LNURL payment request.
optional `wallet` is used to store the pay link in the wallet's stored links.
k1 = bytes.fromhex(parse_qs(cb.query)["k1"][0])
key = wallet.wallet.lnurlauth_key(cb.netloc)
def int_to_bytes_suitable_der(x: int) -> bytes:
"""for strict DER we need to encode the integer with some quirks"""
b = x.to_bytes((x.bit_length() + 7) // 8, "big")
if len(b) == 0:
# ensure there's at least one byte when the int is zero
return bytes([0])
if b[0] & 0x80 != 0:
# ensure it doesn't start with a 0x80 and so it isn't
# interpreted as a negative number
return bytes([0]) + b
return b
def encode_strict_der(r: int, s: int, order: int):
# if s > order/2 verification will fail sometimes
# so we must fix it here see:
# https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147
if s > order // 2:
s = order - s
# now we do the strict DER encoding copied from
# https://github.com/KiriKiri/bip66 (without any checks)
r_temp = int_to_bytes_suitable_der(r)
s_temp = int_to_bytes_suitable_der(s)
r_len = len(r_temp)
s_len = len(s_temp)
sign_len = 6 + r_len + s_len
signature = BytesIO()
signature.write(0x30.to_bytes(1, "big", signed=False))
signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(r_len.to_bytes(1, "big", signed=False))
signature.write(r_temp)
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(s_len.to_bytes(1, "big", signed=False))
signature.write(s_temp)
return signature.getvalue()
sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der)
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
if not key.verifying_key:
raise ValueError("LNURLauth verifying_key does not exist")
check_callback_url(callback)
r = await client.get(
callback,
params={
"k1": k1.hex(),
"key": key.verifying_key.to_string("compressed").hex(),
"sig": sig.hex(),
},
)
try:
resp = json.loads(r.text)
if resp["status"] == "OK":
return None
return LnurlErrorResponse(reason=resp["reason"])
except (KeyError, json.decoder.JSONDecodeError):
return LnurlErrorResponse(
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text
raises `LnurlResponseException` if pay request fails
"""
if not data.res and data.lnurl:
res = await handle(data.lnurl, user_agent=settings.user_agent, timeout=5)
if isinstance(res, LnurlErrorResponse):
raise LnurlResponseException(res.reason)
if not isinstance(res, LnurlPayResponse):
raise LnurlResponseException(
"Invalid LNURL response. Expected LnurlPayResponse."
)
data.res = res
if not data.res:
raise LnurlResponseException("No LNURL pay request provided.")
if data.unit and data.unit != "sat":
# shift to float with 2 decimal places
amount = round(data.amount / 1000, 2)
amount_msat = await fiat_amount_as_satoshis(amount, data.unit)
amount_msat *= 1000
else:
amount_msat = data.amount
res2 = await execute_pay_request(
data.res,
msat=amount_msat,
comment=data.comment,
user_agent=settings.user_agent,
timeout=10,
)
if wallet:
await store_paylink(data.res, res2, wallet, data.lnurl)
return data.res, res2
async def store_paylink(
res: LnurlPayResponse,
res2: LnurlPayActionResponse,
wallet: Wallet,
lnurl: LnAddress | Lnurl | None = None,
) -> None:
if res2.disposable is not False:
return # do not store disposable LNURL pay links
logger.debug(f"storing lnurl pay link for wallet {wallet.id}. ")
stored_paylink = None
# If we have only a LnurlPayResponse, we can use its lnaddress
# because the lnurl is not available.
if not lnurl:
for _data in res.metadata.list():
if _data[0] == "text/identifier":
stored_paylink = StoredPayLink(
lnurl=LnAddress(_data[1]), label=res.metadata.text
)
if not stored_paylink:
logger.warning(
"No lnaddress found in metadata for LNURL pay link. "
"Skipping storage."
)
return # skip if lnaddress not found in metadata
else:
if isinstance(lnurl, Lnurl):
_lnurl = str(lnurl.lud17 or lnurl.bech32)
else:
_lnurl = str(lnurl)
stored_paylink = StoredPayLink(lnurl=_lnurl, label=res.metadata.text)
# update last_used if its already stored
for pl in wallet.stored_paylinks.links:
if pl.lnurl == stored_paylink.lnurl:
pl.last_used = int(time())
await update_wallet(wallet)
logger.debug(
"Updated last used time for LNURL "
f"pay link {stored_paylink.lnurl} in wallet {wallet.id}."
)
return
# if not already stored, append it
if not any(stored_paylink.lnurl == pl.lnurl for pl in wallet.stored_paylinks.links):
wallet.stored_paylinks.links.append(stored_paylink)
await update_wallet(wallet)
logger.debug(
f"Stored LNURL pay link {stored_paylink.lnurl} for wallet {wallet.id}."
)
+150 -73
View File
@@ -4,7 +4,6 @@ import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from http import HTTPStatus
from typing import Optional
import httpx
from loguru import logger
@@ -16,12 +15,14 @@ from lnbits.core.crud import (
get_webpush_subscriptions_for_user,
mark_webhook_sent,
)
from lnbits.core.crud.users import get_user
from lnbits.core.models import Payment, Wallet
from lnbits.core.models.notifications import (
NOTIFICATION_TEMPLATES,
NotificationMessage,
NotificationType,
)
from lnbits.core.models.users import UserNotifications
from lnbits.core.services.nostr import fetch_nip5_details, send_nostr_dm
from lnbits.core.services.websockets import websocket_manager
from lnbits.helpers import check_callback_url, is_valid_email_address
@@ -31,8 +32,8 @@ from lnbits.utils.nostr import normalize_private_key
notifications_queue: asyncio.Queue[NotificationMessage] = asyncio.Queue()
def enqueue_notification(message_type: NotificationType, values: dict) -> None:
if not is_message_type_enabled(message_type):
def enqueue_admin_notification(message_type: NotificationType, values: dict) -> None:
if not _is_message_type_enabled(message_type):
return
try:
notifications_queue.put_nowait(
@@ -42,62 +43,125 @@ def enqueue_notification(message_type: NotificationType, values: dict) -> None:
logger.error(f"Error enqueuing notification: {e}")
def enqueue_user_notification(
message_type: NotificationType, values: dict, user_notifications: UserNotifications
) -> None:
try:
notifications_queue.put_nowait(
NotificationMessage(
message_type=message_type,
values=values,
user_notifications=user_notifications,
)
)
except Exception as e:
logger.error(f"Error enqueuing notification: {e}")
async def process_next_notification() -> None:
notification_message = await notifications_queue.get()
message_type, text = _notification_message_to_text(notification_message)
await send_notification(text, message_type)
user_notifications = notification_message.user_notifications
if user_notifications:
await send_user_notification(user_notifications, text, message_type)
else:
await send_admin_notification(text, message_type)
async def send_admin_notification(
message: str,
message_type: str | None = None,
) -> None:
return await send_notification(
settings.lnbits_telegram_notifications_chat_id,
settings.lnbits_nostr_notifications_identifiers,
settings.lnbits_email_notifications_to_emails,
message,
message_type,
)
async def send_user_notification(
user_notifications: UserNotifications,
message: str,
message_type: str | None = None,
) -> None:
email_address = (
[user_notifications.email_address] if user_notifications.email_address else []
)
nostr_identifiers = (
[user_notifications.nostr_identifier]
if user_notifications.nostr_identifier
else []
)
return await send_notification(
user_notifications.telegram_chat_id,
nostr_identifiers,
email_address,
message,
message_type,
)
async def send_notification(
telegram_chat_id: str | None,
nostr_identifiers: list[str] | None,
email_addresses: list[str] | None,
message: str,
message_type: Optional[str] = None,
message_type: str | None = None,
) -> None:
try:
if settings.lnbits_telegram_notifications_enabled:
await send_telegram_notification(message)
if telegram_chat_id and settings.is_telegram_notifications_configured():
await send_telegram_notification(telegram_chat_id, message)
logger.debug(f"Sent telegram notification: {message_type}")
except Exception as e:
logger.error(f"Error sending telegram notification {message_type}: {e}")
try:
if settings.lnbits_nostr_notifications_enabled:
await send_nostr_notification(message)
if nostr_identifiers and settings.is_nostr_notifications_configured():
await send_nostr_notifications(nostr_identifiers, message)
logger.debug(f"Sent nostr notification: {message_type}")
except Exception as e:
logger.error(f"Error sending nostr notification {message_type}: {e}")
try:
if settings.lnbits_email_notifications_enabled:
await send_email_notification(message)
if email_addresses and settings.lnbits_email_notifications_enabled:
await send_email_notification(email_addresses, message)
logger.debug(f"Sent email notification: {message_type}")
except Exception as e:
logger.error(f"Error sending email notification {message_type}: {e}")
async def send_nostr_notification(message: str) -> dict:
for i in settings.lnbits_nostr_notifications_identifiers:
async def send_nostr_notifications(identifiers: list[str], message: str) -> list[str]:
success_sent: list[str] = []
for identifier in identifiers:
try:
identifier = await fetch_nip5_details(i)
user_pubkey = identifier[0]
relays = identifier[1]
server_private_key = normalize_private_key(
settings.lnbits_nostr_notifications_private_key
)
await send_nostr_dm(
server_private_key,
user_pubkey,
message,
relays,
)
await send_nostr_notification(identifier, message)
success_sent.append(identifier)
except Exception as e:
logger.warning(f"Error notifying identifier {i}: {e}")
return {"status": "ok"}
logger.warning(f"Error notifying identifier {identifier}: {e}")
return success_sent
async def send_telegram_notification(message: str) -> dict:
async def send_nostr_notification(identifier: str, message: str):
nip5 = await fetch_nip5_details(identifier)
user_pubkey = nip5[0]
relays = nip5[1]
server_private_key = normalize_private_key(
settings.lnbits_nostr_notifications_private_key
)
await send_nostr_dm(
server_private_key,
user_pubkey,
message,
relays,
)
async def send_telegram_notification(chat_id: str, message: str) -> dict:
return await send_telegram_message(
settings.lnbits_telegram_notifications_access_token,
settings.lnbits_telegram_notifications_chat_id,
chat_id,
message,
)
@@ -112,7 +176,7 @@ async def send_telegram_message(token: str, chat_id: str, message: str) -> dict:
async def send_email_notification(
message: str, subject: str = "LNbits Notification"
to_emails: list[str], message: str, subject: str = "LNbits Notification"
) -> dict:
if not settings.lnbits_email_notifications_enabled:
return {"status": "error", "message": "Email notifications are disabled"}
@@ -123,7 +187,7 @@ async def send_email_notification(
settings.lnbits_email_notifications_username,
settings.lnbits_email_notifications_password,
settings.lnbits_email_notifications_email,
settings.lnbits_email_notifications_to_emails,
to_emails,
subject,
message,
)
@@ -163,41 +227,6 @@ async def send_email(
return True
def is_message_type_enabled(message_type: NotificationType) -> bool:
if message_type == NotificationType.balance_update:
return settings.lnbits_notification_credit_debit
if message_type == NotificationType.settings_update:
return settings.lnbits_notification_settings_update
if message_type == NotificationType.watchdog_check:
return settings.lnbits_notification_watchdog
if message_type == NotificationType.balance_delta:
return settings.notification_balance_delta_changed
if message_type == NotificationType.server_start_stop:
return settings.lnbits_notification_server_start_stop
if message_type == NotificationType.server_status:
return settings.lnbits_notification_server_status_hours > 0
if message_type == NotificationType.incoming_payment:
return settings.lnbits_notification_incoming_payment_amount_sats > 0
if message_type == NotificationType.outgoing_payment:
return settings.lnbits_notification_outgoing_payment_amount_sats > 0
return False
def _notification_message_to_text(
notification_message: NotificationMessage,
) -> tuple[str, str]:
message_type = notification_message.message_type.value
meesage_value = NOTIFICATION_TEMPLATES.get(message_type, message_type)
try:
text = meesage_value.format(**notification_message.values)
except Exception as e:
logger.warning(f"Error formatting notification message: {e}")
text = meesage_value
text = f"""[{settings.lnbits_site_title}]\n{text}"""
return message_type, text
async def dispatch_webhook(payment: Payment):
"""
Dispatches the webhook to the webhook url.
@@ -231,7 +260,7 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
except Exception as e:
logger.error(f"Error sending websocket payment notification {e!s}")
try:
send_chat_payment_notification(wallet, payment)
await send_chat_payment_notification(wallet, payment)
except Exception as e:
logger.error(f"Error sending chat payment notification {e!s}")
try:
@@ -268,7 +297,7 @@ async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
)
def send_chat_payment_notification(wallet: Wallet, payment: Payment):
async def send_chat_payment_notification(wallet: Wallet, payment: Payment):
amount_sats = abs(payment.sat)
values: dict = {
"wallet_id": wallet.id,
@@ -283,10 +312,23 @@ def send_chat_payment_notification(wallet: Wallet, payment: Payment):
if payment.is_out:
if amount_sats >= settings.lnbits_notification_outgoing_payment_amount_sats:
enqueue_notification(NotificationType.outgoing_payment, values)
else:
if amount_sats >= settings.lnbits_notification_incoming_payment_amount_sats:
enqueue_notification(NotificationType.incoming_payment, values)
enqueue_admin_notification(NotificationType.outgoing_payment, values)
elif amount_sats >= settings.lnbits_notification_incoming_payment_amount_sats:
enqueue_admin_notification(NotificationType.incoming_payment, values)
user = await get_user(wallet.user)
user_notifications = user.extra.notifications if user else None
if user_notifications and wallet.id not in user_notifications.excluded_wallets:
out_limit = user_notifications.outgoing_payments_sats
in_limit = user_notifications.incoming_payments_sats
if payment.is_out and (amount_sats >= out_limit):
enqueue_user_notification(
NotificationType.outgoing_payment, values, user_notifications
)
elif amount_sats >= in_limit:
enqueue_user_notification(
NotificationType.incoming_payment, values, user_notifications
)
async def send_payment_push_notification(wallet: Wallet, payment: Payment):
@@ -330,3 +372,38 @@ async def send_push_notification(subscription, title, body, url=""):
f"failed sending push notification: "
f"{e.response.text if e.response else e}"
)
def _is_message_type_enabled(message_type: NotificationType) -> bool:
if message_type == NotificationType.balance_update:
return settings.lnbits_notification_credit_debit
if message_type == NotificationType.settings_update:
return settings.lnbits_notification_settings_update
if message_type == NotificationType.watchdog_check:
return settings.lnbits_notification_watchdog
if message_type == NotificationType.balance_delta:
return settings.notification_balance_delta_changed
if message_type == NotificationType.server_start_stop:
return settings.lnbits_notification_server_start_stop
if message_type == NotificationType.server_status:
return settings.lnbits_notification_server_status_hours > 0
if message_type == NotificationType.incoming_payment:
return settings.lnbits_notification_incoming_payment_amount_sats > 0
if message_type == NotificationType.outgoing_payment:
return settings.lnbits_notification_outgoing_payment_amount_sats > 0
return False
def _notification_message_to_text(
notification_message: NotificationMessage,
) -> tuple[str, str]:
message_type = notification_message.message_type.value
meesage_value = NOTIFICATION_TEMPLATES.get(message_type, message_type)
try:
text = meesage_value.format(**notification_message.values)
except Exception as e:
logger.warning(f"Error formatting notification message: {e}")
text = meesage_value
text = f"""[{settings.lnbits_site_title}]\n{text}"""
return message_type, text
+136 -81
View File
@@ -1,13 +1,12 @@
import asyncio
import json
import time
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from bolt11 import Bolt11, MilliSatoshi, Tags
from bolt11 import decode as bolt11_decode
from bolt11 import encode as bolt11_encode
from lnurl import LnurlErrorResponse, LnurlSuccessResponse
from lnurl import execute_withdraw as lnurl_withdraw
from loguru import logger
from lnbits.core.crud.payments import get_daily_stats
@@ -16,15 +15,16 @@ from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.payments import CreateInvoice
from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
from lnbits.fiat import get_fiat_provider
from lnbits.helpers import check_callback_url
from lnbits.settings import settings
from lnbits.tasks import create_task, internal_invoice_queue_put
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source
from lnbits.wallets.base import (
InvoiceResponse,
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
@@ -57,11 +57,11 @@ async def pay_invoice(
*,
wallet_id: str,
payment_request: str,
max_sat: Optional[int] = None,
extra: Optional[dict] = None,
max_sat: int | None = None,
extra: dict | None = None,
description: str = "",
tag: str = "",
conn: Optional[Connection] = None,
conn: Connection | None = None,
) -> Payment:
if settings.lnbits_only_allow_incoming_payments:
raise PaymentError("Only incoming payments allowed.", status="failed")
@@ -90,12 +90,8 @@ async def pay_invoice(
payment = await _pay_invoice(wallet.id, create_payment_model, conn)
service_fee_memo = f"""
Service fee for payment of {abs(payment.sat)} sats.
Wallet: '{wallet.name}' ({wallet.id})."""
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
await _credit_service_fee_wallet(payment, service_fee_memo, new_conn)
await _credit_service_fee_wallet(wallet, payment, new_conn)
return payment
@@ -113,7 +109,7 @@ async def create_payment_request(
async def create_fiat_invoice(
wallet_id: str, invoice_data: CreateInvoice, conn: Optional[Connection] = None
wallet_id: str, invoice_data: CreateInvoice, conn: Connection | None = None
):
fiat_provider_name = invoice_data.fiat_provider
if not fiat_provider_name:
@@ -135,6 +131,9 @@ async def create_fiat_invoice(
internal_payment = await create_wallet_invoice(wallet_id, invoice_data)
fiat_provider = await get_fiat_provider(fiat_provider_name)
if not fiat_provider:
raise InvoiceError("No fiat provider found.", status="failed")
fiat_invoice = await fiat_provider.create_invoice(
amount=invoice_data.amount,
payment_hash=internal_payment.payment_hash,
@@ -168,8 +167,8 @@ async def create_fiat_invoice(
async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
description_hash = b""
unhashed_description = b""
description_hash = None
unhashed_description = None
memo = data.memo or settings.lnbits_site_title
if data.description_hash or data.unhashed_description:
if data.description_hash:
@@ -200,30 +199,29 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
extra=data.extra,
webhook=data.webhook,
internal=data.internal,
payment_hash=data.payment_hash,
)
# lnurl_response is not saved in the database
if data.lnurl_callback:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
check_callback_url(data.lnurl_callback)
r = await client.get(
data.lnurl_callback,
params={"pr": payment.bolt11},
timeout=10,
)
if r.is_error:
payment.extra["lnurl_response"] = r.text
else:
resp = json.loads(r.text)
if resp["status"] != "OK":
payment.extra["lnurl_response"] = resp["reason"]
else:
payment.extra["lnurl_response"] = True
except (httpx.ConnectError, httpx.RequestError) as ex:
logger.error(ex)
payment.extra["lnurl_response"] = False
if data.lnurl_withdraw:
try:
check_callback_url(data.lnurl_withdraw.callback)
res = await lnurl_withdraw(
data.lnurl_withdraw,
payment.bolt11,
user_agent=settings.user_agent,
timeout=10,
)
if isinstance(res, LnurlErrorResponse):
payment.extra["lnurl_response"] = res.reason
payment.status = "failed"
elif isinstance(res, LnurlSuccessResponse):
payment.extra["lnurl_response"] = True
payment.status = "success"
except Exception as exc:
payment.extra["lnurl_response"] = str(exc)
payment.status = "failed"
# updating to payment here would run into a race condition
# with the payment listeners and they will overwrite each other
return payment
@@ -232,15 +230,16 @@ async def create_invoice(
*,
wallet_id: str,
amount: float,
currency: Optional[str] = "sat",
currency: str | None = "sat",
memo: str,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
expiry: Optional[int] = None,
extra: Optional[dict] = None,
webhook: Optional[str] = None,
internal: Optional[bool] = False,
conn: Optional[Connection] = None,
description_hash: bytes | None = None,
unhashed_description: bytes | None = None,
expiry: int | None = None,
extra: dict | None = None,
webhook: str | None = None,
internal: bool | None = False,
payment_hash: str | None = None,
conn: Connection | None = None,
) -> Payment:
if not amount > 0:
raise InvoiceError("Amountless invoices not supported.", status="failed")
@@ -273,39 +272,54 @@ async def create_invoice(
status="failed",
)
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 payment_hash:
try:
invoice_response = await funding_source.create_hold_invoice(
amount=amount_sat,
memo=invoice_memo,
payment_hash=payment_hash,
description_hash=description_hash,
)
extra["hold_invoice"] = True
except UnsupportedError as exc:
raise InvoiceError(
"Hold invoices are not supported by the funding source.",
status="failed",
) from exc
else:
invoice_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 payment_response.ok
or not payment_response.payment_request
or not payment_response.checking_id
not invoice_response.ok
or not invoice_response.payment_request
or not invoice_response.checking_id
):
raise InvoiceError(
message=payment_response.error_message or "unexpected backend error.",
message=invoice_response.error_message or "unexpected backend error.",
status="pending",
)
invoice = bolt11_decode(payment_response.payment_request)
invoice = bolt11_decode(invoice_response.payment_request)
create_payment_model = CreatePayment(
wallet_id=wallet_id,
bolt11=payment_response.payment_request,
bolt11=invoice_response.payment_request,
payment_hash=invoice.payment_hash,
preimage=payment_response.preimage,
preimage=invoice_response.preimage,
amount_msat=amount_sat * 1000,
expiry=invoice.expiry_date,
memo=memo,
extra=extra,
webhook=webhook,
fee=payment_response.fee_msat or 0,
fee=invoice_response.fee_msat or 0,
)
payment = await create_payment(
checking_id=payment_response.checking_id,
checking_id=invoice_response.checking_id,
data=create_payment_model,
conn=conn,
)
@@ -412,7 +426,7 @@ def service_fee_fiat(amount_msat: int, fiat_provider_name: str) -> int:
async def update_wallet_balance(
wallet: Wallet,
amount: int,
conn: Optional[Connection] = None,
conn: Connection | None = None,
):
if amount == 0:
raise ValueError("Amount cannot be 0.")
@@ -471,14 +485,14 @@ async def update_wallet_balance(
async def check_wallet_limits(
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
wallet_id: str, amount_msat: int, conn: Connection | None = None
):
await check_time_limit_between_transactions(wallet_id, conn)
await check_wallet_daily_withdraw_limit(wallet_id, amount_msat, conn)
async def check_time_limit_between_transactions(
wallet_id: str, conn: Optional[Connection] = None
wallet_id: str, conn: Connection | None = None
):
limit = settings.lnbits_wallet_limit_secs_between_trans
if not limit or limit <= 0:
@@ -498,7 +512,7 @@ async def check_time_limit_between_transactions(
async def check_wallet_daily_withdraw_limit(
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
wallet_id: str, amount_msat: int, conn: Connection | None = None
):
limit = settings.lnbits_wallet_limit_daily_max_withdraw
if not limit:
@@ -531,8 +545,8 @@ async def check_wallet_daily_withdraw_limit(
async def calculate_fiat_amounts(
amount: float,
wallet: Wallet,
currency: Optional[str] = None,
extra: Optional[dict] = None,
currency: str | None = None,
extra: dict | None = None,
) -> tuple[int, dict]:
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
fiat_amounts: dict = extra or {}
@@ -567,9 +581,9 @@ async def calculate_fiat_amounts(
async def check_transaction_status(
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
wallet_id: str, payment_hash: str, conn: Connection | None = None
) -> PaymentStatus:
payment: Optional[Payment] = await get_wallet_payment(
payment: Payment | None = await get_wallet_payment(
wallet_id, payment_hash, conn=conn
)
if not payment:
@@ -583,7 +597,7 @@ async def check_transaction_status(
async def get_payments_daily_stats(
filters: Filters[PaymentFilters],
user_id: Optional[str] = None,
user_id: str | None = None,
) -> list[PaymentDailyStats]:
data_in, data_out = await get_daily_stats(filters, user_id=user_id)
balance_total: float = 0
@@ -632,7 +646,7 @@ async def get_payments_daily_stats(
async def _pay_invoice(
wallet_id: str,
create_payment_model: CreatePayment,
conn: Optional[Connection] = None,
conn: Connection | None = None,
):
async with payment_lock:
if wallet_id not in wallets_payments_lock:
@@ -655,8 +669,8 @@ async def _pay_invoice(
async def _pay_internal_invoice(
wallet: Wallet,
create_payment_model: CreatePayment,
conn: Optional[Connection] = None,
) -> Optional[Payment]:
conn: Connection | None = None,
) -> Payment | None:
"""
Pay an internal payment.
returns None if the payment is not internal.
@@ -723,7 +737,7 @@ async def _pay_internal_invoice(
async def _pay_external_invoice(
wallet: Wallet,
create_payment_model: CreatePayment,
conn: Optional[Connection] = None,
conn: Connection | None = None,
) -> Payment:
checking_id = create_payment_model.payment_hash
amount_msat = create_payment_model.amount_msat
@@ -792,7 +806,7 @@ async def _pay_external_invoice(
async def update_payment_success_status(
payment: Payment,
status: PaymentStatus,
conn: Optional[Connection] = None,
conn: Connection | None = None,
) -> Payment:
if status.success:
service_fee_msat = service_fee(payment.amount, internal=False)
@@ -816,7 +830,7 @@ async def _fundingsource_pay_invoice(
async def _verify_external_payment(
payment: Payment, conn: Optional[Connection] = None
payment: Payment, conn: Connection | None = None
) -> Payment:
# fail on pending payments
if payment.pending:
@@ -847,7 +861,7 @@ async def _check_wallet_for_payment(
wallet_id: str,
tag: str,
amount_msat: int,
conn: Optional[Connection] = None,
conn: Connection | None = None,
):
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet:
@@ -863,7 +877,7 @@ async def _check_wallet_for_payment(
def _validate_payment_request(
payment_request: str, max_sat: Optional[int] = None
payment_request: str, max_sat: int | None = None
) -> Bolt11:
try:
invoice = bolt11_decode(payment_request)
@@ -886,12 +900,16 @@ def _validate_payment_request(
async def _credit_service_fee_wallet(
payment: Payment, memo: str, conn: Optional[Connection] = None
wallet: Wallet, payment: Payment, conn: Connection | None = None
):
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
return
memo = f"""
Service fee for payment of {abs(payment.sat)} sats.
Wallet: '{wallet.name}' ({wallet.id})."""
create_payment_model = CreatePayment(
wallet_id=settings.lnbits_service_fee_wallet,
bolt11=payment.bolt11,
@@ -908,7 +926,7 @@ async def _credit_service_fee_wallet(
async def _check_fiat_invoice_limits(
amount_sat: int, fiat_provider_name: str, conn: Optional[Connection] = None
amount_sat: int, fiat_provider_name: str, conn: Connection | None = None
):
limits = settings.get_fiat_provider_limits(fiat_provider_name)
if not limits:
@@ -949,3 +967,40 @@ async def _check_fiat_invoice_limits(
f"The amount exceeds the '{fiat_provider_name}'"
"faucet wallet balance.",
)
async def settle_hold_invoice(payment: Payment, preimage: str) -> InvoiceResponse:
if verify_preimage(preimage, payment.payment_hash) is False:
raise InvoiceError("Invalid preimage.", status="failed")
funding_source = get_funding_source()
response = await funding_source.settle_hold_invoice(preimage=preimage)
if not response.ok:
raise InvoiceError(
response.error_message or "Unexpected backend error.", status="failed"
)
payment.preimage = preimage
payment.extra["hold_invoice_settled"] = True
await update_payment(payment)
return response
async def cancel_hold_invoice(payment: Payment) -> InvoiceResponse:
funding_source = get_funding_source()
response = await funding_source.cancel_hold_invoice(
payment_hash=payment.payment_hash
)
if not response.ok:
raise InvoiceError(
response.error_message or "Unexpected backend error.", status="failed"
)
payment.status = PaymentState.FAILED
payment.extra["hold_invoice_cancelled"] = True
await update_payment(payment)
return response
+6 -7
View File
@@ -1,5 +1,4 @@
from pathlib import Path
from typing import Optional
from uuid import uuid4
from loguru import logger
@@ -37,7 +36,7 @@ from .settings import update_cached_settings
async def create_user_account(
account: Optional[Account] = None, wallet_name: Optional[str] = None
account: Account | None = None, wallet_name: str | None = None
) -> User:
if not settings.new_accounts_allowed:
raise ValueError("Account creation is disabled.")
@@ -46,9 +45,9 @@ async def create_user_account(
async def create_user_account_no_ckeck(
account: Optional[Account] = None,
wallet_name: Optional[str] = None,
default_exts: Optional[list[str]] = None,
account: Account | None = None,
wallet_name: str | None = None,
default_exts: list[str] | None = None,
) -> User:
if account:
@@ -165,12 +164,12 @@ async def check_admin_settings():
settings.first_install = True
logger.success(
"✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` "
"✔️ Admin UI is enabled. run `uv run lnbits-cli superuser` "
"to get the superuser."
)
async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings:
async def init_admin_settings(super_user: str | None = None) -> SuperSettings:
account = None
if super_user:
account = await get_account(super_user)
+4 -5
View File
@@ -1,7 +1,6 @@
import asyncio
import traceback
from collections.abc import Coroutine
from typing import Callable
from collections.abc import Callable, Coroutine
from loguru import logger
@@ -22,7 +21,7 @@ from lnbits.core.services.funding_source import (
get_balance_delta,
)
from lnbits.core.services.notifications import (
enqueue_notification,
enqueue_admin_notification,
process_next_notification,
send_payment_notification,
)
@@ -87,7 +86,7 @@ async def _notify_server_status() -> None:
"lnbits_balance_sats": status.lnbits_balance_sats,
"node_balance_sats": status.node_balance_sats,
}
enqueue_notification(NotificationType.server_status, values)
enqueue_admin_notification(NotificationType.server_status, values)
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue) -> None:
@@ -123,7 +122,7 @@ async def wait_notification_messages() -> None:
try:
await process_next_notification()
except Exception as ex:
logger.log("error", ex)
logger.warning("Payment notification error", ex)
await asyncio.sleep(3)
+156 -44
View File
@@ -7,38 +7,71 @@
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div>
<div class="q-gutter-y-md">
<q-tabs v-model="tab" align="left">
<q-tab
name="user"
:label="$t('account_settings')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="theme"
:label="$t('look_and_feel')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="api_acls"
:label="$t('access_control_list')"
@update="val => tab = val.name"
></q-tab>
</q-tabs>
<div class="q-pa-sm">
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<q-btn @click="updateAccount" unelevated color="primary">
<span v-text="$t('update_account')"></span>
</q-btn>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md">
<div v-if="user" class="col-md-12 col-lg-6 q-gutter-y-md">
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<q-card-section>
<div class="row">
<div class="col">
<q-tab-panels v-model="tab">
<q-splitter>
<template v-slot:before>
<q-tabs v-model="tab" vertical active-color="primary">
<q-tab
name="user"
icon="person"
:label="$q.screen.gt.sm ? $t('account_settings') : ''"
@update="val => tab = val.name"
>
<q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('account_settings')"></span
></q-tooltip>
</q-tab>
<q-tab
name="notifications"
icon="notifications"
:label="$q.screen.gt.sm ? $t('notifications') : ''"
@update="val => tab = val.name"
>
<q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('notifications')"></span
></q-tooltip>
</q-tab>
<q-tab
name="theme"
icon="palette"
:label="$q.screen.gt.sm ? $t('look_and_feel') : ''"
@update="val => tab = val.name"
>
<q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('look_and_feel')"></span
></q-tooltip>
</q-tab>
<q-tab
name="api_acls"
icon="lock"
:label="$q.screen.gt.sm ? $t('access_control_list') : ''"
@update="val => tab = val.name"
>
<q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('access_control_list')"></span
></q-tooltip>
</q-tab>
</q-tabs>
</template>
<template v-slot:after>
<q-scroll-area style="height: 80vh">
<q-tab-panels v-if="user" v-model="tab">
<q-tab-panel name="user">
<div v-if="credentialsData.show">
<q-card-section>
@@ -293,9 +326,6 @@
</q-card-section>
<q-separator></q-separator>
<q-card-section>
<q-btn @click="updateAccount" unelevated color="primary">
<span v-text="$t('update_account')"></span>
</q-btn>
<q-btn
@click="showUpdateCredentials()"
:label="$t('change_password')"
@@ -559,12 +589,101 @@
</q-select>
</div>
</div>
<q-separator></q-separator>
<div class="row q-mb-md q-mt-md">
<q-btn @click="updateAccount" unelevated color="primary">
<span v-text="$t('update_account')"></span>
</q-btn>
</div>
</q-tab-panel>
<q-tab-panel name="notifications">
<q-card-section>
<div class="row q-mb-md">
<div class="col-4">
<span
v-text="$t('notifications_nostr_identifier')"
></span>
{%if not nostr_configured%}
<br />
<q-badge v-text="$t('not_connected')"></q-badge>
{%endif%}
</div>
<div class="col-8">
<q-input
filled
dense
v-model="user.extra.notifications.nostr_identifier"
:hint="$t('notifications_nostr_identifier_desc')"
>
</q-input>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('notifications_chat_id')"></span>
{%if not telegram_configured%}
<br />
<q-badge v-text="$t('not_connected')"></q-badge>
{%endif%}
</div>
<div class="col-8">
<q-input
filled
dense
v-model="user.extra.notifications.telegram_chat_id"
:hint="$t('notifications_chat_id_desc')"
/>
</div>
</div>
<q-separator class="q-mb-md"></q-separator>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('notification_outgoing_payment')"></span>
</div>
<div class="col-8">
<q-input
filled
dense
type="number"
min="0"
step="1"
v-model="user.extra.notifications.outgoing_payments_sats"
:hint="$t('notification_outgoing_payment_desc')"
/>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('notification_incoming_payment')"></span>
</div>
<div class="col-8">
<q-input
filled
dense
type="number"
min="0"
step="1"
v-model="user.extra.notifications.incoming_payments_sats"
:hint="$t('notification_incoming_payment_desc')"
/>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('exclude_wallets')"></span>
</div>
<div class="col-8">
<q-select
filled
dense
emit-value
map-options
multiple
v-model="user.extra.notifications.excluded_wallets"
:options="g.user.walletOptions"
:label="$t('exclude_wallets')"
:hint="$t('notifications_excluded_wallets_desc')"
class="q-mt-sm"
>
</q-select>
</div>
</div>
</q-card-section>
</q-tab-panel>
<q-tab-panel name="api_acls">
<div class="row q-mb-md">
@@ -791,16 +910,9 @@
</q-card-section>
</q-tab-panel>
</q-tab-panels>
</div>
</div>
</q-card-section>
</q-card>
</div>
<div v-else class="col-12 col-md-6 q-gutter-y-md">
<q-card>
<q-card-section>
<h4 class="q-my-none"><span v-text="$t('account')"></span></h4>
</q-card-section>
</q-scroll-area>
</template>
</q-splitter>
</q-card>
</div>
</div>
+87 -3
View File
@@ -289,9 +289,87 @@
</q-expansion-item>
<q-separator></q-separator>
{% endif %}
<q-expansion-item
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats'"
group="extras"
icon="qr_code"
v-if="stored_paylinks.length > 0"
:label="$t('stored_paylinks')"
>
<q-card>
<q-card-section>
<div class="row flex" v-for="paylink in stored_paylinks">
<q-btn
dense
flat
color="primary"
icon="send"
size="xs"
@click="sendToPaylink(paylink.lnurl)"
>
<q-tooltip>
<span v-text="`send to: ${paylink.lnurl}`"></span>
</q-tooltip>
</q-btn>
<q-btn
dense
flat
color="secondary"
icon="content_copy"
size="xs"
@click="copyText(paylink.lnurl)"
>
<q-tooltip>
<span v-text="`copy: ${paylink.lnurl}`"></span>
</q-tooltip>
</q-btn>
<span
v-text="paylink.label"
class="q-mr-xs q-ml-xs"
></span>
<q-btn dense flat color="primary" icon="edit" size="xs">
<q-popup-edit
@update:model-value="editPaylink()"
v-model="paylink.label"
v-slot="scope"
>
<q-input
dark
color="white"
v-model="scope.value"
dense
autofocus
counter
@keyup.enter="scope.set"
>
<template v-slot:append>
<q-icon name="edit" />
</template>
</q-input>
</q-popup-edit>
<q-tooltip>
<span v-text="$t('edit')"></span>
</q-tooltip>
</q-btn>
<span style="flex-grow: 1"></span>
<q-btn
dense
flat
color="red"
icon="delete"
size="xs"
@click="deletePaylink(paylink.lnurl)"
>
<q-tooltip>
<span v-text="$t('delete')"></span>
</q-tooltip>
</q-btn>
<span v-text="dateFromNow(paylink.last_used)"></span>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item
group="extras"
icon="phone_android"
:label="$t('access_wallet_on_mobile')"
@@ -661,7 +739,13 @@
:readonly="receive.lnurl && receive.lnurl.fixed"
></q-input>
{% endif %}
<q-input
v-if="has_holdinvoice"
filled
dense
v-model="receive.data.payment_hash"
:label="$t('hold_invoice_payment_hash')"
></q-input>
<q-input
filled
dense
+14 -8
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from shutil import make_archive, move
from subprocess import Popen
from tempfile import NamedTemporaryFile
from typing import IO, Optional
from typing import IO
from urllib.parse import urlparse
import filetype
@@ -16,7 +16,7 @@ from lnbits.core.models import User
from lnbits.core.models.misc import Image, SimpleStatus
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services import (
enqueue_notification,
enqueue_admin_notification,
get_balance_delta,
update_cached_settings,
)
@@ -65,14 +65,16 @@ async def api_monitor():
)
async def api_test_email():
return await send_email_notification(
"This is a LNbits test email.", "LNbits Test Email"
settings.lnbits_email_notifications_to_emails,
"This is a LNbits test email.",
"LNbits Test Email",
)
@admin_router.get("/api/v1/settings", response_model=Optional[AdminSettings])
@admin_router.get("/api/v1/settings")
async def api_get_settings(
user: User = Depends(check_admin),
) -> Optional[AdminSettings]:
) -> AdminSettings | None:
admin_settings = await get_admin_settings(user.super_user)
return admin_settings
@@ -82,7 +84,9 @@ async def api_get_settings(
status_code=HTTPStatus.OK,
)
async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
enqueue_notification(NotificationType.settings_update, {"username": user.username})
enqueue_admin_notification(
NotificationType.settings_update, {"username": user.username}
)
await update_admin_settings(data)
admin_settings = await get_admin_settings(user.super_user)
if not admin_settings:
@@ -113,7 +117,9 @@ 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})
enqueue_admin_notification(
NotificationType.settings_update, {"username": user.username}
)
await reset_core_settings()
server_restart.set()
@@ -141,7 +147,7 @@ async def api_download_backup() -> FileResponse:
pg_backup_filename = f"{settings.lnbits_data_folder}/lnbits-database.dmp"
is_pg = db_url and db_url.startswith("postgres://")
if is_pg:
if is_pg and db_url:
p = urlparse(db_url)
command = (
f"pg_dump --host={p.hostname} "
+3 -157
View File
@@ -1,36 +1,20 @@
import hashlib
import json
from http import HTTPStatus
from io import BytesIO
from time import time
from typing import Any
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
import httpx
import pyqrcode
from fastapi import (
APIRouter,
Depends,
)
from fastapi.exceptions import HTTPException
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from lnbits.core.models import (
BaseWallet,
ConversionData,
CreateLnurlAuth,
CreateWallet,
User,
Wallet,
)
from lnbits.decorators import (
WalletTypeInfo,
check_user_exists,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import check_callback_url
from lnbits.lnurl import decode as lnurl_decode
from lnbits.decorators import check_user_exists
from lnbits.settings import settings
from lnbits.utils.exchange_rates import (
allowed_currencies,
@@ -41,7 +25,7 @@ from lnbits.utils.exchange_rates import (
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import StatusResponse
from ..services import create_user_account, perform_lnurlauth
from ..services import create_user_account
api_router = APIRouter(tags=["Core"])
@@ -92,144 +76,6 @@ async def api_create_account(data: CreateWallet) -> Wallet:
return user.wallets[0]
@api_router.get("/api/v1/lnurlscan/{code}")
async def api_lnurlscan( # noqa: C901
code: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
):
try:
url = str(lnurl_decode(code))
domain = urlparse(url).netloc
except Exception as exc:
# parse internet identifier (user@domain.com)
name_domain = code.split("@")
if len(name_domain) == 2 and len(name_domain[1].split(".")) >= 2:
name, domain = name_domain
url = (
("http://" if domain.endswith(".onion") else "https://")
+ domain
+ "/.well-known/lnurlp/"
+ name
)
# will proceed with these values
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="invalid lnurl"
) from exc
# params is what will be returned to the client
params: dict = {"domain": domain}
if "tag=login" in url:
params.update(kind="auth")
params.update(callback=url) # with k1 already in it
lnurlauth_key = wallet.wallet.lnurlauth_key(domain)
if not lnurlauth_key.verifying_key:
raise ValueError("LNURL auth key not found for this domain.")
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
else:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
check_callback_url(url)
try:
r = await client.get(url, timeout=5)
r.raise_for_status()
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
raise HTTPException(HTTPStatus.NOT_FOUND, "Not found") from exc
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={
"domain": domain,
"message": "failed to get parameters",
},
) from exc
try:
data = json.loads(r.text)
except json.decoder.JSONDecodeError as exc:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={
"domain": domain,
"message": f"got invalid response '{r.text[:200]}'",
},
) from exc
try:
tag: str = data.get("tag")
params.update(**data)
if tag == "channelRequest":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail={
"domain": domain,
"kind": "channel",
"message": "unsupported",
},
)
elif tag == "withdrawRequest":
params.update(kind="withdraw")
params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"])
# callback with k1 already in it
parsed_callback: ParseResult = urlparse(data["callback"])
qs: dict = parse_qs(parsed_callback.query)
qs["k1"] = data["k1"]
# balanceCheck/balanceNotify
if "balanceCheck" in data:
params.update(balanceCheck=data["balanceCheck"])
# format callback url and send to client
parsed_callback = parsed_callback._replace(
query=urlencode(qs, doseq=True)
)
params.update(callback=urlunparse(parsed_callback))
elif tag == "payRequest":
params.update(kind="pay")
params.update(fixed=data["minSendable"] == data["maxSendable"])
params.update(
description_hash=hashlib.sha256(
data["metadata"].encode()
).hexdigest()
)
metadata = json.loads(data["metadata"])
for [k, v] in metadata:
if k == "text/plain":
params.update(description=v)
if k in ("image/jpeg;base64", "image/png;base64"):
data_uri = f"data:{k},{v}"
params.update(image=data_uri)
if k in ("text/email", "text/identifier"):
params.update(targetUser=v)
params.update(commentAllowed=data.get("commentAllowed", 0))
except KeyError as exc:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={
"domain": domain,
"message": f"lnurl JSON response invalid: {exc}",
},
) from exc
return params
@api_router.post("/api/v1/lnurlauth")
async def api_perform_lnurlauth(
data: CreateLnurlAuth, wallet: WalletTypeInfo = Depends(require_admin_key)
):
err = await perform_lnurlauth(data.callback, wallet=wallet)
if err:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason
)
return ""
@api_router.get(
"/api/v1/rate/history",
dependencies=[Depends(check_user_exists)],
+12 -12
View File
@@ -1,9 +1,9 @@
import base64
import importlib
import json
from collections.abc import Callable
from http import HTTPStatus
from time import time
from typing import Callable, Optional
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Request
@@ -238,7 +238,7 @@ async def api_delete_user_api_token(
@auth_router.get("/{provider}", description="SSO Provider")
async def login_with_sso_provider(
request: Request, provider: str, user_id: Optional[str] = None
request: Request, provider: str, user_id: str | None = None
):
provider_sso = _new_sso(provider)
if not provider_sso:
@@ -319,7 +319,7 @@ async def update_pubkey(
data: UpdateUserPubkey,
user: User = Depends(check_user_exists),
payload: AccessTokenPayload = Depends(access_token_payload),
) -> Optional[User]:
) -> User | None:
if data.user_id != user.id:
raise ValueError("Invalid user ID.")
@@ -345,7 +345,7 @@ async def update_password(
data: UpdateUserPassword,
user: User = Depends(check_user_exists),
payload: AccessTokenPayload = Depends(access_token_payload),
) -> Optional[User]:
) -> User | None:
_validate_auth_timeout(payload.auth_time)
if data.user_id != user.id:
raise ValueError("Invalid user ID.")
@@ -419,7 +419,7 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
@auth_router.put("/update")
async def update(
data: UpdateUser, user: User = Depends(check_user_exists)
) -> Optional[User]:
) -> User | None:
if data.user_id != user.id:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
if data.username and not is_valid_username(data.username):
@@ -461,7 +461,7 @@ async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
return _auth_success_response(account.username, account.id, account.email)
async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] = None):
async def _handle_sso_login(userinfo: OpenID, verified_user_id: str | None = None):
email = userinfo.email
if not email or not is_valid_email_address(email):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
@@ -490,9 +490,9 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] =
def _auth_success_response(
username: Optional[str] = None,
user_id: Optional[str] = None,
email: Optional[str] = None,
username: str | None = None,
user_id: str | None = None,
email: str | None = None,
) -> JSONResponse:
payload = AccessTokenPayload(
sub=username or "", usr=user_id, email=email, auth_time=int(time())
@@ -533,7 +533,7 @@ def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
return response
def _new_sso(provider: str) -> Optional[SSOBase]:
def _new_sso(provider: str) -> SSOBase | None:
try:
if not settings.is_auth_method_allowed(AuthMethods(f"{provider}-auth")):
return None
@@ -610,7 +610,7 @@ def _nostr_nip98_event(request: Request) -> dict:
def _check_nostr_event_tags(event: dict):
method: Optional[str] = next((v for k, v in event["tags"] if k == "method"), None)
method: str | None = next((v for k, v in event["tags"] if k == "method"), None)
if not method:
raise ValueError("Tag 'method' is missing.")
if not method.upper() == "POST":
@@ -625,7 +625,7 @@ def _check_nostr_event_tags(event: dict):
raise ValueError(f"Invalid value for tag 'u': '{url}'.")
def _validate_auth_timeout(auth_time: Optional[int] = 0):
def _validate_auth_timeout(auth_time: int | None = 0):
if abs(time() - (auth_time or 0)) > settings.auth_credetials_update_threshold:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
+1 -1
View File
@@ -231,7 +231,7 @@ async def api_disable_extension(
return SimpleStatus(
success=True, message=f"Extension '{ext_id}' already disabled."
)
logger.info(f"Disabeling extension: {ext_id}.")
logger.info(f"Disabling extension: {ext_id}.")
user_ext.active = False
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
+9 -9
View File
@@ -1,5 +1,5 @@
from http import HTTPStatus
from typing import Annotated, Optional, Union
from typing import Annotated
from urllib.parse import urlencode, urlparse
import httpx
@@ -7,7 +7,7 @@ from fastapi import Cookie, Depends, Query, Request
from fastapi.exceptions import HTTPException
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.routing import APIRouter
from lnurl import decode as lnurl_decode
from lnurl import url_decode
from pydantic.types import UUID4
from lnbits.core.helpers import to_valid_user_id
@@ -161,9 +161,9 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
)
async def wallet(
request: Request,
lnbits_last_active_wallet: Annotated[Union[str, None], Cookie()] = None,
lnbits_last_active_wallet: Annotated[str | None, Cookie()] = None,
user: User = Depends(check_user_exists),
wal: Optional[UUID4] = Query(None),
wal: UUID4 | None = Query(None),
):
if wal:
wallet = await get_wallet(wal.hex)
@@ -206,11 +206,15 @@ async def account(
request: Request,
user: User = Depends(check_user_exists),
):
nostr_configured = settings.is_nostr_notifications_configured()
telegram_configured = settings.is_telegram_notifications_configured()
return template_renderer().TemplateResponse(
request,
"core/account.html",
{
"user": user.json(),
"nostr_configured": nostr_configured,
"telegram_configured": telegram_configured,
"ajax": _is_ajax_request(request),
},
)
@@ -341,7 +345,6 @@ async def node(request: Request, user: User = Depends(check_admin)):
"node/index.html",
{
"user": user.json(),
"settings": settings.dict(),
"balance": balance,
"wallets": user.wallets[0].json(),
"ajax": _is_ajax_request(request),
@@ -361,7 +364,6 @@ async def node_public(request: Request):
request,
"node/public.html",
{
"settings": settings.dict(),
"balance": balance,
},
)
@@ -380,7 +382,6 @@ async def admin_index(request: Request, user: User = Depends(check_admin)):
"admin/index.html",
{
"user": user.json(),
"settings": settings.dict(),
"balance": balance,
"currencies": list(currencies.keys()),
"ajax": _is_ajax_request(request),
@@ -398,7 +399,6 @@ async def users_index(request: Request, user: User = Depends(check_admin)):
{
"request": request,
"user": user.json(),
"settings": settings.dict(),
"currencies": list(currencies.keys()),
"ajax": _is_ajax_request(request),
},
@@ -456,7 +456,7 @@ async def lnurlwallet(request: Request, lightning: str = ""):
if not settings.lnbits_allow_new_accounts:
return {"status": "ERROR", "reason": "New accounts are not allowed."}
lnurl = lnurl_decode(lightning)
lnurl = url_decode(lightning)
async with httpx.AsyncClient() as client:
check_callback_url(lnurl)
+175
View File
@@ -0,0 +1,175 @@
from http import HTTPStatus
from typing import Any
from fastapi import (
APIRouter,
Depends,
HTTPException,
)
from lnurl import (
LnurlResponseException,
LnurlSuccessResponse,
)
from lnurl import execute_login as lnurlauth
from lnurl import execute_withdraw as lnurl_withdraw
from lnurl import handle as lnurl_handle
from lnurl.models import (
LnurlAuthResponse,
LnurlErrorResponse,
LnurlPayResponse,
LnurlResponseModel,
LnurlWithdrawResponse,
)
from loguru import logger
from lnbits.core.models import CreateLnurlWithdraw, Payment
from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
from lnbits.decorators import (
WalletTypeInfo,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import check_callback_url
from lnbits.settings import settings
from ..services import fetch_lnurl_pay_request, pay_invoice
lnurl_router = APIRouter(tags=["LNURL"])
async def _handle(lnurl: str) -> LnurlResponseModel:
try:
res = await lnurl_handle(lnurl, user_agent=settings.user_agent, timeout=5)
if isinstance(res, LnurlErrorResponse):
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=res.reason)
except LnurlResponseException as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
return res
@lnurl_router.get(
"/api/v1/lnurlscan/{code}",
dependencies=[Depends(require_invoice_key)],
deprecated=True,
response_model=LnurlPayResponse
| LnurlWithdrawResponse
| LnurlAuthResponse
| LnurlErrorResponse,
)
async def api_lnurlscan(code: str) -> LnurlResponseModel:
res = await _handle(code)
if isinstance(res, LnurlPayResponse | LnurlWithdrawResponse | LnurlAuthResponse):
check_callback_url(res.callback)
return res
@lnurl_router.post(
"/api/v1/lnurlscan",
dependencies=[Depends(require_invoice_key)],
response_model=LnurlPayResponse
| LnurlWithdrawResponse
| LnurlAuthResponse
| LnurlErrorResponse,
)
async def api_lnurlscan_post(scan: LnurlScan) -> LnurlResponseModel:
return await _handle(scan.lnurl)
@lnurl_router.post("/api/v1/lnurlauth")
async def api_perform_lnurlauth(
data: LnurlAuthResponse, key_type: WalletTypeInfo = Depends(require_admin_key)
) -> LnurlResponseModel:
check_callback_url(data.callback)
try:
res = await lnurlauth(
res=data,
seed=key_type.wallet.adminkey,
user_agent=settings.user_agent,
timeout=5,
)
return res
except LnurlResponseException as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
@lnurl_router.post("/api/v1/payments/lnurl")
async def api_payments_pay_lnurl(
data: CreateLnurlPayment, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> Payment:
"""
Pay an LNURL payment request.
Either provice `res` (LnurlPayResponse) or `lnurl` (str) in the `data` object.
"""
if not data.res and not data.lnurl:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Missing LNURL or LnurlPayResponse data.",
)
try:
res, res2 = await fetch_lnurl_pay_request(data=data, wallet=wallet.wallet)
except LnurlResponseException as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
extra: dict[str, Any] = {}
if res2.disposable is False:
extra["stored"] = True
if res2.successAction:
extra["success_action"] = res2.successAction.json()
if data.comment:
extra["comment"] = data.comment
if data.unit and data.unit != "sat":
extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000
payment = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=str(res2.pr),
description=res.metadata.text,
extra=extra,
)
return payment
@lnurl_router.post(
"/api/v1/payments/{payment_request}/pay-with-nfc", status_code=HTTPStatus.OK
)
async def api_payment_pay_with_nfc(
payment_request: str,
lnurl_data: CreateLnurlWithdraw,
) -> LnurlErrorResponse | LnurlSuccessResponse:
if not lnurl_data.lnurl_w.lud17:
return LnurlErrorResponse(reason="LNURL-withdraw lud17 not provided.")
try:
url = lnurl_data.lnurl_w.lud17
res = await lnurl_handle(url, user_agent=settings.user_agent, timeout=10)
except (LnurlResponseException, Exception) as exc:
return LnurlErrorResponse(reason=str(exc))
if not isinstance(res, LnurlWithdrawResponse):
return LnurlErrorResponse(reason="Invalid LNURL-withdraw response.")
try:
check_callback_url(res.callback)
except ValueError as exc:
return LnurlErrorResponse(reason=f"Invalid callback URL: {exc!s}")
try:
res2 = await lnurl_withdraw(
res, payment_request, user_agent=settings.user_agent, timeout=10
)
except (LnurlResponseException, Exception) as exc:
logger.warning(exc)
return LnurlErrorResponse(reason=str(exc))
if not isinstance(res2, LnurlSuccessResponse | LnurlErrorResponse):
return LnurlErrorResponse(reason="Invalid LNURL-withdraw response.")
return res2
+29 -25
View File
@@ -1,5 +1,4 @@
from http import HTTPStatus
from typing import Optional
import httpx
from fastapi import APIRouter, Body, Depends, HTTPException
@@ -7,8 +6,9 @@ from pydantic import BaseModel
from starlette.status import HTTP_503_SERVICE_UNAVAILABLE
from lnbits.decorators import check_admin, check_super_user, parse_filters
from lnbits.nodes import get_node_class
from lnbits.settings import settings
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import Feature
from ...db import Filters, Page
from ...nodes.base import (
@@ -26,9 +26,13 @@ from ...nodes.base import (
from ...utils.cache import cache
def require_node():
node_class = get_node_class()
if not node_class:
def require_node() -> Node:
funding_source = get_funding_source()
if (
not funding_source.features
or Feature.nodemanager not in funding_source.features
or not funding_source.__node_cls__
):
raise HTTPException(
status_code=HTTPStatus.NOT_IMPLEMENTED,
detail="Active backend does not implement Node API",
@@ -38,7 +42,7 @@ def require_node():
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail="Not enabled",
)
return node_class
return funding_source.__node_cls__(funding_source)
def check_public():
@@ -84,14 +88,14 @@ async def api_get_public_info(node: Node = Depends(require_node)) -> PublicNodeI
@node_router.get("/info")
async def api_get_info(
node: Node = Depends(require_node),
) -> Optional[NodeInfoResponse]:
) -> NodeInfoResponse | None:
return await node.get_info()
@node_router.get("/channels")
async def api_get_channels(
node: Node = Depends(require_node),
) -> Optional[list[NodeChannel]]:
) -> list[NodeChannel] | None:
return await node.get_channels()
@@ -99,7 +103,7 @@ async def api_get_channels(
async def api_get_channel(
channel_id: str,
node: Node = Depends(require_node),
) -> Optional[NodeChannel]:
) -> NodeChannel | None:
return await node.get_channel(channel_id)
@@ -108,20 +112,20 @@ async def api_create_channel(
node: Node = Depends(require_node),
peer_id: str = Body(),
funding_amount: int = Body(),
push_amount: Optional[int] = Body(None),
fee_rate: Optional[int] = Body(None),
push_amount: int | None = Body(None),
fee_rate: int | None = Body(None),
):
return await node.open_channel(peer_id, funding_amount, push_amount, fee_rate)
@super_node_router.delete("/channels")
async def api_delete_channel(
short_id: Optional[str],
funding_txid: Optional[str],
output_index: Optional[int],
short_id: str | None,
funding_txid: str | None,
output_index: int | None,
force: bool = False,
node: Node = Depends(require_node),
) -> Optional[list[NodeChannel]]:
) -> list[NodeChannel] | None:
return await node.close_channel(
short_id,
(
@@ -147,7 +151,7 @@ async def api_set_channel_fees(
async def api_get_payments(
node: Node = Depends(require_node),
filters: Filters = Depends(parse_filters(NodePaymentsFilters)),
) -> Optional[Page[NodePayment]]:
) -> Page[NodePayment] | None:
if not settings.lnbits_node_ui_transactions:
raise HTTPException(
HTTP_503_SERVICE_UNAVAILABLE,
@@ -160,7 +164,7 @@ async def api_get_payments(
async def api_get_invoices(
node: Node = Depends(require_node),
filters: Filters = Depends(parse_filters(NodeInvoiceFilters)),
) -> Optional[Page[NodeInvoice]]:
) -> Page[NodeInvoice] | None:
if not settings.lnbits_node_ui_transactions:
raise HTTPException(
HTTP_503_SERVICE_UNAVAILABLE,
@@ -187,25 +191,25 @@ async def api_disconnect_peer(peer_id: str, node: Node = Depends(require_node)):
class NodeRank(BaseModel):
capacity: Optional[int]
channelcount: Optional[int]
age: Optional[int]
growth: Optional[int]
availability: Optional[int]
capacity: int | None
channelcount: int | None
age: int | None
growth: int | None
availability: int | None
# Same for public and private api
@node_router.get(
"/rank",
description="Retrieve node ranks from https://1ml.com",
response_model=Optional[NodeRank],
response_model=NodeRank | None,
)
@public_node_router.get(
"/rank",
description="Retrieve node ranks from https://1ml.com",
response_model=Optional[NodeRank],
response_model=NodeRank | None,
)
async def api_get_1ml_stats(node: Node = Depends(require_node)) -> Optional[NodeRank]:
async def api_get_1ml_stats(node: Node = Depends(require_node)) -> NodeRank | None:
node_id = await node.get_id()
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
+36 -186
View File
@@ -1,11 +1,6 @@
import json
import ssl
from hashlib import sha256
from http import HTTPStatus
from math import ceil
from typing import Optional
from urllib.parse import urlparse
import httpx
from fastapi import (
APIRouter,
Depends,
@@ -14,7 +9,7 @@ from fastapi import (
Query,
)
from fastapi.responses import JSONResponse
from loguru import logger
from lnurl import url_decode
from lnbits import bolt11
from lnbits.core.crud.payments import (
@@ -22,11 +17,10 @@ from lnbits.core.crud.payments import (
get_wallets_stats,
)
from lnbits.core.models import (
CancelInvoice,
CreateInvoice,
CreateLnurl,
DecodePayment,
KeyType,
PayLnurlWData,
Payment,
PaymentCountField,
PaymentCountStat,
@@ -34,6 +28,7 @@ from lnbits.core.models import (
PaymentFilters,
PaymentHistoryPoint,
PaymentWalletStats,
SettleInvoice,
)
from lnbits.core.models.users import User
from lnbits.db import Filters, Page
@@ -45,13 +40,10 @@ from lnbits.decorators import (
require_invoice_key,
)
from lnbits.helpers import (
check_callback_url,
filter_dict_keys,
generate_filter_params_openapi,
)
from lnbits.lnurl import decode as lnurl_decode
from lnbits.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from lnbits.wallets.base import InvoiceResponse
from ..crud import (
DateTrunc,
@@ -60,13 +52,14 @@ from ..crud import (
get_payments_paginated,
get_standalone_payment,
get_wallet_for_key,
update_payment_extra,
)
from ..services import (
cancel_hold_invoice,
create_payment_request,
fee_reserve_total,
get_payments_daily_stats,
pay_invoice,
settle_hold_invoice,
update_pending_payment,
update_pending_payments,
)
@@ -264,38 +257,6 @@ async def api_payments_create(
return await create_payment_request(wallet_id, invoice_data)
@payment_router.patch(
"/extra/{payment_hash}", description="Update extra data for a payment"
)
async def api_payments_update_extra(
payment_hash: str,
extra: dict,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Payment:
payment = await get_standalone_payment(payment_hash, wallet_id=wallet.wallet.id)
if payment is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
if not isinstance(extra, dict):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Extra data must be a dictionary.",
)
if payment.extra is None:
payment.extra = {}
for key, value in extra.items():
if key in payment.extra:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Key '{key}' already exists in extra data.",
)
payment.extra[key] = value
return await update_payment_extra(payment)
@payment_router.get("/fee-reserve")
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
invoice_obj = bolt11.decode(invoice)
@@ -311,95 +272,9 @@ async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONRespo
)
def _validate_lnurl_response(
params: dict, domain: str, amount_msat: int
) -> bolt11.Invoice:
if params.get("status") == "ERROR":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} said: '{params.get('reason', '')}'",
)
if not params.get("pr"):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} did not return a payment request.",
)
invoice = bolt11.decode(params["pr"])
if invoice.amount_msat != amount_msat:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
f"{domain} returned an invalid invoice. Expected"
f" {amount_msat} msat, got {invoice.amount_msat}."
),
)
return invoice
async def _fetch_lnurl_params(data: CreateLnurl, amount_msat: int, domain: str) -> dict:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
try:
r: httpx.Response = await client.get(
data.callback,
params={"amount": amount_msat, "comment": data.comment},
timeout=40,
)
if r.is_error:
raise httpx.ConnectError("LNURL callback connection error")
r.raise_for_status()
except (httpx.HTTPError, ssl.SSLError) as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to connect to {domain}.",
) from exc
return json.loads(r.text)
@payment_router.post("/lnurl")
async def api_payments_pay_lnurl(
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> Payment:
domain = urlparse(data.callback).netloc
check_callback_url(data.callback)
if data.unit and data.unit != "sat":
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
amount_msat = ceil(amount_msat // 1000) * 1000
else:
amount_msat = data.amount
params = await _fetch_lnurl_params(data, amount_msat, domain)
_validate_lnurl_response(params, domain, amount_msat)
extra = {}
if params.get("successAction"):
extra["success_action"] = params["successAction"]
if data.comment:
extra["comment"] = data.comment
if data.unit and data.unit != "sat":
extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000
if data.internal_memo is not None:
if len(data.internal_memo) > 512:
raise ValueError("Internal memo must be 512 characters or less.")
extra["internal_memo"] = data.internal_memo
if data.description is None:
raise ValueError("Description is required")
payment = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=params["pr"],
description=data.description,
extra=extra,
)
return payment
# TODO: refactor this route into a public and admin one
@payment_router.get("/{payment_hash}")
async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
async def api_payment(payment_hash, x_api_key: str | None = Header(None)):
# We use X_Api_Key here because we want this call to work with and without keys
# If a valid key is given, we also return the field "details", otherwise not
wallet = await get_wallet_for_key(x_api_key) if isinstance(x_api_key, str) else None
@@ -442,7 +317,7 @@ async def api_payments_decode(data: DecodePayment) -> JSONResponse:
payment_str = data.data
try:
if payment_str[:5] == "LNURL":
url = str(lnurl_decode(payment_str))
url = str(url_decode(payment_str))
return JSONResponse({"domain": url})
else:
invoice = bolt11.decode(payment_str)
@@ -455,57 +330,32 @@ async def api_payments_decode(data: DecodePayment) -> JSONResponse:
)
@payment_router.post("/{payment_request}/pay-with-nfc", status_code=HTTPStatus.OK)
async def api_payment_pay_with_nfc(
payment_request: str,
lnurl_data: PayLnurlWData,
) -> JSONResponse:
lnurl = lnurl_data.lnurl_w.lower()
@payment_router.post("/settle")
async def api_payments_settle(
data: SettleInvoice, key_type: WalletTypeInfo = Depends(require_admin_key)
) -> InvoiceResponse:
payment_hash = sha256(bytes.fromhex(data.preimage)).hexdigest()
payment = await get_standalone_payment(
payment_hash, incoming=True, wallet_id=key_type.wallet.id
)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Payment does not exist or does not belong to this wallet.",
)
return await settle_hold_invoice(payment, data.preimage)
# Follow LUD-17 -> https://github.com/lnurl/luds/blob/luds/17.md
url = lnurl.replace("lnurlw://", "https://")
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
try:
check_callback_url(url)
lnurl_req = await client.get(url, timeout=10)
if lnurl_req.is_error:
return JSONResponse(
{"success": False, "detail": "Error loading LNURL request"}
)
lnurl_res = lnurl_req.json()
if lnurl_res.get("status") == "ERROR":
return JSONResponse({"success": False, "detail": lnurl_res["reason"]})
if lnurl_res.get("tag") != "withdrawRequest":
return JSONResponse(
{"success": False, "detail": "Invalid LNURL-withdraw"}
)
callback_url = lnurl_res["callback"]
k1 = lnurl_res["k1"]
callback_req = await client.get(
callback_url,
params={"k1": k1, "pr": payment_request},
timeout=10,
)
if callback_req.is_error:
return JSONResponse(
{"success": False, "detail": "Error loading callback request"}
)
callback_res = callback_req.json()
if callback_res.get("status") == "ERROR":
return JSONResponse(
{"success": False, "detail": callback_res["reason"]}
)
else:
return JSONResponse({"success": True, "detail": callback_res})
except Exception as e:
return JSONResponse({"success": False, "detail": f"Unexpected error: {e}"})
@payment_router.post("/cancel")
async def api_payments_cancel(
data: CancelInvoice, key_type: WalletTypeInfo = Depends(require_admin_key)
) -> InvoiceResponse:
payment = await get_standalone_payment(
data.payment_hash, incoming=True, wallet_id=key_type.wallet.id
)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Payment does not exist or does not belong to this wallet.",
)
return await cancel_hold_invoice(payment)
+10 -4
View File
@@ -2,7 +2,6 @@ import base64
import json
import time
from http import HTTPStatus
from typing import Optional
from uuid import uuid4
import shortuuid
@@ -35,7 +34,7 @@ from lnbits.core.models.notifications import NotificationType
from lnbits.core.models.users import Account
from lnbits.core.services import (
create_user_account_no_ckeck,
enqueue_notification,
enqueue_admin_notification,
update_user_account,
update_user_extensions,
update_wallet_balance,
@@ -48,6 +47,7 @@ from lnbits.helpers import (
)
from lnbits.settings import EditableSettings, settings
from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.utils.nostr import normalize_public_key
users_router = APIRouter(
prefix="/users/api/v1", dependencies=[Depends(check_admin)], tags=["Users"]
@@ -95,6 +95,9 @@ async def api_create_user(data: CreateUser) -> CreateUser:
data.extra = data.extra or UserExtra()
data.extra.provider = data.extra.provider or "lnbits"
if data.pubkey:
data.pubkey = normalize_public_key(data.pubkey)
account = Account(
id=uuid4().hex,
username=data.username,
@@ -128,6 +131,9 @@ async def api_update_user(
HTTPStatus.BAD_REQUEST, "Use 'reset password' functionality."
)
if data.pubkey:
data.pubkey = normalize_public_key(data.pubkey)
account = Account(
id=user_id,
username=data.username,
@@ -223,7 +229,7 @@ async def api_users_get_user_wallet(user_id: str) -> list[Wallet]:
@users_router.post("/user/{user_id}/wallet", name="Create a new wallet for user")
async def api_users_create_user_wallet(
user_id: str, name: Optional[str] = Body(None), currency: Optional[str] = Body(None)
user_id: str, name: str | None = Body(None), currency: str | None = Body(None)
):
if currency and currency not in allowed_currencies():
raise ValueError(f"Currency '{currency}' not allowed.")
@@ -321,7 +327,7 @@ async def api_update_balance(data: UpdateBalance) -> SimpleStatus:
if not wallet:
raise HTTPException(HTTPStatus.NOT_FOUND, "Wallet not found.")
await update_wallet_balance(wallet=wallet, amount=int(data.amount))
enqueue_notification(
enqueue_admin_notification(
NotificationType.balance_update,
{
"amount": int(data.amount),
+22 -8
View File
@@ -1,5 +1,4 @@
from http import HTTPStatus
from typing import Optional
from uuid import uuid4
from fastapi import (
@@ -10,11 +9,11 @@ from fastapi import (
)
from lnbits.core.crud.wallets import get_wallets_paginated
from lnbits.core.models import CreateWallet, KeyType, User, Wallet
from lnbits.core.models import CreateWallet, KeyType, User, Wallet, WalletTypeInfo
from lnbits.core.models.lnurl import StoredPayLink, StoredPayLinks
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,
@@ -93,13 +92,28 @@ async def api_reset_wallet_keys(
return wallet
@wallet_router.put("/stored_paylinks/{wallet_id}")
async def api_put_stored_paylinks(
wallet_id: str,
data: StoredPayLinks,
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> list[StoredPayLink]:
if key_info.wallet.id != wallet_id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="You cannot modify this wallet"
)
key_info.wallet.stored_paylinks.links = data.links
wallet = await update_wallet(key_info.wallet)
return wallet.stored_paylinks.links
@wallet_router.patch("")
async def api_update_wallet(
name: Optional[str] = Body(None),
icon: Optional[str] = Body(None),
color: Optional[str] = Body(None),
currency: Optional[str] = Body(None),
pinned: Optional[bool] = Body(None),
name: str | None = Body(None),
icon: str | None = Body(None),
color: str | None = Body(None),
currency: str | None = Body(None),
pinned: bool | None = Body(None),
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> Wallet:
wallet = await get_wallet(key_info.wallet.id)
+9 -5
View File
@@ -281,15 +281,19 @@ class Database(Compat):
self.schema = self.name
self.type = DB_TYPE
if DB_TYPE == SQLITE:
if self.type == POSTGRES and settings.lnbits_database_url:
database_uri = settings.lnbits_database_url.replace(
"postgres://", "postgresql+asyncpg://"
)
elif self.type == COCKROACH and settings.lnbits_database_url:
database_uri = settings.lnbits_database_url.replace(
"cockroachdb://", "cockroachdb+asyncpg://"
)
else:
self.path = os.path.join(
settings.lnbits_data_folder, f"{self.name}.sqlite3"
)
database_uri = f"sqlite+aiosqlite:///{self.path}"
else:
database_uri = settings.lnbits_database_url.replace(
"postgres://", "postgresql+asyncpg://"
)
if self.name.startswith("ext_"):
self.schema = self.name[4:]
+21 -21
View File
@@ -1,5 +1,5 @@
from http import HTTPStatus
from typing import Annotated, Literal, Optional, Union
from typing import Annotated, Literal
import jwt
from fastapi import Cookie, Depends, Query, Request, Security
@@ -55,8 +55,8 @@ api_key_query = APIKeyQuery(
class KeyChecker(SecurityBase):
def __init__(
self,
api_key: Optional[str] = None,
expected_key_type: Optional[KeyType] = None,
api_key: str | None = None,
expected_key_type: KeyType | None = None,
):
self.auto_error: bool = True
self.expected_key_type = expected_key_type
@@ -137,17 +137,17 @@ async def require_invoice_key(
async def check_access_token(
header_access_token: Annotated[Union[str, None], Depends(oauth2_scheme)],
cookie_access_token: Annotated[Union[str, None], Cookie()] = None,
bearer_access_token: Annotated[Union[str, None], Depends(http_bearer)] = None,
) -> Optional[str]:
header_access_token: Annotated[str | None, Depends(oauth2_scheme)],
cookie_access_token: Annotated[str | None, Cookie()] = None,
bearer_access_token: Annotated[str | None, Depends(http_bearer)] = None,
) -> str | None:
return header_access_token or cookie_access_token or bearer_access_token
async def check_user_exists(
r: Request,
access_token: Annotated[Optional[str], Depends(check_access_token)],
usr: Optional[UUID4] = None,
access_token: Annotated[str | None, Depends(check_access_token)],
usr: UUID4 | None = None,
) -> User:
if access_token:
account = await _get_account_from_token(access_token, r["path"], r["method"])
@@ -176,9 +176,9 @@ async def check_user_exists(
async def optional_user_id(
r: Request,
access_token: Annotated[Optional[str], Depends(check_access_token)],
usr: Optional[UUID4] = None,
) -> Optional[str]:
access_token: Annotated[str | None, Depends(check_access_token)],
usr: UUID4 | None = None,
) -> str | None:
if usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
return usr.hex
if access_token:
@@ -189,7 +189,7 @@ async def optional_user_id(
async def access_token_payload(
access_token: Annotated[Optional[str], Depends(check_access_token)],
access_token: Annotated[str | None, Depends(check_access_token)],
) -> AccessTokenPayload:
if not access_token:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing access token.")
@@ -231,11 +231,11 @@ def parse_filters(model: type[TFilterModel]):
def dependency(
request: Request,
limit: Optional[int] = None,
offset: Optional[int] = None,
sortby: Optional[str] = None,
direction: Optional[Literal["asc", "desc"]] = None,
search: Optional[str] = Query(None, description="Text based search"),
limit: int | None = None,
offset: int | None = None,
sortby: str | None = None,
direction: Literal["asc", "desc"] | None = None,
search: str | None = Query(None, description="Text based search"),
):
params = request.query_params
filters = []
@@ -259,7 +259,7 @@ def parse_filters(model: type[TFilterModel]):
async def check_user_extension_access(
user_id: str, ext_id: str, conn: Optional[Connection] = None
user_id: str, ext_id: str, conn: Connection | None = None
) -> SimpleStatus:
"""
Check if the user has access to a particular extension.
@@ -292,7 +292,7 @@ async def _check_user_extension_access(user_id: str, path: str):
async def _get_account_from_token(
access_token: str, path: str, method: str
) -> Optional[Account]:
) -> Account | None:
try:
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
return await _get_account_from_jwt_payload(
@@ -310,7 +310,7 @@ async def _get_account_from_token(
async def _get_account_from_jwt_payload(
payload: AccessTokenPayload, path: str, method: str
) -> Optional[Account]:
) -> Account | None:
account = None
if payload.sub:
account = await get_account_by_username(payload.sub)
+1 -2
View File
@@ -1,7 +1,6 @@
import sys
import traceback
from http import HTTPStatus
from typing import Optional
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
@@ -30,7 +29,7 @@ class UnsupportedError(Exception):
pass
def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
def render_html_error(request: Request, exc: Exception) -> Response | None:
# Only the browser sends "text/html" request
# not fail proof, but everything else get's a JSON response
+11 -4
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
import importlib
from enum import Enum
from loguru import logger
from lnbits.fiat.base import FiatProvider
from lnbits.settings import settings
@@ -15,7 +17,7 @@ class FiatProviderType(Enum):
stripe = "StripeWallet"
async def get_fiat_provider(name: str) -> FiatProvider:
async def get_fiat_provider(name: str) -> FiatProvider | None:
if name not in FiatProviderType.__members__:
raise ValueError(f"Fiat provider '{name}' is not supported.")
@@ -27,13 +29,18 @@ async def get_fiat_provider(name: str) -> FiatProvider:
del fiat_providers[name]
else:
return fiat_provider
fiat_providers[name] = _init_fiat_provider(FiatProviderType[name])
_provider = _init_fiat_provider(FiatProviderType[name])
if not _provider:
return None
fiat_providers[name] = _provider
return fiat_providers[name]
def _init_fiat_provider(fiat_provider: FiatProviderType) -> FiatProvider:
def _init_fiat_provider(fiat_provider: FiatProviderType) -> FiatProvider | None:
if not settings.is_fiat_provider_enabled(fiat_provider.name):
raise ValueError(f"Fiat provider '{fiat_provider.name}' not enabled.")
logger.warning(f"Fiat provider '{fiat_provider.name}' not enabled.")
return None
provider_constructor = getattr(fiat_module, fiat_provider.value)
return provider_constructor()
+2 -3
View File
@@ -2,7 +2,6 @@ import asyncio
import json
from collections.abc import AsyncGenerator
from datetime import datetime, timedelta, timezone
from typing import Optional
from urllib.parse import urlencode
import httpx
@@ -49,7 +48,7 @@ class StripeWallet(FiatProvider):
logger.warning(f"Error closing stripe wallet connection: {e}")
async def status(
self, only_check_settings: Optional[bool] = False
self, only_check_settings: bool | None = False
) -> FiatStatusResponse:
if only_check_settings:
if self._settings_fields != self._settings_connection_fields():
@@ -76,7 +75,7 @@ class StripeWallet(FiatProvider):
amount: float,
payment_hash: str,
currency: str,
memo: Optional[str] = None,
memo: str | None = None,
**kwargs,
) -> FiatInvoiceResponse:
amount_cents = int(amount * 100)
+13 -21
View File
@@ -3,7 +3,7 @@ import json
import re
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Optional
from typing import Any
from urllib import request
from urllib.parse import urlparse
@@ -16,7 +16,6 @@ from packaging import version
from pydantic.schema import field_schema
from lnbits.jinja2_templating import Jinja2Templates
from lnbits.nodes import get_node_class
from lnbits.settings import settings
from lnbits.utils.crypto import AESCipher
@@ -40,7 +39,7 @@ def urlsafe_short_hash() -> str:
return shortuuid.uuid()
def url_for(endpoint: str, external: Optional[bool] = False, **params: Any) -> str:
def url_for(endpoint: str, external: bool | None = False, **params: Any) -> str:
base = f"http://{settings.host}:{settings.port}" if external else ""
url_params = "?"
for key, value in params.items():
@@ -53,7 +52,7 @@ def static_url_for(static: str, path: str) -> str:
return f"/{static}/{path}?v={settings.server_startup_time}"
def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templates:
def template_renderer(additional_folders: list | None = None) -> Jinja2Templates:
folders = ["lnbits/templates", "lnbits/core/templates"]
if additional_folders:
additional_folders += [
@@ -83,8 +82,8 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
"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_NODE_UI": settings.lnbits_node_ui and settings.has_nodemanager,
"LNBITS_NODE_UI_AVAILABLE": settings.has_nodemanager,
"LNBITS_QR_LOGO": settings.lnbits_qr_logo,
"LNBITS_SERVICE_FEE": settings.lnbits_service_fee,
"LNBITS_SERVICE_FEE_MAX": settings.lnbits_service_fee_max,
@@ -100,11 +99,8 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
"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"
),
"LNBITS_DENOMINATION": settings.lnbits_denomination,
"has_holdinvoice": settings.has_holdinvoice,
}
t.env.globals["WINDOW_SETTINGS"] = window_settings
@@ -215,7 +211,7 @@ def is_valid_pubkey(pubkey: str) -> bool:
return False
def create_access_token(data: dict, token_expire_minutes: Optional[int] = None) -> str:
def create_access_token(data: dict, token_expire_minutes: int | None = None) -> str:
minutes = token_expire_minutes or settings.auth_token_expire_minutes
expire = datetime.now(timezone.utc) + timedelta(minutes=minutes)
to_encode = {k: v for k, v in data.items() if v is not None}
@@ -223,9 +219,7 @@ def create_access_token(data: dict, token_expire_minutes: Optional[int] = None)
return jwt.encode(to_encode, settings.auth_secret_key, "HS256")
def encrypt_internal_message(
m: Optional[str] = None, urlsafe: bool = False
) -> Optional[str]:
def encrypt_internal_message(m: str | None = None, urlsafe: bool = False) -> str | None:
"""
Encrypt message with the internal secret key
@@ -238,9 +232,7 @@ def encrypt_internal_message(
return AESCipher(key=settings.auth_secret_key).encrypt(m.encode(), urlsafe=urlsafe)
def decrypt_internal_message(
m: Optional[str] = None, urlsafe: bool = False
) -> Optional[str]:
def decrypt_internal_message(m: str | None = None, urlsafe: bool = False) -> str | None:
"""
Decrypt message with the internal secret key
@@ -253,7 +245,7 @@ def decrypt_internal_message(
return AESCipher(key=settings.auth_secret_key).decrypt(m, urlsafe=urlsafe)
def filter_dict_keys(data: dict, filter_keys: Optional[list[str]]) -> dict:
def filter_dict_keys(data: dict, filter_keys: list[str] | None) -> dict:
if not filter_keys:
# return shallow clone of the dict even if there are no filters
return {**data}
@@ -274,7 +266,7 @@ def version_parse(v: str):
def is_lnbits_version_ok(
min_lnbits_version: Optional[str], max_lnbits_version: Optional[str]
min_lnbits_version: str | None, max_lnbits_version: str | None
) -> bool:
if min_lnbits_version and (
version_parse(min_lnbits_version) > version_parse(settings.version)
@@ -351,7 +343,7 @@ def path_segments(path: str) -> list[str]:
return segments[0:]
def normalize_path(path: Optional[str]) -> str:
def normalize_path(path: str | None) -> str:
path = path or ""
return "/" + "/".join(path_segments(path))
-66
View File
@@ -1,66 +0,0 @@
from http import HTTPStatus
from typing import Callable
from fastapi import HTTPException, Request, Response
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
from lnurl import LnurlErrorResponse, decode, encode, handle
from loguru import logger
from lnbits.exceptions import InvoiceError, PaymentError
class LnurlErrorResponseHandler(APIRoute):
"""
Custom APIRoute class to handle LNURL errors.
LNURL errors always return with status 200 and
a JSON response with `status="ERROR"` and a `reason` key.
Helps to catch HTTPException and return a valid lnurl error response
Example:
withdraw_lnurl_router = APIRouter(prefix="/api/v1/lnurl")
withdraw_lnurl_router.route_class = LnurlErrorResponseHandler
"""
def get_route_handler(self) -> Callable:
original_route_handler = super().get_route_handler()
async def lnurl_route_handler(request: Request) -> Response:
try:
response = await original_route_handler(request)
return response
except (InvoiceError, PaymentError) as exc:
logger.debug(f"Wallet Error: {exc}")
response = JSONResponse(
status_code=HTTPStatus.OK,
content={"status": "ERROR", "reason": f"{exc.message}"},
)
return response
except HTTPException as exc:
logger.debug(f"HTTPException: {exc}")
response = JSONResponse(
status_code=HTTPStatus.OK,
content={"status": "ERROR", "reason": f"{exc.detail}"},
)
return response
except Exception as exc:
logger.error("Unknown Error:", exc)
response = JSONResponse(
status_code=HTTPStatus.OK,
content={
"status": "ERROR",
"reason": f"UNKNOWN ERROR: {exc!s}",
},
)
return response
return lnurl_route_handler
__all__ = [
"LnurlErrorResponse",
"LnurlErrorResponseHandler",
"decode",
"encode",
"handle",
]
+7 -7
View File
@@ -2,7 +2,7 @@ import asyncio
import json
from datetime import datetime, timezone
from http import HTTPStatus
from typing import Any, Optional, Union
from typing import Any
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
@@ -62,7 +62,7 @@ class InstalledExtensionMiddleware:
def _response_by_accepted_type(
self, scope: Scope, headers: list[Any], msg: str, status_code: HTTPStatus
) -> Union[HTMLResponse, JSONResponse]:
) -> HTMLResponse | JSONResponse:
"""
Build an HTTP response containing the `msg` as HTTP body and the `status_code`
as HTTP code. If the `accept` HTTP header is present int the request and
@@ -127,7 +127,7 @@ class AuditMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
start_time = datetime.now(timezone.utc)
request_details = await self._request_details(request)
response: Optional[Response] = None
response: Response | None = None
try:
response = await call_next(request)
@@ -140,13 +140,13 @@ class AuditMiddleware(BaseHTTPMiddleware):
async def _log_audit(
self,
request: Request,
response: Optional[Response],
response: Response | None,
duration: float,
request_details: Optional[str],
request_details: str | None,
):
try:
http_method = request.scope.get("method", None)
path: Optional[str] = getattr(request.scope.get("route", {}), "path", None)
path: str | None = getattr(request.scope.get("route", {}), "path", None)
response_code = str(response.status_code) if response else None
if not settings.audit_http_request(http_method, path, response_code):
return None
@@ -178,7 +178,7 @@ class AuditMiddleware(BaseHTTPMiddleware):
except Exception as ex:
logger.warning(ex)
async def _request_details(self, request: Request) -> Optional[str]:
async def _request_details(self, request: Request) -> str | None:
if not settings.audit_http_request_details():
return None
-15
View File
@@ -1,15 +0,0 @@
from typing import Optional
from .base import Node
def get_node_class() -> Optional[Node]:
return NODE
def set_node_class(node: Node):
global NODE
NODE = node
NODE: Optional[Node] = None
+1 -1
View File
@@ -11,12 +11,12 @@ from httpx import HTTPStatusError
from loguru import logger
from lnbits.db import Filters, Page
from lnbits.nodes import Node
from lnbits.nodes.base import (
ChannelBalance,
ChannelPoint,
ChannelState,
ChannelStats,
Node,
NodeChannel,
NodeFees,
NodeInfoResponse,
+1 -1
View File
@@ -35,7 +35,7 @@ def main(
ssl_certfile: str,
reload: bool,
):
"""Launched with `poetry run lnbits` at root level"""
"""Launched with `uv run lnbits` at root level"""
# create data dir if it does not exist
Path(settings.lnbits_data_folder).mkdir(parents=True, exist_ok=True)
+23 -8
View File
@@ -142,14 +142,14 @@ class ExchangeRateProvider(BaseModel):
class InstalledExtensionsSettings(LNbitsSettings):
# installed extensions that have been deactivated
lnbits_deactivated_extensions: set[str] = Field(default=[])
lnbits_deactivated_extensions: set[str] = Field(default=set())
# upgraded extensions that require API redirects
lnbits_upgraded_extensions: dict[str, str] = Field(default={})
# list of redirects that extensions want to perform
lnbits_extensions_redirects: list[RedirectPath] = Field(default=[])
# list of all extension ids
lnbits_installed_extensions_ids: set[str] = Field(default=[])
lnbits_installed_extensions_ids: set[str] = Field(default=set())
def find_extension_redirect(
self, path: str, req_headers: list[tuple[bytes, bytes]]
@@ -266,7 +266,7 @@ class ThemesSettings(LNbitsSettings):
lnbits_default_theme: str = Field(default="salvador")
lnbits_default_border: str = Field(default="hard-border")
lnbits_default_gradient: bool = Field(default=True)
lnbits_default_bgimage: str = Field(default=None)
lnbits_default_bgimage: str | None = Field(default=None)
class OpsSettings(LNbitsSettings):
@@ -432,6 +432,18 @@ class NotificationsSettings(LNbitsSettings):
default=1_000_000, ge=0
)
def is_nostr_notifications_configured(self) -> bool:
return (
self.lnbits_nostr_notifications_enabled
and self.lnbits_nostr_notifications_private_key is not None
)
def is_telegram_notifications_configured(self) -> bool:
return (
self.lnbits_telegram_notifications_enabled
and self.lnbits_telegram_notifications_access_token is not None
)
class FakeWalletFundingSource(LNbitsSettings):
fake_wallet_secret: str = Field(default="ToTheMoon1")
@@ -717,7 +729,7 @@ class AuthMethods(Enum):
class AuthSettings(LNbitsSettings):
auth_token_expire_minutes: int = Field(default=525600, gt=0)
auth_all_methods = [a.value for a in AuthMethods]
auth_all_methods: list[str] = [a.value for a in AuthMethods]
auth_allowed_methods: list[str] = Field(
default=[
AuthMethods.user_id_only.value,
@@ -935,7 +947,7 @@ class EnvSettings(LNbitsSettings):
class PersistenceSettings(LNbitsSettings):
lnbits_data_folder: str = Field(default="./data")
lnbits_database_url: str = Field(default=None)
lnbits_database_url: str | None = Field(default=None)
class SuperUserSettings(LNbitsSettings):
@@ -981,11 +993,14 @@ class TransientSettings(InstalledExtensionsSettings, ExchangeHistorySettings):
lnbits_running: bool = Field(default=True)
# Remember the latest balance delta in order to compare with the current one
latest_balance_delta_sats: int = Field(default=None)
latest_balance_delta_sats: int = Field(default=0)
lnbits_all_extensions_ids: set[str] = Field(default=[])
lnbits_all_extensions_ids: set[str] = Field(default=set())
server_startup_time: int = Field(default=time())
server_startup_time: int = Field(default=int(time()))
has_holdinvoice: bool = Field(default=False)
has_nodemanager: bool = Field(default=False)
@property
def lnbits_server_up_time(self) -> str:
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
File diff suppressed because one or more lines are too long
+22 -4
View File
@@ -62,6 +62,14 @@ body[data-theme=monochrome].neon-border .q-date--dark {
box-shadow: none;
}
body[data-theme=salvador].neon-border .q-card,
body[data-theme=salvador].neon-border .q-card.q-card--dark,
body[data-theme=salvador].neon-border .q-date,
body[data-theme=salvador].neon-border .q-date--dark {
border: 2px solid #1976d2;
box-shadow: none;
}
body.hard-border .q-card,
body.hard-border .q-card.q-card--dark,
body.hard-border .q-date,
@@ -109,21 +117,21 @@ body[data-theme=bitcoin] [data-theme=bitcoin] .q-stepper--dark {
body[data-theme=freedom] {
--q-primary: #e22156;
--q-secondary: #b91a45;
--q-dark-page: #0a0a0a;
--q-dark-page: #462f36;
}
body[data-theme=freedom] [data-theme=freedom] .q-card--dark,
body[data-theme=freedom] [data-theme=freedom] .q-stepper--dark {
background: #1b1b1b !important;
background: #47393d !important;
}
body[data-theme=cyber] {
--q-primary: #7cb342;
--q-secondary: #558b2f;
--q-dark-page: #0a0a0a;
--q-dark-page: #000;
}
body[data-theme=cyber] [data-theme=cyber] .q-card--dark,
body[data-theme=cyber] [data-theme=cyber] .q-stepper--dark {
background: #1b1b1b !important;
background: #1f2915 !important;
}
body[data-theme=mint] {
@@ -166,6 +174,16 @@ body[data-theme=monochrome] [data-theme=monochrome] .q-stepper--dark {
background: rgb(39, 39, 39) !important;
}
body[data-theme=salvador] {
--q-primary: #1976d2;
--q-secondary: #26a69a;
--q-dark-page: #253647;
}
body[data-theme=salvador] [data-theme=salvador] .q-card--dark,
body[data-theme=salvador] [data-theme=salvador] .q-stepper--dark {
background: #343d47 !important;
}
body.gradient-bg {
background-image: linear-gradient(to bottom right, var(--q-dark-page), #0a0a0a);
background-attachment: fixed;
+22 -4
View File
@@ -49,9 +49,11 @@ window.localisation.en = {
export_to_phone_desc:
'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',
stored_paylinks: 'Stored LNURL pay links',
wallet: 'Wallet: ',
wallet_name: 'Wallet name',
wallets: 'Wallets',
exclude_wallets: 'Exclude Wallets',
add_wallet: 'Add wallet',
add_new_wallet: 'Add a new wallet',
pin_wallet: 'Pin wallet',
@@ -176,7 +178,17 @@ window.localisation.en = {
extension_required_lnbits_version: 'This release requires LNbits version',
min_version: 'Minimum (included)',
max_version: 'Maximum (excluded)',
preimage: 'Preimage',
preimage_hint: 'Preimage to settle the hold invoice',
hold_invoice: 'Hold Invoice',
hold_invoice_description:
'This invoice is on hold and requires a preimage to settle.',
payment_hash: 'Payment Hash',
invoice_cancelled: 'Invoice Cancelled',
invoice_settled: 'Invoice Settled',
hold_invoice_payment_hash: 'Payment hash for hold invoice (optional)',
settle_invoice: 'Settle Invoice',
cancel_invoice: 'Cancel Invoice',
fee: 'Fee',
amount: 'Amount',
amount_limits: 'Amount Limits',
@@ -219,6 +231,9 @@ window.localisation.en = {
notifications_nostr_private_key: 'Nostr Private Key',
notifications_nostr_private_key_desc:
'Private key (hex or nsec) to sign the messages sent to Nostr',
notifications_nostr_identifier: 'Nostr Identifier',
notifications_nostr_identifier_desc:
'Nip5 identifier to send notifications to',
notifications_nostr_identifiers: 'Nostr Identifiers',
notifications_nostr_identifiers_desc:
'List of identifiers to send notifications to',
@@ -228,9 +243,10 @@ window.localisation.en = {
notifications_enable_telegram_desc: 'Send notfications over Telegram',
notifications_telegram_access_token: 'Access Token',
notifications_telegram_access_token_desc: 'Access token for the bot',
notifications_chat_id: 'Chat ID',
notifications_chat_id_desc: 'Chat ID to send the notifications to',
notifications_chat_id: 'Telegram Chat ID',
notifications_chat_id_desc: 'Telegram Chat ID to send the notifications to',
notifications_excluded_wallets_desc:
'Do not send notifications for these wallets',
notifications_email_config: 'Email Configuration',
notifications_enable_email: 'Enable Email',
notifications_enable_email_desc: 'Send notifications over email',
@@ -634,5 +650,7 @@ window.localisation.en = {
'Signing secret for the webhook. Messages will be signed with this secret.',
callback_success_url: 'Callback Success URL',
callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful'
'The user will be redirected to this URL after the payment is successful',
connected: 'Connected',
not_connected: 'Not Connected'
}
+64 -7
View File
@@ -22,6 +22,7 @@ window.localisation.fi = {
active_channels: 'Aktiivisia kanavia',
connect_peer: 'Yhdistä naapuriin',
connect: 'Yhdistä',
reconnect: 'Uudista yhteys',
open_channel: 'Avaa kanava',
open: 'Avaa',
close_channel: 'Sulje kanava',
@@ -51,14 +52,18 @@ window.localisation.fi = {
'Tämä QR-koodi sisältää URL-osoitteen, jolla saa lompakkoosi täydet valtuudet. Voit lukea sen puhelimellasi ja avata sillä lompakkosi. Voit myös lisätä lompakkosi selaimella käytettäväksi PWA-sovellukseksi puhelimen aloitusruudulle. ',
access_wallet_on_mobile: 'Mobiili käyttö',
wallet: 'Lompakko:',
wallet_name: 'Lompakon nimi',
wallets: 'Lompakot',
add_wallet: 'Lisää lompakko',
add_new_wallet: 'Lisää uusi lompakko',
pin_wallet: 'Kiinnitä lompakko',
delete_wallet: 'Poista lompakko',
delete_wallet_desc:
'Lompakko poistetaan pysyvästi. Siirrä lompakosta varat ennalta muualle, sillä tämä toiminto on PERUUTTAMATON!',
rename_wallet: 'Nimeä lompakko uudelleen',
update_name: 'Tallenna',
fiat_tracking: 'Käytettävä valuutta',
fiat_providers: 'Valuutan välittäjät',
currency: 'Valuutta',
update_currency: 'Tallenna',
press_to_claim: 'Lunasta varat painamalla tästä',
@@ -122,6 +127,7 @@ window.localisation.fi = {
no_extensions: 'Laajennuksia ei ole asennettu :(',
created: 'Luotu',
search_extensions: 'Etsi laajennuksia',
search_wallets: 'Etsi lompakkoa',
extension_sources: 'Laajennuslähteet',
ext_sources_hint: 'Lähteet joista laajennuksia voi ladata',
ext_sources_label:
@@ -134,6 +140,7 @@ window.localisation.fi = {
uninstall: 'Poista',
drop_db: 'Poista tiedot',
enable: 'Ota käyttöön',
enabled: 'Käytössä',
pay_to_enable: 'Maksa ottaaksesi käyttöön',
enable_extension_details: 'Ota laajennus käyttöön tälle käyttäjälle',
disable: 'Poista käytöstä',
@@ -165,12 +172,33 @@ window.localisation.fi = {
payment_hash: 'Maksun tiiviste',
fee: 'Kulu',
amount: 'Määrä',
amount_limits: 'Määrien rajat',
amount_sats: 'Määrä (sat)',
faucest_wallet: 'Faucet Wallet',
faucest_wallet_desc_1:
'Each time a payment is confirmed by the {provider} provider funds will be subtracted from this wallet.',
faucest_wallet_desc_2:
'This helps monitor all {provider} payments and their status.',
faucest_wallet_desc_3:
'This wallet must be topped up with the amount of sats that the admin is willing to offer in exchange for the fiat currency.',
faucest_wallet_desc_4:
'If this wallet is configured, but is empty, the {provider} payments will not be processed.',
faucest_wallet_desc_5:
'This wallet can eventually get to a negative balance if parallel fiat payments are made.',
faucest_wallet_id: 'Faucet Wallet ID (optional)',
faucest_wallet_id_hint:
'Wallet ID to use for the faucet. It will be used to send the funds to the user.',
tag: 'Tunniste',
unit: 'Yksikkö',
description: 'Kuvaus',
expiry: 'Vanhenee',
webhook: 'Webhook',
webhook_url: 'Webhook URL',
webhook_url_hint:
'Webhook URL to send the payment details to. It will be called when the payment is completed.',
webhook_events_list: 'The following events must be supported by the webhook:',
webhook_stripe_description:
'One the stripe side you must configure a webhook with a URL that points to your LNbits server.',
payment_proof: 'Maksun varmenne',
update: 'Päivitä',
update_available: 'Saatavilla on päivitys {version}-versioon!',
@@ -262,6 +290,7 @@ window.localisation.fi = {
notification_source_label:
'Lähde-URL (käytä ainoastaan LNbits:iä tai muuta luotettavaa lähdettä)',
more: 'näytä lisää',
more_count: 'näytä {count} lisää',
less: 'supista',
releases: 'Julkaisut',
watchdog: 'Watchdog',
@@ -276,7 +305,7 @@ window.localisation.fi = {
callback_url_rules: 'Callback URL -säännöt',
enter_callback_url_rule: 'Anna URL-sääntö regex-muodossa ja paina enter',
callback_url_rule_hint:
'Callback URL:it (kuten LNURL) tarkistetaan näiden sääntöjen mukaisesti. Jos sääntöjä ei ole määritetty, kaikki URL:it ovat sallittuja.',
'Callback URL:it (kuten LNURL) tarkistetaan kaikkien näiden sääntöjen mukaisesti. Jos sääntöjä ei ole määritetty, kaikki URL:it ovat sallittuja.',
wallet_limiter: 'Lompakon käyttörajoitin',
wallet_config: 'Wallet Config',
wallet_charts: 'Wallet Charts',
@@ -301,6 +330,9 @@ window.localisation.fi = {
login_with_user_id: 'Kirjaudu käyttäjä-ID:llä',
or: 'tai',
create_new_wallet: 'Avaa uusi lompakko',
delete_all_wallets: 'Poista kaikki lompakot',
confirm_delete_all_wallets:
'Oletko todellakin varma, että haluat poistaa käyttäjältä KAIKKI lompakot?',
login_to_account: 'Kirjaudu käyttäjänimellä',
create_account: 'Luo tili',
account_settings: 'Tilin asetukset',
@@ -309,7 +341,7 @@ window.localisation.fi = {
signin_with_nostr: 'Kirjaudu Nostr:lla',
signin_with_google: 'Kirjaudu Google-tunnuksella',
signin_with_github: 'Kirjaudu GitHub-tunnuksella',
signin_with_keycloak: 'Kirjaudu Keycloak-tunnuksella',
signin_with_custom_org: 'Kirjaudu {custom_org}-palvelulla',
username_or_email: 'Käyttäjänimi tai sähköposti',
password: 'Anna uusi salasana',
password_config: 'Salasanan määritys',
@@ -318,7 +350,10 @@ window.localisation.fi = {
change_password: 'Vaihda salasana',
update_credentials: 'Päivitä käyttöoikeustiedot',
update_pubkey: 'Päivitä julkinen avain',
nostr_pubkey_tooltip:
'Syötä tämän käyttäjän julkinen Nostr avain (hex arvona)',
set_password: 'Aseta salasana',
set_password_tooltip: 'Aseta käyttäjätunnukselle 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.',
@@ -327,7 +362,8 @@ window.localisation.fi = {
register: 'Rekisteröidy',
username: 'Käyttäjänimi',
pubkey: 'Julkinen avain',
user_id: 'Käyttäjä ID',
user_id: 'Käyttäjä tunnus',
id: 'tunnus',
email: 'Sähköposti',
first_name: 'Etunimi',
last_name: 'Sukunimi',
@@ -343,6 +379,8 @@ window.localisation.fi = {
back: 'Takaisin',
logout: 'Poistu',
look_and_feel: 'Kieli ja värit',
endpoint: 'Endpoint',
api: 'API',
api_token: 'API Token',
api_tokens: 'API Tokens',
access_control_list: 'Access Control List',
@@ -354,11 +392,14 @@ window.localisation.fi = {
gradient_background: 'Gradient Background',
language: 'Kieli',
color_scheme: 'Väriteema',
visible_wallet_count: 'Näytettävien lompakkojen määrä',
admin_settings: 'Pääkäyttäjän asetukset',
extension_cost: 'Tämä laajennus edellyttää vähintään {cost} sat maksua.',
extension_paid_sats: 'Olet jo maksanut {paid_sats} satsia.',
release_details_error: 'Ei voi hakea julkaisun tietoja.',
pay_from_wallet: 'Maksa lompakosta',
pay_with: 'Maksa {provider}:lla',
select_payment_provider: 'Valitse maksun välittäjä',
wallet_required: 'Lompakko *',
show_qr: 'Näytä QR',
retry_install: 'Yritä asennusta uudelleen',
@@ -371,6 +412,9 @@ window.localisation.fi = {
'{name} -laajennuksen aktivointi edellyttää vähintään {amount} sat maksua.',
hide_empty_wallets: 'Piilota tyhjät lompakot',
recheck: 'Tarkista uudelleen',
check: 'Tarkista',
check_connection: 'Tarkista yhteys',
check_webhook: 'Tarkista Webhook',
contributors: 'Avustajat',
license: 'Lisenssi',
reset_key: 'Vaihda avain',
@@ -447,9 +491,9 @@ window.localisation.fi = {
fee_reserve_percent: 'Kuluvaraus prosentteina',
fee_reserve_msats: 'Kuluvaraus milli-sat',
reserve_fee_in_percent: 'Kuluvaraus prosentteina',
payment_wait_time: 'Maksun odotusaika',
payment_wait_time: 'Maksun odotusaika (sekuntia)',
payment_wait_time_desc:
'Kuinka pitkään maksua odotetaan saapuvaksi ennenkuin se merkitään Odotetaan -tilaan. Aseta pidemmäksi käytettäessä HODL-laskuja, Boltz-palvelua tms',
'Kuinka pitkään maksua odotetaan saapuvaksi, ennen kuin se merkitään Odotetaan-tilaan. Aseta pidemmäksi käytettäessä HODL-laskuja, Boltz-palvelua, tms',
server_management: 'Palvelimen hallinta',
base_url: 'Palvelimen URL-osoite',
base_url_label: 'Palvelun staattinen pohja-URL',
@@ -474,12 +518,16 @@ window.localisation.fi = {
auth_keycloak_ci_hint:
'Varmista, että valtuutuksen palautus-URL on asetettu muotoon https://{domain}/api/v1/auth/keycloak/token',
auth_keycloak_cs_label: 'Keycloak-asiakassalasana',
auth_keycloak_custom_org_label: 'Valinnainen Keycloak-organisaatio',
auth_keycloak_custom_icon_label: 'Valinnainen Keycloak-kuvake (URL)',
currency_settings: 'Valuutta-asetukset',
allowed_currencies: 'Käytettävät valuutat',
allowed_currencies_hint: 'Valitse käytettävissä olevat fiat-valuutat',
default_account_currency: 'Tilin oletusvaluutta',
default_account_currency_hint: 'Kirjanpidon oletusvaluutta',
min_incoming_payment_amount: 'Pienin vastaanotettava maksun määrä',
min_incoming_payment_amount_desc:
'Pienin maksun määrä jolle voi luoda laskun',
max_incoming_payment_amount: 'Saapuvan maksun enimmäismäärä',
max_incoming_payment_amount_desc: 'Enimmäismäärä jonka voi laskuttaa',
max_outgoing_payment_amount: 'Lähtevän maksun enimmäismäärä',
@@ -542,6 +590,8 @@ window.localisation.fi = {
admin_users_label: 'Käyttäjätunnus',
allowed_users: 'Sallitut käyttäjät',
allowed_users_hint: 'Vain nämä käyttäjät voivat käyttää LNbitsiä',
allowed_users_hint_feature:
'Ainoastaan nämä käyttäjät voivat käyttää ominaisuutta {feature}',
allowed_users_label: 'Käyttäjätunnus',
allow_creation_user: 'Salli uusien käyttäjien luominen',
allow_creation_user_desc: 'Etusivulta on mahdollisuus luoda uusia käyttäjiä',
@@ -577,5 +627,12 @@ window.localisation.fi = {
view_column: 'Näytä lompakot rinnakkain',
filter_payments: 'Suodata maksuja',
filter_date: 'Suodata päiväyksellä',
websocket_example: 'Websocket esimerkki'
websocket_example: 'Websocket example',
secret_key: 'Secret Key',
signing_secret: 'Signing Secret',
signing_secret_hint:
'Signing secret for the webhook. Messages will be signed with this secret.',
callback_success_url: 'Callback Success URL',
callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful'
}
+6
View File
@@ -80,6 +80,11 @@ window.AccountPageLogic = {
token_id_list: [],
allRead: false,
allWrite: false
},
notifications: {
nostr: {
identifier: ''
}
}
}
},
@@ -396,6 +401,7 @@ window.AccountPageLogic = {
}
}
},
async created() {
try {
const {data} = await LNbits.api.getAuthenticatedUser()
+14 -36
View File
@@ -19,17 +19,19 @@ window.LNbits = {
amount,
memo,
unit = 'sat',
lnurlCallback = null,
lnurlWithdraw = null,
fiatProvider = null,
internalMemo = null
internalMemo = null,
payment_hash = null
) {
const data = {
out: false,
amount: amount,
memo: memo,
unit: unit,
lnurl_callback: lnurlCallback,
fiat_provider: fiatProvider
lnurl_withdraw: lnurlWithdraw,
fiat_provider: fiatProvider,
payment_hash: payment_hash
}
if (internalMemo) {
data.extra = {
@@ -50,39 +52,14 @@ window.LNbits = {
}
return this.request('post', '/api/v1/payments', wallet.adminkey, data)
},
payLnurl(
wallet,
callback,
description_hash,
amount,
description = '',
comment = '',
unit = '',
internalMemo = null
) {
const data = {
callback,
description_hash,
amount,
comment,
description,
unit
}
if (internalMemo) {
data.internal_memo = String(internalMemo)
}
return this.request(
'post',
'/api/v1/payments/lnurl',
wallet.adminkey,
data
)
cancelInvoice(wallet, paymentHash) {
return this.request('post', '/api/v1/payments/cancel', wallet.adminkey, {
payment_hash: paymentHash
})
},
authLnurl(wallet, callback) {
return this.request('post', '/api/v1/lnurlauth', wallet.adminkey, {
callback
settleInvoice(wallet, preimage) {
return this.request('post', `/api/v1/payments/settle`, wallet.adminkey, {
preimage: preimage
})
},
createAccount(name) {
@@ -638,6 +615,7 @@ window.windowMixin = {
}
},
applyBackgroundImage() {
if (this.bgimageChoice == 'null') this.bgimageChoice = ''
if (this.bgimageChoice == '') {
document.body.classList.remove('bg-image')
} else {
-28
View File
@@ -187,34 +187,6 @@ window.app.component('lnbits-lnurlpay-success-action', {
}
})
window.app.component('lnbits-qrcode', {
mixins: [window.windowMixin],
template: '#lnbits-qrcode',
components: {
QrcodeVue
},
props: {
value: {
type: String,
required: true
},
options: Object
},
data() {
return {
custom: {
margin: 3,
width: 350,
size: 350,
logo: LNBITS_QR_LOGO
}
}
},
created() {
this.custom = {...this.custom, ...this.options}
}
})
window.app.component('lnbits-notifications-btn', {
template: '#lnbits-notifications-btn',
mixins: [window.windowMixin],
@@ -0,0 +1,43 @@
window.app.component('lnbits-qrcode-lnurl', {
template: '#lnbits-qrcode-lnurl',
mixins: [window.windowMixin],
props: {
url: {
required: true,
type: String
},
prefix: {
type: String,
default: 'lnurlp'
}
},
data() {
return {
tab: 'bech32',
lnurl: ''
}
},
methods: {
setLnurl() {
if (this.tab == 'bech32') {
const bytes = new TextEncoder().encode(this.url)
const bech32 = NostrTools.nip19.encodeBytes('lnurl', bytes)
this.lnurl = `lightning:${bech32.toUpperCase()}`
} else if (this.tab == 'lud17') {
this.lnurl = this.url.replace('https://', this.prefix + '://')
}
this.$emit('update:lnurl', this.lnurl)
}
},
watch: {
url() {
this.setLnurl()
},
tab() {
this.setLnurl()
}
},
created() {
this.setLnurl()
}
})
@@ -0,0 +1,27 @@
window.app.component('lnbits-qrcode', {
mixins: [window.windowMixin],
template: '#lnbits-qrcode',
components: {
QrcodeVue
},
props: {
value: {
type: String,
required: true
},
options: Object
},
data() {
return {
custom: {
margin: 3,
width: 350,
size: 350,
logo: LNBITS_QR_LOGO
}
}
},
created() {
this.custom = {...this.custom, ...this.options}
}
})
@@ -118,7 +118,13 @@ window.app.component('payment-list', {
field: row => row.extra.wallet_fiat_amount
}
],
preimage: null,
loading: false
},
hodlInvoice: {
show: false,
payment: null,
preimage: null
}
}
},
@@ -223,6 +229,35 @@ window.app.component('payment-list', {
})
.catch(LNbits.utils.notifyApiError)
},
showHoldInvoiceDialog(payment) {
this.hodlInvoice.show = true
this.hodlInvoice.preimage = ''
this.hodlInvoice.payment = payment
},
cancelHoldInvoice(payment_hash) {
LNbits.api
.cancelInvoice(this.g.wallet, payment_hash)
.then(() => {
this.update = !this.update
Quasar.Notify.create({
type: 'positive',
message: this.$t('invoice_cancelled')
})
})
.catch(LNbits.utils.notifyApiError)
},
settleHoldInvoice(preimage) {
LNbits.api
.settleInvoice(this.g.wallet, preimage)
.then(() => {
this.update = !this.update
Quasar.Notify.create({
type: 'positive',
message: this.$t('invoice_settled')
})
})
.catch(LNbits.utils.notifyApiError)
},
paymentTableRowKey(row) {
return row.payment_hash + row.amount
},
+148 -113
View File
@@ -5,6 +5,7 @@ window.WalletPageLogic = {
origin: window.location.origin,
baseUrl: `${window.location.protocol}//${window.location.host}/`,
websocketUrl: `${'http:' ? 'ws://' : 'wss://'}${window.location.host}/api/v1/ws`,
stored_paylinks: [],
parse: {
show: false,
invoice: null,
@@ -40,7 +41,8 @@ window.WalletPageLogic = {
data: {
amount: null,
memo: '',
internalMemo: null
internalMemo: null,
payment_hash: null
}
},
invoiceQrCode: '',
@@ -140,6 +142,13 @@ window.WalletPageLogic = {
},
canPay() {
if (!this.parse.invoice) return false
if (this.parse.invoice.expired) {
Quasar.Notify.create({
message: 'Invoice has expired',
color: 'negative'
})
return false
}
return this.parse.invoice.sat <= this.g.wallet.sat
},
formattedAmount() {
@@ -169,6 +178,10 @@ window.WalletPageLogic = {
}
},
methods: {
dateFromNow(unix) {
const date = new Date(unix * 1000)
return moment.utc(date).fromNow()
},
formatFiatAmount(amount, currency) {
this.update.currency = currency
this.formattedFiatAmount = LNbits.utils.formatCurrency(
@@ -200,6 +213,7 @@ window.WalletPageLogic = {
this.receive.data.amount = null
this.receive.data.memo = null
this.receive.data.internalMemo = null
this.receive.data.payment_hash = null
this.receive.unit = this.isFiatPriority
? this.g.wallet.currency || 'sat'
: 'sat'
@@ -256,9 +270,10 @@ window.WalletPageLogic = {
this.receive.data.amount,
this.receive.data.memo,
this.receive.unit,
this.receive.lnurl && this.receive.lnurl.callback,
this.receive.lnurlWithdraw,
this.receive.fiatProvider,
this.receive.data.internalMemo
this.receive.data.internalMemo,
this.receive.data.payment_hash
)
.then(response => {
this.g.updatePayments = !this.g.updatePayments
@@ -271,27 +286,29 @@ window.WalletPageLogic = {
if (!this.receive.lnurl) {
this.readNfcTag()
}
// TODO: lnurl_callback and lnurl_response
// WITHDRAW
if (response.data.lnurl_response !== null) {
if (response.data.lnurl_response === false) {
response.data.lnurl_response = `Unable to connect`
if (
this.receive.lnurl &&
response.data.extra?.lnurl_response !== null
) {
if (response.data.extra.lnurl_response === false) {
response.data.extra.lnurl_response = `Unable to connect`
}
if (typeof response.data.lnurl_response === 'string') {
const domain = this.receive.lnurl.callback.split('/')[2]
if (typeof response.data.extra.lnurl_response === 'string') {
// failure
Quasar.Notify.create({
timeout: 5000,
type: 'warning',
message: `${this.receive.lnurl.domain} lnurl-withdraw call failed.`,
caption: response.data.lnurl_response
message: `${domain} lnurl-withdraw call failed.`,
caption: response.data.extra.lnurl_response
})
return
} else if (response.data.lnurl_response === true) {
} else if (response.data.extra.lnurl_response === true) {
// success
Quasar.Notify.create({
timeout: 5000,
message: `Invoice sent to ${this.receive.lnurl.domain}!`,
timeout: 3000,
message: `Invoice sent to ${domain}!`,
spinner: true
})
}
@@ -340,32 +357,30 @@ window.WalletPageLogic = {
},
lnurlScan() {
LNbits.api
.request(
'GET',
'/api/v1/lnurlscan/' + this.parse.data.request,
this.g.wallet.adminkey
)
.request('POST', '/api/v1/lnurlscan', this.g.wallet.adminkey, {
lnurl: this.parse.data.request
})
.then(response => {
const data = response.data
if (data.status === 'ERROR') {
Quasar.Notify.create({
timeout: 5000,
type: 'warning',
message: `${data.domain} lnurl call failed.`,
message: `lnurl scan failed.`,
caption: data.reason
})
return
}
if (data.kind === 'pay') {
if (data.tag === 'payRequest') {
this.parse.lnurlpay = Object.freeze(data)
this.parse.data.amount = data.minSendable / 1000
} else if (data.kind === 'auth') {
} else if (data.tag === 'login') {
this.parse.lnurlauth = Object.freeze(data)
} else if (data.kind === 'withdraw') {
} else if (data.tag === 'withdrawRequest') {
this.parse.show = false
this.receive.show = true
this.receive.lnurlWithdraw = Object.freeze(data)
this.receive.status = 'pending'
this.receive.paymentReq = null
this.receive.paymentHash = null
@@ -375,8 +390,9 @@ window.WalletPageLogic = {
data.minWithdrawable / 1000,
data.maxWithdrawable / 1000
]
const domain = data.callback.split('/')[2]
this.receive.lnurl = {
domain: data.domain,
domain: domain,
callback: data.callback,
fixed: data.fixed
}
@@ -391,19 +407,29 @@ window.WalletPageLogic = {
this.decodeRequest()
this.parse.camera.show = false
},
isLnurl(req) {
return (
req.toLowerCase().startsWith('lnurl1') ||
req.startsWith('lnurlp://') ||
req.startsWith('lnurlw://') ||
req.startsWith('lnurlauth://') ||
req.match(/[\w.+-~_]+@[\w.+-~_]/)
)
},
decodeRequest() {
this.parse.show = true
this.parse.data.request = this.parse.data.request.trim().toLowerCase()
let req = this.parse.data.request
this.parse.data.request = this.parse.data.request.trim()
const req = this.parse.data.request.toLowerCase()
if (req.startsWith('lightning:')) {
this.parse.data.request = req.slice(10)
this.parse.data.request = this.parse.data.request.slice(10)
} else if (req.startsWith('lnurl:')) {
this.parse.data.request = req.slice(6)
this.parse.data.request = this.parse.data.request.slice(6)
} else if (req.includes('lightning=lnurl1')) {
this.parse.data.request = req.split('lightning=')[1].split('&')[0]
this.parse.data.request = this.parse.data.request
.split('lightning=')[1]
.split('&')[0]
}
req = this.parse.data.request
if (req.startsWith('lnurl1') || req.match(/[\w.+-~_]+@[\w.+-~_]/)) {
if (this.isLnurl(this.parse.data.request)) {
this.lnurlScan()
return
}
@@ -512,86 +538,50 @@ window.WalletPageLogic = {
})
},
payLnurl() {
const dismissPaymentMsg = Quasar.Notify.create({
timeout: 0,
message: 'Processing payment...'
})
LNbits.api
.payLnurl(
this.g.wallet,
this.parse.lnurlpay.callback,
this.parse.lnurlpay.description_hash,
this.parse.data.amount * 1000,
this.parse.lnurlpay.description.slice(0, 120),
this.parse.data.comment,
this.parse.data.unit,
this.parse.data.internalMemo
)
.request('post', '/api/v1/payments/lnurl', this.g.wallet.adminkey, {
res: this.parse.lnurlpay,
lnurl: this.parse.data.request,
unit: this.parse.data.unit,
amount: this.parse.data.amount * 1000,
comment: this.parse.data.comment,
internalMemo: this.parse.data.internalMemo
})
.then(response => {
this.parse.show = false
clearInterval(this.parse.paymentChecker)
setTimeout(() => {
clearInterval(this.parse.paymentChecker)
}, 40000)
this.parse.paymentChecker = setInterval(() => {
LNbits.api
.getPayment(this.g.wallet, response.data.payment_hash)
.then(res => {
if (res.data.paid) {
dismissPaymentMsg()
clearInterval(this.parse.paymentChecker)
// show lnurlpay success action
const extra = response.data.extra
if (extra.success_action) {
switch (extra.success_action.tag) {
case 'url':
Quasar.Notify.create({
message: `<a target="_blank" style="color: inherit" href="${extra.success_action.url}">${extra.success_action.url}</a>`,
caption: extra.success_action.description,
html: true,
type: 'positive',
timeout: 0,
closeBtn: true
})
break
case 'message':
Quasar.Notify.create({
message: extra.success_action.message,
type: 'positive',
timeout: 0,
closeBtn: true
})
break
case 'aes':
LNbits.api
.getPayment(this.g.wallet, response.data.payment_hash)
.then(({data: payment}) =>
decryptLnurlPayAES(
extra.success_action,
payment.preimage
)
)
.then(value => {
Quasar.Notify.create({
message: value,
caption: extra.success_action.description,
html: true,
type: 'positive',
timeout: 0,
closeBtn: true
})
})
break
}
}
}
})
}, 2000)
})
.catch(err => {
dismissPaymentMsg()
LNbits.utils.notifyApiError(err)
if (response.data.extra.success_action) {
const action = JSON.parse(response.data.extra.success_action)
switch (action.tag) {
case 'url':
Quasar.Notify.create({
message: `<a target="_blank" style="color: inherit" href="${action.url}">${action.url}</a>`,
caption: action.description,
html: true,
type: 'positive',
timeout: 0,
closeBtn: true
})
break
case 'message':
Quasar.Notify.create({
message: action.message,
type: 'positive',
timeout: 0,
closeBtn: true
})
break
case 'aes':
decryptLnurlPayAES(action, response.data.preimage)
Quasar.Notify.create({
message: value,
caption: extra.success_action.description,
html: true,
type: 'positive',
timeout: 0,
closeBtn: true
})
}
}
})
},
authLnurl() {
@@ -599,9 +589,13 @@ window.WalletPageLogic = {
timeout: 10,
message: 'Performing authentication...'
})
LNbits.api
.authLnurl(this.g.wallet, this.parse.lnurlauth.callback)
.request(
'post',
'/api/v1/lnurlauth',
wallet.adminkey,
this.parse.lnurlauth
)
.then(_ => {
dismissAuthMsg()
Quasar.Notify.create({
@@ -612,10 +606,9 @@ window.WalletPageLogic = {
this.parse.show = false
})
.catch(err => {
dismissAuthMsg()
if (err.response.data.reason) {
Quasar.Notify.create({
message: `Authentication failed. ${this.parse.lnurlauth.domain} says:`,
message: `Authentication failed. ${this.parse.lnurlauth.callback} says:`,
caption: err.response.data.reason,
type: 'warning',
timeout: 5000
@@ -1111,9 +1104,51 @@ window.WalletPageLogic = {
saveChartsPreferences() {
this.$q.localStorage.set('lnbits.wallets.chartConfig', this.chartConfig)
this.refreshCharts()
},
updatePaylinks() {
LNbits.api
.request(
'PUT',
`/api/v1/wallet/stored_paylinks/${this.g.wallet.id}`,
this.g.wallet.adminkey,
{
links: this.stored_paylinks
}
)
.then(() => {
Quasar.Notify.create({
message: 'Paylinks updated.',
type: 'positive',
timeout: 3500
})
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
sendToPaylink(lnurl) {
this.parse.data.request = lnurl
this.parse.show = true
this.lnurlScan()
},
editPaylink() {
this.$nextTick(() => {
this.updatePaylinks()
})
},
deletePaylink(lnurl) {
const links = []
this.stored_paylinks.forEach(link => {
if (link.lnurl !== lnurl) {
links.push(link)
}
})
this.stored_paylinks = links
this.updatePaylinks()
}
},
created() {
this.stored_paylinks = wallet.stored_paylinks.links
const urlParams = new URLSearchParams(window.location.search)
if (urlParams.has('lightning') || urlParams.has('lnurl')) {
this.parse.data.request =
+12 -4
View File
@@ -18,16 +18,16 @@ $themes: (
'freedom': (
primary: #e22156,
secondary: #b91a45,
dark: #0a0a0a,
info: #1b1b1b,
dark: #462f36,
info: #47393d,
marginal-bg: #2d293b,
marginal-text: #fff
),
'cyber': (
primary: #7cb342,
secondary: #558b2f,
dark: #0a0a0a,
info: #1b1b1b,
dark: #000,
info: #1f2915,
marginal-bg: #2d293b,
marginal-text: #fff
),
@@ -62,5 +62,13 @@ $themes: (
info: rgb(39, 39, 39),
marginal-bg: #000,
marginal-text: rgb(255, 255, 255)
),
'salvador': (
primary: #1976d2,
secondary: #26a69a,
dark: #253647,
info: #343d47,
marginal-bg: #0d1620,
marginal-text: rgb(255, 255, 255)
)
);
+2
View File
@@ -38,6 +38,8 @@
"js/bolt11-decoder.js"
],
"components": [
"js/components/lnbits-qrcode.js",
"js/components/lnbits-qrcode-lnurl.js",
"js/components/lnbits-funding-sources.js",
"js/components/extension-settings.js",
"js/components/payment-list.js",
File diff suppressed because one or more lines are too long
+2 -6
View File
@@ -1,11 +1,7 @@
import asyncio
import traceback
import uuid
from collections.abc import Coroutine
from typing import (
Callable,
Optional,
)
from collections.abc import Callable, Coroutine
from loguru import logger
@@ -84,7 +80,7 @@ invoice_listeners: dict[str, asyncio.Queue] = {}
# TODO: name should not be optional
# some extensions still dont use a name, but they should
def register_invoice_listener(send_chan: asyncio.Queue, name: Optional[str] = None):
def register_invoice_listener(send_chan: asyncio.Queue, name: str | None = None):
"""
A method intended for extensions (and core/tasks.py) to call when they want to be
notified about new invoice payments incoming. Will emit all incoming payments.
+89 -2
View File
@@ -370,7 +370,12 @@
v-if="extras.length"
>
<template v-for="entry in extras">
<q-item v-if="!!entry.value" key="entry.key" class="text-grey-4">
<q-item
v-if="!!entry.value"
key="entry.key"
class="text-grey-4"
style="white-space: normal; word-break: break-all"
>
<q-item-section>
<q-item-label v-text="entry.key"></q-item-label>
<q-item-label caption v-text="entry.value"></q-item-label>
@@ -630,6 +635,25 @@
</div>
</template>
<template id="lnbits-qrcode-lnurl">
<div class="qrcode_lnurl__wrapper">
<q-tabs
v-model="tab"
dense
class="text-grey q-mb-md"
active-color="primary"
indicator-color="primary"
align="justify"
narrow-indicator
inline-label
>
<q-tab name="bech32" icon="qr_code" label="bech32"></q-tab>
<q-tab name="lud17" icon="link" label="url (lud17)"></q-tab>
</q-tabs>
<lnbits-qrcode :value="lnurl" class="rounded-borders"></lnbits-qrcode>
</div>
</template>
<template id="lnbits-lnurlpay-success-action">
<div>
<p
@@ -880,6 +904,19 @@
:props="props"
style="white-space: normal; word-break: break-all"
>
<q-icon
v-if="
props.row.isIn &&
props.row.isPending &&
props.row.extra.hold_invoice
"
name="pause_presentation"
color="grey"
class="cursor-pointer q-mr-sm"
@click="showHoldInvoiceDialog(props.row)"
>
<q-tooltip><span v-text="$t('hold_invoice')"></span></q-tooltip>
</q-icon>
<q-badge
v-if="props.row.tag"
color="yellow"
@@ -941,7 +978,7 @@
</q-td>
<q-dialog v-model="props.expand" :props="props" position="top">
<q-card class="q-pa-sm q-pt-xl lnbits__dialog-card">
<q-card-section class="">
<q-card-section>
<q-list bordered separator>
<q-expansion-item
expand-separator
@@ -1004,6 +1041,7 @@
></lnbits-payment-details>
</q-expansion-item>
</q-list>
<div
v-if="props.row.isIn && props.row.isPending && props.row.bolt11"
class="text-center q-my-lg"
@@ -1060,6 +1098,55 @@
</q-card-section>
</q-card>
</q-dialog>
<q-dialog v-model="hodlInvoice.show" position="top">
<q-card class="q-pa-sm q-pt-xl lnbits__dialog-card">
<q-card-section>
<q-item-label class="text-h6">
<span v-text="$t('hold_invoice')"></span>
</q-item-label>
<q-item-label class="text-subtitle2">
<span v-text="$t('hold_invoice_description')"></span>
</q-item-label>
</q-card-section>
<q-card-section>
<q-input
filled
:label="$t('preimage')"
:hint="$t('preimage_hint')"
v-model="hodlInvoice.preimage"
dense
autofocus
@keyup.enter="settleHoldInvoice(hodlInvoice.preimage)"
>
</q-input>
</q-card-section>
<q-card-section class="row q-gutter-x-sm">
<q-btn
@click="settleHoldInvoice(hodlInvoice.preimage)"
outline
v-close-popup
color="grey"
:label="$t('settle_invoice')"
>
</q-btn>
<q-btn
v-close-popup
outline
color="grey"
class="q-ml-sm"
@click="cancelHoldInvoice(hodlInvoice.payment.payment_hash)"
:label="$t('cancel_invoice')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('close')"
></q-btn>
</q-card-section>
</q-card>
</q-dialog>
</q-tr>
</template>
</q-table>
+1 -2
View File
@@ -1,6 +1,5 @@
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
@@ -40,7 +39,7 @@ class AESCipher:
AES.decrypt(encrypted, password).toString(Utf8);
"""
def __init__(self, key: Union[bytes, str], block_size: int = 16):
def __init__(self, key: bytes | str, block_size: int = 16):
self.block_size = block_size
if isinstance(key, bytes):
self.key = key
+1 -2
View File
@@ -1,6 +1,5 @@
import asyncio
import statistics
from typing import Optional
import httpx
import jsonpath_ng.ext as jpx
@@ -250,7 +249,7 @@ async def btc_rates(currency: str) -> list[tuple[str, float]]:
async def fetch_price(
provider: ExchangeRateProvider,
) -> Optional[tuple[str, float]]:
) -> tuple[str, float] | None:
if currency.lower() in provider.exclude_to:
logger.warning(f"Provider {provider.name} does not support {currency}.")
return None
+2 -2
View File
@@ -1,9 +1,9 @@
import asyncio
import logging
import sys
from collections.abc import Callable
from hashlib import sha256
from pathlib import Path
from typing import Callable
from loguru import logger
@@ -13,7 +13,7 @@ from lnbits.settings import settings
def log_server_info():
logger.info("Starting LNbits")
logger.info("LNbits Info")
logger.info(f"Version: {settings.version}")
logger.info(f"Baseurl: {settings.lnbits_baseurl}")
logger.info(f"Host: {settings.host}")
+1 -2
View File
@@ -2,7 +2,6 @@ import base64
import hashlib
import json
import re
from typing import Union
from urllib.parse import urlparse
import secp256k1
@@ -149,7 +148,7 @@ def sign_event(
return event
def json_dumps(data: Union[dict, list]) -> str:
def json_dumps(data: dict | list) -> str:
"""
Converts a Python dictionary to a JSON string with compact encoding.
+4 -5
View File
@@ -2,9 +2,8 @@ from __future__ import annotations
import importlib
from lnbits.nodes import set_node_class
from lnbits.settings import settings
from lnbits.wallets.base import Wallet
from lnbits.wallets.base import Feature, Wallet
from .alby import AlbyWallet
from .blink import BlinkWallet
@@ -35,13 +34,13 @@ from .void import VoidWallet
from .zbd import ZBDWallet
def set_funding_source(class_name: str | None = None):
def set_funding_source(class_name: str | None = None) -> None:
backend_wallet_class = class_name or settings.lnbits_backend_wallet_class
funding_source_constructor = getattr(wallets_module, backend_wallet_class)
global funding_source
funding_source = funding_source_constructor()
if funding_source.__node_cls__:
set_node_class(funding_source.__node_cls__(funding_source))
settings.has_nodemanager = funding_source.has_feature(Feature.nodemanager)
settings.has_holdinvoice = funding_source.has_feature(Feature.holdinvoice)
def get_funding_source() -> Wallet:
+3 -4
View File
@@ -2,7 +2,6 @@ import asyncio
import hashlib
import json
from collections.abc import AsyncGenerator
from typing import Optional
import httpx
from loguru import logger
@@ -71,9 +70,9 @@ class AlbyWallet(Wallet):
async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
memo: str | None = None,
description_hash: bytes | None = None,
unhashed_description: bytes | None = None,
**_,
) -> InvoiceResponse:
# https://api.getalby.com/invoices
+35
View File
@@ -3,16 +3,24 @@ from __future__ import annotations
import asyncio
from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator, Coroutine
from enum import Enum
from typing import TYPE_CHECKING, NamedTuple
from loguru import logger
from lnbits.exceptions import InvoiceError
from lnbits.settings import settings
if TYPE_CHECKING:
from lnbits.nodes.base import Node
class Feature(Enum):
nodemanager = "nodemanager"
holdinvoice = "holdinvoice"
# bolt12 = "bolt12"
class StatusResponse(NamedTuple):
error_message: str | None
balance_msat: int
@@ -100,6 +108,10 @@ class PaymentPendingStatus(PaymentStatus):
class Wallet(ABC):
__node_cls__: type[Node] | None = None
features: list[Feature] | None = None
def has_feature(self, feature: Feature) -> bool:
return self.features is not None and feature in self.features
def __init__(self) -> None:
self.pending_invoices: list[str] = []
@@ -141,6 +153,29 @@ class Wallet(ABC):
) -> Coroutine[None, None, PaymentStatus]:
pass
async def create_hold_invoice(
self,
amount: int,
payment_hash: str,
memo: str | None = None,
description_hash: bytes | None = None,
unhashed_description: bytes | None = None,
**kwargs,
) -> InvoiceResponse:
raise InvoiceError(
message="Hold invoices are not supported by this wallet.", status="failed"
)
async def settle_hold_invoice(self, preimage: str) -> InvoiceResponse:
raise InvoiceError(
message="Hold invoices are not supported by this wallet.", status="failed"
)
async def cancel_hold_invoice(self, payment_hash: str) -> InvoiceResponse:
raise InvoiceError(
message="Hold invoices are not supported by this wallet.", status="failed"
)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while settings.lnbits_running:
for invoice in self.pending_invoices:
+4 -6
View File
@@ -2,13 +2,11 @@ import asyncio
import hashlib
import json
from collections.abc import AsyncGenerator
from typing import Optional
import httpx
from loguru import logger
from pydantic import BaseModel
from websockets.legacy.client import connect
from websockets.typing import Subprotocol
from websockets import Subprotocol, connect
from lnbits import bolt11
from lnbits.helpers import normalize_endpoint
@@ -103,9 +101,9 @@ class BlinkWallet(Wallet):
async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
memo: str | None = None,
description_hash: bytes | None = None,
unhashed_description: bytes | None = None,
**kwargs,
) -> InvoiceResponse:
# https://dev.blink.sv/api/btc-ln-receive
+4 -5
View File
@@ -1,6 +1,5 @@
import asyncio
from collections.abc import AsyncGenerator
from typing import Optional
from bolt11.decode import decode
from grpc.aio import AioRpcError
@@ -107,9 +106,9 @@ class BoltzWallet(Wallet):
async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
memo: str | None = None,
description_hash: bytes | None = None,
unhashed_description: bytes | None = None,
**_,
) -> InvoiceResponse:
pair = boltzrpc_pb2.Pair(to=boltzrpc_pb2.LBTC)
@@ -254,7 +253,7 @@ class BoltzWallet(Wallet):
)
await asyncio.sleep(5)
async def _fetch_wallet(self, wallet_name: str) -> Optional[str]:
async def _fetch_wallet(self, wallet_name: str) -> str | None:
try:
request = boltzrpc_pb2.GetWalletRequest(name=wallet_name)
response = await self.rpc.GetWallet(request, metadata=self.metadata)
+1 -2
View File
@@ -1,3 +1,2 @@
wget https://raw.githubusercontent.com/BoltzExchange/boltz-client/refs/heads/master/pkg/boltzrpc/boltzrpc.proto -O boltz_grpc_files/boltzrpc.proto
poetry run python -m grpc_tools.protoc -I . --python_out=. --grpc_python_out=. --pyi_out=. boltz_grpc_files/boltzrpc.proto
uv run python -m grpc_tools.protoc -I . --python_out=. --grpc_python_out=. --pyi_out=. boltz_grpc_files/boltzrpc.proto
+6 -7
View File
@@ -9,14 +9,13 @@ if not find_spec("breez_sdk"):
def __init__(self):
raise RuntimeError(
"Breez SDK is not installed. "
"Ask admin to run `poetry install -E breez` to install it."
"Ask admin to run `uv sync --extra breez` to install it."
)
else:
import asyncio
from collections.abc import AsyncGenerator
from pathlib import Path
from typing import Optional
from bolt11 import Bolt11Exception
from bolt11 import decode as bolt11_decode
@@ -66,7 +65,7 @@ else:
):
breez_incoming_queue.put_nowait(e.details)
def load_bytes(source: str, extension: str) -> Optional[bytes]:
def load_bytes(source: str, extension: str) -> bytes | None:
# first check if it can be read from a file
if source.split(".")[-1] == extension:
with open(source, "rb") as f:
@@ -85,7 +84,7 @@ else:
logger.debug(exc)
return None
def load_greenlight_credentials() -> Optional[GreenlightCredentials]:
def load_greenlight_credentials() -> GreenlightCredentials | None:
if (
settings.breez_greenlight_device_key
and settings.breez_greenlight_device_cert
@@ -168,9 +167,9 @@ else:
async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
memo: str | None = None,
description_hash: bytes | None = None,
unhashed_description: bytes | None = None,
**kwargs,
) -> InvoiceResponse:
# if description_hash or unhashed_description:

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