Compare commits

...
66 Commits
Author SHA1 Message Date
dni ⚡andGitHub 723d8ba98f chore: update to v1.0.0-rc2 (#2705) 2024-09-24 11:48:28 +02:00
daee2b3418 Check for theme params on the URL (#2678)
---------

Co-authored-by: dni  <office@dnilabs.com>
2024-09-24 11:44:07 +02:00
dni ⚡andGitHub 9d7e54f6b2 refactor: use CreatePayment model instead of a lot of kwargs (#2667)
- refactoring create_payment a bit to use a model instead of 10 kwargs
2024-09-24 11:13:30 +02:00
053ea20508 feat: update to Vue3 (#2677)
* update packages for vue3
* fix make bundle and make checkbundle to include bundle-components
* add lnbits/static/bundle-components.js

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-09-24 11:06:27 +02:00
dni ⚡andGitHub 04aefc8077 refactor: remove get_key_type decorator (#2676)
* refactor: remove `get_key_type` decorator
breaking change for 1.0.0
2024-09-24 10:56:34 +02:00
21d87adc52 mega chore: update sqlalchemy (#2611)
* update sqlalchemy to 1.4
* async postgres

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-09-24 10:56:03 +02:00
dni ⚡andGitHub c637e8d31e fix: internal payment can still be pending (#2686)
bug introduced last commit
2024-09-16 20:43:17 +02:00
dni ⚡andGitHub d26e50ec9a refactor: rename is_uncheckable (#2670)
it actually means is_internal and internal payments cant fail so we return success status
2024-09-16 17:34:30 +02:00
ArcandGitHub d229b7a765 fix: bash failed using after install (#2685) 2024-09-14 11:08:25 +02:00
ceb43f384e feat: install lnbits.sh bash script (#2684)
Co-authored-by: arcbtc <ben@arc.wales>
2024-09-12 08:02:47 +02:00
dni ⚡andGitHub 22e6326bce fix: gitignore extensions (#2682) 2024-09-11 19:34:35 +02:00
Vlad StanandGitHub 5f4f1288d7 Fix overlapping redirect paths (#2671) 2024-09-11 12:41:37 +03:00
blackcoffeexbtandGitHub 7a5e7fbd8c feat: UI / UX improvements to Users balance / tx chart (#2672)
* Updates to user manager chart to add axis label, bubble radius depending on balance and bubble labels with wallet info

* Fixed bg colour missing on toggle admin on user manager table
2024-09-11 09:40:41 +02:00
dni ⚡andGitHub 6c8d56e40c chore: update to 1.0.0-rc1 (#2675)
* chore: update to 1.0.0-rc1
2024-09-05 12:28:40 +02:00
Tiago VasconcelosandGitHub 8aa1716e32 Add detailed CSV export option (#2618) 2024-09-04 13:04:55 +03:00
fbd22c1a22 fix: QR request case (#2668)
* fix the hotfix to fix a fuckup
* fixup!

---------

Co-authored-by: dni  <office@dnilabs.com>
2024-09-03 16:05:55 +02:00
dni ⚡andGitHub e8a6870d7a fix: update lnurl for backwards compatibility (#2666) 2024-09-03 12:03:26 +02:00
Tiago VasconcelosandGitHub 937936ff33 fix fuck up on refactor (#2665)
Bug introduced in #2656
2024-09-03 12:03:09 +02:00
Pavol RusnakandGitHub ab3eb967b4 chore: update pyproject dependencies (#2619) 2024-09-03 11:09:36 +02:00
dni ⚡andGitHub 6341e1edaf feat: add baseurl to admin ui settings (#2644)
* feat: add baseurl to admin ui settings

server tab with a hint that it is currently not used.

i ran into an issue developing an extension where i needed to know the
url inside a task, where i cannot pass the `Request` object.
so i depend on `settings.lnbits_baseurl` there
2024-09-03 07:08:56 +02:00
69d518bac0 Uncomment LNBITS_ALLOWED_FUNDING_SOURCES (#2663)
* Uncomment LNBITS_ALLOWED_FUNDING_SOURCES

I don't see why this exists

---------

Co-authored-by: dni  <office@dnilabs.com>
2024-08-30 18:51:28 +02:00
dni ⚡andGitHub 9db2429a45 refactor: move migrate_databases to core helpers (#2636)
commands.py should be used for cli commands
2024-08-30 18:48:50 +02:00
dd90dec768 feat: frontend gradient option (#2561)
---------

Co-authored-by: dni  <office@dnilabs.com>
2024-08-30 18:16:24 +02:00
dni ⚡andGitHub eb37a064ad feat: vue components lnbits-dynamic-fields validation (#2645)
* feat: vue components lnbits-dynamic-fields validation

- add validation to fields if require = true
- add type hidden field (can be useful for create/update dialog with
passing item_id into update hidden field)
2024-08-30 18:06:55 +02:00
Tiago VasconcelosandGitHub 28df100d9a Fix lnurl fallback (#2656)
* Fix lnurl fallback

Wrong string case search

Closes #1599

* optimize the code

* one more clean up
2024-08-30 12:48:24 +01:00
6e6b387b7d feat: log with console.error on notifyApiError (#2646)
* feat: log with `console.error` on `notifyApiError`

usually why have code like that
```
    getAudit() {
      LNbits.api
        .request('GET', '/admin/api/v1/audit',
this.g.user.wallets[0].adminkey)
        .then(response => {
          this.auditData = response.data
          xonst myerror = isSwallowed()
        })
        .catch(function (error) {
          LNbits.utils.notifyApiError(error)
        })
    },

```
which if you make a mistake hides your error. logging console.error here
again makes it easier to see what is happening while developing and
doesnt hurt for the user aswell

* chore: bundle

* fixup!

---------

Co-authored-by: Arc <33088785+arcbtc@users.noreply.github.com>
2024-08-30 12:23:11 +01:00
Tiago VasconcelosandGitHub 209cf7fbe0 fix chips on server page (#2654)
Fix chips not having the remove functionality

Closes #2471
2024-08-30 12:19:22 +01:00
Pavol RusnakandGitHub 596167f443 chore: update install instructions for developers (#2652) 2024-08-30 12:18:42 +01:00
Gonçalo ValérioandGitHub 4732c4b296 add proper validation for the unit field when creating an invoice (#2647) 2024-08-30 12:17:52 +01:00
Tiago VasconcelosandGitHub 405a2f0776 fix decode info (#2655)
Closes #1837
2024-08-30 12:15:29 +01:00
dni ⚡andGitHub 304ad3035b feat: add generic lnurl error response handler (#2638)
* feat: add generic lnurl error response handler

this is used multiple times in extensions to safeguard `views_lnurl.py`
endpoint to not return a wrong lnurl error response.

you use it by just setting following on your lnurl router/view

```
withdraw_ext_lnurl = APIRouter(prefix="/api/v1/lnurl")
withdraw_ext_lnurl.route_class = LNURLErrorResponseHandler
```
2024-08-30 13:12:55 +02:00
Pavol RusnakandGitHub 8cffda5a55 chore: update package.json (#2635)
* chore: update package.json

* chore: make bundle
2024-08-29 22:51:41 +02:00
Tiago VasconcelosandGitHub cbe858b385 show wallet names in dropdown (#2653)
Extensions will need it also

Fix #2517
2024-08-29 21:43:00 +01:00
dni ⚡andGitHub 65ecca2507 feat: add rate endpoint per currency (#2641)
this is already used and implemented by tpos, lnurlp and i probably need
it for satspay aswell
2024-08-20 10:52:39 +01:00
1900cf9aa4 feat: add boltz client fundingsource (#2358)
* feat: add boltz client standalone fundingsource
WIP.
https://docs.boltz.exchange/v/boltz-client

this fundingsource utilizing the boltz client standalone function: https://github.com/BoltzExchange/boltz-client/pull/123
this makes him act like a lightning node while submarine swapping everything on liquid network. like aqua does in its wallet.

* feat: paid_invoices_stream

* feat: proper invoice and payment status check

* feat: authenticate over insecure channel aswell

* chore: lint

* docs: add more setup instructions

* chore: add `boltz_client_cert` in frontend

* feat: populate fee_msat in get_payment_status and get_invoice_status

* fixup!

* chore: bundle

* added boltz logo

* add BoltzWallet to __all__

* chore: bump grpcio and protobuf deps and add grpcio-tools as dev dependency

* chore: update protos

* feat: pass description when creating swap

* fixup!

* chore: bundle

---------

Co-authored-by: jackstar12 <jkranawetter05@gmail.com>
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-08-20 10:38:30 +01:00
Tiago VasconcelosandGitHub 296b1dfa9a add copy buttons (#2643) 2024-08-19 15:23:03 +02:00
dni ⚡andGitHub b14e0e4cc6 feat: add label to lnbits-dynamic-fields (#2637)
* feat: add label to `lnbits-dynamic-fields`

* chore: bundle
2024-08-14 16:52:19 +02:00
Ross SavageandGitHub fb585402dd update breez-sdk to 0.5.2 (#2639) 2024-08-14 15:42:12 +01:00
dni ⚡andGitHub 63f246908e fix: add back lnurl wallet (#2625)
* fix: add back lnurl wallet
* add comment from withdraw ext
* fixup, when you use unique links
2024-08-08 11:09:01 +02:00
dni ⚡andGitHub 8ac827f5a4 fix: export nwc wallet (#2632)
quick fix, #2630
2024-08-08 07:34:31 +02:00
dni ⚡andGitHub 74d4ddd312 feat: use __all__ to export deps from __init__.py (#2630)
* feat: export wallets
* remove linting exception
2024-08-08 07:29:21 +02:00
dni ⚡andGitHub 40ffa7dea0 test: refactor to not use paid_invoices stream for real invoice tests (#2628) 2024-08-07 16:19:53 +02:00
dni ⚡andGitHub ddb8fcb986 feat: add typing for tasks (#2629)
* feat: add typing for tasks

* fixup!
2024-08-07 09:57:15 +02:00
27b9e8254c feat: NWC Funding source #2579 (#2631)
* feat: nwc funding source

* implement paid_invoices_stream, fix for unsettled invoices where settled_at is present but None

* cancel pending_payments_lookup_task on cleanup

* Rename subscription_timeout_task to timeout_task

* ensure preimage is not None

* Improve readability, return failed status on expiration in get_payment_status, ensure result_type is checked after error (some implementations might not set a result_type on error)

* fetch account info when possible

* workaround possible race condition on some nwc service providers, improve performance of fallback by using payment_hash from bolt11 invoice

* fundle

* make format

* fix formatting

* fix C901 `_on_message` is too complex (21 > 16)

* format

* fix lint

* format

* fix tests/wallets/test_nwc_wallets.py:80:11: C901 `run` is too complex (17 > 16)

* fix padding

* fix documentation for _verify_event method

* refactoring and fixes

* Split NWCWallet - NWCConnection

* refactor class methods into helpers

* update bundle

* format

* catch NWCError failure codes

* format and fix

* chore: bundle

* add example

* typos

---------

Co-authored-by: Riccardo Balbo <riccardo0blb@gmail.com>
Co-authored-by: benarc <ben@arc.wales>
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-08-07 09:56:53 +02:00
daa4b92331 fix: db helpers to be used with timestamps (#2627)
* fix: db helpers to be used with timestamps

those helpers are used in boltz extension and they did not take dates
into consideration yet

* vlad picks
* refactor get_placeholder

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-08-06 12:43:44 +02:00
0015314e11 feat: add Breez SDK wallet (#1897)
* add Breez SDK wallet
* use more description status classes
* fix: add try-except

---------

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>
2024-08-06 10:06:21 +02:00
blackcoffeexbtandGitHub 235f8a6c19 Invoice creation UI: Replace input mask with pattern and inputmode (#2623)
* Invoice creation UI. Replace input mask with pattern and inputmode
2024-08-02 09:26:10 +03:00
Pavol RusnakandGitHub bab399f825 fix: run cachix nix action for all branches (#2624) 2024-08-01 21:54:15 +02:00
dni ⚡andGitHub 646a604221 feat: add release flow for release candidates (#2620)
- pushes docker tag
- pushes to pypi for extensions to update
- generates a prerelease on github
2024-08-01 13:05:02 +02:00
7d8fad267a fix: set a maximium sleep time when retrying to connect to the funding source (#2622)
---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-08-01 13:02:55 +02:00
dni ⚡andGitHub ce0aff206d fix: skip pending payment check on void (#2610)
skips payment check if voidwallet is active
2024-07-31 15:06:31 +03:00
80e7b9639d feat: filter response fields for /api/v1/payments/decode (#2612)
* feat: filter response fields

* chore: `make format`

* chore: comment

* Update lnbits/helpers.py

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>

* Update lnbits/helpers.py

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>

* chore: code format

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-07-31 14:36:42 +03:00
dni ⚡andGitHub 94caa2e1ba fix: still flaky regtest (#2617)
increase wait
2024-07-31 12:05:07 +02:00
dni ⚡andGitHub b0a66e8cf5 fix: random exceptions inside logs in middleware (#2608)
closes #2599

special thanks to bitkarrot for figuring that out, it was actually
initialized twice, the decorator alone is enough!

also was issue for first_install middleware
2024-07-31 12:00:40 +02:00
dni ⚡andGitHub ffba71c0ce test: fix flaky regtest (#2616)
* add log for invoice success
* add internal flag
* sleeping inside the tasks to not block
* sleep was wrong decrease wait time
2024-07-31 11:41:19 +02:00
b41705167f feat: extra log for tests phases (#2604)
* fix: set `corelightning_rest_cert`
* feat: extra log for tests phases

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-07-31 11:40:25 +02:00
blackcoffeexbtandGitHub 0387db3b55 Update installation.md (#2615)
mkdir data no longer needed for standard installation
2024-07-31 10:40:17 +03:00
Tiago VasconcelosandGitHub 38ef1b0061 feat: add a copy wallet button (#2613)
* feat: add a copy wallet button
* fix: make button consistent
* feat: hide API keys
Closes #2462
* fix: consistency in icons
* fix: missing end tag
2024-07-31 08:51:17 +02:00
Pavol RusnakandGitHub 19c231a2f4 chore: update pyln-client to 24.5 (#2614) 2024-07-30 18:04:28 +02:00
BitkarrotandGitHub 026c9b5155 Fix for LNURLp with ZBD wallet (#2609) 2024-07-30 18:04:07 +02:00
Pavol RusnakandGitHub c834929f8b add bitpay and yadio fiat rate providers + increase precision of blockchain.info fiat rate provider (#2605)
* feat: increase precision of blockchain.info fiat rate provider
* feat: add bitpay and yadio fiat rate providers
2024-07-30 13:44:08 +02:00
dni ⚡andGitHub 97de4eda18 feat: add exclude_to list for btcprice providers (#2602) 2024-07-26 11:31:24 +02:00
dni ⚡andGitHub a844711530 bug: removing admin user was not persistent (#2603)
thanks for reporting, that was a serious bug. the indentation was wrong
2024-07-26 11:15:34 +02:00
dni ⚡andGitHub e2522b358a chore: bump version to 0.12.11 (#2601)
this is needed for testing the new lndhub version
2024-07-25 10:19:18 +02:00
dni ⚡andGitHub 8f761dfd0f refactor: add status column to apipayments (#2537)
* refactor: add status column to apipayments

keep track of the payment status with an enum and persist it as string
to db. `pending`, `success`, `failed`.

- database migration
- remove deleting of payments, failed payments stay
2024-07-24 16:47:26 +03:00
Pavol RusnakandGitHub b14d36a0aa chore(deps): replace python-jose with pyjwt (#2591)
python-jose had no release since 3 years
2024-07-24 10:42:47 +02:00
128 changed files with 29954 additions and 12831 deletions
+18 -1
View File
@@ -28,7 +28,7 @@ PORT=5000
######################################
# which fundingsources are allowed in the admin ui
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet"
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet"
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
@@ -87,6 +87,12 @@ LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
ALBY_API_ENDPOINT=https://api.getalby.com/
ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
# BoltzWallet
BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
BOLTZ_CLIENT_WALLET="lnbits"
# ZBDWallet
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
ZBD_API_KEY=ZBD_ACCESS_TOKEN
@@ -112,11 +118,22 @@ LNBITS_DENOMINATION=sats
ECLAIR_URL=http://127.0.0.1:8283
ECLAIR_PASS=eclairpw
# NWCWalllet
NWC_PAIRING_URL="nostr+walletconnect://000...000?relay=example.com&secret=123"
# LnTipsWallet
# Enter /api in LightningTipBot to get your key
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
LNTIPS_API_ENDPOINT=https://ln.tips
# BreezSdkWallet
BREEZ_API_KEY=KEY
BREEZ_GREENLIGHT_SEED=SEED
# A Greenlight invite code or Greenlight partner certificate/key can be used
BREEZ_GREENLIGHT_INVITE_CODE=CODE
BREEZ_GREENLIGHT_DEVICE_KEY="/path/to/breezsdk/device.pem" # or BASE64/HEXSTRING
BREEZ_GREENLIGHT_DEVICE_CERT="/path/to/breezsdk/device.crt" # or BASE64/HEXSTRING
######################################
####### Auth Configurations ##########
######################################
+4 -1
View File
@@ -46,7 +46,10 @@ runs:
- name: Install the project dependencies
shell: bash
run: poetry install
run: |
poetry install
# needed for conv tests
poetry add psycopg2-binary
- name: Use Node.js ${{ inputs.node-version }}
if: ${{ (inputs.npm == 'true') }}
+3 -4
View File
@@ -28,11 +28,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v24
- uses: cachix/install-nix-action@v27
with:
nix_path: nixpkgs=channel:nixos-23.11
- uses: cachix/cachix-action@v13
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/cachix'
nix_path: nixpkgs=channel:nixos-24.05
- uses: cachix/cachix-action@v15
with:
name: lnbits
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
+44
View File
@@ -0,0 +1,44 @@
name: release-rc
on:
push:
tags:
- "*-rc[0-9]"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --generate-notes --prerelease
docker:
needs: [ release ]
uses: ./.github/workflows/docker.yml
with:
tag: ${{ github.ref_name }}
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi:
runs-on: ubuntu-latest
steps:
- name: Install dependencies for building secp256k1
run: |
sudo apt-get update
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
- uses: actions/checkout@v4
- name: Build and publish to pypi
uses: JRubics/poetry-publish@v1.15
with:
pypi_token: ${{ secrets.PYPI_API_KEY }}
+3 -2
View File
@@ -35,6 +35,7 @@ __bundle__
coverage.xml
node_modules
lnbits/static/bundle.js
lnbits/static/bundle-components.js
lnbits/static/bundle.css
lnbits/static/bundle.min.js.old
lnbits/static/bundle.min.css.old
@@ -49,8 +50,8 @@ fly.toml
lnbits-backup.zip
# Ignore extensions (post installable extension PR)
extensions
upgrades/
/lnbits/extensions
/upgrades/
# builded python package
dist
+1
View File
@@ -10,6 +10,7 @@
**/lnbits/static/vendor
**/lnbits/static/bundle.*
**/lnbits/static/bundle-components.*
**/lnbits/static/css/*
flake.lock
+8 -7
View File
@@ -39,24 +39,28 @@ dev:
poetry run lnbits --reload
test-wallets:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry 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
test-api:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/api
test-regtest:
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/regtest
@@ -99,24 +103,21 @@ sass:
bundle:
npm install
npm run sass
npm run vendor_copy
npm run vendor_json
npm run bundle
poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
npm run vendor_bundle_css
npm run vendor_minify_css
npm run vendor_bundle_js
npm run vendor_minify_js
checkbundle:
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
cp lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old
cp lnbits/static/bundle-components.min.js lnbits/static/bundle-components.min.js.old
make bundle
diff -q lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old || exit 1
diff -q lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old || exit 1
diff -q lnbits/static/bundle-components.min.js lnbits/static/bundle-components.min.js.old || exit 1
@echo "Bundle is OK"
rm lnbits/static/bundle.min.js.old
rm lnbits/static/bundle.min.css.old
rm lnbits/static/bundle-components.min.js.old
install-pre-commit-hook:
@echo "Installing pre-commit hook to git"
+14 -2
View File
@@ -11,13 +11,16 @@ Thanks for contributing :)
# Run
This starts the lnbits uvicorn server
Follow the [Basic installation: Option 1 (recommended): poetry](https://docs.lnbits.org/guide/installation.html#option-1-recommended-poetry)
guide to install poetry and other dependencies.
Then you can start LNbits uvicorn server with:
```bash
poetry run lnbits
```
This starts the lnbits uvicorn with hot reloading.
Or you can use the following to start uvicorn with hot reloading enabled:
```bash
make dev
@@ -25,6 +28,15 @@ make dev
poetry run lnbits --reload
```
You might need the following extra dependencies on clean installation of Debian:
```
sudo apt install nodejs
sudo apt install npm
npm install
sudo apt-get install autoconf libtool libpg-dev
```
# Precommit hooks
This ensures that all commits adhere to the formatting and linting rules.
+20 -18
View File
@@ -10,22 +10,17 @@ The following sections explain how to install LNbits using varions package manag
Note that by default LNbits uses SQLite as its database, which is simple and effective but you can configure it to use PostgreSQL instead which is also described in a section below.
## Option 1 (recommended): poetry
## Option 1 (recommended): Poetry
Mininum poetry version has is ^1.2, but it is recommended to use latest poetry. (including OSX)
Make sure you have Python 3.9 or 3.10 installed.
It is recommended to use the latest version of Poetry. Make sure you have Python version 3.9 or higher installed.
### install python on ubuntu
### Verify Python version
```sh
# for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version
sudo apt update
sudo apt install software-properties-common
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt install python3.9 python3.9-distutils
python3 --version
```
### install poetry
### Install Poetry
```sh
curl -sSL https://install.python-poetry.org | python3 -
@@ -38,13 +33,8 @@ git clone https://github.com/lnbits/lnbits.git
cd lnbits
git checkout main
# Next command, you can exchange with python3.10 or newer versions.
# Identify your version with python3 --version and specify in the next line
# command is only needed when your default python is not ^3.9 or ^3.10
poetry env use python3.9
poetry install --only main
mkdir data
cp .env.example .env
# set funding source amongst other options
nano .env
@@ -70,7 +60,19 @@ poetry install --only main
# Start LNbits with `poetry run lnbits`
```
## Option 2: Nix
## Option 2: Install script (on Debian/Ubuntu)
```sh
wget https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits.sh &&
chmod +x lnbits.sh &&
./lnbits.sh
```
Now visit `0.0.0.0:5000` to make a super-user account.
`export PATH="/home/$USER/.local/bin:$PATH"` then `./lnbits.sh` can be used to run, but for more control `cd lnbits` and use `poetry 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)
@@ -108,7 +110,7 @@ LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
```
## Option 3: Docker
## Option 4: Docker
use latest version from docker hub
@@ -130,7 +132,7 @@ mkdir data
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
```
## Option 4: Fly.io
## Option 5: Fly.io
Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use.
+32
View File
@@ -93,6 +93,18 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
- `ALBY_API_ENDPOINT`: https://api.getalby.com/
- `ALBY_ACCESS_TOKEN`: AlbyAccessToken
### Boltz
This funding source connects to a running [boltz-client](https://docs.boltz.exchange/v/boltz-client) and handles all lightning payments through submarine swaps on the liquid network.
You can configure the daemon to run in standalone mode by `standalone = True` in the config file or using the cli flag (`boltzd --standalone`).
Once running, you can create a liquid wallet using `boltzcli wallet create lnbits lbtc`.
- `LNBITS_BACKEND_WALLET_CLASS`: **BoltzWallet**
- `BOLTZ_CLIENT_ENDPOINT`: 127.0.0.1:9002
- `BOLTZ_CLIENT_MACAROON`: /home/bob/.boltz/macaroons/admin.macaroon or Base64/Hex
- `BOLTZ_CLIENT_CERT`: /home/bob/.boltz/tls.cert or Base64/Hex
- `BOLTZ_CLIENT_WALLET`: lnbits
### ZBD
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an ZBD API Key here: https://zbd.dev/docs/dashboard/projects/api
@@ -110,6 +122,26 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
- `PHOENIXD_API_ENDPOINT`: http://localhost:9740/
- `PHOENIXD_API_PASSWORD`: PhoenixdApiPassword
### Breez SDK
A Greenlight invite code or Greenlight partner certificate/key can be used to register a new node with Greenlight. If the Greenlight node already exists, neither are required.
- `LNBITS_BACKEND_WALLET_CLASS`: **BreezSdkWallet**
- `BREEZ_API_KEY`: ...
- `BREEZ_GREENLIGHT_SEED`: ...
- `BREEZ_GREENLIGHT_INVITE_CODE`: ...
- `BREEZ_GREENLIGHT_DEVICE_KEY`: /path/to/breezsdk/device.pem or Base64/Hex
- `BREEZ_GREENLIGHT_DEVICE_CERT`: /path/to/breezsdk/device.crt or Base64/Hex
### Cliche Wallet
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
### Nostr Wallet Connect (NWC)
To use NWC as funding source in LNbits you'll need a pairing URL (also known as pairing secret) from a NWC service provider. You can find a list of providers [here](https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets).
You can configure Nostr Wallet Connect in the admin ui or using the following environment variables:
- `LNBITS_BACKEND_WALLET_CLASS`: **NWCWallet**
- `NWC_PAIRING_URL`: **nostr+walletconnect://...your...pairing...secret...**
Generated
+16 -16
View File
@@ -5,11 +5,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1694529238,
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
@@ -26,11 +26,11 @@
]
},
"locked": {
"lastModified": 1698974481,
"narHash": "sha256-yPncV9Ohdz1zPZxYHQf47S8S0VrnhV7nNhCawY46hDA=",
"lastModified": 1703863825,
"narHash": "sha256-rXwqjtwiGKJheXB43ybM8NwWB8rO2dSRrEqes0S7F5Y=",
"owner": "nix-community",
"repo": "nix-github-actions",
"rev": "4bb5e752616262457bc7ca5882192a564c0472d2",
"rev": "5163432afc817cf8bd1f031418d1869e4c9d5547",
"type": "github"
},
"original": {
@@ -41,16 +41,16 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1702233072,
"narHash": "sha256-H5G2wgbim2Ku6G6w+NSaQaauv6B6DlPhY9fMvArKqRo=",
"lastModified": 1723938990,
"narHash": "sha256-9tUadhnZQbWIiYVXH8ncfGXGvkNq3Hag4RCBEMUk7MI=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "781e2a9797ecf0f146e81425c822dca69fe4a348",
"rev": "c42fcfbdfeae23e68fc520f9182dde9f38ad1890",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-23.11",
"ref": "nixos-24.05",
"repo": "nixpkgs",
"type": "github"
}
@@ -66,11 +66,11 @@
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1702334837,
"narHash": "sha256-QZG6+zFshyY+L8m2tlOTm75U5m9y7z01g0josVK+8Os=",
"lastModified": 1724134185,
"narHash": "sha256-nDqpGjz7cq3ThdC98BPe1ANCNlsJds/LLZ3/MdIXjA0=",
"owner": "nix-community",
"repo": "poetry2nix",
"rev": "1f4bcbf1be73abc232a972a77102a3e820485a99",
"rev": "5ee730a8752264e463c0eaf06cc060fd07f6dae9",
"type": "github"
},
"original": {
@@ -122,11 +122,11 @@
]
},
"locked": {
"lastModified": 1699786194,
"narHash": "sha256-3h3EH1FXQkIeAuzaWB+nK0XK54uSD46pp+dMD3gAcB4=",
"lastModified": 1719749022,
"narHash": "sha256-ddPKHcqaKCIFSFc/cvxS14goUhCOAwsM1PbMr0ZtHMg=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "e82f32aa7f06bbbd56d7b12186d555223dc399d1",
"rev": "8df5ff62195d4e67e2264df0b7f5e8c9995fd0bd",
"type": "github"
},
"original": {
+2 -17
View File
@@ -2,7 +2,7 @@
description = "LNbits, free and open-source Lightning wallet and accounts system";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11";
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05";
poetry2nix = {
url = "github:nix-community/poetry2nix";
inputs.nixpkgs.follows = "nixpkgs";
@@ -30,25 +30,10 @@
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; };
# remove the following override when https://github.com/nix-community/poetry2nix/pull/1563 is merged
asgi-lifespan = prev.asgi-lifespan.overridePythonAttrs (
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
);
dnspython = prev.dnspython.overridePythonAttrs (
old: { buildInputs = (old.buildInputs or []) ++ [ prev.hatchling ]; }
);
jinja2 = prev.jinja2.overridePythonAttrs (
old: { buildInputs = (old.buildInputs or []) ++ [ prev.flit-core ]; }
);
pytest-md = prev.pytest-md.overridePythonAttrs (
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
);
types-mock = prev.pytest-md.overridePythonAttrs (
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
);
});
};
});
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
# Check install has not already run
if [ ! -d lnbits/data ]; then
# Update package list and install prerequisites non-interactively
sudo apt update -y
sudo apt install -y software-properties-common
# Add the deadsnakes PPA repository non-interactively
sudo add-apt-repository -y ppa:deadsnakes/ppa
# Install Python 3.9 and distutils non-interactively
sudo apt install -y python3.9 python3.9-distutils
# Install Poetry
curl -sSL https://install.python-poetry.org | python3.9 -
# Add Poetry to PATH for the current session
export PATH="/home/$USER/.local/bin:$PATH"
if [ ! -d lnbits/wallets ]; then
# Clone the LNbits repository
git clone https://github.com/lnbits/lnbits.git
if [ $? -ne 0 ]; then
echo "Failed to clone the repository ... FAIL"
exit 1
fi
# Ensure we are in the lnbits directory
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
fi
git checkout main
# Make data folder
mkdir data
# Copy the .env.example to .env
cp .env.example .env
elif [ ! -d lnbits/wallets ]; then
# cd into lnbits
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
fi
# Set path for running after install
export PATH="/home/$USER/.local/bin:$PATH"
# Install the dependencies using Poetry
poetry env use python3.9
poetry install --only main
# Set environment variables for LNbits
export LNBITS_ADMIN_UI=true
export HOST=0.0.0.0
# Run LNbits
poetry run lnbits
+14 -19
View File
@@ -17,10 +17,13 @@ from slowapi.util import get_remote_address
from starlette.middleware.sessions import SessionMiddleware
from lnbits.core.crud import (
add_installed_extension,
get_dbversions,
get_installed_extensions,
update_installed_extension_state,
)
from lnbits.core.extensions.extension_manager import deactivate_extension
from lnbits.core.extensions.helpers import version_parse
from lnbits.core.helpers import migrate_extension_database
from lnbits.core.tasks import ( # watchdog_task
killswitch_task,
@@ -44,14 +47,8 @@ from lnbits.wallets import get_funding_source, set_funding_source
from .commands import migrate_databases
from .core import init_core_routers
from .core.db import core_app_extra
from .core.extensions.models import Extension, InstallableExtension
from .core.services import check_admin_settings, check_webpush_settings
from .core.views.extension_api import add_installed_extension
from .extension_manager import (
Extension,
InstallableExtension,
get_valid_extensions,
version_parse,
)
from .middleware import (
CustomGZipMiddleware,
ExtensionsRedirectMiddleware,
@@ -206,7 +203,7 @@ async def check_funding_source() -> None:
break
retry_counter += 1
sleep_time = 0.25 * (2**retry_counter)
sleep_time = min(0.25 * (2**retry_counter), 60)
logger.warning(
f"Retrying connection to backend in {sleep_time} seconds... "
f"({retry_counter}/{max_retries})"
@@ -243,6 +240,7 @@ async def check_installed_extensions(app: FastAPI):
)
except Exception as e:
logger.warning(e)
await deactivate_extension(ext.id)
logger.warning(
f"Failed to re-install extension: {ext.id} ({ext.installed_version})"
)
@@ -317,7 +315,6 @@ async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
# mount routes for the new version
core_app_extra.register_new_ext_routes(extension)
ext.notify_upgrade(extension.upgrade_hash)
def register_custom_extensions_path():
@@ -380,24 +377,22 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
)
app.mount(s["path"], StaticFiles(directory=static_dir), s["name"])
if hasattr(ext_module, f"{ext.code}_redirect_paths"):
ext_redirects = getattr(ext_module, f"{ext.code}_redirect_paths")
settings.lnbits_extensions_redirects = [
r for r in settings.lnbits_extensions_redirects if r["ext_id"] != ext.code
]
for r in ext_redirects:
r["ext_id"] = ext.code
settings.lnbits_extensions_redirects.append(r)
ext_redirects = (
getattr(ext_module, f"{ext.code}_redirect_paths")
if hasattr(ext_module, f"{ext.code}_redirect_paths")
else []
)
logger.trace(f"adding route for extension {ext_module}")
settings.activate_extension_paths(ext.code, ext.upgrade_hash, ext_redirects)
logger.trace(f"Adding route for extension {ext_module}.")
prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash != "" else ""
app.include_router(router=ext_route, prefix=prefix)
async def check_and_register_extensions(app: FastAPI):
await check_installed_extensions(app)
for ext in get_valid_extensions(False):
for ext in Extension.get_valid_extensions(False):
try:
register_ext_routes(app, ext)
except Exception as exc:
+20 -70
View File
@@ -12,18 +12,8 @@ from fastapi.exceptions import HTTPException
from loguru import logger
from packaging import version
from lnbits.core.models import Payment, User
from lnbits.core.services import check_admin_settings
from lnbits.core.views.extension_api import (
api_install_extension,
api_uninstall_extension,
)
from lnbits.settings import settings
from lnbits.wallets.base import Wallet
from .core import db as core_db
from .core import migrations as core_migrations
from .core.crud import (
from lnbits.core import db as core_db
from lnbits.core.crud import (
delete_accounts_no_wallets,
delete_unused_wallets,
delete_wallet_by_id,
@@ -35,14 +25,20 @@ from .core.crud import (
remove_deleted_wallets,
update_payment_status,
)
from .core.helpers import migrate_extension_database, run_migration
from .db import COCKROACH, POSTGRES, SQLITE
from .extension_manager import (
from lnbits.core.extensions.models import (
CreateExtension,
ExtensionRelease,
InstallableExtension,
get_valid_extensions,
)
from lnbits.core.helpers import migrate_databases
from lnbits.core.models import Payment, PaymentState
from lnbits.core.services import check_admin_settings
from lnbits.core.views.extension_api import (
api_install_extension,
api_uninstall_extension,
)
from lnbits.settings import settings
from lnbits.wallets.base import Wallet
def coro(f):
@@ -122,48 +118,6 @@ def database_migrate():
loop.run_until_complete(migrate_databases())
async def db_migrate():
task = asyncio.create_task(migrate_databases())
await task
async def migrate_databases():
"""Creates the necessary databases if they don't exist already; or migrates them."""
async with core_db.connect() as conn:
exists = False
if conn.type == SQLITE:
exists = await conn.fetchone(
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
)
elif conn.type in {POSTGRES, COCKROACH}:
exists = await conn.fetchone(
"SELECT * FROM information_schema.tables WHERE table_schema = 'public'"
" AND table_name = 'dbversions'"
)
if not exists:
await core_migrations.m000_create_migrations_table(conn)
current_versions = await get_dbversions(conn)
core_version = current_versions.get("core", 0)
await run_migration(conn, core_migrations, "core", core_version)
# here is the first place we can be sure that the
# `installed_extensions` table has been created
await load_disabled_extension_list()
# todo: revisit, use installed extensions
for ext in get_valid_extensions(False):
current_version = current_versions.get(ext.code, 0)
try:
await migrate_extension_database(ext, current_version)
except Exception as e:
logger.exception(f"Error migrating extension {ext.code}: {e}")
logger.info("✔️ All migrations done.")
@db.command("versions")
@coro
async def db_versions():
@@ -216,10 +170,12 @@ async def database_delete_wallet_payment(wallet: str, checking_id: str):
@db.command("mark-payment-pending")
@click.option("-c", "--checking-id", required=True, help="Payment checking Id.")
@coro
async def database_revert_payment(checking_id: str, pending: bool = True):
"""Mark wallet as deleted"""
async def database_revert_payment(checking_id: str):
"""Mark payment as pending"""
async with core_db.connect() as conn:
await update_payment_status(pending=pending, checking_id=checking_id, conn=conn)
await update_payment_status(
status=PaymentState.PENDING, checking_id=checking_id, conn=conn
)
@db.command("cleanup-accounts")
@@ -313,12 +269,6 @@ async def check_invalid_payments(
click.echo(" ".join([w, str(data[0]), str(data[1] / 1000).ljust(10)]))
async def load_disabled_extension_list() -> None:
"""Update list of extensions that have been explicitly disabled"""
inactive_extensions = await get_installed_extensions(active=False)
settings.lnbits_deactivated_extensions.update([e.id for e in inactive_extensions])
@extensions.command("list")
@coro
async def extensions_list():
@@ -661,7 +611,7 @@ async def _call_install_extension(
)
resp.raise_for_status()
else:
await api_install_extension(data, User(id="mock_id"))
await api_install_extension(data)
async def _call_uninstall_extension(
@@ -675,7 +625,7 @@ async def _call_uninstall_extension(
)
resp.raise_for_status()
else:
await api_uninstall_extension(extension, User(id="mock_id"))
await api_uninstall_extension(extension)
async def _can_run_operation(url) -> bool:
@@ -694,7 +644,7 @@ async def _can_run_operation(url) -> bool:
elif url:
click.echo(
"The option '--url' has been provided,"
+ f" but no server found runnint at '{url}'"
f" but no server found running at '{url}'"
)
return False
+3
View File
@@ -38,3 +38,6 @@ def init_core_routers(app: FastAPI):
app.include_router(tinyurl_router)
app.include_router(webpush_router)
app.include_router(users_router)
__all__ = ["core_app", "core_app_extra", "db"]
+294 -301
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,93 @@
import asyncio
import importlib
from loguru import logger
from lnbits.core.crud import (
add_installed_extension,
delete_installed_extension,
get_dbversions,
get_installed_extension,
update_installed_extension_state,
)
from lnbits.core.db import core_app_extra
from lnbits.core.helpers import migrate_extension_database
from lnbits.settings import settings
from .models import Extension, InstallableExtension
async def install_extension(ext_info: InstallableExtension) -> Extension:
extension = Extension.from_installable_ext(ext_info)
installed_ext = await get_installed_extension(ext_info.id)
ext_info.payments = installed_ext.payments if installed_ext else []
await ext_info.download_archive()
ext_info.extract_archive()
db_version = (await get_dbversions()).get(ext_info.id, 0)
await migrate_extension_database(extension, db_version)
await add_installed_extension(ext_info)
if extension.is_upgrade_extension:
# call stop while the old routes are still active
await stop_extension_background_work(ext_info.id)
return extension
async def uninstall_extension(ext_id: str):
await stop_extension_background_work(ext_id)
settings.deactivate_extension_paths(ext_id)
extension = await get_installed_extension(ext_id)
if extension:
extension.clean_extension_files()
await delete_installed_extension(ext_id=ext_id)
async def activate_extension(ext: Extension):
core_app_extra.register_new_ext_routes(ext)
await update_installed_extension_state(ext_id=ext.code, active=True)
async def deactivate_extension(ext_id: str):
settings.deactivate_extension_paths(ext_id)
await update_installed_extension_state(ext_id=ext_id, active=False)
async def stop_extension_background_work(ext_id: str) -> bool:
"""
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extensions SHOULD expose a `api_stop()` function.
"""
upgrade_hash = settings.lnbits_upgraded_extensions.get(ext_id, "")
ext = Extension(ext_id, True, False, upgrade_hash=upgrade_hash)
try:
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
old_module = importlib.import_module(ext.module_name)
# Extensions must expose an `{ext_id}_stop()` function at the module level
# The `api_stop()` function is for backwards compatibility (will be deprecated)
stop_fns = [f"{ext_id}_stop", "api_stop"]
stop_fn_name = next((fn for fn in stop_fns if hasattr(old_module, fn)), None)
assert stop_fn_name, "No stop function found for '{ext.module_name}'"
stop_fn = getattr(old_module, stop_fn_name)
if stop_fn:
if asyncio.iscoroutinefunction(stop_fn):
await stop_fn()
else:
stop_fn()
logger.info(f"Stopped background work for extension '{ext.module_name}'.")
except Exception as ex:
logger.warning(f"Failed to stop background work for '{ext.module_name}'.")
logger.warning(ex)
return False
return True
+56
View File
@@ -0,0 +1,56 @@
import hashlib
from typing import Any, Optional
from urllib import request
import httpx
from loguru import logger
from packaging import version
from lnbits.settings import settings
def version_parse(v: str):
"""
Wrapper for version.parse() that does not throw if the version is invalid.
Instead it return the lowest possible version ("0.0.0")
"""
try:
return version.parse(v)
except Exception:
return version.parse("0.0.0")
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
headers = {"User-Agent": settings.user_agent}
if settings.lnbits_ext_github_token:
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
async with httpx.AsyncClient(headers=headers) as client:
resp = await client.get(url)
if resp.status_code != 200:
logger.warning(f"{error_msg} ({url}): {resp.text}")
resp.raise_for_status()
return resp.json()
def download_url(url, save_path):
with request.urlopen(url, timeout=60) as dl_file:
with open(save_path, "wb") as out_file:
out_file.write(dl_file.read())
def file_hash(filename):
h = hashlib.sha256()
b = bytearray(128 * 1024)
mv = memoryview(b)
with open(filename, "rb", buffering=0) as f:
while n := f.readinto(mv):
h.update(mv[:n])
return h.hexdigest()
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
if not path:
return ""
_, _, *rest = path.split("/")
tail = "/".join(rest)
return f"https://github.com/{source_repo}/raw/main/{tail}"
@@ -1,3 +1,5 @@
from __future__ import annotations
import asyncio
import hashlib
import json
@@ -6,16 +8,22 @@ import shutil
import sys
import zipfile
from pathlib import Path
from typing import Any, List, NamedTuple, Optional, Tuple
from urllib import request
from typing import Any, NamedTuple, Optional
import httpx
from loguru import logger
from packaging import version
from pydantic import BaseModel
from lnbits.settings import settings
from .helpers import (
download_url,
file_hash,
github_api_get,
icon_to_github_url,
version_parse,
)
class ExplicitRelease(BaseModel):
id: str
@@ -23,7 +31,7 @@ class ExplicitRelease(BaseModel):
version: str
archive: str
hash: str
dependencies: List[str] = []
dependencies: list[str] = []
repo: Optional[str]
icon: Optional[str]
short_description: Optional[str]
@@ -48,9 +56,9 @@ class GitHubRelease(BaseModel):
class Manifest(BaseModel):
featured: List[str] = []
extensions: List["ExplicitRelease"] = []
repos: List["GitHubRelease"] = []
featured: list[str] = []
extensions: list[ExplicitRelease] = []
repos: list[GitHubRelease] = []
class GitHubRepoRelease(BaseModel):
@@ -81,6 +89,17 @@ class ExtensionConfig(BaseModel):
return True
return version_parse(self.min_lnbits_version) <= version_parse(settings.version)
@classmethod
async def fetch_github_release_config(
cls, org: str, repo: str, tag_name: str
) -> Optional[ExtensionConfig]:
config_url = (
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
)
error_msg = "Cannot fetch GitHub extension config"
config = await github_api_get(config_url, error_msg)
return ExtensionConfig.parse_obj(config)
class ReleasePaymentInfo(BaseModel):
amount: Optional[int] = None
@@ -112,7 +131,7 @@ class UserExtension(BaseModel):
return self.extra.paid_to_enable is True
@classmethod
def from_row(cls, data: dict) -> "UserExtension":
def from_row(cls, data: dict) -> UserExtension:
ext = UserExtension(**data)
ext.extra = (
UserExtensionInfo(**json.loads(data["_extra"] or "{}"))
@@ -122,124 +141,6 @@ class UserExtension(BaseModel):
return ext
def download_url(url, save_path):
with request.urlopen(url, timeout=60) as dl_file:
with open(save_path, "wb") as out_file:
out_file.write(dl_file.read())
def file_hash(filename):
h = hashlib.sha256()
b = bytearray(128 * 1024)
mv = memoryview(b)
with open(filename, "rb", buffering=0) as f:
while n := f.readinto(mv):
h.update(mv[:n])
return h.hexdigest()
async def fetch_github_repo_info(
org: str, repository: str
) -> Tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]:
repo_url = f"https://api.github.com/repos/{org}/{repository}"
error_msg = "Cannot fetch extension repo"
repo = await github_api_get(repo_url, error_msg)
github_repo = GitHubRepo.parse_obj(repo)
lates_release_url = (
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
)
error_msg = "Cannot fetch extension releases"
latest_release: Any = await github_api_get(lates_release_url, error_msg)
config_url = f"https://raw.githubusercontent.com/{org}/{repository}/{github_repo.default_branch}/config.json"
error_msg = "Cannot fetch config for extension"
config = await github_api_get(config_url, error_msg)
return (
github_repo,
GitHubRepoRelease.parse_obj(latest_release),
ExtensionConfig.parse_obj(config),
)
async def fetch_manifest(url) -> Manifest:
error_msg = "Cannot fetch extensions manifest"
manifest = await github_api_get(url, error_msg)
return Manifest.parse_obj(manifest)
async def fetch_github_releases(org: str, repo: str) -> List[GitHubRepoRelease]:
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
error_msg = "Cannot fetch extension releases"
releases = await github_api_get(releases_url, error_msg)
return [GitHubRepoRelease.parse_obj(r) for r in releases]
async def fetch_github_release_config(
org: str, repo: str, tag_name: str
) -> Optional[ExtensionConfig]:
config_url = (
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
)
error_msg = "Cannot fetch GitHub extension config"
config = await github_api_get(config_url, error_msg)
return ExtensionConfig.parse_obj(config)
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
headers = {"User-Agent": settings.user_agent}
if settings.lnbits_ext_github_token:
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
async with httpx.AsyncClient(headers=headers) as client:
resp = await client.get(url)
if resp.status_code != 200:
logger.warning(f"{error_msg} ({url}): {resp.text}")
resp.raise_for_status()
return resp.json()
async def fetch_release_payment_info(
url: str, amount: Optional[int] = None
) -> Optional[ReleasePaymentInfo]:
if amount:
url = f"{url}?amount={amount}"
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url)
resp.raise_for_status()
return ReleasePaymentInfo(**resp.json())
except Exception as e:
logger.warning(e)
return None
async def fetch_release_details(details_link: str) -> Optional[dict]:
try:
async with httpx.AsyncClient() as client:
resp = await client.get(details_link)
resp.raise_for_status()
data = resp.json()
if "description_md" in data:
resp = await client.get(data["description_md"])
if not resp.is_error:
data["description_md"] = resp.text
return data
except Exception as e:
logger.warning(e)
return None
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
if not path:
return ""
_, _, *rest = path.split("/")
tail = "/".join(rest)
return f"https://github.com/{source_repo}/raw/main/{tail}"
class Extension(NamedTuple):
code: str
is_valid: bool
@@ -247,7 +148,7 @@ class Extension(NamedTuple):
name: Optional[str] = None
short_description: Optional[str] = None
tile: Optional[str] = None
contributors: Optional[List[str]] = None
contributors: Optional[list[str]] = None
hidden: bool = False
migration_module: Optional[str] = None
db_name: Optional[str] = None
@@ -269,7 +170,7 @@ class Extension(NamedTuple):
return self.upgrade_hash != ""
@classmethod
def from_installable_ext(cls, ext_info: "InstallableExtension") -> "Extension":
def from_installable_ext(cls, ext_info: InstallableExtension) -> Extension:
return Extension(
code=ext_info.id,
is_valid=True,
@@ -278,22 +179,43 @@ class Extension(NamedTuple):
upgrade_hash=ext_info.hash if ext_info.module_installed else "",
)
@classmethod
def get_valid_extensions(
cls, include_deactivated: Optional[bool] = True
) -> list[Extension]:
valid_extensions = [
extension for extension in cls._extensions() if extension.is_valid
]
# All subdirectories in the current directory, not recursive.
if include_deactivated:
return valid_extensions
if settings.lnbits_extensions_deactivate_all:
return []
class ExtensionManager:
def __init__(self) -> None:
return [
e
for e in valid_extensions
if e.code not in settings.lnbits_deactivated_extensions
]
@classmethod
def get_valid_extension(
cls, ext_id: str, include_deactivated: Optional[bool] = True
) -> Optional[Extension]:
all_extensions = cls.get_valid_extensions(include_deactivated)
return next((e for e in all_extensions if e.code == ext_id), None)
@classmethod
def _extensions(cls) -> list[Extension]:
p = Path(settings.lnbits_extensions_path, "extensions")
Path(p).mkdir(parents=True, exist_ok=True)
self._extension_folders: List[Path] = [f for f in p.iterdir() if f.is_dir()]
extension_folders: list[Path] = [f for f in p.iterdir() if f.is_dir()]
@property
def extensions(self) -> List[Extension]:
# todo: remove this property somehow, it is too expensive
output: List[Extension] = []
output: list[Extension] = []
for extension_folder in self._extension_folders:
for extension_folder in extension_folders:
extension_code = extension_folder.parts[-1]
try:
with open(extension_folder / "config.json") as json_file:
@@ -356,13 +278,27 @@ class ExtensionRelease(BaseModel):
if not self.pay_link:
return
payment_info = await fetch_release_payment_info(self.pay_link)
payment_info = await self.fetch_release_payment_info()
self.cost_sats = payment_info.amount if payment_info else None
async def fetch_release_payment_info(
self, amount: Optional[int] = None
) -> Optional[ReleasePaymentInfo]:
url = f"{self.pay_link}?amount={amount}" if amount else self.pay_link
assert url, "Missing URL for payment info."
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url)
resp.raise_for_status()
return ReleasePaymentInfo(**resp.json())
except Exception as e:
logger.warning(e)
return None
@classmethod
def from_github_release(
cls, source_repo: str, r: "GitHubRepoRelease"
) -> "ExtensionRelease":
cls, source_repo: str, r: GitHubRepoRelease
) -> ExtensionRelease:
return ExtensionRelease(
name=r.name,
description=r.name,
@@ -377,8 +313,8 @@ class ExtensionRelease(BaseModel):
@classmethod
def from_explicit_release(
cls, source_repo: str, e: "ExplicitRelease"
) -> "ExtensionRelease":
cls, source_repo: str, e: ExplicitRelease
) -> ExtensionRelease:
return ExtensionRelease(
name=e.name,
version=e.version,
@@ -397,9 +333,9 @@ class ExtensionRelease(BaseModel):
)
@classmethod
async def get_github_releases(cls, org: str, repo: str) -> List["ExtensionRelease"]:
async def get_github_releases(cls, org: str, repo: str) -> list[ExtensionRelease]:
try:
github_releases = await fetch_github_releases(org, repo)
github_releases = await cls.fetch_github_releases(org, repo)
return [
ExtensionRelease.from_github_release(f"{org}/{repo}", r)
for r in github_releases
@@ -408,6 +344,33 @@ class ExtensionRelease(BaseModel):
logger.warning(e)
return []
@classmethod
async def fetch_github_releases(
cls, org: str, repo: str
) -> list[GitHubRepoRelease]:
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
error_msg = "Cannot fetch extension releases"
releases = await github_api_get(releases_url, error_msg)
return [GitHubRepoRelease.parse_obj(r) for r in releases]
@classmethod
async def fetch_release_details(cls, details_link: str) -> Optional[dict]:
try:
async with httpx.AsyncClient() as client:
resp = await client.get(details_link)
resp.raise_for_status()
data = resp.json()
if "description_md" in data:
resp = await client.get(data["description_md"])
if not resp.is_error:
data["description_md"] = resp.text
return data
except Exception as e:
logger.warning(e)
return None
class InstallableExtension(BaseModel):
id: str
@@ -415,13 +378,13 @@ class InstallableExtension(BaseModel):
active: Optional[bool] = False
short_description: Optional[str] = None
icon: Optional[str] = None
dependencies: List[str] = []
dependencies: list[str] = []
is_admin_only: bool = False
stars: int = 0
featured = False
latest_release: Optional[ExtensionRelease] = None
installed_release: Optional[ExtensionRelease] = None
payments: List[ReleasePaymentInfo] = []
payments: list[ReleasePaymentInfo] = []
pay_to_enable: Optional[PayToEnableInfo] = None
archive: Optional[str] = None
@@ -546,16 +509,6 @@ class InstallableExtension(BaseModel):
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
logger.success(f"Extension {self.name} ({self.installed_version}) installed.")
def notify_upgrade(self, upgrade_hash: Optional[str]) -> None:
"""
Update the list of upgraded extensions. The middleware will perform
redirects based on this
"""
if upgrade_hash:
settings.lnbits_upgraded_extensions.add(f"{self.hash}/{self.id}")
settings.lnbits_all_extensions_ids.add(self.id)
def clean_extension_files(self):
# remove downloaded archive
if self.zip_path.is_file():
@@ -610,7 +563,7 @@ class InstallableExtension(BaseModel):
self.payments.append(payment_info)
@classmethod
def from_row(cls, data: dict) -> "InstallableExtension":
def from_row(cls, data: dict) -> InstallableExtension:
meta = json.loads(data["meta"])
ext = InstallableExtension(**data)
if "installed_release" in meta:
@@ -623,9 +576,7 @@ class InstallableExtension(BaseModel):
return ext
@classmethod
def from_rows(
cls, rows: Optional[List[Any]] = None
) -> List["InstallableExtension"]:
def from_rows(cls, rows: Optional[list[Any]] = None) -> list[InstallableExtension]:
if rows is None:
rows = []
return [InstallableExtension.from_row(row) for row in rows]
@@ -633,9 +584,9 @@ class InstallableExtension(BaseModel):
@classmethod
async def from_github_release(
cls, github_release: GitHubRelease
) -> Optional["InstallableExtension"]:
) -> Optional[InstallableExtension]:
try:
repo, latest_release, config = await fetch_github_repo_info(
repo, latest_release, config = await cls.fetch_github_repo_info(
github_release.organisation, github_release.repository
)
source_repo = f"{github_release.organisation}/{github_release.repository}"
@@ -657,7 +608,7 @@ class InstallableExtension(BaseModel):
return None
@classmethod
def from_explicit_release(cls, e: ExplicitRelease) -> "InstallableExtension":
def from_explicit_release(cls, e: ExplicitRelease) -> InstallableExtension:
return InstallableExtension(
id=e.id,
name=e.name,
@@ -670,13 +621,13 @@ class InstallableExtension(BaseModel):
@classmethod
async def get_installable_extensions(
cls,
) -> List["InstallableExtension"]:
extension_list: List[InstallableExtension] = []
extension_id_list: List[str] = []
) -> list[InstallableExtension]:
extension_list: list[InstallableExtension] = []
extension_id_list: list[str] = []
for url in settings.lnbits_extensions_manifests:
try:
manifest = await fetch_manifest(url)
manifest = await cls.fetch_manifest(url)
for r in manifest.repos:
ext = await InstallableExtension.from_github_release(r)
@@ -712,12 +663,12 @@ class InstallableExtension(BaseModel):
return extension_list
@classmethod
async def get_extension_releases(cls, ext_id: str) -> List["ExtensionRelease"]:
extension_releases: List[ExtensionRelease] = []
async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
extension_releases: list[ExtensionRelease] = []
for url in settings.lnbits_extensions_manifests:
try:
manifest = await fetch_manifest(url)
manifest = await cls.fetch_manifest(url)
for r in manifest.repos:
if r.id != ext_id:
continue
@@ -741,8 +692,8 @@ class InstallableExtension(BaseModel):
@classmethod
async def get_extension_release(
cls, ext_id: str, source_repo: str, archive: str, version: str
) -> Optional["ExtensionRelease"]:
all_releases: List[ExtensionRelease] = (
) -> Optional[ExtensionRelease]:
all_releases: list[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(ext_id)
)
selected_release = [
@@ -755,6 +706,37 @@ class InstallableExtension(BaseModel):
return selected_release[0] if len(selected_release) != 0 else None
@classmethod
async def fetch_github_repo_info(
cls, org: str, repository: str
) -> tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]:
repo_url = f"https://api.github.com/repos/{org}/{repository}"
error_msg = "Cannot fetch extension repo"
repo = await github_api_get(repo_url, error_msg)
github_repo = GitHubRepo.parse_obj(repo)
lates_release_url = (
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
)
error_msg = "Cannot fetch extension releases"
latest_release: Any = await github_api_get(lates_release_url, error_msg)
config_url = f"https://raw.githubusercontent.com/{org}/{repository}/{github_repo.default_branch}/config.json"
error_msg = "Cannot fetch config for extension"
config = await github_api_get(config_url, error_msg)
return (
github_repo,
GitHubRepoRelease.parse_obj(latest_release),
ExtensionConfig.parse_obj(config),
)
@classmethod
async def fetch_manifest(cls, url) -> Manifest:
error_msg = "Cannot fetch extensions manifest"
manifest = await github_api_get(url, error_msg)
return Manifest.parse_obj(manifest)
class CreateExtension(BaseModel):
ext_id: str
@@ -769,32 +751,3 @@ class ExtensionDetailsRequest(BaseModel):
ext_id: str
source_repo: str
version: str
def get_valid_extensions(include_deactivated: Optional[bool] = True) -> List[Extension]:
valid_extensions = [
extension for extension in ExtensionManager().extensions if extension.is_valid
]
if include_deactivated:
return valid_extensions
if settings.lnbits_extensions_deactivate_all:
return []
return [
e
for e in valid_extensions
if e.code not in settings.lnbits_deactivated_extensions
]
def version_parse(v: str):
"""
Wrapper for version.parse() that does not throw if the version is invalid.
Instead it return the lowest possible version ("0.0.0")
"""
try:
return version.parse(v)
except Exception:
return version.parse("0.0.0")
+54 -68
View File
@@ -1,18 +1,23 @@
import importlib
import re
from typing import Any, Optional
from typing import Any
from uuid import UUID
import httpx
from loguru import logger
from lnbits.core import migrations as core_migrations
from lnbits.core.crud import (
get_dbversions,
get_installed_extensions,
update_migration_version,
)
from lnbits.core.db import db as core_db
from lnbits.db import Connection
from lnbits.extension_manager import Extension
from lnbits.core.extensions.models import (
Extension,
)
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
from lnbits.settings import settings
from .crud import update_migration_version
async def migrate_extension_database(ext: Extension, current_version):
try:
@@ -48,68 +53,6 @@ async def run_migration(
await update_migration_version(conn, db_name, version)
async def stop_extension_background_work(
ext_id: str, user: str, access_token: Optional[str] = None
):
"""
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extensions SHOULD expose a `api_stop()` function and/or a DELETE enpoint
at the root level of their API.
"""
stopped = await _stop_extension_background_work(ext_id)
if not stopped:
# fallback to REST API call
await _stop_extension_background_work_via_api(ext_id, user, access_token)
async def _stop_extension_background_work(ext_id) -> bool:
upgrade_hash = settings.extension_upgrade_hash(ext_id) or ""
ext = Extension(ext_id, True, False, upgrade_hash=upgrade_hash)
try:
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
old_module = importlib.import_module(ext.module_name)
# Extensions must expose an `{ext_id}_stop()` function at the module level
# The `api_stop()` function is for backwards compatibility (will be deprecated)
stop_fns = [f"{ext_id}_stop", "api_stop"]
stop_fn_name = next((fn for fn in stop_fns if hasattr(old_module, fn)), None)
assert stop_fn_name, "No stop function found for '{ext.module_name}'"
stop_fn = getattr(old_module, stop_fn_name)
if stop_fn:
await stop_fn()
logger.info(f"Stopped background work for extension '{ext.module_name}'.")
except Exception as ex:
logger.warning(f"Failed to stop background work for '{ext.module_name}'.")
logger.warning(ex)
return False
return True
async def _stop_extension_background_work_via_api(ext_id, user, access_token):
logger.info(
f"Stopping background work for extension '{ext_id}' using the REST API."
)
async with httpx.AsyncClient() as client:
try:
url = f"http://{settings.host}:{settings.port}/{ext_id}/api/v1?usr={user}"
headers = (
{"Authorization": "Bearer " + access_token} if access_token else None
)
resp = await client.delete(url=url, headers=headers)
resp.raise_for_status()
logger.info(f"Stopped background work for extension '{ext_id}'.")
except Exception as ex:
logger.warning(
f"Failed to stop background work for '{ext_id}' using the REST API."
)
logger.warning(ex)
def to_valid_user_id(user_id: str) -> UUID:
if len(user_id) < 32:
raise ValueError("User ID must have at least 128 bits")
@@ -119,3 +62,46 @@ def to_valid_user_id(user_id: str) -> UUID:
raise ValueError("Invalid hex string for User ID.") from exc
return UUID(hex=user_id[:32], version=4)
async def load_disabled_extension_list() -> None:
"""Update list of extensions that have been explicitly disabled"""
inactive_extensions = await get_installed_extensions(active=False)
settings.lnbits_deactivated_extensions.update([e.id for e in inactive_extensions])
async def migrate_databases():
"""Creates the necessary databases if they don't exist already; or migrates them."""
async with core_db.connect() as conn:
exists = False
if conn.type == SQLITE:
exists = await conn.fetchone(
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
)
elif conn.type in {POSTGRES, COCKROACH}:
exists = await conn.fetchone(
"SELECT * FROM information_schema.tables WHERE table_schema = 'public'"
" AND table_name = 'dbversions'"
)
if not exists:
await core_migrations.m000_create_migrations_table(conn)
current_versions = await get_dbversions(conn)
core_version = current_versions.get("core", 0)
await run_migration(conn, core_migrations, "core", core_version)
# here is the first place we can be sure that the
# `installed_extensions` table has been created
await load_disabled_extension_list()
# todo: revisit, use installed extensions
for ext in Extension.get_valid_extensions(False):
current_version = current_versions.get(ext.code, 0)
try:
await migrate_extension_database(ext, current_version)
except Exception as e:
logger.exception(f"Error migrating extension {ext.code}: {e}")
logger.info("✔️ All migrations done.")
+74 -51
View File
@@ -1,4 +1,3 @@
import datetime
from time import time
from loguru import logger
@@ -102,7 +101,7 @@ async def m002_add_fields_to_apipayments(db):
import json
rows = await (await db.execute("SELECT * FROM apipayments")).fetchall()
rows = await db.fetchall("SELECT * FROM apipayments")
for row in rows:
if not row["memo"] or not row["memo"].startswith("#"):
continue
@@ -113,15 +112,15 @@ async def m002_add_fields_to_apipayments(db):
new = row["memo"][len(prefix) :]
await db.execute(
"""
UPDATE apipayments SET extra = ?, memo = ?
WHERE checking_id = ? AND memo = ?
UPDATE apipayments SET extra = :extra, memo = :memo1
WHERE checking_id = :checking_id AND memo = :memo2
""",
(
json.dumps({"tag": ext}),
new,
row["checking_id"],
row["memo"],
),
{
"extra": json.dumps({"tag": ext}),
"memo1": new,
"checking_id": row["checking_id"],
"memo2": row["memo"],
},
)
break
except OperationalError:
@@ -212,19 +211,17 @@ async def m007_set_invoice_expiries(db):
Precomputes invoice expiry for existing pending incoming payments.
"""
try:
rows = await (
await db.execute(
f"""
SELECT bolt11, checking_id
FROM apipayments
WHERE pending = true
AND amount > 0
AND bolt11 IS NOT NULL
AND expiry IS NULL
AND time < {db.timestamp_now}
"""
)
).fetchall()
rows = await db.fetchall(
f"""
SELECT bolt11, checking_id
FROM apipayments
WHERE pending = true
AND amount > 0
AND bolt11 IS NOT NULL
AND expiry IS NULL
AND time < {db.timestamp_now}
"""
)
if len(rows):
logger.info(f"Migration: Checking expiry of {len(rows)} invoices")
for i, (
@@ -236,22 +233,17 @@ async def m007_set_invoice_expiries(db):
if invoice.expiry is None:
continue
expiration_date = datetime.datetime.fromtimestamp(
invoice.date + invoice.expiry
)
expiration_date = invoice.date + invoice.expiry
logger.info(
f"Migration: {i+1}/{len(rows)} setting expiry of invoice"
f" {invoice.payment_hash} to {expiration_date}"
)
await db.execute(
"""
UPDATE apipayments SET expiry = ?
WHERE checking_id = ? AND amount > 0
f"""
UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')}
WHERE checking_id = :checking_id AND amount > 0
""",
(
db.datetime_to_timestamp(expiration_date),
checking_id,
),
{"expiry": expiration_date, "checking_id": checking_id},
)
except Exception:
continue
@@ -347,17 +339,15 @@ async def m014_set_deleted_wallets(db):
Sets deleted column to wallets.
"""
try:
rows = await (
await db.execute(
"""
SELECT *
FROM wallets
WHERE user LIKE 'del:%'
AND adminkey LIKE 'del:%'
AND inkey LIKE 'del:%'
"""
)
).fetchall()
rows = await db.fetchall(
"""
SELECT *
FROM wallets
WHERE user LIKE 'del:%'
AND adminkey LIKE 'del:%'
AND inkey LIKE 'del:%'
"""
)
for row in rows:
try:
@@ -367,10 +357,15 @@ async def m014_set_deleted_wallets(db):
await db.execute(
"""
UPDATE wallets SET
"user" = ?, adminkey = ?, inkey = ?, deleted = true
WHERE id = ?
"user" = :user, adminkey = :adminkey, inkey = :inkey, deleted = true
WHERE id = :wallet
""",
(user, adminkey, inkey, row[0]),
{
"user": user,
"adminkey": adminkey,
"inkey": inkey,
"wallet": row.get("id"),
},
)
except Exception:
continue
@@ -456,17 +451,17 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db):
now = int(time())
await db.execute(
f"""
UPDATE wallets SET created_at = {db.timestamp_placeholder}
UPDATE wallets SET created_at = {db.timestamp_placeholder('now')}
WHERE created_at IS NULL
""",
(now,),
{"now": now},
)
await db.execute(
f"""
UPDATE accounts SET created_at = {db.timestamp_placeholder}
UPDATE accounts SET created_at = {db.timestamp_placeholder('now')}
WHERE created_at IS NULL
""",
(now,),
{"now": now},
)
except OperationalError as exc:
@@ -520,3 +515,31 @@ async def m020_add_column_column_to_user_extensions(db):
Adds extra column to user extensions.
"""
await db.execute("ALTER TABLE extensions ADD COLUMN extra TEXT")
async def m021_add_success_failed_to_apipayments(db):
"""
Adds success and failed columns to apipayments.
"""
await db.execute("ALTER TABLE apipayments ADD COLUMN status TEXT DEFAULT 'pending'")
# set all not pending to success true, failed payments were deleted until now
await db.execute("UPDATE apipayments SET status = 'success' WHERE NOT pending")
await db.execute("DROP VIEW balances")
await db.execute(
"""
CREATE VIEW balances AS
SELECT apipayments.wallet,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
FROM wallets
LEFT JOIN apipayments ON apipayments.wallet = wallets.id
WHERE (wallets.deleted = false OR wallets.deleted is NULL)
AND (
(apipayments.status = 'success' AND apipayments.amount > 0)
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
)
GROUP BY apipayments.wallet
"""
)
# TODO: drop column in next release
# await db.execute("ALTER TABLE apipayments DROP COLUMN pending")
+64 -77
View File
@@ -7,20 +7,24 @@ import json
import time
from dataclasses import dataclass
from enum import Enum
from sqlite3 import Row
from typing import Callable, Optional
from ecdsa import SECP256k1, SigningKey
from fastapi import Query
from loguru import logger
from pydantic import BaseModel
from pydantic import BaseModel, validator
from lnbits.db import Connection, FilterModel, FromRowModel
from lnbits.db import FilterModel, FromRowModel
from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings
from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import PaymentPendingStatus, PaymentStatus
from lnbits.wallets.base import (
PaymentFailedStatus,
PaymentPendingStatus,
PaymentStatus,
PaymentSuccessStatus,
)
class BaseWallet(BaseModel):
@@ -199,9 +203,33 @@ class LoginUsernamePassword(BaseModel):
password: str
class PaymentState(str, Enum):
PENDING = "pending"
SUCCESS = "success"
FAILED = "failed"
def __str__(self) -> str:
return self.value
class CreatePayment(BaseModel):
wallet_id: str
payment_request: str
payment_hash: str
amount: int
memo: str
preimage: Optional[str] = None
expiry: Optional[datetime.datetime] = None
extra: Optional[dict] = None
webhook: Optional[str] = None
fee: int = 0
class Payment(FromRowModel):
checking_id: str
status: str
# TODO should be removed in the future, backward compatibility
pending: bool
checking_id: str
amount: int
fee: int
memo: Optional[str]
@@ -210,20 +238,30 @@ class Payment(FromRowModel):
preimage: str
payment_hash: str
expiry: Optional[float]
extra: dict = {}
extra: Optional[dict]
wallet_id: str
webhook: Optional[str]
webhook_status: Optional[int]
@property
def success(self) -> bool:
return self.status == PaymentState.SUCCESS.value
@property
def failed(self) -> bool:
return self.status == PaymentState.FAILED.value
@classmethod
def from_row(cls, row: Row):
def from_row(cls, row: dict):
return cls(
checking_id=row["checking_id"],
payment_hash=row["hash"] or "0" * 64,
bolt11=row["bolt11"] or "",
preimage=row["preimage"] or "0" * 64,
extra=json.loads(row["extra"] or "{}"),
pending=row["pending"],
status=row["status"],
# TODO should be removed in the future, backward compatibility
pending=row["status"] == PaymentState.PENDING.value,
amount=row["amount"],
fee=row["fee"],
memo=row["memo"],
@@ -261,83 +299,23 @@ class Payment(FromRowModel):
return self.expiry < time.time() if self.expiry else False
@property
def is_uncheckable(self) -> bool:
def is_internal(self) -> bool:
return self.checking_id.startswith("internal_")
async def update_status(
self,
status: PaymentStatus,
conn: Optional[Connection] = None,
) -> None:
from .crud import update_payment_details
await update_payment_details(
checking_id=self.checking_id,
pending=status.pending,
fee=status.fee_msat,
preimage=status.preimage,
conn=conn,
)
async def set_pending(self, pending: bool) -> None:
from .crud import update_payment_status
self.pending = pending
await update_payment_status(self.checking_id, pending)
async def check_status(
self,
conn: Optional[Connection] = None,
) -> PaymentStatus:
if self.is_uncheckable:
async def check_status(self) -> PaymentStatus:
if self.is_internal:
if self.success:
return PaymentSuccessStatus()
if self.failed:
return PaymentFailedStatus()
return PaymentPendingStatus()
logger.debug(
f"Checking {'outgoing' if self.is_out else 'incoming'} "
f"pending payment {self.checking_id}"
)
funding_source = get_funding_source()
if self.is_out:
status = await funding_source.get_payment_status(self.checking_id)
else:
status = await funding_source.get_invoice_status(self.checking_id)
logger.debug(f"Status: {status}")
if self.is_in and status.pending and self.is_expired and self.expiry:
expiration_date = datetime.datetime.fromtimestamp(self.expiry)
logger.debug(
f"Deleting expired incoming pending payment {self.checking_id}: "
f"expired {expiration_date}"
)
await self.delete(conn)
# wait at least 15 minutes before deleting failed outgoing payments
elif self.is_out and status.failed:
if self.time + 900 < int(time.time()):
logger.warning(
f"Deleting outgoing failed payment {self.checking_id}: {status}"
)
await self.delete(conn)
else:
logger.warning(
f"Tried to delete outgoing payment {self.checking_id}: "
"skipping because it's not old enough"
)
elif not status.pending:
logger.info(
f"Marking '{'in' if self.is_in else 'out'}' "
f"{self.checking_id} as not pending anymore: {status}"
)
await self.update_status(status, conn=conn)
return status
async def delete(self, conn: Optional[Connection] = None) -> None:
from .crud import delete_wallet_payment
await delete_wallet_payment(self.checking_id, self.wallet_id, conn=conn)
class PaymentFilters(FilterModel):
__search_fields__ = ["memo", "amount"]
@@ -381,7 +359,7 @@ class TinyURL(BaseModel):
time: float
@classmethod
def from_row(cls, row: Row):
def from_row(cls, row: dict):
return cls(**dict(row))
@@ -397,6 +375,7 @@ class Callback(BaseModel):
class DecodePayment(BaseModel):
data: str
filter_fields: Optional[list[str]] = []
class CreateLnurl(BaseModel):
@@ -422,6 +401,14 @@ class CreateInvoice(BaseModel):
bolt11: Optional[str] = None
lnurl_callback: Optional[str] = None
@validator("unit")
@classmethod
def unit_is_from_allowed_currencies(cls, v):
if v != "sat" and v not in allowed_currencies():
raise ValueError("The provided unit is not supported")
return v
class CreateTopup(BaseModel):
id: str
+141 -79
View File
@@ -1,14 +1,14 @@
import asyncio
import datetime
import json
import time
from io import BytesIO
from pathlib import Path
from typing import Dict, List, Optional, Tuple, TypedDict
from typing import Optional
from urllib.parse import parse_qs, urlparse
from uuid import UUID, uuid4
import httpx
from bolt11 import MilliSatoshi
from bolt11 import decode as bolt11_decode
from cryptography.hazmat.primitives import serialization
from fastapi import Depends, WebSocket
@@ -24,6 +24,7 @@ from lnbits.decorators import (
check_user_extension_access,
require_admin_key,
)
from lnbits.exceptions import InvoiceError, PaymentError
from lnbits.helpers import url_for
from lnbits.lnurl import LnurlErrorResponse
from lnbits.lnurl import decode as decode_lnurl
@@ -50,7 +51,6 @@ from .crud import (
create_admin_settings,
create_payment,
create_wallet,
delete_wallet_payment,
get_account,
get_account_by_email,
get_account_by_username,
@@ -67,28 +67,24 @@ from .crud import (
update_user_extension,
)
from .helpers import to_valid_user_id
from .models import BalanceDelta, Payment, User, UserConfig, Wallet
class PaymentError(Exception):
def __init__(self, message: str, status: str = "pending"):
self.message = message
self.status = status
class InvoiceError(Exception):
def __init__(self, message: str, status: str = "pending"):
self.message = message
self.status = status
from .models import (
BalanceDelta,
CreatePayment,
Payment,
PaymentState,
User,
UserConfig,
Wallet,
)
async def calculate_fiat_amounts(
amount: float,
wallet_id: str,
currency: Optional[str] = None,
extra: Optional[Dict] = None,
extra: Optional[dict] = None,
conn: Optional[Connection] = None,
) -> Tuple[int, Optional[Dict]]:
) -> tuple[int, Optional[dict]]:
wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "invalid wallet_id"
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
@@ -129,11 +125,11 @@ async def create_invoice(
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
expiry: Optional[int] = None,
extra: Optional[Dict] = None,
extra: Optional[dict] = None,
webhook: Optional[str] = None,
internal: Optional[bool] = False,
conn: Optional[Connection] = None,
) -> Tuple[str, str]:
) -> tuple[str, str]:
if not amount > 0:
raise InvoiceError("Amountless invoices not supported.", status="failed")
@@ -178,17 +174,20 @@ async def create_invoice(
invoice = bolt11_decode(payment_request)
amount_msat = 1000 * amount_sat
await create_payment(
create_payment_model = CreatePayment(
wallet_id=wallet_id,
checking_id=checking_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=amount_msat,
amount=amount_sat * 1000,
expiry=invoice.expiry_date,
memo=memo,
extra=extra,
webhook=webhook,
)
await create_payment(
checking_id=checking_id,
data=create_payment_model,
conn=conn,
)
@@ -200,7 +199,7 @@ async def pay_invoice(
wallet_id: str,
payment_request: str,
max_sat: Optional[int] = None,
extra: Optional[Dict] = None,
extra: Optional[dict] = None,
description: str = "",
conn: Optional[Connection] = None,
) -> str:
@@ -234,17 +233,7 @@ async def pay_invoice(
invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn
)
# put all parameters that don't change here
class PaymentKwargs(TypedDict):
wallet_id: str
payment_request: str
payment_hash: str
amount: int
memo: str
expiry: Optional[datetime.datetime]
extra: Optional[Dict]
payment_kwargs: PaymentKwargs = PaymentKwargs(
create_payment_model = CreatePayment(
wallet_id=wallet_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
@@ -263,9 +252,6 @@ async def pay_invoice(
# (pending only)
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn)
if internal_checking_id:
fee_reserve_total_msat = fee_reserve_total(
invoice.amount_msat, internal=True
)
# perform additional checks on the internal payment
# the payment hash is not enough to make sure that this is the same invoice
internal_invoice = await get_standalone_payment(
@@ -280,35 +266,29 @@ async def pay_invoice(
logger.debug(f"creating temporary internal payment with id {internal_id}")
# create a new payment from this wallet
fee_reserve_total_msat = fee_reserve_total(
invoice.amount_msat, internal=True
)
create_payment_model.fee = abs(fee_reserve_total_msat)
new_payment = await create_payment(
checking_id=internal_id,
fee=0 + abs(fee_reserve_total_msat),
pending=False,
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
**payment_kwargs,
)
else:
fee_reserve_total_msat = fee_reserve_total(
invoice.amount_msat, internal=False
new_payment = await _create_external_payment(
temp_id=temp_id,
amount_msat=invoice.amount_msat,
data=create_payment_model,
conn=conn,
)
logger.debug(f"creating temporary payment with id {temp_id}")
# create a temporary payment here so we can check if
# the balance is enough in the next step
try:
new_payment = await create_payment(
checking_id=temp_id,
fee=-abs(fee_reserve_total_msat),
conn=conn,
**payment_kwargs,
)
except Exception as exc:
logger.error(f"could not create temporary payment: {exc}")
# happens if the same wallet tries to pay an invoice twice
raise PaymentError("Could not make payment.", status="failed") from exc
# do the balance check
wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "Wallet for balancecheck could not be fetched"
fee_reserve_total_msat = fee_reserve_total(invoice.amount_msat, internal=False)
_check_wallet_balance(wallet, fee_reserve_total_msat, internal_checking_id)
if extra and "tag" in extra:
@@ -325,7 +305,9 @@ async def pay_invoice(
# the payer has enough to deduct from
async with db.connect() as conn:
await update_payment_status(
checking_id=internal_checking_id, pending=False, conn=conn
checking_id=internal_checking_id,
status=PaymentState.SUCCESS,
conn=conn,
)
await send_payment_notification(wallet, new_payment)
@@ -350,15 +332,18 @@ async def pay_invoice(
f" {payment.checking_id})"
)
logger.debug(f"backend: pay_invoice finished {temp_id}")
logger.debug(f"backend: pay_invoice response {payment}")
logger.debug(f"backend: pay_invoice finished {temp_id}, {payment}")
if payment.checking_id and payment.ok is not False:
# payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {temp_id}")
async with db.connect() as conn:
await update_payment_details(
checking_id=temp_id,
pending=payment.ok is not True,
status=(
PaymentState.SUCCESS
if payment.ok is True
else PaymentState.PENDING
),
fee=-(
abs(payment.fee_msat if payment.fee_msat else 0)
+ abs(service_fee_msat)
@@ -373,13 +358,16 @@ async def pay_invoice(
)
if wallet and updated:
await send_payment_notification(wallet, updated)
logger.debug(f"payment successful {payment.checking_id}")
logger.success(f"payment successful {payment.checking_id}")
elif payment.checking_id is None and payment.ok is False:
# payment failed
logger.warning("backend sent payment failure")
logger.debug(f"payment failed {temp_id}, {payment.error_message}")
async with db.connect() as conn:
logger.debug(f"deleting temporary payment {temp_id}")
await delete_wallet_payment(temp_id, wallet_id, conn=conn)
await update_payment_status(
checking_id=temp_id,
status=PaymentState.FAILED,
conn=conn,
)
raise PaymentError(
f"Payment failed: {payment.error_message}"
or "Payment failed, but backend didn't give us an error message.",
@@ -393,19 +381,72 @@ async def pay_invoice(
# credit service fee wallet
if settings.lnbits_service_fee_wallet and service_fee_msat:
new_payment = await create_payment(
create_payment_model = CreatePayment(
wallet_id=settings.lnbits_service_fee_wallet,
fee=0,
amount=abs(service_fee_msat),
memo="Service fee",
checking_id="service_fee" + temp_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
pending=False,
amount=abs(service_fee_msat),
memo="Service fee",
)
new_payment = await create_payment(
checking_id=f"service_fee_{temp_id}",
data=create_payment_model,
status=PaymentState.SUCCESS,
)
return invoice.payment_hash
async def _create_external_payment(
temp_id: str,
amount_msat: MilliSatoshi,
data: CreatePayment,
conn: Optional[Connection],
) -> Payment:
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
# check if there is already a payment with the same checking_id
old_payment = await get_standalone_payment(temp_id, conn=conn)
if old_payment:
# fail on pending payments
if old_payment.pending:
raise PaymentError("Payment is still pending.", status="pending")
if old_payment.success:
raise PaymentError("Payment already paid.", status="success")
if old_payment.failed:
status = await old_payment.check_status()
if status.success:
# payment was successful on the fundingsource
await update_payment_status(
checking_id=temp_id, status=PaymentState.SUCCESS, conn=conn
)
raise PaymentError(
"Failed payment was already paid on the fundingsource.",
status="success",
)
if status.failed:
raise PaymentError(
"Payment is failed node, retrying is not possible.", status="failed"
)
# status.pending fall through and try again
return old_payment
logger.debug(f"creating temporary payment with id {temp_id}")
# create a temporary payment here so we can check if
# the balance is enough in the next step
try:
data.fee = -abs(fee_reserve_total_msat)
new_payment = await create_payment(
checking_id=temp_id,
data=data,
conn=conn,
)
return new_payment
except Exception as exc:
logger.error(f"could not create temporary payment: {exc}")
# happens if the same wallet tries to pay an invoice twice
raise PaymentError("Could not make payment", status="failed") from exc
def _check_wallet_balance(
wallet: Wallet,
fee_reserve_total_msat: int,
@@ -479,7 +520,7 @@ async def redeem_lnurl_withdraw(
wallet_id: str,
lnurl_request: str,
memo: Optional[str] = None,
extra: Optional[Dict] = None,
extra: Optional[dict] = None,
wait_seconds: int = 0,
conn: Optional[Connection] = None,
) -> None:
@@ -617,12 +658,11 @@ async def check_transaction_status(
)
if not payment:
return PaymentPendingStatus()
if not payment.pending:
# note: before, we still checked the status of the payment again
if payment.status == PaymentState.SUCCESS.value:
return PaymentSuccessStatus(fee_msat=payment.fee)
status: PaymentStatus = await payment.check_status()
return status
return await payment.check_status()
# WARN: this same value must be used for balance check and passed to
@@ -680,7 +720,9 @@ async def update_wallet_balance(wallet_id: str, amount: int):
async with db.connect() as conn:
checking_id = await check_internal(payment_hash, conn=conn)
assert checking_id, "newly created checking_id cannot be retrieved"
await update_payment_status(checking_id=checking_id, pending=False, conn=conn)
await update_payment_status(
checking_id=checking_id, status=PaymentState.SUCCESS, conn=conn
)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
@@ -817,7 +859,7 @@ async def create_user_account(
class WebsocketConnectionManager:
def __init__(self) -> None:
self.active_connections: List[WebSocket] = []
self.active_connections: list[WebSocket] = []
async def connect(self, websocket: WebSocket, item_id: str):
logger.debug(f"Websocket connected to {item_id}")
@@ -856,3 +898,23 @@ async def get_balance_delta() -> BalanceDelta:
lnbits_balance_msats=lnbits_balance,
node_balance_msats=status.balance_msat,
)
async def update_pending_payments(wallet_id: str):
pending_payments = await get_payments(
wallet_id=wallet_id,
pending=True,
exclude_uncheckable=True,
)
for payment in pending_payments:
status = await payment.check_status()
if status.failed:
await update_payment_status(
checking_id=payment.checking_id,
status=PaymentState.FAILED,
)
elif status.success:
await update_payment_status(
checking_id=payment.checking_id,
status=PaymentState.SUCCESS,
)
@@ -173,7 +173,7 @@
@remove="removeBlockedIPs(blocked_ip)"
color="primary"
text-color="white"
v-text="blocked_ip"
:label="blocked_ip"
></q-chip>
</div>
<br />
@@ -202,7 +202,7 @@
@remove="removeAllowedIPs(allowed_ip)"
color="primary"
text-color="white"
v-text="allowed_ip"
:label="allowed_ip"
></q-chip>
</div>
<br />
+8 -13
View File
@@ -1,24 +1,19 @@
<q-tab-panel name="server">
<q-card-section class="q-pa-none">
<h6 class="q-my-none">Server Management</h6>
<br />
<div>
<div class="row">
<div class="col">
<p>Server Info</p>
<ul>
<li
v-if="settings.lnbits_data_folder"
v-text="'SQlite: ' + settings.lnbits_data_folder"
></li>
<li
v-if="settings.lnbits_database_url"
v-text="'Postgres: ' + settings.lnbits_database_url"
></li>
</ul>
<div class="col-md-6">
<p>Base URL</p>
<q-input
filled
v-model.number="formData.lnbits_baseurl"
label="Static/Base url for the server"
></q-input>
<br />
</div>
</div>
<h6 class="q-my-none">Currency Settings</h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>Allowed currencies</p>
+2 -2
View File
@@ -23,8 +23,8 @@
@remove="removeAdminUser(user)"
color="primary"
text-color="white"
:label="user"
>
<span v-text="user"></span>
</q-chip>
</div>
<br />
@@ -49,8 +49,8 @@
@remove="removeAllowedUser(user)"
color="primary"
text-color="white"
:label="user"
>
<span v-text="user" />
</q-chip>
</div>
<br />
+74 -9
View File
@@ -5,10 +5,77 @@
:content-inset-level="0.5"
>
<q-card-section>
<strong>Node URL: </strong><em v-text="origin"></em><br />
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
<q-list>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Node URL: </strong><em v-text="origin"></em>
</q-item-label>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon
name="content_copy"
class="cursor-pointer"
@click="copyText('{{ wallet.id }}')"
></q-icon>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Admin key: </strong
><em
v-text="adminkeyHidden ? '****************' : `{{ wallet.adminkey }}`"
></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<div>
<q-icon
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="adminkeyHidden = !adminkeyHidden"
></q-icon>
<q-icon
name="content_copy"
class="cursor-pointer q-ml-sm"
@click="copyText('{{ wallet.adminkey }}')"
></q-icon>
</div>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Invoice/read key: </strong
><em
v-text="inkeyHidden ? '****************' : `{{ wallet.inkey }}`"
></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<div>
<q-icon
:name="inkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="inkeyHidden = !inkeyHidden"
></q-icon>
<q-icon
name="content_copy"
class="cursor-pointer q-ml-sm"
@click="copyText('{{ wallet.inkey }}')"
></q-icon>
</div>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-expansion-item
group="api"
@@ -109,18 +176,16 @@
><span class="text-light-green">POST</span>
/api/v1/payments/decode</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"invoice": &lt;string&gt;}</code>
<code>{"data": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}api/v1/payments/decode -d
'{"data": &lt;bolt11/lnurl, string&gt;}' -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
'{"data": &lt;bolt11/lnurl, string&gt;}' -H "Content-type:
application/json"</code
>
</q-card-section>
</q-card>
+21
View File
@@ -373,6 +373,27 @@
</q-btn>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('gradient_background')"></span>
</div>
<div class="col-8">
<q-btn
dense
flat
round
@click="toggleGradient"
icon="gradient"
size="sm"
v-model="gradientChoice"
>
<q-tooltip
><span v-text="$t('toggle_gradient')"></span
></q-tooltip>
</q-btn>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('toggle_darkmode')"></span>
+4 -1
View File
@@ -471,6 +471,7 @@
filled
dense
emit-value
map-options
v-model="release.wallet"
:options="g.user.walletOptions"
:label="$t('wallet_required')"
@@ -607,6 +608,7 @@
filled
dense
emit-value
map-options
v-model="selectedExtension.payToEnable.wallet"
:options="g.user.walletOptions"
label="Wallet *"
@@ -721,6 +723,7 @@
dense
v-model="selectedExtension.payToEnable.paymentWallet"
emit-value
map-options
:options="g.user.walletOptions"
:label="$t('wallet_required')"
class="q-mt-sm"
@@ -898,7 +901,7 @@
</q-dialog>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
new Vue({
window.app = Vue.createApp({
el: '#vue',
data: function () {
+39 -2
View File
@@ -33,7 +33,7 @@
color="primary"
@click="processing"
type="a"
href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}"
href="/lnurlwallet?lightning={{ lnurl }}"
v-text="$t('press_to_claim')"
class="full-width"
></q-btn>
@@ -484,6 +484,32 @@
</a>
</div>
</div>
<div class="row">
<div class="col">
<a
href="https://breez.technology/sdk/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/breez.png') }}' : '{{ static_url_for('static', 'images/breezl.png') }}'"
></q-img>
</a>
</div>
<div class="col q-pl-md">
<a
href="https://blockstream.com/lightning/greenlight/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/greenlight.png') }}' : '{{ static_url_for('static', 'images/greenlightl.png') }}'"
></q-img>
</a>
</div>
</div>
<div class="row">
<div class="col">
<a
@@ -519,7 +545,18 @@
></q-img>
</a>
</div>
<div class="col q-pl-md"></div>
<div class="col">
<a
href="https://boltz.exchange/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/boltz.svg') }}' : '{{ static_url_for('static', 'images/boltz.svg') }}'"
></q-img>
</a>
</div>
</div>
</div>
</div>
+13 -7
View File
@@ -154,11 +154,19 @@
<q-card>
<q-card-section class="text-center">
<p v-text="$t('export_to_phone_desc')"></p>
<qrcode
<qrcode-vue
:value="'{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}'"
:options="{width:240}"
></qrcode>
:options="{ width: 256 }"
></qrcode-vue>
</q-card-section>
<q-card-actions class="flex-center q-pb-md">
<q-btn
outline
color="grey"
:label="$t('copy_wallet_url')"
@click="copyText('{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}')"
></q-btn>
</q-card-actions>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
@@ -311,13 +319,11 @@
<q-input
ref="setAmount"
filled
:pattern="receive.unit === 'sat' ? '\\d*' : '\\d*\\.?\\d*'"
inputmode="numeric"
dense
v-model.number="receive.data.amount"
:label="$t('amount') + ' (' + receive.unit + ') *'"
:mask="receive.unit != 'sat' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
:step="receive.unit != 'sat' ? '0.01' : '1'"
:min="receive.minMax[0]"
:max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed"
@@ -51,11 +51,11 @@
>
<a :href="'lightning:' + transactionDetailsDialog.data.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
<qrcode-vue
:value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()"
:options="{width: 340}"
class="rounded-borders"
></qrcode>
></qrcode-vue>
</q-responsive>
</a>
<q-btn
@@ -138,11 +138,11 @@
>
<a :href="'lightning:' + props.row.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
<qrcode-vue
:value="'lightning:' + props.row.bolt11.toUpperCase()"
:options="{width: 340}"
class="rounded-borders"
></qrcode>
></qrcode-vue>
</q-responsive>
</a>
</div>
+4 -4
View File
@@ -7,7 +7,7 @@ include "users/_createWalletDialog.html" %}
<div class="row q-col-gutter-md justify-center">
<div class="col q-gutter-y-md" style="width: 300px">
<div style="width: 600px">
<div style="width: 100%; max-width: 2000px">
<canvas ref="chart1"></canvas>
</div>
</div>
@@ -24,8 +24,8 @@ include "users/_createWalletDialog.html" %}
</q-btn>
</div>
<q-table
:data="users"
:row-key="usersTableRowKey"
row-key="id"
:rows="users"
:columns="usersTable.columns"
:pagination.sync="usersTable.pagination"
:no-data-label="$t('no_users')"
@@ -70,7 +70,7 @@ include "users/_createWalletDialog.html" %}
v-if="!props.row.is_super_user"
icon="build"
size="sm"
:color="props.row.is_admin ? 'primary' : ''"
:color="props.row.is_admin ? 'primary' : 'grey'"
@click="toggleAdmin(props.row.id)"
>
<q-tooltip>Toggle Admin</q-tooltip>
+7
View File
@@ -33,6 +33,7 @@ from lnbits.settings import settings
from lnbits.utils.exchange_rates import (
allowed_currencies,
fiat_amount_as_satoshis,
get_fiat_rate_satoshis,
satoshis_amount_as_fiat,
)
@@ -200,6 +201,12 @@ async def api_perform_lnurlauth(
return ""
@api_router.get("/api/v1/rate/{currency}")
async def api_check_fiat_rate(currency: str) -> Dict[str, float]:
rate = await get_fiat_rate_satoshis(currency)
return {"rate": rate}
@api_router.get("/api/v1/currencies")
async def api_list_currencies_available() -> List[str]:
return allowed_currencies()
+59 -100
View File
@@ -1,8 +1,6 @@
import sys
from http import HTTPStatus
from typing import (
List,
Optional,
)
from bolt11 import decode as bolt11_decode
@@ -13,10 +11,21 @@ from fastapi import (
)
from loguru import logger
from lnbits.core.db import core_app_extra
from lnbits.core.helpers import (
migrate_extension_database,
stop_extension_background_work,
from lnbits.core.extensions.extension_manager import (
activate_extension,
deactivate_extension,
install_extension,
uninstall_extension,
)
from lnbits.core.extensions.models import (
CreateExtension,
Extension,
ExtensionConfig,
ExtensionRelease,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
UserExtensionInfo,
)
from lnbits.core.models import (
SimpleStatus,
@@ -24,36 +33,18 @@ from lnbits.core.models import (
)
from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.decorators import (
check_access_token,
check_admin,
check_user_exists,
)
from lnbits.extension_manager import (
CreateExtension,
Extension,
ExtensionRelease,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
UserExtensionInfo,
fetch_github_release_config,
fetch_release_details,
fetch_release_payment_info,
get_valid_extensions,
)
from lnbits.settings import settings
from ..crud import (
add_installed_extension,
delete_dbversion,
delete_installed_extension,
drop_extension_db,
get_dbversions,
get_installed_extension,
get_installed_extensions,
get_user_extension,
update_extension_pay_to_enable,
update_installed_extension_state,
update_user_extension,
update_user_extension_extra,
)
@@ -64,12 +55,8 @@ extension_router = APIRouter(
)
@extension_router.post("")
async def api_install_extension(
data: CreateExtension,
user: User = Depends(check_admin),
access_token: Optional[str] = Depends(check_access_token),
):
@extension_router.post("", dependencies=[Depends(check_admin)])
async def api_install_extension(data: CreateExtension):
release = await InstallableExtension.get_extension_release(
data.ext_id, data.source_repo, data.archive, data.version
)
@@ -89,43 +76,36 @@ async def api_install_extension(
)
try:
installed_ext = await get_installed_extension(data.ext_id)
ext_info.payments = installed_ext.payments if installed_ext else []
extension = await install_extension(ext_info)
await ext_info.download_archive()
ext_info.extract_archive()
extension = Extension.from_installable_ext(ext_info)
db_version = (await get_dbversions()).get(data.ext_id, 0)
await migrate_extension_database(extension, db_version)
ext_info.active = True
await add_installed_extension(ext_info)
if extension.is_upgrade_extension:
# call stop while the old routes are still active
await stop_extension_background_work(data.ext_id, user.id, access_token)
# mount routes for the new version
core_app_extra.register_new_ext_routes(extension)
ext_info.notify_upgrade(extension.upgrade_hash)
settings.lnbits_deactivated_extensions.discard(data.ext_id)
return extension
except AssertionError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except Exception as exc:
logger.warning(exc)
ext_info.clean_extension_files()
detail = (
str(exc)
if isinstance(exc, AssertionError)
else f"Failed to install extension '{ext_info.id}'."
f"({ext_info.installed_version})."
)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(
f"Failed to install extension {ext_info.id} "
f"({ext_info.installed_version})."
),
detail=detail,
) from exc
try:
await activate_extension(extension)
return extension
except Exception as exc:
logger.warning(exc)
await deactivate_extension(extension.code)
detail = (
str(exc)
if isinstance(exc, AssertionError)
else f"Extension `{extension.code}` installed, but activation failed."
)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=detail,
) from exc
@@ -143,7 +123,7 @@ async def api_extension_details(
)
assert release, "Details not found for release"
release_details = await fetch_release_details(details_link)
release_details = await ExtensionRelease.fetch_release_details(details_link)
assert release_details, "Cannot fetch details for release"
release_details["icon"] = release.icon
release_details["repo"] = release.repo
@@ -186,7 +166,7 @@ async def api_update_pay_to_enable(
async def api_enable_extension(
ext_id: str, user: User = Depends(check_user_exists)
) -> SimpleStatus:
if ext_id not in [e.code for e in get_valid_extensions()]:
if ext_id not in [e.code for e in Extension.get_valid_extensions()]:
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
)
@@ -249,7 +229,7 @@ async def api_enable_extension(
async def api_disable_extension(
ext_id: str, user: User = Depends(check_user_exists)
) -> SimpleStatus:
if ext_id not in [e.code for e in get_valid_extensions()]:
if ext_id not in [e.code for e in Extension.get_valid_extensions()]:
raise HTTPException(
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist."
)
@@ -270,20 +250,14 @@ async def api_activate_extension(ext_id: str) -> SimpleStatus:
try:
logger.info(f"Activating extension: '{ext_id}'.")
all_extensions = get_valid_extensions()
ext = next((e for e in all_extensions if e.code == ext_id), None)
ext = Extension.get_valid_extension(ext_id)
assert ext, f"Extension '{ext_id}' doesn't exist."
# if extension never loaded (was deactivated on server startup)
if ext_id not in sys.modules.keys():
# run extension start-up routine
core_app_extra.register_new_ext_routes(ext)
settings.lnbits_deactivated_extensions.discard(ext_id)
await update_installed_extension_state(ext_id=ext_id, active=True)
await activate_extension(ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' activated.")
except Exception as exc:
logger.warning(exc)
await deactivate_extension(ext_id)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(f"Failed to activate '{ext_id}'."),
@@ -295,13 +269,10 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
try:
logger.info(f"Deactivating extension: '{ext_id}'.")
all_extensions = get_valid_extensions()
ext = next((e for e in all_extensions if e.code == ext_id), None)
ext = Extension.get_valid_extension(ext_id)
assert ext, f"Extension '{ext_id}' doesn't exist."
settings.lnbits_deactivated_extensions.add(ext_id)
await update_installed_extension_state(ext_id=ext_id, active=False)
await deactivate_extension(ext_id)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' deactivated.")
except Exception as exc:
logger.warning(exc)
@@ -311,23 +282,19 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
) from exc
@extension_router.delete("/{ext_id}")
async def api_uninstall_extension(
ext_id: str,
user: User = Depends(check_admin),
access_token: Optional[str] = Depends(check_access_token),
) -> SimpleStatus:
installed_extensions = await get_installed_extensions()
@extension_router.delete("/{ext_id}", dependencies=[Depends(check_admin)])
async def api_uninstall_extension(ext_id: str) -> SimpleStatus:
extensions = [e for e in installed_extensions if e.id == ext_id]
if len(extensions) == 0:
extension = await get_installed_extension(ext_id)
if not extension:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Unknown extension id: {ext_id}",
)
installed_extensions = await get_installed_extensions()
# check that other extensions do not depend on this one
for valid_ext_id in [ext.code for ext in get_valid_extensions()]:
for valid_ext_id in [ext.code for ext in Extension.get_valid_extensions()]:
installed_ext = next(
(ext for ext in installed_extensions if ext.id == valid_ext_id), None
)
@@ -341,14 +308,7 @@ async def api_uninstall_extension(
)
try:
# call stop while the old routes are still active
await stop_extension_background_work(ext_id, user.id, access_token)
settings.lnbits_deactivated_extensions.add(ext_id)
for ext_info in extensions:
ext_info.clean_extension_files()
await delete_installed_extension(ext_id=ext_info.id)
await uninstall_extension(ext_id)
logger.success(f"Extension '{ext_id}' uninstalled.")
return SimpleStatus(success=True, message=f"Extension '{ext_id}' uninstalled.")
@@ -397,9 +357,8 @@ async def get_pay_to_install_invoice(
assert release, "Release not found."
assert release.pay_link, "Pay link not found for release."
payment_info = await fetch_release_payment_info(
release.pay_link, data.cost_sats
)
payment_info = await release.fetch_release_payment_info(data.cost_sats)
assert payment_info and payment_info.payment_request, "Cannot request invoice."
invoice = bolt11_decode(payment_info.payment_request)
@@ -474,7 +433,7 @@ async def get_pay_to_enable_invoice(
)
async def get_extension_release(org: str, repo: str, tag_name: str):
try:
config = await fetch_github_release_config(org, repo, tag_name)
config = await ExtensionConfig.fetch_github_release_config(org, repo, tag_name)
if not config:
return {}
+58 -4
View File
@@ -1,25 +1,29 @@
from http import HTTPStatus
from pathlib import Path
from typing import Annotated, List, Optional, Union
from urllib.parse import urlparse
from urllib.parse import urlencode, urlparse
import httpx
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 loguru import logger
from pydantic.types import UUID4
from lnbits.core.extensions.models import Extension, InstallableExtension
from lnbits.core.helpers import to_valid_user_id
from lnbits.core.models import User
from lnbits.core.services import create_invoice
from lnbits.decorators import check_admin, check_user_exists
from lnbits.helpers import template_renderer
from lnbits.settings import settings
from lnbits.wallets import get_funding_source
from ...extension_manager import InstallableExtension, get_valid_extensions
from ...utils.exchange_rates import allowed_currencies, currencies
from ..crud import (
create_account,
create_wallet,
get_dbversions,
get_installed_extensions,
@@ -71,7 +75,7 @@ async def robots():
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
async def extensions(request: Request, user: User = Depends(check_user_exists)):
try:
installed_exts: List["InstallableExtension"] = await get_installed_extensions()
installed_exts: List[InstallableExtension] = await get_installed_extensions()
installed_exts_ids = [e.id for e in installed_exts]
installable_exts = await InstallableExtension.get_installable_extensions()
@@ -100,7 +104,7 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
installed_exts_ids = []
try:
all_ext_ids = [ext.code for ext in get_valid_extensions()]
all_ext_ids = [ext.code for ext in Extension.get_valid_extensions()]
inactive_extensions = [
e.id for e in await get_installed_extensions(active=False)
]
@@ -389,3 +393,53 @@ async def hex_to_uuid4(hex_value: str):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
@generic_router.get("/lnurlwallet", response_class=RedirectResponse)
async def lnurlwallet(request: Request):
"""
If a user doesn't have a Lightning Network wallet and scans the LNURLw QR code with
their smartphone camera, or a QR scanner app, they can follow the link provided to
claim their satoshis and get an instant LNbits wallet! lnbits/withdraw docs
"""
lightning_param = request.query_params.get("lightning")
if not lightning_param:
return {"status": "ERROR", "reason": "lightning parameter not provided."}
if not settings.lnbits_allow_new_accounts:
return {"status": "ERROR", "reason": "New accounts are not allowed."}
lnurl = lnurl_decode(lightning_param)
async with httpx.AsyncClient() as client:
res1 = await client.get(lnurl, timeout=2)
res1.raise_for_status()
data1 = res1.json()
if data1.get("tag") != "withdrawRequest":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid lnurl. Expected tag=withdrawRequest",
)
if not data1.get("maxWithdrawable"):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid lnurl. Expected maxWithdrawable",
)
account = await create_account()
wallet = await create_wallet(user_id=account.id)
_, payment_request = await create_invoice(
wallet_id=wallet.id,
amount=data1.get("maxWithdrawable") / 1000,
memo=data1.get("defaultDescription", "lnurl wallet withdraw"),
)
url = data1.get("callback")
params = {"k1": data1.get("k1"), "pr": payment_request}
callback = url + ("&" if urlparse(url).query else "?") + urlencode(params)
res2 = await client.get(callback, timeout=2)
res2.raise_for_status()
return RedirectResponse(
f"/wallet?usr={account.id}&wal={wallet.id}",
)
+19 -27
View File
@@ -35,12 +35,11 @@ from lnbits.core.models import (
from lnbits.db import Filters, Page
from lnbits.decorators import (
WalletTypeInfo,
get_key_type,
parse_filters,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import generate_filter_params_openapi
from lnbits.helpers import 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
@@ -52,13 +51,12 @@ from ..crud import (
get_payments_paginated,
get_standalone_payment,
get_wallet_for_key,
update_pending_payments,
)
from ..services import (
check_transaction_status,
create_invoice,
fee_reserve_total,
pay_invoice,
update_pending_payments,
)
from ..tasks import api_invoice_listeners
@@ -74,12 +72,12 @@ payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments(
wallet: WalletTypeInfo = Depends(get_key_type),
key_info: WalletTypeInfo = Depends(require_invoice_key),
filters: Filters = Depends(parse_filters(PaymentFilters)),
):
await update_pending_payments(wallet.wallet.id)
await update_pending_payments(key_info.wallet.id)
return await get_payments(
wallet_id=wallet.wallet.id,
wallet_id=key_info.wallet.id,
pending=True,
complete=True,
filters=filters,
@@ -93,12 +91,12 @@ async def api_payments(
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments_history(
wallet: WalletTypeInfo = Depends(get_key_type),
key_info: WalletTypeInfo = Depends(require_invoice_key),
group: DateTrunc = Query("day"),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
):
await update_pending_payments(wallet.wallet.id)
return await get_payments_history(wallet.wallet.id, group, filters)
await update_pending_payments(key_info.wallet.id)
return await get_payments_history(key_info.wallet.id, group, filters)
@payment_router.get(
@@ -110,12 +108,12 @@ async def api_payments_history(
openapi_extra=generate_filter_params_openapi(PaymentFilters),
)
async def api_payments_paginated(
wallet: WalletTypeInfo = Depends(get_key_type),
key_info: WalletTypeInfo = Depends(require_invoice_key),
filters: Filters = Depends(parse_filters(PaymentFilters)),
):
await update_pending_payments(wallet.wallet.id)
await update_pending_payments(key_info.wallet.id)
page = await get_payments_paginated(
wallet_id=wallet.wallet.id,
wallet_id=key_info.wallet.id,
pending=True,
complete=True,
filters=filters,
@@ -379,10 +377,10 @@ async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
@payment_router.get("/sse")
async def api_payments_sse(
request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
request: Request, key_info: WalletTypeInfo = Depends(require_invoice_key)
):
return EventSourceResponse(
subscribe_wallet_invoices(request, wallet.wallet),
subscribe_wallet_invoices(request, key_info.wallet),
ping=20,
media_type="text/event-stream",
)
@@ -402,15 +400,8 @@ async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
await check_transaction_status(payment.wallet_id, payment_hash)
payment = await get_standalone_payment(
payment_hash, wallet_id=wallet.id if wallet else None
)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
elif not payment.pending:
if payment.success:
if wallet and wallet.id == payment.wallet_id:
return {"paid": True, "preimage": payment.preimage, "details": payment}
return {"paid": True, "preimage": payment.preimage}
@@ -424,12 +415,12 @@ async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
if wallet and wallet.id == payment.wallet_id:
return {
"paid": not payment.pending,
"paid": payment.success,
"status": f"{status!s}",
"preimage": payment.preimage,
"details": payment,
}
return {"paid": not payment.pending, "preimage": payment.preimage}
return {"paid": payment.success, "preimage": payment.preimage}
@payment_router.post("/decode", status_code=HTTPStatus.OK)
@@ -441,7 +432,8 @@ async def api_payments_decode(data: DecodePayment) -> JSONResponse:
return JSONResponse({"domain": url})
else:
invoice = bolt11.decode(payment_str)
return JSONResponse(invoice.data)
filtered_data = filter_dict_keys(invoice.data, data.filter_fields)
return JSONResponse(filtered_data)
except Exception as exc:
return JSONResponse(
{"message": f"Failed to decode: {exc!s}"},
+2 -1
View File
@@ -20,7 +20,8 @@ async def api_public_payment_longpolling(payment_hash):
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
elif not payment.pending:
# TODO: refactor to use PaymentState
if payment.success:
return {"status": "paid"}
try:
+4 -4
View File
@@ -84,10 +84,10 @@ async def api_users_toggle_admin(user_id: str) -> None:
settings.lnbits_admin_users.remove(user_id)
else:
settings.lnbits_admin_users.append(user_id)
update_settings = EditableSettings(
lnbits_admin_users=settings.lnbits_admin_users
)
await update_admin_settings(update_settings)
update_settings = EditableSettings(
lnbits_admin_users=settings.lnbits_admin_users
)
await update_admin_settings(update_settings)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
+8 -9
View File
@@ -13,8 +13,8 @@ from lnbits.core.models import (
)
from lnbits.decorators import (
WalletTypeInfo,
get_key_type,
require_admin_key,
require_invoice_key,
)
from ..crud import (
@@ -27,15 +27,14 @@ wallet_router = APIRouter(prefix="/api/v1/wallet", tags=["Wallet"])
@wallet_router.get("")
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
async def api_wallet(wallet: WalletTypeInfo = Depends(require_invoice_key)):
res = {
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat,
}
if wallet.key_type == KeyType.admin:
return {
"id": wallet.wallet.id,
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat,
}
else:
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
res["id"] = wallet.wallet.id
return res
@wallet_router.put("/{new_name}")
+130 -119
View File
@@ -7,14 +7,13 @@ import re
import time
from contextlib import asynccontextmanager
from enum import Enum
from sqlite3 import Row
from typing import Any, Generic, Literal, Optional, TypeVar
from loguru import logger
from pydantic import BaseModel, ValidationError, root_validator
from sqlalchemy import create_engine
from sqlalchemy_aio.base import AsyncConnection
from sqlalchemy_aio.strategy import ASYNCIO_STRATEGY
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
from sqlalchemy.sql import text
from lnbits.settings import settings
@@ -24,31 +23,15 @@ SQLITE = "SQLITE"
if settings.lnbits_database_url:
database_uri = settings.lnbits_database_url
if database_uri.startswith("cockroachdb://"):
DB_TYPE = COCKROACH
else:
if not database_uri.startswith("postgres://"):
raise ValueError(
"Please use the 'postgres://...' " "format for the database URL."
)
DB_TYPE = POSTGRES
from psycopg2.extensions import DECIMAL, new_type, register_type
def _parse_timestamp(value, _):
if value is None:
return None
f = "%Y-%m-%d %H:%M:%S.%f"
if "." not in value:
f = "%Y-%m-%d %H:%M:%S"
return time.mktime(datetime.datetime.strptime(value, f).timetuple())
register_type(
new_type(
DECIMAL.values,
"DEC2FLOAT",
lambda value, curs: float(value) if value is not None else None,
)
)
register_type(new_type((1184, 1114), "TIMESTAMP2INT", _parse_timestamp))
else:
if not os.path.isdir(settings.lnbits_data_folder):
os.mkdir(settings.lnbits_data_folder)
@@ -56,13 +39,21 @@ else:
DB_TYPE = SQLITE
def compat_timestamp_placeholder():
def compat_timestamp_placeholder(key: str):
if DB_TYPE == POSTGRES:
return "to_timestamp(?)"
return f"to_timestamp(:{key})"
elif DB_TYPE == COCKROACH:
return "cast(? AS timestamp)"
return f"cast(:{key} AS timestamp)"
else:
return "?"
return f":{key}"
def get_placeholder(model: Any, field: str) -> str:
type_ = model.__fields__[field].type_
if type_ == datetime.datetime:
return compat_timestamp_placeholder(field)
else:
return f":{field}"
class Compat:
@@ -119,15 +110,13 @@ class Compat:
return "BIGINT"
return "INT"
@property
def timestamp_placeholder(self) -> str:
return compat_timestamp_placeholder()
def timestamp_placeholder(self, key: str) -> str:
return compat_timestamp_placeholder(key)
class Connection(Compat):
def __init__(self, conn: AsyncConnection, txn, typ, name, schema):
def __init__(self, conn: AsyncConnection, typ, name, schema):
self.conn = conn
self.txn = txn
self.type = typ
self.name = name
self.schema = schema
@@ -138,45 +127,42 @@ class Connection(Compat):
query = query.replace("?", "%s")
return query
def rewrite_values(self, values):
def rewrite_values(self, values: dict) -> dict:
# strip html
clean_regex = re.compile("<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});")
# tuple to list and back to tuple
raw_values = [values] if isinstance(values, str) else list(values)
values = []
for raw_value in raw_values:
clean_values: dict = {}
for key, raw_value in values.items():
if isinstance(raw_value, str):
values.append(re.sub(clean_regex, "", raw_value))
clean_values[key] = re.sub(clean_regex, "", raw_value)
elif isinstance(raw_value, datetime.datetime):
ts = raw_value.timestamp()
if self.type == SQLITE:
values.append(int(ts))
clean_values[key] = int(ts)
else:
values.append(ts)
clean_values[key] = ts
else:
values.append(raw_value)
return tuple(values)
clean_values[key] = raw_value
return clean_values
async def fetchall(self, query: str, values: tuple = ()) -> list:
result = await self.conn.execute(
self.rewrite_query(query), self.rewrite_values(values)
)
return await result.fetchall()
async def fetchall(self, query: str, values: Optional[dict] = None) -> list[dict]:
params = self.rewrite_values(values) if values else {}
result = await self.conn.execute(text(self.rewrite_query(query)), params)
row = result.mappings().all()
result.close()
return row
async def fetchone(self, query: str, values: tuple = ()):
result = await self.conn.execute(
self.rewrite_query(query), self.rewrite_values(values)
)
row = await result.fetchone()
await result.close()
async def fetchone(self, query: str, values: Optional[dict] = None) -> dict:
params = self.rewrite_values(values) if values else {}
result = await self.conn.execute(text(self.rewrite_query(query)), params)
row = result.mappings().first()
result.close()
return row
async def fetch_page(
self,
query: str,
where: Optional[list[str]] = None,
values: Optional[list[str]] = None,
values: Optional[dict] = None,
filters: Optional[Filters] = None,
model: Optional[type[TRowModel]] = None,
group_by: Optional[list[str]] = None,
@@ -203,14 +189,14 @@ class Connection(Compat):
{filters.order_by()}
{filters.pagination()}
""",
parsed_values,
self.rewrite_values(parsed_values),
)
if rows:
# no need for extra query if no pagination is specified
if filters.offset or filters.limit:
count = await self.fetchone(
result = await self.fetchone(
f"""
SELECT COUNT(*) FROM (
SELECT COUNT(*) as count FROM (
{query}
{clause}
{group_by_string}
@@ -218,21 +204,22 @@ class Connection(Compat):
""",
parsed_values,
)
count = int(count[0])
count = int(result.get("count", 0))
else:
count = len(rows)
else:
count = 0
return Page(
data=[model.from_row(row) for row in rows] if model else rows,
data=[model.from_row(row) for row in rows] if model else [],
total=count,
)
async def execute(self, query: str, values: tuple = ()):
return await self.conn.execute(
self.rewrite_query(query), self.rewrite_values(values)
)
async def execute(self, query: str, values: Optional[dict] = None):
params = self.rewrite_values(values) if values else {}
result = await self.conn.execute(text(self.rewrite_query(query)), params)
await self.conn.commit()
return result
class Database(Compat):
@@ -245,18 +232,44 @@ class Database(Compat):
self.path = os.path.join(
settings.lnbits_data_folder, f"{self.name}.sqlite3"
)
database_uri = f"sqlite:///{self.path}"
database_uri = f"sqlite+aiosqlite:///{self.path}"
else:
database_uri = settings.lnbits_database_url
database_uri = settings.lnbits_database_url.replace(
"postgres://", "postgresql+asyncpg://"
)
if self.name.startswith("ext_"):
self.schema = self.name[4:]
else:
self.schema = None
self.engine = create_engine(
database_uri, strategy=ASYNCIO_STRATEGY, echo=settings.debug_database
self.engine: AsyncEngine = create_async_engine(
database_uri, echo=settings.debug_database
)
if self.type in {POSTGRES, COCKROACH}:
@event.listens_for(self.engine.sync_engine, "connect")
def register_custom_types(dbapi_connection, *_):
def _parse_timestamp(value):
if value is None:
return None
f = "%Y-%m-%d %H:%M:%S.%f"
if "." not in value:
f = "%Y-%m-%d %H:%M:%S"
return int(
time.mktime(datetime.datetime.strptime(value, f).timetuple())
)
dbapi_connection.run_async(
lambda connection: connection.set_type_codec(
"TIMESTAMP",
encoder=datetime.datetime,
decoder=_parse_timestamp,
schema="pg_catalog",
)
)
self.lock = asyncio.Lock()
logger.trace(f"database {self.type} added for {self.name}")
@@ -265,41 +278,37 @@ class Database(Compat):
async def connect(self):
await self.lock.acquire()
try:
async with self.engine.connect() as conn: # type: ignore
async with conn.begin() as txn:
wconn = Connection(conn, txn, self.type, self.name, self.schema)
async with self.engine.connect() as conn:
if not conn:
raise Exception("Could not connect to the database")
if self.schema:
if self.type in {POSTGRES, COCKROACH}:
await wconn.execute(
f"CREATE SCHEMA IF NOT EXISTS {self.schema}"
)
elif self.type == SQLITE:
await wconn.execute(
f"ATTACH '{self.path}' AS {self.schema}"
)
wconn = Connection(conn, self.type, self.name, self.schema)
yield wconn
if self.schema:
if self.type in {POSTGRES, COCKROACH}:
await wconn.execute(
f"CREATE SCHEMA IF NOT EXISTS {self.schema}"
)
elif self.type == SQLITE:
await wconn.execute(f"ATTACH '{self.path}' AS {self.schema}")
yield wconn
finally:
self.lock.release()
async def fetchall(self, query: str, values: tuple = ()) -> list:
async def fetchall(self, query: str, values: Optional[dict] = None) -> list[dict]:
async with self.connect() as conn:
result = await conn.execute(query, values)
return await result.fetchall()
return await conn.fetchall(query, values)
async def fetchone(self, query: str, values: tuple = ()):
async def fetchone(self, query: str, values: Optional[dict] = None) -> dict:
async with self.connect() as conn:
result = await conn.execute(query, values)
row = await result.fetchone()
await result.close()
return row
return await conn.fetchone(query, values)
async def fetch_page(
self,
query: str,
where: Optional[list[str]] = None,
values: Optional[list[str]] = None,
values: Optional[dict] = None,
filters: Optional[Filters] = None,
model: Optional[type[TRowModel]] = None,
group_by: Optional[list[str]] = None,
@@ -307,7 +316,7 @@ class Database(Compat):
async with self.connect() as conn:
return await conn.fetch_page(query, where, values, filters, model, group_by)
async def execute(self, query: str, values: tuple = ()):
async def execute(self, query: str, values: Optional[dict] = None):
async with self.connect() as conn:
return await conn.execute(query, values)
@@ -365,8 +374,8 @@ class Operator(Enum):
class FromRowModel(BaseModel):
@classmethod
def from_row(cls, row: Row):
return cls(**dict(row))
def from_row(cls, row: dict):
return cls(**row)
class FilterModel(BaseModel):
@@ -388,12 +397,13 @@ class Page(BaseModel, Generic[T]):
class Filter(BaseModel, Generic[TFilterModel]):
field: str
op: Operator = Operator.EQ
values: list[Any]
model: Optional[type[TFilterModel]]
values: Optional[dict] = None
@classmethod
def parse_query(cls, key: str, raw_values: list[Any], model: type[TFilterModel]):
def parse_query(
cls, key: str, raw_values: list[Any], model: type[TFilterModel], i: int = 0
):
# Key format:
# key[operator]
# e.g. name[eq]
@@ -409,12 +419,12 @@ class Filter(BaseModel, Generic[TFilterModel]):
if field in model.__fields__:
compare_field = model.__fields__[field]
values = []
values: dict = {}
for raw_value in raw_values:
validated, errors = compare_field.validate(raw_value, {}, loc="none")
if errors:
raise ValidationError(errors=[errors], model=model)
values.append(validated)
values[f"{field}__{i}"] = validated
else:
raise ValueError("Unknown filter field")
@@ -422,15 +432,17 @@ class Filter(BaseModel, Generic[TFilterModel]):
@property
def statement(self):
if self.model and self.model.__fields__[self.field].type_ == datetime.datetime:
placeholder = compat_timestamp_placeholder()
else:
placeholder = "?"
if self.op in (Operator.INCLUDE, Operator.EXCLUDE):
placeholders = ", ".join([placeholder] * len(self.values))
stmt = [f"{self.field} {self.op.as_sql} ({placeholders})"]
else:
stmt = [f"{self.field} {self.op.as_sql} {placeholder}"] * len(self.values)
stmt = []
for key in self.values.keys() if self.values else []:
clean_key = key.split("__")[0]
if (
self.model
and self.model.__fields__[clean_key].type_ == datetime.datetime
):
placeholder = compat_timestamp_placeholder(key)
else:
placeholder = f":{key}"
stmt.append(f"{clean_key} {self.op.as_sql} {placeholder}")
return " OR ".join(stmt)
@@ -481,14 +493,11 @@ class Filters(BaseModel, Generic[TFilterModel]):
for page_filter in self.filters:
where_stmts.append(page_filter.statement)
if self.search and self.model:
fields = self.model.__search_fields__
if DB_TYPE == POSTGRES:
where_stmts.append(
f"lower(concat({', '.join(self.model.__search_fields__)})) LIKE ?"
)
where_stmts.append(f"lower(concat({', '.join(fields)})) LIKE :search")
elif DB_TYPE == SQLITE:
where_stmts.append(
f"lower({'||'.join(self.model.__search_fields__)}) LIKE ?"
)
where_stmts.append(f"lower({'||'.join(fields)}) LIKE :search")
if where_stmts:
return "WHERE " + " AND ".join(where_stmts)
return ""
@@ -498,12 +507,14 @@ class Filters(BaseModel, Generic[TFilterModel]):
return f"ORDER BY {self.sortby} {self.direction or 'asc'}"
return ""
def values(self, values: Optional[list[str]] = None) -> tuple:
def values(self, values: Optional[dict] = None) -> dict:
if not values:
values = []
values = {}
if self.filters:
for page_filter in self.filters:
values.extend(page_filter.values)
if page_filter.values:
for key, value in page_filter.values.items():
values[key] = value
if self.search and self.model:
values.append(f"%{self.search}%")
return tuple(values)
values["search"] = f"%{self.search}%"
return values
+6 -15
View File
@@ -1,12 +1,12 @@
from http import HTTPStatus
from typing import Annotated, Literal, Optional, Type, Union
import jwt
from fastapi import Cookie, Depends, Query, Request, Security
from fastapi.exceptions import HTTPException
from fastapi.openapi.models import APIKey, APIKeyIn, SecuritySchemeType
from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer
from fastapi.security.base import SecurityBase
from jose import ExpiredSignatureError, JWTError, jwt
from loguru import logger
from pydantic.types import UUID4
@@ -95,15 +95,6 @@ class KeyChecker(SecurityBase):
return WalletTypeInfo(key_type, wallet)
async def get_key_type(
request: Request,
api_key_header: str = Security(api_key_header),
api_key_query: str = Security(api_key_query),
) -> WalletTypeInfo:
check: KeyChecker = KeyChecker(api_key=api_key_header or api_key_query)
return await check(request)
async def require_admin_key(
request: Request,
api_key_header: str = Security(api_key_header),
@@ -204,9 +195,9 @@ def parse_filters(model: Type[TFilterModel]):
):
params = request.query_params
filters = []
for key in params.keys():
for i, key in enumerate(params.keys()):
try:
filters.append(Filter.parse_query(key, params.getlist(key), model))
filters.append(Filter.parse_query(key, params.getlist(key), model, i))
except ValueError:
continue
@@ -256,7 +247,7 @@ async def _check_user_extension_access(user_id: str, current_path: str):
async def _get_account_from_token(access_token):
try:
payload = jwt.decode(access_token, settings.auth_secret_key, "HS256")
payload = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
if "sub" in payload and payload.get("sub"):
return await get_account_by_username(str(payload.get("sub")))
if "usr" in payload and payload.get("usr"):
@@ -265,10 +256,10 @@ async def _get_account_from_token(access_token):
return await get_account_by_email(str(payload.get("email")))
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Data missing for access token.")
except ExpiredSignatureError as exc:
except jwt.ExpiredSignatureError as exc:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Session expired.", {"token-expired": "true"}
) from exc
except JWTError as exc:
except jwt.PyJWTError as exc:
logger.debug(exc)
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.") from exc
+14 -4
View File
@@ -8,11 +8,21 @@ from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, RedirectResponse, Response
from loguru import logger
from lnbits.core.services import InvoiceError, PaymentError
from .helpers import template_renderer
class PaymentError(Exception):
def __init__(self, message: str, status: str = "pending"):
self.message = message
self.status = status
class InvoiceError(Exception):
def __init__(self, message: str, status: str = "pending"):
self.message = message
self.status = status
def register_exception_handlers(app: FastAPI):
register_exception_handler(app)
register_request_validation_exception_handler(app)
@@ -90,7 +100,7 @@ def register_http_exception_handler(app: FastAPI):
def register_payment_error_handler(app: FastAPI):
@app.exception_handler(PaymentError)
async def payment_error_handler(request: Request, exc: PaymentError):
logger.error(f"PaymentError: {exc.message}, {exc.status}")
logger.error(f"{exc.message}, {exc.status}")
return JSONResponse(
status_code=520,
content={"detail": exc.message, "status": exc.status},
@@ -100,7 +110,7 @@ def register_payment_error_handler(app: FastAPI):
def register_invoice_error_handler(app: FastAPI):
@app.exception_handler(InvoiceError)
async def invoice_error_handler(request: Request, exc: InvoiceError):
logger.error(f"InvoiceError: {exc.message}, Status: {exc.status}")
logger.error(f"{exc.message}, Status: {exc.status}")
return JSONResponse(
status_code=520,
content={"detail": exc.message, "status": exc.status},
+27 -8
View File
@@ -5,11 +5,13 @@ from pathlib import Path
from typing import Any, List, Optional, Type
import jinja2
import jwt
import shortuuid
from jose import jwt
from pydantic import BaseModel
from pydantic.schema import field_schema
from lnbits.core.extensions.models import Extension
from lnbits.db import get_placeholder
from lnbits.jinja2_templating import Jinja2Templates
from lnbits.nodes import get_node_class
from lnbits.requestvars import g
@@ -17,7 +19,6 @@ from lnbits.settings import settings
from lnbits.utils.crypto import AESCipher
from .db import FilterModel
from .extension_manager import get_valid_extensions
def get_db_vendor_name():
@@ -92,19 +93,21 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
settings.lnbits_node_ui and get_node_class() is not None
)
t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None
t.env.globals["EXTENSIONS"] = get_valid_extensions(False)
t.env.globals["EXTENSIONS"] = Extension.get_valid_extensions(False)
if settings.lnbits_custom_logo:
t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo
if settings.bundle_assets:
t.env.globals["INCLUDED_JS"] = ["bundle.min.js"]
t.env.globals["INCLUDED_CSS"] = ["bundle.min.css"]
t.env.globals["INCLUDED_COMPONENTS"] = ["bundle-components.min.js"]
else:
vendor_filepath = Path(settings.lnbits_path, "static", "vendor.json")
with open(vendor_filepath) as vendor_file:
vendor_files = json.loads(vendor_file.read())
t.env.globals["INCLUDED_JS"] = vendor_files["js"]
t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
t.env.globals["INCLUDED_COMPONENTS"] = vendor_files["components"]
t.env.globals["WEBPUSH_PUBKEY"] = settings.lnbits_webpush_pubkey
@@ -178,19 +181,28 @@ def insert_query(table_name: str, model: BaseModel) -> str:
:param table_name: Name of the table
:param model: Pydantic model
"""
placeholders = ", ".join(["?"] * len(model.dict().keys()))
placeholders = []
for field in model.dict().keys():
placeholders.append(get_placeholder(model, field))
fields = ", ".join(model.dict().keys())
return f"INSERT INTO {table_name} ({fields}) VALUES ({placeholders})"
values = ", ".join(placeholders)
return f"INSERT INTO {table_name} ({fields}) VALUES ({values})"
def update_query(table_name: str, model: BaseModel, where: str = "WHERE id = ?") -> str:
def update_query(
table_name: str, model: BaseModel, where: str = "WHERE id = :id"
) -> str:
"""
Generate an update query with placeholders for a given table and model
:param table_name: Name of the table
:param model: Pydantic model
:param where: Where string, default to `WHERE id = ?`
:param where: Where string, default to `WHERE id = :id`
"""
query = ", ".join([f"{field} = ?" for field in model.dict().keys()])
fields = []
for field in model.dict().keys():
placeholder = get_placeholder(model, field)
fields.append(f"{field} = {placeholder}")
query = ", ".join(fields)
return f"UPDATE {table_name} SET {query} {where}"
@@ -223,3 +235,10 @@ def decrypt_internal_message(m: Optional[str] = None) -> Optional[str]:
if not m:
return None
return AESCipher(key=settings.auth_secret_key).decrypt(m)
def filter_dict_keys(data: dict, filter_keys: Optional[list[str]]) -> dict:
if not filter_keys:
# return shallow clone of the dict even if there are no filters
return {**data}
return {key: data[key] for key in filter_keys if key in data}
+65 -1
View File
@@ -1 +1,65 @@
from lnurl import LnurlErrorResponse, decode, encode, handle # noqa: F401
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=200,
content={"status": "ERROR", "reason": f"{exc.message}"},
)
return response
except HTTPException as exc:
logger.debug(f"HTTPException: {exc}")
response = JSONResponse(
status_code=200,
content={"status": "ERROR", "reason": f"{exc.detail}"},
)
return response
except Exception as exc:
logger.error("Unknown Error:", exc)
response = JSONResponse(
status_code=200,
content={
"status": "ERROR",
"reason": f"UNKNOWN ERROR: {exc!s}",
},
)
return response
return lnurl_route_handler
__all__ = [
"decode",
"encode",
"handle",
"LnurlErrorResponse",
"LnurlErrorResponseHandler",
]
+7 -76
View File
@@ -1,5 +1,5 @@
from http import HTTPStatus
from typing import Any, List, Tuple, Union
from typing import Any, List, Union
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
@@ -45,16 +45,11 @@ class InstalledExtensionMiddleware:
await self.app(scope, receive, send)
return
upgrade_path = next(
(
e
for e in settings.lnbits_upgraded_extensions
if e.endswith(f"/{top_path}")
),
None,
)
# re-route all trafic if the extension has been upgraded
if upgrade_path:
if top_path in settings.lnbits_upgraded_extensions:
upgrade_path = (
f"""{settings.lnbits_upgraded_extensions[top_path]}/{top_path}"""
)
tail = "/".join(rest)
scope["path"] = f"/upgrades/{upgrade_path}/{tail}"
@@ -118,72 +113,12 @@ class ExtensionsRedirectMiddleware:
return
req_headers = scope["headers"] if "headers" in scope else []
redirect = self._find_redirect(scope["path"], req_headers)
redirect = settings.find_extension_redirect(scope["path"], req_headers)
if redirect:
scope["path"] = self._new_path(redirect, scope["path"])
scope["path"] = redirect.new_path_from(scope["path"])
await self.app(scope, receive, send)
def _find_redirect(self, path: str, req_headers: List[Tuple[bytes, bytes]]):
return next(
(
r
for r in settings.lnbits_extensions_redirects
if self._redirect_matches(r, path, req_headers)
),
None,
)
def _redirect_matches(
self, redirect: dict, path: str, req_headers: List[Tuple[bytes, bytes]]
) -> bool:
if "from_path" not in redirect:
return False
header_filters = (
redirect["header_filters"] if "header_filters" in redirect else {}
)
return self._has_common_path(redirect["from_path"], path) and self._has_headers(
header_filters, req_headers
)
def _has_headers(
self, filter_headers: dict, req_headers: List[Tuple[bytes, bytes]]
) -> bool:
for h in filter_headers:
if not self._has_header(req_headers, (str(h), str(filter_headers[h]))):
return False
return True
def _has_header(
self, req_headers: List[Tuple[bytes, bytes]], header: Tuple[str, str]
) -> bool:
for h in req_headers:
if (
h[0].decode().lower() == header[0].lower()
and h[1].decode() == header[1]
):
return True
return False
def _has_common_path(self, redirect_path: str, req_path: str) -> bool:
redirect_path_elements = redirect_path.split("/")
req_path_elements = req_path.split("/")
if len(redirect_path) > len(req_path):
return False
sub_path = req_path_elements[: len(redirect_path_elements)]
return redirect_path == "/".join(sub_path)
def _new_path(self, redirect: dict, req_path: str) -> str:
from_path = redirect["from_path"].split("/")
redirect_to = redirect["redirect_to_path"].split("/")
req_tail_path = req_path.split("/")[len(from_path) :]
elements = [
e for e in ([redirect["ext_id"], *redirect_to, *req_tail_path]) if e != ""
]
return "/" + "/".join(elements)
def add_ratelimit_middleware(app: FastAPI):
core_app_extra.register_new_ratelimiter()
@@ -214,8 +149,6 @@ def add_ip_block_middleware(app: FastAPI):
)
return await call_next(request)
app.middleware("http")(block_allow_ip_middleware)
def add_first_install_middleware(app: FastAPI):
@app.middleware("http")
@@ -228,5 +161,3 @@ def add_first_install_middleware(app: FastAPI):
):
return RedirectResponse("/first_install")
return await call_next(request)
app.middleware("http")(first_install_middleware)
+138 -7
View File
@@ -62,26 +62,132 @@ class ExtensionsInstallSettings(LNbitsSettings):
lnbits_ext_github_token: str = Field(default="")
class RedirectPath(BaseModel):
ext_id: str
from_path: str
redirect_to_path: str
header_filters: dict = {}
def in_conflict(self, other: RedirectPath) -> bool:
if self.ext_id == other.ext_id:
return False
return self.redirect_matches(
other.from_path, list(other.header_filters.items())
) or other.redirect_matches(self.from_path, list(self.header_filters.items()))
def find_in_conflict(self, others: list[RedirectPath]) -> Optional[RedirectPath]:
for other in others:
if self.in_conflict(other):
return other
return None
def new_path_from(self, req_path: str) -> str:
from_path = self.from_path.split("/")
redirect_to = self.redirect_to_path.split("/")
req_tail_path = req_path.split("/")[len(from_path) :]
elements = [e for e in ([self.ext_id, *redirect_to, *req_tail_path]) if e != ""]
return "/" + "/".join(elements)
def redirect_matches(self, path: str, req_headers: list[tuple[str, str]]) -> bool:
return self._has_common_path(path) and self._has_headers(req_headers)
def _has_common_path(self, req_path: str) -> bool:
if len(self.from_path) > len(req_path):
return False
redirect_path_elements = self.from_path.split("/")
req_path_elements = req_path.split("/")
sub_path = req_path_elements[: len(redirect_path_elements)]
return self.from_path == "/".join(sub_path)
def _has_headers(self, req_headers: list[tuple[str, str]]) -> bool:
for h in self.header_filters:
if not self._has_header(req_headers, (str(h), str(self.header_filters[h]))):
return False
return True
def _has_header(
self, req_headers: list[tuple[str, str]], header: tuple[str, str]
) -> bool:
for h in req_headers:
if h[0].lower() == header[0].lower() and h[1].lower() == header[1].lower():
return True
return False
class InstalledExtensionsSettings(LNbitsSettings):
# installed extensions that have been deactivated
lnbits_deactivated_extensions: set[str] = Field(default=[])
# upgraded extensions that require API redirects
lnbits_upgraded_extensions: set[str] = Field(default=[])
lnbits_upgraded_extensions: dict[str, str] = Field(default={})
# list of redirects that extensions want to perform
lnbits_extensions_redirects: list[Any] = Field(default=[])
lnbits_extensions_redirects: list[RedirectPath] = Field(default=[])
# list of all extension ids
lnbits_all_extensions_ids: set[Any] = Field(default=[])
def extension_upgrade_path(self, ext_id: str) -> Optional[str]:
def find_extension_redirect(
self, path: str, req_headers: list[tuple[bytes, bytes]]
) -> Optional[RedirectPath]:
headers = [(k.decode(), v.decode()) for k, v in req_headers]
return next(
(e for e in self.lnbits_upgraded_extensions if e.endswith(f"/{ext_id}")),
(
r
for r in self.lnbits_extensions_redirects
if r.redirect_matches(path, headers)
),
None,
)
def extension_upgrade_hash(self, ext_id: str) -> Optional[str]:
path = settings.extension_upgrade_path(ext_id)
return path.split("/")[0] if path else None
def activate_extension_paths(
self,
ext_id: str,
upgrade_hash: Optional[str] = None,
ext_redirects: Optional[list[dict]] = None,
):
self.lnbits_deactivated_extensions.discard(ext_id)
"""
Update the list of upgraded extensions. The middleware will perform
redirects based on this
"""
if upgrade_hash:
self.lnbits_upgraded_extensions[ext_id] = upgrade_hash
if ext_redirects:
self._activate_extension_redirects(ext_id, ext_redirects)
self.lnbits_all_extensions_ids.add(ext_id)
def deactivate_extension_paths(self, ext_id: str):
self.lnbits_deactivated_extensions.add(ext_id)
self._remove_extension_redirects(ext_id)
def _activate_extension_redirects(self, ext_id: str, ext_redirects: list[dict]):
ext_redirect_paths = [
RedirectPath(**{"ext_id": ext_id, **er}) for er in ext_redirects
]
existing_redirects = {
r.ext_id
for r in self.lnbits_extensions_redirects
if r.find_in_conflict(ext_redirect_paths)
}
assert len(existing_redirects) == 0, (
f"Cannot redirect for extension '{ext_id}'."
f" Already mapped by {existing_redirects}."
)
self._remove_extension_redirects(ext_id)
self.lnbits_extensions_redirects += ext_redirect_paths
def _remove_extension_redirects(self, ext_id: str):
self.lnbits_extensions_redirects = [
er for er in self.lnbits_extensions_redirects if er.ext_id != ext_id
]
class ThemesSettings(LNbitsSettings):
@@ -258,6 +364,25 @@ class LnTipsFundingSource(LNbitsSettings):
lntips_invoice_key: Optional[str] = Field(default=None)
class NWCFundingSource(LNbitsSettings):
nwc_pairing_url: Optional[str] = Field(default=None)
class BreezSdkFundingSource(LNbitsSettings):
breez_api_key: Optional[str] = Field(default=None)
breez_greenlight_seed: Optional[str] = Field(default=None)
breez_greenlight_invite_code: Optional[str] = Field(default=None)
breez_greenlight_device_key: Optional[str] = Field(default=None)
breez_greenlight_device_cert: Optional[str] = Field(default=None)
class BoltzFundingSource(LNbitsSettings):
boltz_client_endpoint: Optional[str] = Field(default="127.0.0.1:9002")
boltz_client_macaroon: Optional[str] = Field(default=None)
boltz_client_wallet: Optional[str] = Field(default="lnbits")
boltz_client_cert: Optional[str] = Field(default=None)
class LightningSettings(LNbitsSettings):
lightning_invoice_expiry: int = Field(default=3600)
@@ -274,11 +399,14 @@ class FundingSourcesSettings(
LnPayFundingSource,
BlinkFundingSource,
AlbyFundingSource,
BoltzFundingSource,
ZBDFundingSource,
PhoenixdFundingSource,
OpenNodeFundingSource,
SparkFundingSource,
LnTipsFundingSource,
NWCFundingSource,
BreezSdkFundingSource,
):
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
@@ -422,7 +550,9 @@ class SuperUserSettings(LNbitsSettings):
lnbits_allowed_funding_sources: list[str] = Field(
default=[
"AlbyWallet",
"BoltzWallet",
"BlinkWallet",
"BreezSdkWallet",
"CoreLightningRestWallet",
"CoreLightningWallet",
"EclairWallet",
@@ -436,6 +566,7 @@ class SuperUserSettings(LNbitsSettings):
"PhoenixdWallet",
"VoidWallet",
"ZBDWallet",
"NWCWallet",
]
)
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
+34 -46
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -241,6 +241,8 @@ window.localisation.en = {
back: 'Back',
logout: 'Logout',
look_and_feel: 'Look and Feel',
toggle_gradient: 'Toggle Gradient',
gradient_background: 'Gradient Background',
language: 'Language',
color_scheme: 'Color Scheme',
extension_cost: 'This release requires a payment of minimum %{cost} sats.',
+10
View File
@@ -0,0 +1,10 @@
<svg width="46.626mm" height="12.965mm" version="1.1" viewBox="0 0 46.626 12.965" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(.85851 0 0 .85851 -56.357 -42.14)">
<g transform="matrix(.26458 0 0 .26458 69.488 50.153)" style="fill:none">
<path d="m14.462 28.768-.0035.0072h-12.787c-.4776 0-.8756-.1467-1.194-.4401s-.4776-.6619-.4776-1.1055c0-.4723.20262-.9589.60786-1.4598l20.342-24.837c.3763-.48659.7924-.77997 1.2482-.88015.4559-.10018.8648-.057246 1.2266.1288.3618.18605.6151.49016.7598.91235.1448.42218.1086.92666-.1085 1.5134l-6.643 17.625h1.1057l.0035-.0072h12.787c.4776 0 .8756.1467 1.194.4401s.4776.6619.4776 1.1055c0 .4723-.2026.9589-.6079 1.4598l-20.342 24.837c-.3763.4865-.7924.7799-1.2482.8801-.4559.1002-.86479.0573-1.2266-.1288-.36182-.186-.61509-.4902-.75982-.9123-.14473-.4222-.10855-.9267.10854-1.5135l6.643-17.624z" clip-rule="evenodd" fill="#fff" fill-rule="evenodd" style="fill:#fff"/>
</g>
<g transform="matrix(.26458 0 0 .26458 81.643 50.938)" style="fill:none">
<path d="m.92 42h19.488c7.672 0 12.488-4.144 12.488-11.312 0-5.152-2.072-7.952-5.264-9.464v-.224c1.96-1.4 3.808-3.752 3.808-8.008 0-6.44-4.76-10.192-11.76-10.192h-18.76zm8.736-24.08v-7.392h8.736c2.968 0 4.144 1.568 4.144 3.696s-1.176 3.696-4.144 3.696zm0 16.296v-8.68h9.296c3.808 0 4.984 2.352 4.984 4.312 0 2.352-1.232 4.368-4.984 4.368zm40.657 8.288c9.52 0 14.616-6.384 14.616-15.12 0-8.792-5.208-15.176-14.616-15.176s-14.616 6.384-14.616 15.176 5.096 15.12 14.616 15.12zm0-7.56c-3.528 0-5.824-2.24-5.824-7.56 0-5.264 2.24-7.616 5.824-7.616s5.824 2.296 5.824 7.616-2.24 7.56-5.824 7.56zm18.831 7.056h8.624v-41.44h-8.624zm26.552.504c2.744 0 5.0404-.504 5.9364-.84v-7.168h-.56c-.896.336-1.8484.504-2.8004.504-2.576 0-3.752-1.176-3.752-3.92v-11.032h7.0004v-7.336h-7.0004v-7.672h-.56l-7.672 1.568v6.104h-5.152v7.336h4.76v13.104c0 5.32 3.024 9.352 9.8 9.352zm10.397-.504h24.192v-7.336h-13.552v-.224l13.44-14.448v-7.28h-24.024v7.336h13.44v.224l-13.496 14.504z" fill="#fff" style="fill:#fff"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg width="46.626mm" height="12.965mm" version="1.1" viewBox="0 0 46.626 12.965" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(.85851 0 0 .85851 -56.357 -42.14)">
<g transform="matrix(.26458 0 0 .26458 69.488 50.153)">
<path d="m14.462 28.768-.0035.0072h-12.787c-.4776 0-.8756-.1467-1.194-.4401s-.4776-.6619-.4776-1.1055c0-.4723.20262-.9589.60786-1.4598l20.342-24.837c.3763-.48659.7924-.77997 1.2482-.88015.4559-.10018.8648-.057246 1.2266.1288.3618.18605.6151.49016.7598.91235.1448.42218.1086.92666-.1085 1.5134l-6.643 17.625h1.1057l.0035-.0072h12.787c.4776 0 .8756.1467 1.194.4401s.4776.6619.4776 1.1055c0 .4723-.2026.9589-.6079 1.4598l-20.342 24.837c-.3763.4865-.7924.7799-1.2482.8801-.4559.1002-.86479.0573-1.2266-.1288-.36182-.186-.61509-.4902-.75982-.9123-.14473-.4222-.10855-.9267.10854-1.5135l6.643-17.624z" clip-rule="evenodd" fill="#fff" fill-rule="evenodd" style="fill:#000"/>
</g>
<g transform="matrix(.26458 0 0 .26458 81.643 50.938)">
<path d="m.92 42h19.488c7.672 0 12.488-4.144 12.488-11.312 0-5.152-2.072-7.952-5.264-9.464v-.224c1.96-1.4 3.808-3.752 3.808-8.008 0-6.44-4.76-10.192-11.76-10.192h-18.76zm8.736-24.08v-7.392h8.736c2.968 0 4.144 1.568 4.144 3.696s-1.176 3.696-4.144 3.696zm0 16.296v-8.68h9.296c3.808 0 4.984 2.352 4.984 4.312 0 2.352-1.232 4.368-4.984 4.368zm40.657 8.288c9.52 0 14.616-6.384 14.616-15.12 0-8.792-5.208-15.176-14.616-15.176s-14.616 6.384-14.616 15.176 5.096 15.12 14.616 15.12zm0-7.56c-3.528 0-5.824-2.24-5.824-7.56 0-5.264 2.24-7.616 5.824-7.616s5.824 2.296 5.824 7.616-2.24 7.56-5.824 7.56zm18.831 7.056h8.624v-41.44h-8.624zm26.552.504c2.744 0 5.0404-.504 5.9364-.84v-7.168h-.56c-.896.336-1.8484.504-2.8004.504-2.576 0-3.752-1.176-3.752-3.92v-11.032h7.0004v-7.336h-7.0004v-7.672h-.56l-7.672 1.568v6.104h-5.152v7.336h4.76v13.104c0 5.32 3.024 9.352 9.8 9.352zm10.397-.504h24.192v-7.336h-13.552v-.224l13.44-14.448v-7.28h-24.024v7.336h13.44v.224l-13.496 14.504z" fill="#fff" style="fill:#000"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+45 -2
View File
@@ -1,6 +1,6 @@
new Vue({
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
mixins: [window.windowMixin],
data: function () {
return {
user: null,
@@ -32,6 +32,42 @@ new Vue({
toggleDarkMode: function () {
this.$q.dark.toggle()
this.$q.localStorage.set('lnbits.darkMode', this.$q.dark.isActive)
if (!this.$q.dark.isActive && this.gradientChoice) {
this.toggleGradient()
}
},
applyGradient: function () {
darkBgColor = this.$q.localStorage.getItem('lnbits.darkBgColor')
primaryColor = this.$q.localStorage.getItem('lnbits.primaryColor')
if (this.gradientChoice) {
if (!this.$q.dark.isActive) {
this.toggleDarkMode()
}
const gradientStyle = `linear-gradient(to bottom right, ${LNbits.utils.hexDarken(String(primaryColor), -70)}, #0a0a0a)`
document.body.style.setProperty(
'background-image',
gradientStyle,
'important'
)
const gradientStyleCards = `background-color: ${LNbits.utils.hexAlpha(String(darkBgColor), 0.4)} !important`
const style = document.createElement('style')
style.innerHTML =
`body[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"] .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark), body.body${this.$q.dark.isActive ? '--dark' : ''} .q-header, body.body${this.$q.dark.isActive ? '--dark' : ''} .q-drawer { ${gradientStyleCards} }` +
`body[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"].body--dark{background: ${LNbits.utils.hexDarken(String(primaryColor), -88)} !important; }` +
`[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"] .q-card--dark{background: ${String(darkBgColor)} !important;} }`
document.head.appendChild(style)
this.$q.localStorage.set('lnbits.gradientBg', true)
} else {
this.$q.localStorage.set('lnbits.gradientBg', false)
}
},
toggleGradient: function () {
this.gradientChoice = !this.gradientChoice
this.applyGradient()
if (!this.gradientChoice) {
window.location.reload()
}
this.gradientChoice = this.$q.localStorage.getItem('lnbits.gradientBg')
},
reactionChoiceFunc: function () {
this.$q.localStorage.set('lnbits.reactions', this.reactionChoice)
@@ -39,6 +75,10 @@ new Vue({
changeColor: function (newValue) {
document.body.setAttribute('data-theme', newValue)
this.$q.localStorage.set('lnbits.theme', newValue)
this.setColors()
if (this.$q.localStorage.getItem('lnbits.gradientBg')) {
this.applyGradient()
}
},
updateAccount: async function () {
try {
@@ -118,5 +158,8 @@ new Vue({
} catch (e) {
LNbits.utils.notifyApiError(e)
}
if (this.$q.localStorage.getItem('lnbits.gradientBg')) {
this.applyGradient()
}
}
})
+1 -1
View File
@@ -1,4 +1,4 @@
new Vue({
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
data: function () {
+114 -14
View File
@@ -1,15 +1,10 @@
/* globals crypto, moment, Vue, axios, Quasar, _ */
Vue.use(VueI18n)
window.LOCALE = 'en'
window.i18n = new VueI18n({
window.i18n = new VueI18n.createI18n({
locale: window.LOCALE,
fallbackLocale: window.LOCALE,
messages: window.localisation
})
window.EventHub = new Vue()
window.LNbits = {
api: {
request: function (method, url, apiKey, data) {
@@ -247,7 +242,7 @@ window.LNbits = {
payment: function (data) {
obj = {
checking_id: data.checking_id,
pending: data.pending,
status: data.status,
amount: data.amount,
fee: data.fee,
memo: data.memo,
@@ -264,12 +259,12 @@ window.LNbits = {
fiat_currency: data.fiat_currency
}
obj.date = Quasar.utils.date.formatDate(
obj.date = Quasar.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.dateFrom = moment(obj.date).fromNow()
obj.expirydate = Quasar.utils.date.formatDate(
obj.expirydate = Quasar.date.formatDate(
new Date(obj.expiry * 1000),
'YYYY-MM-DD HH:mm'
)
@@ -280,14 +275,21 @@ window.LNbits = {
obj.fsat = new Intl.NumberFormat(window.LOCALE).format(obj.sat)
obj.isIn = obj.amount > 0
obj.isOut = obj.amount < 0
obj.isPaid = !obj.pending
obj.isPending = obj.status === 'pending'
obj.isPaid = obj.status === 'success'
obj.isFailed = obj.status === 'failed'
obj._q = [obj.memo, obj.sat].join(' ').toLowerCase()
try {
obj.details = JSON.parse(data.extra?.details || '{}')
} catch {
obj.details = {extraDetails: data.extra?.details}
}
return obj
}
},
utils: {
confirmDialog: function (msg) {
return Quasar.plugins.Dialog.create({
return Quasar.Dialog.create({
message: msg,
ok: {
flat: true,
@@ -321,6 +323,7 @@ window.LNbits = {
return this.formatSat(value / 1000)
},
notifyApiError: function (error) {
console.error(error)
var types = {
400: 'warning',
401: 'warning',
@@ -403,14 +406,14 @@ window.LNbits = {
)
.join('\r\n')
var status = Quasar.utils.exportFile(
var status = Quasar.exportFile(
`${fileName || 'table-export'}.csv`,
content,
'text/csv'
)
if (status !== true) {
Quasar.plugins.Notify.create({
Quasar.Notify.create({
message: 'Browser denied file download...',
color: 'negative',
icon: null
@@ -422,6 +425,18 @@ window.LNbits = {
converter.setFlavor('github')
converter.setOption('simpleLineBreaks', true)
return converter.makeHtml(text)
},
hexToRgb: function (hex) {
return Quasar.colors.hexToRgb(hex)
},
hexDarken: function (hex, percent) {
return Quasar.colors.lighten(hex, percent)
},
hexAlpha: function (hex, alpha) {
return Quasar.colors.changeAlpha(hex, alpha)
},
getPaletteColor: function (color) {
return Quasar.colors.getPaletteColor(color)
}
}
}
@@ -432,6 +447,8 @@ window.windowMixin = {
return {
toggleSubs: true,
reactionChoice: 'confettiBothSides',
gradientChoice:
this.$q.localStorage.getItem('lnbits.gradientBg') || false,
isUserAuthorized: false,
g: {
offline: !navigator.onLine,
@@ -451,9 +468,43 @@ window.windowMixin = {
document.body.setAttribute('data-theme', newValue)
this.$q.localStorage.set('lnbits.theme', newValue)
},
applyGradient: function () {
if (this.$q.localStorage.getItem('lnbits.gradientBg')) {
this.setColors()
darkBgColor = this.$q.localStorage.getItem('lnbits.darkBgColor')
primaryColor = this.$q.localStorage.getItem('lnbits.primaryColor')
const gradientStyle = `linear-gradient(to bottom right, ${LNbits.utils.hexDarken(String(primaryColor), -70)}, #0a0a0a)`
document.body.style.setProperty(
'background-image',
gradientStyle,
'important'
)
const gradientStyleCards = `background-color: ${LNbits.utils.hexAlpha(String(darkBgColor), 0.4)} !important`
const style = document.createElement('style')
style.innerHTML =
`body[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"] .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark), body.body${this.$q.dark.isActive ? '--dark' : ''} .q-header, body.body${this.$q.dark.isActive ? '--dark' : ''} .q-drawer { ${gradientStyleCards} }` +
`body[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"].body--dark{background: ${LNbits.utils.hexDarken(String(primaryColor), -88)} !important; }` +
`[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"] .q-card--dark{background: ${String(darkBgColor)} !important;} }`
document.head.appendChild(style)
}
},
setColors: function () {
this.$q.localStorage.set(
'lnbits.primaryColor',
LNbits.utils.getPaletteColor('primary')
)
this.$q.localStorage.set(
'lnbits.secondaryColor',
LNbits.utils.getPaletteColor('secondary')
)
this.$q.localStorage.set(
'lnbits.darkBgColor',
LNbits.utils.getPaletteColor('dark')
)
},
copyText: function (text, message, position) {
var notify = this.$q.notify
Quasar.utils.copyToClipboard(text).then(function () {
Quasar.copyToClipboard(text).then(function () {
notify({
message: message || 'Copied to clipboard!',
position: position || 'bottom'
@@ -500,6 +551,52 @@ window.windowMixin = {
LNbits.utils.notifyApiError(e)
}
})
},
themeParams() {
const url = new URL(window.location.href)
const params = new URLSearchParams(window.location.search)
const fields = ['theme', 'dark', 'gradient']
const toBoolean = value =>
value.trim().toLowerCase() === 'true' || value === '1'
// Check if any of the relevant parameters ('theme', 'dark', 'gradient') are present in the URL.
if (fields.some(param => params.has(param))) {
const theme = params.get('theme')
const darkMode = params.get('dark')
const gradient = params.get('gradient')
if (
theme &&
this.g.allowedThemes.includes(theme.trim().toLowerCase())
) {
const normalizedTheme = theme.trim().toLowerCase()
document.body.setAttribute('data-theme', normalizedTheme)
this.$q.localStorage.set('lnbits.theme', normalizedTheme)
}
if (darkMode) {
const isDark = toBoolean(darkMode)
this.$q.localStorage.set('lnbits.darkMode', isDark)
if (!isDark) {
this.$q.localStorage.set('lnbits.gradientBg', false)
}
}
if (gradient) {
const isGradient = toBoolean(gradient)
this.$q.localStorage.set('lnbits.gradientBg', isGradient)
if (isGradient) {
this.$q.localStorage.set('lnbits.darkMode', true)
}
}
// Remove processed parameters
fields.forEach(param => params.delete(param))
window.history.replaceState(null, null, url.pathname)
}
this.setColors()
}
},
created: async function () {
@@ -552,6 +649,8 @@ window.windowMixin = {
)
}
this.applyGradient()
if (window.user) {
this.g.user = Object.freeze(window.LNbits.map.user(window.user))
}
@@ -590,6 +689,7 @@ window.windowMixin = {
this.g.extensions = extensions
}
await this.checkUsrInUrl()
this.themeParams()
}
}
+108 -48
View File
@@ -1,6 +1,6 @@
/* global _, Vue, moment, LNbits, EventHub, decryptLnurlPayAES */
window.app.component(QrcodeVue)
Vue.component('lnbits-fsat', {
window.app.component('lnbits-fsat', {
props: {
amount: {
type: Number,
@@ -15,12 +15,13 @@ Vue.component('lnbits-fsat', {
}
})
Vue.component('lnbits-wallet-list', {
window.app.component('lnbits-wallet-list', {
props: ['balance'],
data: function () {
return {
user: null,
activeWallet: null,
activeBalance: [],
balance: 0,
showForm: false,
walletName: '',
LNBITS_DENOMINATION: LNBITS_DENOMINATION
@@ -74,7 +75,7 @@ Vue.component('lnbits-wallet-list', {
`,
computed: {
wallets: function () {
var bal = this.activeBalance
var bal = this.balance
return this.user.wallets.map(function (obj) {
obj.live_fsat =
bal.length && bal[0] === obj.id
@@ -87,9 +88,6 @@ Vue.component('lnbits-wallet-list', {
methods: {
createWallet: function () {
LNbits.api.createWallet(this.user.wallets[0], this.walletName)
},
updateWalletBalance: function (payload) {
this.activeBalance = payload
}
},
created: function () {
@@ -99,11 +97,11 @@ Vue.component('lnbits-wallet-list', {
if (window.wallet) {
this.activeWallet = LNbits.map.wallet(window.wallet)
}
EventHub.$on('update-wallet-balance', this.updateWalletBalance)
document.addEventListener('updateWalletBalance', this.updateWalletBalance)
}
})
Vue.component('lnbits-extension-list', {
window.app.component('lnbits-extension-list', {
data: function () {
return {
extensions: [],
@@ -169,7 +167,7 @@ Vue.component('lnbits-extension-list', {
}
})
Vue.component('lnbits-manage', {
window.app.component('lnbits-manage', {
props: ['showAdmin', 'showNode', 'showExtensions', 'showUsers'],
methods: {
isActive: function (path) {
@@ -229,9 +227,9 @@ Vue.component('lnbits-manage', {
}
})
Vue.component('lnbits-payment-details', {
window.app.component('lnbits-payment-details', {
props: ['payment'],
mixins: [windowMixin],
mixins: [window.windowMixin],
data: function () {
return {
LNBITS_DENOMINATION: LNBITS_DENOMINATION
@@ -345,7 +343,7 @@ Vue.component('lnbits-payment-details', {
}
})
Vue.component('lnbits-lnurlpay-success-action', {
window.app.component('lnbits-lnurlpay-success-action', {
props: ['payment', 'success_action'],
data() {
return {
@@ -374,10 +372,12 @@ Vue.component('lnbits-lnurlpay-success-action', {
}
})
Vue.component('lnbits-qrcode', {
mixins: [windowMixin],
window.app.component('lnbits-qrcode', {
mixins: [window.windowMixin],
components: {
QrcodeVue
},
props: ['value'],
components: {[VueQrcode.name]: VueQrcode},
data() {
return {
logo: LNBITS_QR_LOGO
@@ -385,15 +385,14 @@ Vue.component('lnbits-qrcode', {
},
template: `
<div class="qrcode__wrapper">
<qrcode :value="value"
:options="{errorCorrectionLevel: 'Q', width: 800}" class="rounded-borders"></qrcode>
<qrcode-vue :value="value" size="350" class="rounded-borders"></qrcode-vue>
<img class="qrcode__image" :src="logo" alt="..." />
</div>
`
})
Vue.component('lnbits-notifications-btn', {
mixins: [windowMixin],
window.app.component('lnbits-notifications-btn', {
mixins: [window.windowMixin],
props: ['pubkey'],
data() {
return {
@@ -605,12 +604,13 @@ Vue.component('lnbits-notifications-btn', {
}
})
Vue.component('lnbits-dynamic-fields', {
mixins: [windowMixin],
window.app.component('lnbits-dynamic-fields', {
mixins: [window.windowMixin],
props: ['options', 'value'],
data() {
return {
formData: null
formData: null,
rules: [val => !!val || 'Field is required']
}
},
@@ -619,49 +619,109 @@ Vue.component('lnbits-dynamic-fields', {
<div class="row q-mb-lg" v-for="o in options">
<div class="col auto-width">
<p v-if=o.options?.length class="q-ml-xl">
<span v-text="o.name"></span> <small v-if="o.description"> (<span v-text="o.description"></span>)</small>
<span v-text="o.label || o.name"></span> <small v-if="o.description"> (<span v-text="o.description"></span>)</small>
</p>
<lnbits-dynamic-fields v-if="o.options?.length" :options="o.options" v-model="formData[o.name]"
@input="handleValueChanged" class="q-ml-xl">
</lnbits-dynamic-fields>
<div v-else>
<q-input v-if="o.type === 'number'" v-model="formData[o.name]" @input="handleValueChanged" type="number"
:label="o.name" :hint="o.description" filled dense>
</q-input>
<q-input v-else-if="o.type === 'text'" v-model="formData[o.name]" @input="handleValueChanged" type="textarea"
rows="5" :label="o.name" :hint="o.description" filled dense>
</q-input>
<q-input v-else-if="o.type === 'password'" v-model="formData[o.name]" @input="handleValueChanged" type="password"
:label="o.name" :hint="o.description" filled dense>
</q-input>
<q-input
v-if="o.type === 'number'"
type="number"
v-model="formData[o.name]"
@input="handleValueChanged"
:label="o.label || o.name"
:hint="o.description"
:rules="applyRules(o.required)"
filled
dense
></q-input>
<q-input
v-else-if="o.type === 'text'"
type="textarea"
rows="5"
v-model="formData[o.name]"
@input="handleValueChanged"
:label="o.label || o.name"
:hint="o.description"
:rules="applyRules(o.required)"
filled
dense
></q-input>
<q-input
v-else-if="o.type === 'password'"
v-model="formData[o.name]"
@input="handleValueChanged"
type="password"
:label="o.label || o.name"
:hint="o.description"
:rules="applyRules(o.required)"
filled
dense
></q-input>
<q-select
v-else-if="o.type === 'select'"
v-model="formData[o.name]"
@input="handleValueChanged"
:label="o.label || o.name"
:hint="o.description"
:options="o.values"
:rules="applyRules(o.required)"
></q-select>
<q-select
v-else-if="o.isList"
v-model.trim="formData[o.name]"
@input="handleValueChanged"
input-debounce="0"
new-value-mode="add-unique"
:label="o.label || o.name"
:hint="o.description"
:rules="applyRules(o.required)"
filled
multiple
dense
use-input
use-chips
multiple
hide-dropdown-icon
></q-select>
<div v-else-if="o.type === 'bool'">
<q-item tag="label" v-ripple>
<q-item-section avatar top>
<q-checkbox v-model="formData[o.name]" @input="handleValueChanged" />
</q-item-section>
<q-item-section>
<q-item-label><span v-text="o.name"></span></q-item-label>
<q-item-label><span v-text="o.label || o.name"></span></q-item-label>
<q-item-label caption> <span v-text="o.description"></span> </q-item-label>
</q-item-section>
</q-item>
</div>
<q-select v-else-if="o.type === 'select'" v-model="formData[o.name]" @input="handleValueChanged" :label="o.name"
:hint="o.description" :options="o.values"></q-select>
<q-select v-else-if="o.isList" filled multiple dense v-model.trim="formData[o.name]" use-input use-chips
@input="handleValueChanged" multiple hide-dropdown-icon input-debounce="0" new-value-mode="add-unique"
:label="o.name" :hint="o.description">
</q-select>
<q-input v-else v-model="formData[o.name]" @input="handleValueChanged" :label="o.name" :hint="o.description"
filled dense>
</q-input>
<q-input
v-else-if="o.type === 'hidden'"
v-model="formData[o.name]"
type="text"
style="display: none"
:rules="applyRules(o.required)"
></q-input>
<q-input
v-else
v-model="formData[o.name]"
@input="handleValueChanged"
:hint="o.description"
:label="o.label || o.name"
:rules="applyRules(o.required)"
filled
dense
></q-input>
</div>
</div>
</div>
</div>
`,
methods: {
applyRules(required) {
return required ? this.rules : []
},
buildData(options, data = {}) {
return options.reduce((d, option) => {
if (option.options?.length) {
@@ -681,8 +741,8 @@ Vue.component('lnbits-dynamic-fields', {
}
})
Vue.component('lnbits-update-balance', {
mixins: [windowMixin],
window.app.component('lnbits-update-balance', {
mixins: [window.windowMixin],
props: ['wallet_id', 'callback'],
computed: {
denomination() {
@@ -1,4 +1,4 @@
Vue.component('lnbits-extension-rating', {
window.app.component('lnbits-extension-rating', {
name: 'lnbits-extension-rating',
props: ['rating'],
template: `
@@ -1,10 +1,10 @@
Vue.component('lnbits-extension-settings-form', {
window.app.component('lnbits-extension-settings-form', {
name: 'lnbits-extension-settings-form',
props: ['options', 'adminkey', 'endpoint'],
methods: {
updateSettings: async function () {
async updateSettings() {
if (!this.settings) {
return Quasar.plugins.Notify.create({
return this.$q.notify({
message: 'No settings to update',
type: 'negative'
})
@@ -66,7 +66,7 @@ Vue.component('lnbits-extension-settings-form', {
}
})
Vue.component('lnbits-extension-settings-btn-dialog', {
window.app.component('lnbits-extension-settings-btn-dialog', {
name: 'lnbits-extension-settings-btn-dialog',
props: ['options', 'adminkey', 'endpoint'],
template: `
@@ -1,5 +1,5 @@
Vue.component('lnbits-funding-sources', {
mixins: [windowMixin],
window.app.component('lnbits-funding-sources', {
mixins: [window.windowMixin],
props: ['form-data', 'allowed-funding-sources'],
methods: {
getFundingSourceLabel(item) {
@@ -127,6 +127,16 @@ Vue.component('lnbits-funding-sources', {
alby_access_token: 'Key'
}
],
[
'BoltzWallet',
'Boltz',
{
boltz_client_endpoint: 'Endpoint',
boltz_client_macaroon: 'Admin Macaroon path or hex',
boltz_client_cert: 'Certificate path or hex',
boltz_client_wallet: 'Wallet Name'
}
],
[
'ZBDWallet',
'ZBD',
@@ -165,6 +175,24 @@ Vue.component('lnbits-funding-sources', {
spark_url: 'Endpoint',
spark_token: 'Token'
}
],
[
'NWCWallet',
'Nostr Wallet Connect',
{
nwc_pairing_url: 'Pairing URL'
}
],
[
'BreezSdkWallet',
'Breez SDK',
{
breez_api_key: 'Breez API Key',
breez_greenlight_seed: 'Greenlight Seed',
breez_greenlight_device_key: 'Greenlight Device Key',
breez_greenlight_device_cert: 'Greenlight Device Cert',
breez_greenlight_invite_code: 'Greenlight Invite Code'
}
]
]
}
+2 -2
View File
@@ -80,10 +80,10 @@ function generateChart(canvas, rawData) {
})
}
Vue.component('payment-chart', {
window.app.component('payment-chart', {
name: 'payment-chart',
props: ['wallet'],
mixins: [windowMixin],
mixins: [window.windowMixin],
data: function () {
return {
paymentsChart: {
+119 -11
View File
@@ -1,7 +1,7 @@
Vue.component('payment-list', {
window.app.component('payment-list', {
name: 'payment-list',
props: ['update', 'wallet', 'mobileSimple', 'lazy'],
mixins: [windowMixin],
mixins: [window.windowMixin],
data: function () {
return {
denomination: LNBITS_DENOMINATION,
@@ -33,6 +33,8 @@ Vue.component('payment-list', {
search: null,
loading: false
},
exportTagName: '',
exportPaymentTagList: [],
paymentsCSV: {
columns: [
{
@@ -146,7 +148,7 @@ Vue.component('payment-list', {
paymentTableRowKey: function (row) {
return row.payment_hash + row.amount
},
exportCSV: function () {
exportCSV(detailed = false) {
// status is important for export but it is not in paymentsTable
// because it is manually added with payment detail link and icons
// and would cause duplication in the list
@@ -157,14 +159,51 @@ Vue.component('payment-list', {
}
const params = new URLSearchParams(query)
LNbits.api.getPayments(this.wallet, params).then(response => {
const payments = response.data.data.map(LNbits.map.payment)
let payments = response.data.data.map(LNbits.map.payment)
let columns = this.paymentsCSV.columns
if (detailed) {
if (this.exportPaymentTagList.length) {
payments = payments.filter(p =>
this.exportPaymentTagList.includes(p.tag)
)
}
const extraColumns = Object.keys(
payments.reduce((e, p) => ({...e, ...p.details}), {})
).map(col => ({
name: col,
align: 'right',
label:
col.charAt(0).toUpperCase() +
col.slice(1).replace(/([A-Z])/g, ' $1'),
field: row => row.details[col],
format: data =>
typeof data === 'object' ? JSON.stringify(data) : data
}))
columns = this.paymentsCSV.columns.concat(extraColumns)
}
LNbits.utils.exportCSV(
this.paymentsCSV.columns,
columns,
payments,
this.wallet.name + '-payments'
)
})
},
addFilterTag: function () {
if (!this.exportTagName) return
const value = this.exportTagName.trim()
this.exportPaymentTagList = this.exportPaymentTagList.filter(
v => v !== value
)
this.exportPaymentTagList.push(value)
this.exportTagName = ''
},
removeExportTag: function (value) {
this.exportPaymentTagList = this.exportPaymentTagList.filter(
v => v !== value
)
},
formatCurrency: function (amount, currency) {
try {
return LNbits.utils.formatCurrency(amount, currency)
@@ -202,7 +241,59 @@ Vue.component('payment-list', {
></h5>
</div>
<div class="gt-sm col-auto">
<q-btn flat color="grey" @click="exportCSV" :label="$t('export_csv')" ></q-btn>
<q-btn-dropdown
outline
persistent
class="q-mr-sm"
color="grey"
:label="$t('export_csv')"
split
@click="exportCSV(false)"
>
<q-list>
<q-item>
<q-item-section>
<q-input
@keydown.enter="addFilterTag"
filled
dense
v-model="exportTagName"
type="text"
label="Payment Tags"
class="q-pa-sm"
>
<q-btn
@click="addFilterTag"
dense
flat
icon="add"
></q-btn>
</q-input>
</q-item-section>
</q-item>
<q-item v-if="exportPaymentTagList.length">
<q-item-section>
<div>
<q-chip
v-for="tag in exportPaymentTagList"
:key="tag"
removable
@remove="removeExportTag(tag)"
color="primary"
text-color="white"
:label="tag"
></q-chip>
</div>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-btn v-close-popup outline color="grey" @click="exportCSV(true)" label="Export to CSV with details" ></q-btn>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<payment-chart :wallet="wallet" />
</div>
</div>
@@ -222,7 +313,7 @@ Vue.component('payment-list', {
<q-table
dense
flat
:data="paymentsOmitter"
:rows="paymentsOmitter"
:row-key="paymentTableRowKey"
:columns="paymentsTable.columns"
:pagination.sync="paymentsTable.pagination"
@@ -254,6 +345,16 @@ Vue.component('payment-list', {
:color="props.row.isOut ? 'pink' : 'green'"
@click="props.expand = !props.expand"
></q-icon>
<q-icon
v-else-if="props.row.isFailed"
name="warning"
color="yellow"
@click="props.expand = !props.expand"
>
<q-tooltip
><span>failed</span
></q-tooltip>
</q-icon>
<q-icon
v-else
name="settings_ethernet"
@@ -319,7 +420,7 @@ Vue.component('payment-list', {
<q-dialog v-model="props.expand" :props="props" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<div v-if="props.row.isIn && props.row.pending">
<div v-if="props.row.isIn && props.row.isPending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
<span v-text="$t('invoice_waiting')"></span>
<lnbits-payment-details
@@ -353,6 +454,13 @@ Vue.component('payment-list', {
></q-btn>
</div>
</div>
<div v-else-if="props.row.isOut && props.row.isPending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
<span v-text="$t('outgoing_payment_pending')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isPaid && props.row.isIn">
<q-icon
size="18px"
@@ -375,9 +483,9 @@ Vue.component('payment-list', {
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isOut && props.row.pending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
<span v-text="$t('outgoing_payment_pending')"></span>
<div v-else-if="props.row.isFailed">
<q-icon name="warning" color="yellow"></q-icon>
<span>Payment failed</span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
+2 -3
View File
@@ -1,6 +1,6 @@
new Vue({
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
mixins: [window.windowMixin],
data: function () {
return {
disclaimerDialog: {
@@ -93,7 +93,6 @@ new Vue({
},
created() {
this.description = SITE_DESCRIPTION
this.isUserAuthorized = !!this.$q.cookies.get('is_lnbits_user_authorized')
if (this.isUserAuthorized) {
window.location.href = '/wallet'
+4
View File
@@ -0,0 +1,4 @@
window.app.use(VueQrcodeReader)
window.app.use(Quasar)
window.app.use(window.i18n)
window.app.mount('#vue')
+12 -12
View File
@@ -4,7 +4,7 @@ function shortenNodeId(nodeId) {
: '...'
}
Vue.component('lnbits-node-ranks', {
window.app.component('lnbits-node-ranks', {
props: ['ranks'],
data: function () {
return {
@@ -35,7 +35,7 @@ Vue.component('lnbits-node-ranks', {
`
})
Vue.component('lnbits-channel-stats', {
window.app.component('lnbits-channel-stats', {
props: ['stats'],
data: function () {
return {
@@ -71,7 +71,7 @@ Vue.component('lnbits-channel-stats', {
}
})
Vue.component('lnbits-stat', {
window.app.component('lnbits-stat', {
props: ['title', 'amount', 'msat', 'btc'],
computed: {
value: function () {
@@ -99,20 +99,20 @@ Vue.component('lnbits-stat', {
`
})
Vue.component('lnbits-node-qrcode', {
window.app.component('lnbits-node-qrcode', {
props: ['info'],
mixins: [windowMixin],
mixins: [window.windowMixin],
template: `
<q-card class="my-card">
<q-card-section>
<div class="text-h6">
<div style="text-align: center">
<qrcode
<vue-qrcode
:value="info.addresses[0]"
:options="{width: 250}"
v-if='info.addresses[0]'
class="rounded-borders"
></qrcode>
></vue-qrcode>
<div v-else class='text-subtitle1'>
No addresses available
</div>
@@ -132,14 +132,14 @@ Vue.component('lnbits-node-qrcode', {
`
})
Vue.component('lnbits-node-info', {
window.app.component('lnbits-node-info', {
props: ['info'],
data() {
return {
showDialog: false
}
},
mixins: [windowMixin],
mixins: [window.windowMixin],
methods: {
shortenNodeId
},
@@ -177,7 +177,7 @@ Vue.component('lnbits-node-info', {
`
})
Vue.component('lnbits-stat', {
window.app.component('lnbits-stat', {
props: ['title', 'amount', 'msat', 'btc'],
computed: {
value: function () {
@@ -205,7 +205,7 @@ Vue.component('lnbits-stat', {
`
})
Vue.component('lnbits-channel-balance', {
window.app.component('lnbits-channel-balance', {
props: ['balance', 'color'],
methods: {
formatMsat: function (msat) {
@@ -246,7 +246,7 @@ Vue.component('lnbits-channel-balance', {
`
})
Vue.component('lnbits-date', {
window.app.component('lnbits-date', {
props: ['ts'],
computed: {
date: function () {
+49 -7
View File
@@ -1,6 +1,6 @@
new Vue({
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
mixins: [window.windowMixin],
data: function () {
return {
isSuperUser: false,
@@ -164,6 +164,41 @@ new Vue({
this.chart1 = new Chart(this.$refs.chart1.getContext('2d'), {
type: 'bubble',
options: {
scales: {
xAxes: [
{
type: 'linear',
ticks: {
beginAtZero: true
},
scaleLabel: {
display: true,
labelString: 'Tx count'
}
}
],
yAxes: [
{
type: 'linear',
ticks: {
beginAtZero: true
},
scaleLabel: {
display: true,
labelString: 'User balance in million sats'
}
}
]
},
tooltips: {
callbacks: {
label: function (tooltipItem, data) {
const dataset = data.datasets[tooltipItem.datasetIndex]
const dataPoint = dataset.data[tooltipItem.index]
return dataPoint.customLabel || ''
}
}
},
layout: {
padding: 10
}
@@ -171,7 +206,7 @@ new Vue({
data: {
datasets: [
{
label: 'Balance - TX Count in million sats',
label: 'Wallet balance vs transaction count',
backgroundColor: 'rgb(255, 99, 132)',
data: []
}
@@ -183,9 +218,6 @@ new Vue({
formatSat: function (value) {
return LNbits.utils.formatSat(Math.floor(value / 1000))
},
usersTableRowKey: function (row) {
return row.id
},
createUser() {
LNbits.api
.request('POST', '/users/api/v1/user', null, this.createUserDialog.data)
@@ -291,10 +323,20 @@ new Vue({
})
const data = filtered.map(user => {
const labelUsername = `${user.username ? 'User: ' + user.username + '. ' : ''}`
const userBalanceSats = Math.floor(
user.balance_msat / 1000
).toLocaleString()
return {
x: user.transaction_count,
y: user.balance_msat / 1000000000,
r: 3
r: 4,
customLabel:
labelUsername +
'Balance: ' +
userBalanceSats +
' sats. Tx count: ' +
user.transaction_count
}
})
this.chart1.data.datasets[0].data = data
+70 -73
View File
@@ -1,11 +1,6 @@
/* globals windowMixin, decode, Vue, VueQrcodeReader, VueQrcode, Quasar, LNbits, _, EventHub, decryptLnurlPayAES */
Vue.component(VueQrcode.name, VueQrcode)
Vue.use(VueQrcodeReader)
new Vue({
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
mixins: [window.windowMixin],
data: function () {
return {
updatePayments: false,
@@ -56,7 +51,9 @@ new Vue({
update: {
name: null,
currency: null
}
},
inkeyHidden: true,
adminkeyHidden: true
}
},
computed: {
@@ -209,6 +206,54 @@ new Vue({
})
}
},
lnurlScan() {
LNbits.api
.request(
'GET',
'/api/v1/lnurlscan/' + this.parse.data.request,
this.g.wallet.adminkey
)
.catch(err => {
LNbits.utils.notifyApiError(err)
})
.then(response => {
let data = response.data
if (data.status === 'ERROR') {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: `${data.domain} lnurl call failed.`,
caption: data.reason
})
return
}
if (data.kind === 'pay') {
this.parse.lnurlpay = Object.freeze(data)
this.parse.data.amount = data.minSendable / 1000
} else if (data.kind === 'auth') {
this.parse.lnurlauth = Object.freeze(data)
} else if (data.kind === 'withdraw') {
this.parse.show = false
this.receive.show = true
this.receive.status = 'pending'
this.receive.paymentReq = null
this.receive.paymentHash = null
this.receive.data.amount = data.maxWithdrawable / 1000
this.receive.data.memo = data.defaultDescription
this.receive.minMax = [
data.minWithdrawable / 1000,
data.maxWithdrawable / 1000
]
this.receive.lnurl = {
domain: data.domain,
callback: data.callback,
fixed: data.fixed
}
}
})
},
decodeQR: function (res) {
this.parse.data.request = res
this.decodeRequest()
@@ -216,67 +261,18 @@ new Vue({
},
decodeRequest: function () {
this.parse.show = true
let req = this.parse.data.request.toLowerCase()
if (this.parse.data.request.toLowerCase().startsWith('lightning:')) {
this.parse.data.request = this.parse.data.request.slice(10)
} else if (this.parse.data.request.toLowerCase().startsWith('lnurl:')) {
this.parse.data.request = this.parse.data.request.slice(6)
} else if (req.indexOf('lightning=lnurl1') !== -1) {
this.parse.data.request = this.parse.data.request
.split('lightning=')[1]
.split('&')[0]
this.parse.data.request = this.parse.data.request.trim().toLowerCase()
let req = this.parse.data.request
if (req.startsWith('lightning:')) {
this.parse.data.request = req.slice(10)
} else if (req.startsWith('lnurl:')) {
this.parse.data.request = req.slice(6)
} else if (req.includes('lightning=lnurl1')) {
this.parse.data.request = req.split('lightning=')[1].split('&')[0]
}
if (
this.parse.data.request.toLowerCase().startsWith('lnurl1') ||
this.parse.data.request.match(/[\w.+-~_]+@[\w.+-~_]/)
) {
LNbits.api
.request(
'GET',
'/api/v1/lnurlscan/' + this.parse.data.request,
this.g.wallet.adminkey
)
.catch(err => {
LNbits.utils.notifyApiError(err)
})
.then(response => {
let data = response.data
if (data.status === 'ERROR') {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: `${data.domain} lnurl call failed.`,
caption: data.reason
})
return
}
if (data.kind === 'pay') {
this.parse.lnurlpay = Object.freeze(data)
this.parse.data.amount = data.minSendable / 1000
} else if (data.kind === 'auth') {
this.parse.lnurlauth = Object.freeze(data)
} else if (data.kind === 'withdraw') {
this.parse.show = false
this.receive.show = true
this.receive.status = 'pending'
this.receive.paymentReq = null
this.receive.paymentHash = null
this.receive.data.amount = data.maxWithdrawable / 1000
this.receive.data.memo = data.defaultDescription
this.receive.minMax = [
data.minWithdrawable / 1000,
data.maxWithdrawable / 1000
]
this.receive.lnurl = {
domain: data.domain,
callback: data.callback,
fixed: data.fixed
}
}
})
req = this.parse.data.request
if (req.startsWith('lnurl1') || req.match(/[\w.+-~_]+@[\w.+-~_]/)) {
this.lnurlScan()
return
}
@@ -320,7 +316,7 @@ new Vue({
var expireDate = new Date(
(invoice.data.time_stamp + tag.value) * 1000
)
cleanInvoice.expireDate = Quasar.utils.date.formatDate(
cleanInvoice.expireDate = this.$q.utils.date.formatDate(
expireDate,
'YYYY-MM-DDTHH:mm:ss.SSSZ'
)
@@ -513,10 +509,11 @@ new Vue({
fetchBalance: function () {
LNbits.api.getWallet(this.g.wallet).then(response => {
this.balance = Math.floor(response.data.balance / 1000)
EventHub.$emit('update-wallet-balance', [
this.g.wallet.id,
this.balance
])
document.dispatchEvent(
new CustomEvent('updateWalletBalance', {
detail: [this.g.wallet.id, this.balance]
})
)
})
if (this.g.wallet.currency) {
this.updateFiatBalance()
+15 -13
View File
@@ -3,15 +3,14 @@
"vendor/moment.js",
"vendor/underscore.js",
"vendor/axios.js",
"vendor/vue.js",
"vendor/vue-router.js",
"vendor/VueQrcodeReader.umd.js",
"vendor/vue-qrcode.js",
"vendor/vuex.js",
"vendor/quasar.ie.polyfills.umd.min.js",
"vendor/quasar.umd.js",
"vendor/Chart.bundle.js",
"vendor/vue-i18n.js",
"vendor/vue.global.prod.js",
"vendor/quasar.umd.prod.js",
"vendor/vuex.global.js",
"vendor/vue-i18n.global.prod.js",
"vendor/vue-router.global.js",
"vendor/vue-qrcode-reader.umd.js",
"vendor/qrcode.vue.browser.js",
"vendor/chart.umd.js",
"vendor/showdown.js",
"i18n/i18n.js",
"i18n/de.js",
@@ -34,14 +33,17 @@
"i18n/kr.js",
"i18n/fi.js",
"js/base.js",
"js/components.js",
"js/event-reactions.js",
"js/bolt11-decoder.js"
],
"components": [
"js/components/lnbits-funding-sources.js",
"js/components/extension-settings.js",
"js/components/extension-rating.js",
"js/components/payment-list.js",
"js/components/payment-chart.js",
"js/event-reactions.js",
"js/bolt11-decoder.js"
"js/components.js",
"js/init-app.js"
],
"css": ["vendor/quasar.css", "vendor/Chart.css", "css/base.css"]
"css": ["vendor/quasar.css", "css/base.css"]
}
+1759 -453
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+368 -365
View File
@@ -1,5 +1,5 @@
//! moment.js
//! version : 2.29.4
//! version : 2.30.1
//! authors : Tim Wood, Iskren Chernev, Moment.js contributors
//! license : MIT
//! momentjs.com
@@ -155,24 +155,25 @@
}
function isValid(m) {
if (m._isValid == null) {
var flags = getParsingFlags(m),
parsedParts = some.call(flags.parsedDateParts, function (i) {
return i != null;
}),
isNowValid =
!isNaN(m._d.getTime()) &&
flags.overflow < 0 &&
!flags.empty &&
!flags.invalidEra &&
!flags.invalidMonth &&
!flags.invalidWeekday &&
!flags.weekdayMismatch &&
!flags.nullInput &&
!flags.invalidFormat &&
!flags.userInvalidated &&
(!flags.meridiem || (flags.meridiem && parsedParts));
var flags = null,
parsedParts = false,
isNowValid = m._d && !isNaN(m._d.getTime());
if (isNowValid) {
flags = getParsingFlags(m);
parsedParts = some.call(flags.parsedDateParts, function (i) {
return i != null;
});
isNowValid =
flags.overflow < 0 &&
!flags.empty &&
!flags.invalidEra &&
!flags.invalidMonth &&
!flags.invalidWeekday &&
!flags.weekdayMismatch &&
!flags.nullInput &&
!flags.invalidFormat &&
!flags.userInvalidated &&
(!flags.meridiem || (flags.meridiem && parsedParts));
if (m._strict) {
isNowValid =
isNowValid &&
@@ -180,12 +181,11 @@
flags.unusedTokens.length === 0 &&
flags.bigHour === undefined;
}
if (Object.isFrozen == null || !Object.isFrozen(m)) {
m._isValid = isNowValid;
} else {
return isNowValid;
}
}
if (Object.isFrozen == null || !Object.isFrozen(m)) {
m._isValid = isNowValid;
} else {
return isNowValid;
}
return m._isValid;
}
@@ -630,12 +630,56 @@
return isFunction(format) ? format(output) : format.replace(/%s/i, output);
}
var aliases = {};
function addUnitAlias(unit, shorthand) {
var lowerCase = unit.toLowerCase();
aliases[lowerCase] = aliases[lowerCase + 's'] = aliases[shorthand] = unit;
}
var aliases = {
D: 'date',
dates: 'date',
date: 'date',
d: 'day',
days: 'day',
day: 'day',
e: 'weekday',
weekdays: 'weekday',
weekday: 'weekday',
E: 'isoWeekday',
isoweekdays: 'isoWeekday',
isoweekday: 'isoWeekday',
DDD: 'dayOfYear',
dayofyears: 'dayOfYear',
dayofyear: 'dayOfYear',
h: 'hour',
hours: 'hour',
hour: 'hour',
ms: 'millisecond',
milliseconds: 'millisecond',
millisecond: 'millisecond',
m: 'minute',
minutes: 'minute',
minute: 'minute',
M: 'month',
months: 'month',
month: 'month',
Q: 'quarter',
quarters: 'quarter',
quarter: 'quarter',
s: 'second',
seconds: 'second',
second: 'second',
gg: 'weekYear',
weekyears: 'weekYear',
weekyear: 'weekYear',
GG: 'isoWeekYear',
isoweekyears: 'isoWeekYear',
isoweekyear: 'isoWeekYear',
w: 'week',
weeks: 'week',
week: 'week',
W: 'isoWeek',
isoweeks: 'isoWeek',
isoweek: 'isoWeek',
y: 'year',
years: 'year',
year: 'year',
};
function normalizeUnits(units) {
return typeof units === 'string'
@@ -660,11 +704,24 @@
return normalizedInput;
}
var priorities = {};
function addUnitPriority(unit, priority) {
priorities[unit] = priority;
}
var priorities = {
date: 9,
day: 11,
weekday: 11,
isoWeekday: 11,
dayOfYear: 4,
hour: 13,
millisecond: 16,
minute: 14,
month: 8,
quarter: 7,
second: 15,
weekYear: 1,
isoWeekYear: 1,
week: 5,
isoWeek: 5,
year: 1,
};
function getPrioritizedUnits(unitsObj) {
var units = [],
@@ -680,96 +737,6 @@
return units;
}
function isLeapYear(year) {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
function absFloor(number) {
if (number < 0) {
// -0 -> 0
return Math.ceil(number) || 0;
} else {
return Math.floor(number);
}
}
function toInt(argumentForCoercion) {
var coercedNumber = +argumentForCoercion,
value = 0;
if (coercedNumber !== 0 && isFinite(coercedNumber)) {
value = absFloor(coercedNumber);
}
return value;
}
function makeGetSet(unit, keepTime) {
return function (value) {
if (value != null) {
set$1(this, unit, value);
hooks.updateOffset(this, keepTime);
return this;
} else {
return get(this, unit);
}
};
}
function get(mom, unit) {
return mom.isValid()
? mom._d['get' + (mom._isUTC ? 'UTC' : '') + unit]()
: NaN;
}
function set$1(mom, unit, value) {
if (mom.isValid() && !isNaN(value)) {
if (
unit === 'FullYear' &&
isLeapYear(mom.year()) &&
mom.month() === 1 &&
mom.date() === 29
) {
value = toInt(value);
mom._d['set' + (mom._isUTC ? 'UTC' : '') + unit](
value,
mom.month(),
daysInMonth(value, mom.month())
);
} else {
mom._d['set' + (mom._isUTC ? 'UTC' : '') + unit](value);
}
}
}
// MOMENTS
function stringGet(units) {
units = normalizeUnits(units);
if (isFunction(this[units])) {
return this[units]();
}
return this;
}
function stringSet(units, value) {
if (typeof units === 'object') {
units = normalizeObjectUnits(units);
var prioritized = getPrioritizedUnits(units),
i,
prioritizedLen = prioritized.length;
for (i = 0; i < prioritizedLen; i++) {
this[prioritized[i].unit](units[prioritized[i].unit]);
}
} else {
units = normalizeUnits(units);
if (isFunction(this[units])) {
return this[units](value);
}
}
return this;
}
var match1 = /\d/, // 0 - 9
match2 = /\d\d/, // 00 - 99
match3 = /\d{3}/, // 000 - 999
@@ -790,6 +757,8 @@
// includes scottish gaelic two word and hyphenated months
matchWord =
/[0-9]{0,256}['a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFF07\uFF10-\uFFEF]{1,256}|[\u0600-\u06FF\/]{1,256}(\s*?[\u0600-\u06FF]{1,256}){1,2}/i,
match1to2NoLeadingZero = /^[1-9]\d?/, // 1-99
match1to2HasZero = /^([1-9]\d|\d)/, // 0-99
regexes;
regexes = {};
@@ -828,6 +797,26 @@
return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
}
function absFloor(number) {
if (number < 0) {
// -0 -> 0
return Math.ceil(number) || 0;
} else {
return Math.floor(number);
}
}
function toInt(argumentForCoercion) {
var coercedNumber = +argumentForCoercion,
value = 0;
if (coercedNumber !== 0 && isFinite(coercedNumber)) {
value = absFloor(coercedNumber);
}
return value;
}
var tokens = {};
function addParseToken(token, callback) {
@@ -861,6 +850,10 @@
}
}
function isLeapYear(year) {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
var YEAR = 0,
MONTH = 1,
DATE = 2,
@@ -871,6 +864,173 @@
WEEK = 7,
WEEKDAY = 8;
// FORMATTING
addFormatToken('Y', 0, 0, function () {
var y = this.year();
return y <= 9999 ? zeroFill(y, 4) : '+' + y;
});
addFormatToken(0, ['YY', 2], 0, function () {
return this.year() % 100;
});
addFormatToken(0, ['YYYY', 4], 0, 'year');
addFormatToken(0, ['YYYYY', 5], 0, 'year');
addFormatToken(0, ['YYYYYY', 6, true], 0, 'year');
// PARSING
addRegexToken('Y', matchSigned);
addRegexToken('YY', match1to2, match2);
addRegexToken('YYYY', match1to4, match4);
addRegexToken('YYYYY', match1to6, match6);
addRegexToken('YYYYYY', match1to6, match6);
addParseToken(['YYYYY', 'YYYYYY'], YEAR);
addParseToken('YYYY', function (input, array) {
array[YEAR] =
input.length === 2 ? hooks.parseTwoDigitYear(input) : toInt(input);
});
addParseToken('YY', function (input, array) {
array[YEAR] = hooks.parseTwoDigitYear(input);
});
addParseToken('Y', function (input, array) {
array[YEAR] = parseInt(input, 10);
});
// HELPERS
function daysInYear(year) {
return isLeapYear(year) ? 366 : 365;
}
// HOOKS
hooks.parseTwoDigitYear = function (input) {
return toInt(input) + (toInt(input) > 68 ? 1900 : 2000);
};
// MOMENTS
var getSetYear = makeGetSet('FullYear', true);
function getIsLeapYear() {
return isLeapYear(this.year());
}
function makeGetSet(unit, keepTime) {
return function (value) {
if (value != null) {
set$1(this, unit, value);
hooks.updateOffset(this, keepTime);
return this;
} else {
return get(this, unit);
}
};
}
function get(mom, unit) {
if (!mom.isValid()) {
return NaN;
}
var d = mom._d,
isUTC = mom._isUTC;
switch (unit) {
case 'Milliseconds':
return isUTC ? d.getUTCMilliseconds() : d.getMilliseconds();
case 'Seconds':
return isUTC ? d.getUTCSeconds() : d.getSeconds();
case 'Minutes':
return isUTC ? d.getUTCMinutes() : d.getMinutes();
case 'Hours':
return isUTC ? d.getUTCHours() : d.getHours();
case 'Date':
return isUTC ? d.getUTCDate() : d.getDate();
case 'Day':
return isUTC ? d.getUTCDay() : d.getDay();
case 'Month':
return isUTC ? d.getUTCMonth() : d.getMonth();
case 'FullYear':
return isUTC ? d.getUTCFullYear() : d.getFullYear();
default:
return NaN; // Just in case
}
}
function set$1(mom, unit, value) {
var d, isUTC, year, month, date;
if (!mom.isValid() || isNaN(value)) {
return;
}
d = mom._d;
isUTC = mom._isUTC;
switch (unit) {
case 'Milliseconds':
return void (isUTC
? d.setUTCMilliseconds(value)
: d.setMilliseconds(value));
case 'Seconds':
return void (isUTC ? d.setUTCSeconds(value) : d.setSeconds(value));
case 'Minutes':
return void (isUTC ? d.setUTCMinutes(value) : d.setMinutes(value));
case 'Hours':
return void (isUTC ? d.setUTCHours(value) : d.setHours(value));
case 'Date':
return void (isUTC ? d.setUTCDate(value) : d.setDate(value));
// case 'Day': // Not real
// return void (isUTC ? d.setUTCDay(value) : d.setDay(value));
// case 'Month': // Not used because we need to pass two variables
// return void (isUTC ? d.setUTCMonth(value) : d.setMonth(value));
case 'FullYear':
break; // See below ...
default:
return; // Just in case
}
year = value;
month = mom.month();
date = mom.date();
date = date === 29 && month === 1 && !isLeapYear(year) ? 28 : date;
void (isUTC
? d.setUTCFullYear(year, month, date)
: d.setFullYear(year, month, date));
}
// MOMENTS
function stringGet(units) {
units = normalizeUnits(units);
if (isFunction(this[units])) {
return this[units]();
}
return this;
}
function stringSet(units, value) {
if (typeof units === 'object') {
units = normalizeObjectUnits(units);
var prioritized = getPrioritizedUnits(units),
i,
prioritizedLen = prioritized.length;
for (i = 0; i < prioritizedLen; i++) {
this[prioritized[i].unit](units[prioritized[i].unit]);
}
} else {
units = normalizeUnits(units);
if (isFunction(this[units])) {
return this[units](value);
}
}
return this;
}
function mod(n, x) {
return ((n % x) + x) % x;
}
@@ -919,17 +1079,9 @@
return this.localeData().months(this, format);
});
// ALIASES
addUnitAlias('month', 'M');
// PRIORITY
addUnitPriority('month', 8);
// PARSING
addRegexToken('M', match1to2);
addRegexToken('M', match1to2, match1to2NoLeadingZero);
addRegexToken('MM', match1to2, match2);
addRegexToken('MMM', function (isStrict, locale) {
return locale.monthsShortRegex(isStrict);
@@ -1095,8 +1247,6 @@
// MOMENTS
function setMonth(mom, value) {
var dayOfMonth;
if (!mom.isValid()) {
// No op
return mom;
@@ -1114,8 +1264,13 @@
}
}
dayOfMonth = Math.min(mom.date(), daysInMonth(mom.year(), value));
mom._d['set' + (mom._isUTC ? 'UTC' : '') + 'Month'](value, dayOfMonth);
var month = value,
date = mom.date();
date = date < 29 ? date : Math.min(date, daysInMonth(mom.year(), month));
void (mom._isUTC
? mom._d.setUTCMonth(month, date)
: mom._d.setMonth(month, date));
return mom;
}
@@ -1182,27 +1337,24 @@
longPieces = [],
mixedPieces = [],
i,
mom;
mom,
shortP,
longP;
for (i = 0; i < 12; i++) {
// make the regex if we don't have it already
mom = createUTC([2000, i]);
shortPieces.push(this.monthsShort(mom, ''));
longPieces.push(this.months(mom, ''));
mixedPieces.push(this.months(mom, ''));
mixedPieces.push(this.monthsShort(mom, ''));
shortP = regexEscape(this.monthsShort(mom, ''));
longP = regexEscape(this.months(mom, ''));
shortPieces.push(shortP);
longPieces.push(longP);
mixedPieces.push(longP);
mixedPieces.push(shortP);
}
// Sorting makes sure if one month (or abbr) is a prefix of another it
// will match the longer piece.
shortPieces.sort(cmpLenRev);
longPieces.sort(cmpLenRev);
mixedPieces.sort(cmpLenRev);
for (i = 0; i < 12; i++) {
shortPieces[i] = regexEscape(shortPieces[i]);
longPieces[i] = regexEscape(longPieces[i]);
}
for (i = 0; i < 24; i++) {
mixedPieces[i] = regexEscape(mixedPieces[i]);
}
this._monthsRegex = new RegExp('^(' + mixedPieces.join('|') + ')', 'i');
this._monthsShortRegex = this._monthsRegex;
@@ -1216,69 +1368,6 @@
);
}
// FORMATTING
addFormatToken('Y', 0, 0, function () {
var y = this.year();
return y <= 9999 ? zeroFill(y, 4) : '+' + y;
});
addFormatToken(0, ['YY', 2], 0, function () {
return this.year() % 100;
});
addFormatToken(0, ['YYYY', 4], 0, 'year');
addFormatToken(0, ['YYYYY', 5], 0, 'year');
addFormatToken(0, ['YYYYYY', 6, true], 0, 'year');
// ALIASES
addUnitAlias('year', 'y');
// PRIORITIES
addUnitPriority('year', 1);
// PARSING
addRegexToken('Y', matchSigned);
addRegexToken('YY', match1to2, match2);
addRegexToken('YYYY', match1to4, match4);
addRegexToken('YYYYY', match1to6, match6);
addRegexToken('YYYYYY', match1to6, match6);
addParseToken(['YYYYY', 'YYYYYY'], YEAR);
addParseToken('YYYY', function (input, array) {
array[YEAR] =
input.length === 2 ? hooks.parseTwoDigitYear(input) : toInt(input);
});
addParseToken('YY', function (input, array) {
array[YEAR] = hooks.parseTwoDigitYear(input);
});
addParseToken('Y', function (input, array) {
array[YEAR] = parseInt(input, 10);
});
// HELPERS
function daysInYear(year) {
return isLeapYear(year) ? 366 : 365;
}
// HOOKS
hooks.parseTwoDigitYear = function (input) {
return toInt(input) + (toInt(input) > 68 ? 1900 : 2000);
};
// MOMENTS
var getSetYear = makeGetSet('FullYear', true);
function getIsLeapYear() {
return isLeapYear(this.year());
}
function createDate(y, m, d, h, M, s, ms) {
// can't just apply() to create a date:
// https://stackoverflow.com/q/181348
@@ -1384,21 +1473,11 @@
addFormatToken('w', ['ww', 2], 'wo', 'week');
addFormatToken('W', ['WW', 2], 'Wo', 'isoWeek');
// ALIASES
addUnitAlias('week', 'w');
addUnitAlias('isoWeek', 'W');
// PRIORITIES
addUnitPriority('week', 5);
addUnitPriority('isoWeek', 5);
// PARSING
addRegexToken('w', match1to2);
addRegexToken('w', match1to2, match1to2NoLeadingZero);
addRegexToken('ww', match1to2, match2);
addRegexToken('W', match1to2);
addRegexToken('W', match1to2, match1to2NoLeadingZero);
addRegexToken('WW', match1to2, match2);
addWeekParseToken(
@@ -1460,17 +1539,6 @@
addFormatToken('e', 0, 0, 'weekday');
addFormatToken('E', 0, 0, 'isoWeekday');
// ALIASES
addUnitAlias('day', 'd');
addUnitAlias('weekday', 'e');
addUnitAlias('isoWeekday', 'E');
// PRIORITY
addUnitPriority('day', 11);
addUnitPriority('weekday', 11);
addUnitPriority('isoWeekday', 11);
// PARSING
addRegexToken('d', match1to2);
@@ -1550,24 +1618,24 @@
return m === true
? shiftWeekdays(weekdays, this._week.dow)
: m
? weekdays[m.day()]
: weekdays;
? weekdays[m.day()]
: weekdays;
}
function localeWeekdaysShort(m) {
return m === true
? shiftWeekdays(this._weekdaysShort, this._week.dow)
: m
? this._weekdaysShort[m.day()]
: this._weekdaysShort;
? this._weekdaysShort[m.day()]
: this._weekdaysShort;
}
function localeWeekdaysMin(m) {
return m === true
? shiftWeekdays(this._weekdaysMin, this._week.dow)
: m
? this._weekdaysMin[m.day()]
: this._weekdaysMin;
? this._weekdaysMin[m.day()]
: this._weekdaysMin;
}
function handleStrictParse$1(weekdayName, format, strict) {
@@ -1716,7 +1784,8 @@
if (!this.isValid()) {
return input != null ? this : NaN;
}
var day = this._isUTC ? this._d.getUTCDay() : this._d.getDay();
var day = get(this, 'Day');
if (input != null) {
input = parseWeekday(input, this.localeData());
return this.add(input - day, 'd');
@@ -1915,13 +1984,6 @@
meridiem('a', true);
meridiem('A', false);
// ALIASES
addUnitAlias('hour', 'h');
// PRIORITY
addUnitPriority('hour', 13);
// PARSING
function matchMeridiem(isStrict, locale) {
@@ -1930,9 +1992,9 @@
addRegexToken('a', matchMeridiem);
addRegexToken('A', matchMeridiem);
addRegexToken('H', match1to2);
addRegexToken('h', match1to2);
addRegexToken('k', match1to2);
addRegexToken('H', match1to2, match1to2HasZero);
addRegexToken('h', match1to2, match1to2NoLeadingZero);
addRegexToken('k', match1to2, match1to2NoLeadingZero);
addRegexToken('HH', match1to2, match2);
addRegexToken('hh', match1to2, match2);
addRegexToken('kk', match1to2, match2);
@@ -2082,7 +2144,8 @@
function isLocaleNameSane(name) {
// Prevent names that look like filesystem paths, i.e contain '/' or '\'
return name.match('^[^/\\\\]*$') != null;
// Ensure name is available and function returns boolean
return !!(name && name.match('^[^/\\\\]*$'));
}
function loadLocale(name) {
@@ -2274,21 +2337,21 @@
a[MONTH] < 0 || a[MONTH] > 11
? MONTH
: a[DATE] < 1 || a[DATE] > daysInMonth(a[YEAR], a[MONTH])
? DATE
: a[HOUR] < 0 ||
a[HOUR] > 24 ||
(a[HOUR] === 24 &&
(a[MINUTE] !== 0 ||
a[SECOND] !== 0 ||
a[MILLISECOND] !== 0))
? HOUR
: a[MINUTE] < 0 || a[MINUTE] > 59
? MINUTE
: a[SECOND] < 0 || a[SECOND] > 59
? SECOND
: a[MILLISECOND] < 0 || a[MILLISECOND] > 999
? MILLISECOND
: -1;
? DATE
: a[HOUR] < 0 ||
a[HOUR] > 24 ||
(a[HOUR] === 24 &&
(a[MINUTE] !== 0 ||
a[SECOND] !== 0 ||
a[MILLISECOND] !== 0))
? HOUR
: a[MINUTE] < 0 || a[MINUTE] > 59
? MINUTE
: a[SECOND] < 0 || a[SECOND] > 59
? SECOND
: a[MILLISECOND] < 0 || a[MILLISECOND] > 999
? MILLISECOND
: -1;
if (
getParsingFlags(m)._overflowDayOfYear &&
@@ -3729,16 +3792,16 @@
return diff < -6
? 'sameElse'
: diff < -1
? 'lastWeek'
: diff < 0
? 'lastDay'
: diff < 1
? 'sameDay'
: diff < 2
? 'nextDay'
: diff < 7
? 'nextWeek'
: 'sameElse';
? 'lastWeek'
: diff < 0
? 'lastDay'
: diff < 1
? 'sameDay'
: diff < 2
? 'nextDay'
: diff < 7
? 'nextWeek'
: 'sameElse';
}
function calendar$1(time, formats) {
@@ -4546,16 +4609,22 @@
mixedPieces = [],
i,
l,
erasName,
erasAbbr,
erasNarrow,
eras = this.eras();
for (i = 0, l = eras.length; i < l; ++i) {
namePieces.push(regexEscape(eras[i].name));
abbrPieces.push(regexEscape(eras[i].abbr));
narrowPieces.push(regexEscape(eras[i].narrow));
erasName = regexEscape(eras[i].name);
erasAbbr = regexEscape(eras[i].abbr);
erasNarrow = regexEscape(eras[i].narrow);
mixedPieces.push(regexEscape(eras[i].name));
mixedPieces.push(regexEscape(eras[i].abbr));
mixedPieces.push(regexEscape(eras[i].narrow));
namePieces.push(erasName);
abbrPieces.push(erasAbbr);
narrowPieces.push(erasNarrow);
mixedPieces.push(erasName);
mixedPieces.push(erasAbbr);
mixedPieces.push(erasNarrow);
}
this._erasRegex = new RegExp('^(' + mixedPieces.join('|') + ')', 'i');
@@ -4588,14 +4657,6 @@
// ALIASES
addUnitAlias('weekYear', 'gg');
addUnitAlias('isoWeekYear', 'GG');
// PRIORITY
addUnitPriority('weekYear', 1);
addUnitPriority('isoWeekYear', 1);
// PARSING
addRegexToken('G', matchSigned);
@@ -4625,7 +4686,7 @@
this,
input,
this.week(),
this.weekday(),
this.weekday() + this.localeData()._week.dow,
this.localeData()._week.dow,
this.localeData()._week.doy
);
@@ -4687,14 +4748,6 @@
addFormatToken('Q', 0, 'Qo', 'quarter');
// ALIASES
addUnitAlias('quarter', 'Q');
// PRIORITY
addUnitPriority('quarter', 7);
// PARSING
addRegexToken('Q', match1);
@@ -4714,16 +4767,9 @@
addFormatToken('D', ['DD', 2], 'Do', 'date');
// ALIASES
addUnitAlias('date', 'D');
// PRIORITY
addUnitPriority('date', 9);
// PARSING
addRegexToken('D', match1to2);
addRegexToken('D', match1to2, match1to2NoLeadingZero);
addRegexToken('DD', match1to2, match2);
addRegexToken('Do', function (isStrict, locale) {
// TODO: Remove "ordinalParse" fallback in next major release.
@@ -4745,13 +4791,6 @@
addFormatToken('DDD', ['DDDD', 3], 'DDDo', 'dayOfYear');
// ALIASES
addUnitAlias('dayOfYear', 'DDD');
// PRIORITY
addUnitPriority('dayOfYear', 4);
// PARSING
addRegexToken('DDD', match1to3);
@@ -4776,17 +4815,9 @@
addFormatToken('m', ['mm', 2], 0, 'minute');
// ALIASES
addUnitAlias('minute', 'm');
// PRIORITY
addUnitPriority('minute', 14);
// PARSING
addRegexToken('m', match1to2);
addRegexToken('m', match1to2, match1to2HasZero);
addRegexToken('mm', match1to2, match2);
addParseToken(['m', 'mm'], MINUTE);
@@ -4798,17 +4829,9 @@
addFormatToken('s', ['ss', 2], 0, 'second');
// ALIASES
addUnitAlias('second', 's');
// PRIORITY
addUnitPriority('second', 15);
// PARSING
addRegexToken('s', match1to2);
addRegexToken('s', match1to2, match1to2HasZero);
addRegexToken('ss', match1to2, match2);
addParseToken(['s', 'ss'], SECOND);
@@ -4846,14 +4869,6 @@
return this.millisecond() * 1000000;
});
// ALIASES
addUnitAlias('millisecond', 'ms');
// PRIORITY
addUnitPriority('millisecond', 16);
// PARSING
addRegexToken('S', match1to3, match1);
@@ -5161,12 +5176,12 @@
toInt((number % 100) / 10) === 1
? 'th'
: b === 1
? 'st'
: b === 2
? 'nd'
: b === 3
? 'rd'
: 'th';
? 'st'
: b === 2
? 'nd'
: b === 3
? 'rd'
: 'th';
return number + output;
},
});
@@ -5339,19 +5354,6 @@
}
}
// TODO: Use this.as('ms')?
function valueOf$1() {
if (!this.isValid()) {
return NaN;
}
return (
this._milliseconds +
this._days * 864e5 +
(this._months % 12) * 2592e6 +
toInt(this._months / 12) * 31536e6
);
}
function makeAs(alias) {
return function () {
return this.as(alias);
@@ -5366,7 +5368,8 @@
asWeeks = makeAs('w'),
asMonths = makeAs('M'),
asQuarters = makeAs('Q'),
asYears = makeAs('y');
asYears = makeAs('y'),
valueOf$1 = asMilliseconds;
function clone$1() {
return createDuration(this);
@@ -5635,7 +5638,7 @@
//! moment.js
hooks.version = '2.29.4';
hooks.version = '2.30.1';
setHookCallback(createLocal);
File diff suppressed because it is too large Load Diff
+3125 -3166
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+13 -8
View File
@@ -7,13 +7,13 @@
exports.noConflict = function () { global._ = current; return exports; };
}()));
}(this, (function () {
// Underscore.js 1.13.6
// Underscore.js 1.13.7
// https://underscorejs.org
// (c) 2009-2022 Jeremy Ashkenas, Julian Gonggrijp, and DocumentCloud and Investigative Reporters & Editors
// (c) 2009-2024 Jeremy Ashkenas, Julian Gonggrijp, and DocumentCloud and Investigative Reporters & Editors
// Underscore may be freely distributed under the MIT license.
// Current version.
var VERSION = '1.13.6';
var VERSION = '1.13.7';
// Establish the root object, `window` (`self`) in the browser, `global`
// on the server, or `this` in some virtual machines. We use `self`
@@ -150,8 +150,11 @@
// In IE 10 - Edge 13, `DataView` has string tag `'[object Object]'`.
// In IE 11, the most common among them, this problem also applies to
// `Map`, `WeakMap` and `Set`.
var hasStringTagBug = (
supportsDataView && hasObjectTag(new DataView(new ArrayBuffer(8)))
// Also, there are cases where an application can override the native
// `DataView` object, in cases like that we can't use the constructor
// safely and should just rely on alternate `DataView` checks
var hasDataViewBug = (
supportsDataView && (!/\[native code\]/.test(String(DataView)) || hasObjectTag(new DataView(new ArrayBuffer(8))))
),
isIE11 = (typeof Map !== 'undefined' && hasObjectTag(new Map));
@@ -159,11 +162,13 @@
// In IE 10 - Edge 13, we need a different heuristic
// to determine whether an object is a `DataView`.
function ie10IsDataView(obj) {
// Also, in cases where the native `DataView` is
// overridden we can't rely on the tag itself.
function alternateIsDataView(obj) {
return obj != null && isFunction$1(obj.getInt8) && isArrayBuffer(obj.buffer);
}
var isDataView$1 = (hasStringTagBug ? ie10IsDataView : isDataView);
var isDataView$1 = (hasDataViewBug ? alternateIsDataView : isDataView);
// Is a given value an array?
// Delegates to ECMA5's native `Array.isArray`.
@@ -376,7 +381,7 @@
var className = toString.call(a);
if (className !== toString.call(b)) return false;
// Work around a bug in IE 10 - Edge 13.
if (hasStringTagBug && className == '[object Object]' && isDataView$1(a)) {
if (hasDataViewBug && className == '[object Object]' && isDataView$1(a)) {
if (!isDataView$1(b)) return false;
className = tagDataView;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+112 -5508
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+94 -54
View File
@@ -4,18 +4,26 @@ import time
import traceback
import uuid
from http import HTTPStatus
from typing import Dict, List, Optional
from typing import (
Callable,
Coroutine,
Dict,
List,
Optional,
)
from loguru import logger
from py_vapid import Vapid
from pywebpush import WebPushException, webpush
from lnbits.core.crud import (
delete_expired_invoices,
delete_webpush_subscriptions,
get_payments,
get_standalone_payment,
update_payment_details,
update_payment_status,
)
from lnbits.core.models import Payment, PaymentState
from lnbits.settings import settings
from lnbits.wallets import get_funding_source
@@ -23,17 +31,13 @@ tasks: List[asyncio.Task] = []
unique_tasks: Dict[str, asyncio.Task] = {}
def create_task(coro):
def create_task(coro: Coroutine) -> asyncio.Task:
task = asyncio.create_task(coro)
tasks.append(task)
return task
def create_permanent_task(func):
return create_task(catch_everything_and_restart(func))
def create_unique_task(name: str, coro):
def create_unique_task(name: str, coro: Coroutine) -> asyncio.Task:
if unique_tasks.get(name):
logger.warning(f"task `{name}` already exists, cancelling it")
try:
@@ -45,11 +49,17 @@ def create_unique_task(name: str, coro):
return task
def create_permanent_unique_task(name: str, coro):
def create_permanent_task(func: Callable[[], Coroutine]) -> asyncio.Task:
return create_task(catch_everything_and_restart(func))
def create_permanent_unique_task(
name: str, coro: Callable[[], Coroutine]
) -> asyncio.Task:
return create_unique_task(name, catch_everything_and_restart(coro, name))
def cancel_all_tasks():
def cancel_all_tasks() -> None:
for task in tasks:
try:
task.cancel()
@@ -62,9 +72,12 @@ def cancel_all_tasks():
logger.warning(f"error while cancelling task `{name}`: {exc!s}")
async def catch_everything_and_restart(func, name: str = "unnamed"):
async def catch_everything_and_restart(
func: Callable[[], Coroutine],
name: str = "unnamed",
) -> Coroutine:
try:
await func()
return await func()
except asyncio.CancelledError:
raise # because we must pass this up
except Exception as exc:
@@ -72,7 +85,7 @@ async def catch_everything_and_restart(func, name: str = "unnamed"):
logger.error(traceback.format_exc())
logger.error("will restart the task in 5 seconds.")
await asyncio.sleep(5)
await catch_everything_and_restart(func, name)
return catch_everything_and_restart(func, name)
invoice_listeners: Dict[str, asyncio.Queue] = {}
@@ -99,7 +112,7 @@ def register_invoice_listener(send_chan: asyncio.Queue, name: Optional[str] = No
internal_invoice_queue: asyncio.Queue = asyncio.Queue(0)
async def internal_invoice_listener():
async def internal_invoice_listener() -> None:
"""
internal_invoice_queue will be filled directly in core/services.py
after the payment was deemed to be settled internally.
@@ -108,11 +121,11 @@ async def internal_invoice_listener():
"""
while settings.lnbits_running:
checking_id = await internal_invoice_queue.get()
logger.info("> got internal payment notification", checking_id)
create_task(invoice_callback_dispatcher(checking_id))
logger.info(f"got an internal payment notification {checking_id}")
await invoice_callback_dispatcher(checking_id, is_internal=True)
async def invoice_listener():
async def invoice_listener() -> None:
"""
invoice_listener will collect all invoices that come directly
from the backend wallet.
@@ -121,8 +134,23 @@ async def invoice_listener():
"""
funding_source = get_funding_source()
async for checking_id in funding_source.paid_invoices_stream():
logger.info("> got a payment notification", checking_id)
create_task(invoice_callback_dispatcher(checking_id))
logger.info(f"got a payment notification {checking_id}")
await invoice_callback_dispatcher(checking_id)
def wait_for_paid_invoices(
invoice_listener_name: str,
func: Callable[[Payment], Coroutine],
) -> Callable[[], Coroutine]:
async def wrapper() -> None:
invoice_queue: asyncio.Queue = asyncio.Queue()
register_invoice_listener(invoice_queue, invoice_listener_name)
while settings.lnbits_running:
payment = await invoice_queue.get()
await func(payment)
return wrapper
async def check_pending_payments():
@@ -131,59 +159,68 @@ async def check_pending_payments():
the backend and also to delete expired invoices. Incoming payments will be
checked only once, outgoing pending payments will be checked regularly.
"""
outgoing = True
incoming = True
sleep_time = 60 * 30 # 30 minutes
while settings.lnbits_running:
logger.info(
f"Task: checking all pending payments (incoming={incoming},"
f" outgoing={outgoing}) of last 15 days"
)
funding_source = get_funding_source()
if funding_source.__class__.__name__ == "VoidWallet":
logger.warning("Task: skipping pending check for VoidWallet")
await asyncio.sleep(sleep_time)
continue
start_time = time.time()
pending_payments = await get_payments(
since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago
complete=False,
pending=True,
outgoing=outgoing,
incoming=incoming,
exclude_uncheckable=True,
)
for payment in pending_payments:
await payment.check_status()
await asyncio.sleep(0.01) # to avoid complete blocking
logger.info(
f"Task: pending check finished for {len(pending_payments)} payments"
f" (took {time.time() - start_time:0.3f} s)"
)
# we delete expired invoices once upon the first pending check
if incoming:
logger.debug("Task: deleting all expired invoices")
start_time = time.time()
await delete_expired_invoices()
count = len(pending_payments)
if count > 0:
logger.info(f"Task: checking {count} pending payments of last 15 days...")
for i, payment in enumerate(pending_payments):
status = await payment.check_status()
prefix = f"payment ({i+1} / {count})"
if status.failed:
await update_payment_status(
payment.checking_id, status=PaymentState.FAILED
)
logger.debug(f"{prefix} failed {payment.checking_id}")
elif status.success:
await update_payment_details(
checking_id=payment.checking_id,
fee=status.fee_msat,
preimage=status.preimage,
status=PaymentState.SUCCESS,
)
logger.debug(f"{prefix} success {payment.checking_id}")
else:
logger.debug(f"{prefix} pending {payment.checking_id}")
await asyncio.sleep(0.01) # to avoid complete blocking
logger.info(
"Task: expired invoice deletion finished (took"
f" {time.time() - start_time:0.3f} s)"
f"Task: pending check finished for {count} payments"
f" (took {time.time() - start_time:0.3f} s)"
)
# after the first check we will only check outgoing, not incoming
# that will be handled by the global invoice listeners, hopefully
incoming = False
await asyncio.sleep(60 * 30) # every 30 minutes
await asyncio.sleep(sleep_time)
async def invoice_callback_dispatcher(checking_id: str):
async def invoice_callback_dispatcher(checking_id: str, is_internal: bool = False):
"""
Takes an incoming payment, checks its status, and dispatches it to
invoice_listeners from core and extensions.
"""
payment = await get_standalone_payment(checking_id, incoming=True)
if payment and payment.is_in:
logger.trace(
f"invoice listeners: sending invoice callback for payment {checking_id}"
status = await payment.check_status()
await update_payment_details(
checking_id=payment.checking_id,
fee=status.fee_msat,
preimage=status.preimage,
status=PaymentState.SUCCESS,
)
await payment.check_status()
payment = await get_standalone_payment(checking_id, incoming=True)
assert payment, "updated payment not found"
internal = "internal" if is_internal else ""
logger.success(f"{internal} invoice {checking_id} settled")
for name, send_chan in invoice_listeners.items():
logger.trace(f"invoice listeners: sending to `{name}`")
await send_chan.put(payment)
@@ -204,8 +241,11 @@ async def send_push_notification(subscription, title, body, url=""):
{"aud": "", "sub": "mailto:alan@lnbits.com"},
)
except WebPushException as e:
if e.response.status_code == HTTPStatus.GONE:
if e.response and e.response.status_code == HTTPStatus.GONE:
# cleanup unsubscribed or expired push subscriptions
await delete_webpush_subscriptions(subscription.endpoint)
else:
logger.error(f"failed sending push notification: {e.response.text}")
logger.error(
f"failed sending push notification: "
f"{e.response.text if e.response else e}"
)
+188 -184
View File
@@ -32,196 +32,198 @@
</head>
<body data-theme="bitcoin">
<q-layout id="vue" view="hHh lpR lfr" v-cloak>
<q-header bordered class="bg-marginal-bg">
<q-toolbar>
{% block drawer_toggle %}
<q-btn
dense
flat
round
icon="menu"
@click="g.visibleDrawer = !g.visibleDrawer"
></q-btn>
{% endblock %}
<q-toolbar-title>
{% block toolbar_title %}
<q-btn flat no-caps dense size="lg" type="a" href="/"
>{% if USE_CUSTOM_LOGO %}
<img height="30px" alt="Logo" src="{{ USE_CUSTOM_LOGO }}" />
{%else%} {% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {% else
%}
<span><strong>LN</strong>bits</span> {% endif %} {%endif%} </q-btn
>{% endblock %} {% block toolbar_subtitle %}{%if user and
user.super_user%}
<q-badge align="middle">Super User</q-badge>
{% elif user and user.admin %}
<q-badge align="middle">Admin User</q-badge>
{%endif%}{% endblock %}
</q-toolbar-title>
{% block beta %} {% if VOIDWALLET %}
<q-badge
v-text="$t('voidwallet_active')"
color="red"
class="q-mr-md gt-md"
>
</q-badge>
{%endif%}
<q-badge
v-if="'{{LNBITS_CUSTOM_BADGE}}' && '{{LNBITS_CUSTOM_BADGE}}' != 'None'"
v-show="$q.screen.gt.sm"
color="{{ LNBITS_CUSTOM_BADGE_COLOR }}"
class="q-mr-md"
label="{{LNBITS_CUSTOM_BADGE}}"
>
</q-badge>
{% if LNBITS_SERVICE_FEE > 0 %}
<q-badge
v-show="$q.screen.gt.sm"
v-if="g.user"
color="green"
class="q-mr-md"
>
{% if LNBITS_SERVICE_FEE_MAX > 0 %}
<span
v-text='$t("service_fee_max", { amount: "{{ LNBITS_SERVICE_FEE }}", max: "{{ LNBITS_SERVICE_FEE_MAX }}"})'
></span>
{%else%}
<span
v-text='$t("service_fee", { amount: "{{ LNBITS_SERVICE_FEE }}" })'
></span>
<div id="vue">
<q-layout view="hHh lpR lfr" v-cloak>
<q-header bordered class="bg-marginal-bg">
<q-toolbar>
{% block drawer_toggle %}
<q-btn
dense
flat
round
icon="menu"
@click="g.visibleDrawer = !g.visibleDrawer"
></q-btn>
{% endblock %}
<q-toolbar-title>
{% block toolbar_title %}
<q-btn flat no-caps dense size="lg" type="a" href="/"
>{% if USE_CUSTOM_LOGO %}
<img height="30px" alt="Logo" src="{{ USE_CUSTOM_LOGO }}" />
{%else%} {% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {%
else %}
<span><strong>LN</strong>bits</span> {% endif %} {%endif%} </q-btn
>{% endblock %} {% block toolbar_subtitle %}{%if user and
user.super_user%}
<q-badge align="middle">Super User</q-badge>
{% elif user and user.admin %}
<q-badge align="middle">Admin User</q-badge>
{%endif%}{% endblock %}
</q-toolbar-title>
{% block beta %} {% if VOIDWALLET %}
<q-badge
v-text="$t('voidwallet_active')"
color="red"
class="q-mr-md gt-md"
>
</q-badge>
{%endif%}
<q-tooltip
><span v-text='$t("service_fee_tooltip")'></span
></q-tooltip>
</q-badge>
<q-badge
v-if="'{{LNBITS_CUSTOM_BADGE}}' && '{{LNBITS_CUSTOM_BADGE}}' != 'None'"
v-show="$q.screen.gt.sm"
color="{{ LNBITS_CUSTOM_BADGE_COLOR }}"
class="q-mr-md"
label="{{LNBITS_CUSTOM_BADGE}}"
>
</q-badge>
{% if LNBITS_SERVICE_FEE > 0 %}
<q-badge
v-show="$q.screen.gt.sm"
v-if="g.user"
color="green"
class="q-mr-md"
>
{% if LNBITS_SERVICE_FEE_MAX > 0 %}
<span
v-text='$t("service_fee_max", { amount: "{{ LNBITS_SERVICE_FEE }}", max: "{{ LNBITS_SERVICE_FEE_MAX }}"})'
></span>
{%else%}
<span
v-text='$t("service_fee", { amount: "{{ LNBITS_SERVICE_FEE }}" })'
></span>
{%endif%}
<q-tooltip
><span v-text='$t("service_fee_tooltip")'></span
></q-tooltip>
</q-badge>
{%endif%} {% endblock %}
<q-badge v-if="g.offline" color="red" class="q-mr-md">
<span>OFFLINE</span>
</q-badge>
{%endif%} {% endblock %}
<q-badge v-if="g.offline" color="red" class="q-mr-md">
<span>OFFLINE</span>
</q-badge>
<q-btn-dropdown
v-if="isUserAuthorized"
dense
flat
round
size="sm"
class="q-pl-sm"
>
<template v-slot:label>
<div>
{%if user and user.config and user.config.picture%}
<img src="{{user.config.picture}}" style="max-width: 32px" />
{%else%}
<q-icon name="account_circle" />
{%endif%}
</div>
</template>
<q-list>
<q-item tag="a" href="/account" clickable v-close-popup
><q-item-section>
<q-icon name="person" />
</q-item-section>
<q-item-section>
<q-item-label>
<span v-text="$t('my_account')"></span>
</q-item-label>
</q-item-section>
<q-item-section>
<q-item-label> </q-item-label>
</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item clickable v-close-popup @click="logout"
><q-item-section>
<q-icon name="logout" />
</q-item-section>
<q-item-section>
<q-item-label>
<span v-text="$t('logout')"></span>
</q-item-label>
</q-item-section>
<q-item-section>
<q-item-label> </q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</q-toolbar>
</q-header>
<q-btn-dropdown
v-if="isUserAuthorized"
dense
flat
round
size="sm"
class="q-pl-sm"
>
<template v-slot:label>
<div>
{%if user and user.config and user.config.picture%}
<img src="{{user.config.picture}}" style="max-width: 32px" />
{%else%}
<q-icon name="account_circle" />
{%endif%}
</div>
</template>
<q-list>
<q-item tag="a" href="/account" clickable v-close-popup
><q-item-section>
<q-icon name="person" />
</q-item-section>
<q-item-section>
<q-item-label>
<span v-text="$t('my_account')"></span>
</q-item-label>
</q-item-section>
<q-item-section>
<q-item-label> </q-item-label>
</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item clickable v-close-popup @click="logout"
><q-item-section>
<q-icon name="logout" />
</q-item-section>
<q-item-section>
<q-item-label>
<span v-text="$t('logout')"></span>
</q-item-label>
</q-item-section>
<q-item-section>
<q-item-label> </q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</q-toolbar>
</q-header>
{% block drawer %}
<q-drawer
v-model="g.visibleDrawer"
side="left"
:width="($q.screen.lt.md) ? 260 : 230"
show-if-above
:elevated="$q.screen.lt.md"
>
<lnbits-wallet-list></lnbits-wallet-list>
{% block drawer %}
<q-drawer
v-model="g.visibleDrawer"
side="left"
:width="($q.screen.lt.md) ? 260 : 230"
show-if-above
:elevated="$q.screen.lt.md"
>
<lnbits-wallet-list :balance="balance"></lnbits-wallet-list>
<lnbits-manage
:show-admin="'{{LNBITS_ADMIN_UI}}' == 'True'"
:show-users="'{{LNBITS_ADMIN_UI}}' == 'True'"
:show-node="'{{LNBITS_NODE_UI}}' == 'True'"
:show-extensions="'{{LNBITS_EXTENSIONS_DEACTIVATE_ALL}}' == 'False'"
></lnbits-manage>
<lnbits-extension-list class="q-pb-xl"></lnbits-extension-list>
</q-drawer>
{% endblock %} {% block page_container %}
<q-page-container>
<q-page class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}">
{% block page %}{% endblock %}
</q-page>
</q-page-container>
{% endblock %} {% block footer %}
<lnbits-manage
:show-admin="'{{LNBITS_ADMIN_UI}}' == 'True'"
:show-users="'{{LNBITS_ADMIN_UI}}' == 'True'"
:show-node="'{{LNBITS_NODE_UI}}' == 'True'"
:show-extensions="'{{LNBITS_EXTENSIONS_DEACTIVATE_ALL}}' == 'False'"
></lnbits-manage>
<lnbits-extension-list class="q-pb-xl"></lnbits-extension-list>
</q-drawer>
{% endblock %} {% block page_container %}
<q-page-container>
<q-page class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}">
{% block page %}{% endblock %}
</q-page>
</q-page-container>
{% endblock %} {% block footer %}
<q-footer
class="bg-transparent q-px-lg q-py-md"
:class="{'text-dark': !$q.dark.isActive}"
>
<q-space class="q-py-lg lt-md"></q-space>
<q-toolbar class="gt-sm">
<q-toolbar-title class="text-caption">
{{ SITE_TITLE }}, {{SITE_TAGLINE}}
<br />
<small
v-text="$t('lnbits_version') + ': {{LNBITS_VERSION}}'"
></small>
</q-toolbar-title>
<q-space></q-space>
<q-btn
flat
dense
:color="($q.dark.isActive) ? 'white' : 'primary'"
type="a"
href="/docs"
target="_blank"
rel="noopener noreferrer"
>
<span v-text="$t('api_docs')"></span>
<q-tooltip
><span v-text="$t('view_swagger_docs')"></span
></q-tooltip>
</q-btn>
<q-btn
flat
dense
:color="($q.dark.isActive) ? 'white' : 'primary'"
icon="code"
type="a"
href="https://github.com/lnbits/lnbits"
target="_blank"
rel="noopener noreferrer"
>
<q-tooltip><span v-text="$t('view_github')"></span></q-tooltip>
</q-btn>
</q-toolbar>
</q-footer>
<q-footer
class="bg-transparent q-px-lg q-py-md"
:class="{'text-dark': !$q.dark.isActive}"
>
<q-space class="q-py-lg lt-md"></q-space>
<q-toolbar class="gt-sm">
<q-toolbar-title class="text-caption">
{{ SITE_TITLE }}, {{SITE_TAGLINE}}
<br />
<small
v-text="$t('lnbits_version') + ': {{LNBITS_VERSION}}'"
></small>
</q-toolbar-title>
<q-space></q-space>
<q-btn
flat
dense
:color="($q.dark.isActive) ? 'white' : 'primary'"
type="a"
href="/docs"
target="_blank"
rel="noopener noreferrer"
>
<span v-text="$t('api_docs')"></span>
<q-tooltip
><span v-text="$t('view_swagger_docs')"></span
></q-tooltip>
</q-btn>
<q-btn
flat
dense
:color="($q.dark.isActive) ? 'white' : 'primary'"
icon="code"
type="a"
href="https://github.com/lnbits/lnbits"
target="_blank"
rel="noopener noreferrer"
>
<q-tooltip><span v-text="$t('view_github')"></span></q-tooltip>
</q-btn>
</q-toolbar>
</q-footer>
{% endblock %}
</q-layout>
{% endblock %}
</q-layout>
</div>
{% block vue_templates %}{% endblock %}
<!---->
@@ -258,6 +260,8 @@
{ value: 'fi', label: 'Suomi', display: '🇫🇮 FI' }
]
</script>
{% block scripts %}{% endblock %}
{% block scripts %}{% endblock %} {% for url in INCLUDED_COMPONENTS %}
<script src="{{ static_url_for('static', url) }}"></script>
{% endfor %}
</body>
</html>
+25 -2
View File
@@ -191,6 +191,7 @@ class Provider(NamedTuple):
domain: str
api_url: str
getter: Callable
exclude_to: list = []
exchange_rate_providers = {
@@ -200,30 +201,34 @@ exchange_rate_providers = {
"binance.com",
"https://api.binance.com/api/v3/ticker/price?symbol={FROM}{TO}",
lambda data, replacements: data["price"],
["czk"],
),
"blockchain": Provider(
"Blockchain",
"blockchain.com",
"https://blockchain.info/tobtc?currency={TO}&value=1",
lambda data, replacements: 1 / data,
"https://blockchain.info/tobtc?currency={TO}&value=1000000",
lambda data, replacements: 1000000 / data,
),
"exir": Provider(
"Exir",
"exir.io",
"https://api.exir.io/v1/ticker?symbol={from}-{to}",
lambda data, replacements: data["last"],
["czk", "eur"],
),
"bitfinex": Provider(
"Bitfinex",
"bitfinex.com",
"https://api.bitfinex.com/v1/pubticker/{from}{to}",
lambda data, replacements: data["last_price"],
["czk"],
),
"bitstamp": Provider(
"Bitstamp",
"bitstamp.net",
"https://www.bitstamp.net/api/v2/ticker/{from}{to}/",
lambda data, replacements: data["last"],
["czk"],
),
"coinbase": Provider(
"Coinbase",
@@ -242,6 +247,21 @@ exchange_rate_providers = {
"kraken.com",
"https://api.kraken.com/0/public/Ticker?pair=XBT{TO}",
lambda data, replacements: data["result"]["XXBTZ" + replacements["TO"]]["c"][0],
["czk"],
),
"bitpay": Provider(
"BitPay",
"bitpay.com",
"https://bitpay.com/rates",
lambda data, replacements: next(
i["rate"] for i in data["data"] if i["code"] == replacements["TO"]
),
),
"yadio": Provider(
"yadio",
"yadio.io",
"https://api.yadio.io/exrates/{FROM}",
lambda data, replacements: data[replacements["FROM"]][replacements["TO"]],
),
}
@@ -255,6 +275,9 @@ async def btc_price(currency: str) -> float:
}
async def fetch_price(provider: Provider):
if currency.lower() in provider.exclude_to:
raise Exception(f"Provider {provider.name} does not support {currency}.")
url = provider.api_url.format(**replacements)
try:
headers = {"User-Agent": settings.user_agent}
+4 -4
View File
@@ -74,10 +74,10 @@ def configure_logger() -> None:
logging.getLogger("uvicorn.error").propagate = False
logging.getLogger("sqlalchemy").handlers = [InterceptHandler()]
logging.getLogger("sqlalchemy.engine.base").handlers = [InterceptHandler()]
logging.getLogger("sqlalchemy.engine.base").propagate = False
logging.getLogger("sqlalchemy.engine.base.Engine").handlers = [InterceptHandler()]
logging.getLogger("sqlalchemy.engine.base.Engine").propagate = False
logging.getLogger("sqlalchemy.engine").handlers = [InterceptHandler()]
logging.getLogger("sqlalchemy.engine").propagate = False
logging.getLogger("sqlalchemy.engine.Engine").handlers = [InterceptHandler()]
logging.getLogger("sqlalchemy.engine.Engine").propagate = False
class Formatter:
+28
View File
@@ -9,6 +9,8 @@ from lnbits.wallets.base import Wallet
from .alby import AlbyWallet
from .blink import BlinkWallet
from .boltz import BoltzWallet
from .breez import BreezSdkWallet
from .cliche import ClicheWallet
from .corelightning import CoreLightningWallet
@@ -23,6 +25,7 @@ from .lndgrpc import LndWallet
from .lndrest import LndRestWallet
from .lnpay import LNPayWallet
from .lntips import LnTipsWallet
from .nwc import NWCWallet
from .opennode import OpenNodeWallet
from .phoenixd import PhoenixdWallet
from .spark import SparkWallet
@@ -48,3 +51,28 @@ fake_wallet = FakeWallet()
# initialize as fake wallet
funding_source: Wallet = fake_wallet
__all__ = [
"AlbyWallet",
"BlinkWallet",
"BoltzWallet",
"BreezSdkWallet",
"ClicheWallet",
"CoreLightningWallet",
"CLightningWallet",
"CoreLightningRestWallet",
"EclairWallet",
"FakeWallet",
"LNbitsWallet",
"LndWallet",
"LndRestWallet",
"LNPayWallet",
"LnTipsWallet",
"NWCWallet",
"OpenNodeWallet",
"PhoenixdWallet",
"SparkWallet",
"VoidWallet",
"ZBDWallet",
]
+214
View File
@@ -0,0 +1,214 @@
import asyncio
from typing import AsyncGenerator, Optional
from bolt11.decode import decode
from grpc.aio import AioRpcError
from loguru import logger
from lnbits.settings import settings
from lnbits.wallets.boltz_grpc_files import boltzrpc_pb2, boltzrpc_pb2_grpc
from lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc import grpc
from lnbits.wallets.macaroon.macaroon import load_macaroon
from .base import (
InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
PaymentSuccessStatus,
StatusResponse,
Wallet,
)
class BoltzWallet(Wallet):
"""
Utilizing Boltz Client gRPC interface
gRPC Bindings can be updated by running lnbits/wallets/boltz_grpc_files/update.sh
"""
async def cleanup(self):
logger.warning("Cleaning up BoltzWallet...")
def __init__(self):
if not settings.boltz_client_endpoint:
raise ValueError(
"cannot initialize BoltzWallet: missing boltz_client_endpoint"
)
if not settings.boltz_client_wallet:
raise ValueError(
"cannot initialize BoltzWallet: missing boltz_client_wallet"
)
self.endpoint = self.normalize_endpoint(
settings.boltz_client_endpoint, add_proto=True
)
if settings.boltz_client_macaroon:
self.metadata = [
("macaroon", load_macaroon(settings.boltz_client_macaroon))
]
else:
self.metadata = None
if settings.boltz_client_cert:
cert = open(settings.boltz_client_cert, "rb").read()
creds = grpc.ssl_channel_credentials(cert)
channel = grpc.aio.secure_channel(settings.boltz_client_endpoint, creds)
else:
channel = grpc.aio.insecure_channel(settings.boltz_client_endpoint)
self.rpc = boltzrpc_pb2_grpc.BoltzStub(channel)
self.wallet_id: int = 0
async def status(self) -> StatusResponse:
try:
request = boltzrpc_pb2.GetWalletRequest(name=settings.boltz_client_wallet)
response: boltzrpc_pb2.Wallet = await self.rpc.GetWallet(
request, metadata=self.metadata
)
except AioRpcError as exc:
return StatusResponse(
exc.details()
+ " make sure you have macaroon and certificate configured, unless your client runs without", # noqa: E501
0,
)
self.wallet_id = response.id
return StatusResponse(None, response.balance.total * 1000)
async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**_,
) -> InvoiceResponse:
pair = boltzrpc_pb2.Pair(to=boltzrpc_pb2.LBTC)
request = boltzrpc_pb2.CreateReverseSwapRequest(
amount=amount,
pair=pair,
wallet_id=self.wallet_id,
accept_zero_conf=True,
external_pay=True,
description=memo,
)
response: boltzrpc_pb2.CreateReverseSwapResponse
try:
response = await self.rpc.CreateReverseSwap(request, metadata=self.metadata)
except AioRpcError as exc:
return InvoiceResponse(ok=False, error_message=exc.details())
return InvoiceResponse(
ok=True, checking_id=response.id, payment_request=response.invoice
)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
pair = boltzrpc_pb2.Pair(**{"from": boltzrpc_pb2.LBTC})
try:
pair_info: boltzrpc_pb2.PairInfo
pair_request = boltzrpc_pb2.GetPairInfoRequest(
type=boltzrpc_pb2.SUBMARINE, pair=pair
)
pair_info = await self.rpc.GetPairInfo(pair_request, metadata=self.metadata)
invoice = decode(bolt11)
assert invoice.amount_msat, "amountless invoice"
service_fee: float = invoice.amount_msat * pair_info.fees.percentage / 100
estimate = service_fee + pair_info.fees.miner_fees * 1000
if estimate > fee_limit_msat:
error = f"fee of {estimate} msat exceeds limit of {fee_limit_msat} msat"
return PaymentResponse(ok=False, error_message=error)
request = boltzrpc_pb2.CreateSwapRequest(
invoice=bolt11,
pair=pair,
wallet_id=self.wallet_id,
zero_conf=True,
send_from_internal=True,
)
response: boltzrpc_pb2.CreateSwapResponse
response = await self.rpc.CreateSwap(request, metadata=self.metadata)
except AioRpcError as exc:
return PaymentResponse(ok=False, error_message=exc.details())
try:
info_request = boltzrpc_pb2.GetSwapInfoRequest(id=response.id)
info: boltzrpc_pb2.GetSwapInfoResponse
async for info in self.rpc.GetSwapInfoStream(
info_request, metadata=self.metadata
):
if info.swap.state == boltzrpc_pb2.SUCCESSFUL:
return PaymentResponse(
ok=True,
checking_id=response.id,
fee_msat=(info.swap.onchain_fee + info.swap.service_fee) * 1000,
preimage=info.swap.preimage,
)
elif info.swap.error != "":
return PaymentResponse(ok=False, error_message=info.swap.error)
return PaymentResponse(
ok=False, error_message="stream stopped unexpectedly"
)
except AioRpcError as exc:
return PaymentResponse(ok=False, error_message=exc.details())
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
response: boltzrpc_pb2.GetSwapInfoResponse = await self.rpc.GetSwapInfo(
boltzrpc_pb2.GetSwapInfoRequest(id=checking_id), metadata=self.metadata
)
swap = response.reverse_swap
except AioRpcError:
return PaymentPendingStatus()
if swap.state == boltzrpc_pb2.SwapState.SUCCESSFUL:
return PaymentSuccessStatus(
fee_msat=(
(swap.service_fee + swap.onchain_fee) * 1000 + swap.routing_fee_msat
),
preimage=swap.preimage,
)
elif swap.state == boltzrpc_pb2.SwapState.PENDING:
return PaymentPendingStatus()
return PaymentFailedStatus()
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try:
response: boltzrpc_pb2.GetSwapInfoResponse = await self.rpc.GetSwapInfo(
boltzrpc_pb2.GetSwapInfoRequest(id=checking_id), metadata=self.metadata
)
swap = response.swap
except AioRpcError:
return PaymentPendingStatus()
if swap.state == boltzrpc_pb2.SwapState.SUCCESSFUL:
return PaymentSuccessStatus(
fee_msat=(swap.service_fee + swap.onchain_fee) * 1000,
preimage=swap.preimage,
)
elif swap.state == boltzrpc_pb2.SwapState.PENDING:
return PaymentPendingStatus()
return PaymentFailedStatus()
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True:
try:
request = boltzrpc_pb2.GetSwapInfoRequest()
info: boltzrpc_pb2.GetSwapInfoResponse
async for info in self.rpc.GetSwapInfoStream(
request, metadata=self.metadata
):
reverse = info.reverse_swap
if reverse and reverse.state == boltzrpc_pb2.SUCCESSFUL:
yield reverse.id
except Exception as exc:
logger.error(
f"lost connection to boltz client swap stream: '{exc}', retrying in"
" 5 seconds"
)
await asyncio.sleep(5)
@@ -0,0 +1,742 @@
syntax = "proto3";
package boltzrpc;
option go_package = "github.com/BoltzExchange/boltz-client/boltzrpc";
import "google/protobuf/empty.proto";
service Boltz {
/*
Gets general information about the daemon like the chain of the lightning node it is connected to
and the IDs of pending swaps.
*/
rpc GetInfo (GetInfoRequest) returns (GetInfoResponse);
/*
Fetches the latest limits and fees from the Boltz backend API it is connected to.
*/
rpc GetServiceInfo (GetServiceInfoRequest) returns (GetServiceInfoResponse) {
option deprecated = true;
};
/*
Fetches information about a specific pair for a chain swap.
*/
rpc GetPairInfo (GetPairInfoRequest) returns (PairInfo);
/*
Fetches all available pairs for submarine and reverse swaps.
*/
rpc GetPairs (google.protobuf.Empty) returns (GetPairsResponse);
/*
Returns a list of all swaps, reverse swaps, and chain swaps in the database.
*/
rpc ListSwaps (ListSwapsRequest) returns (ListSwapsResponse);
/*
Returns stats of all swaps, reverse swaps, and chain swaps in the database.
*/
rpc GetStats (GetStatsRequest) returns (GetStatsResponse);
/*
Refund a failed swap manually.
This is only required when no refund address has been set and the swap does not have an associated wallet.
*/
rpc RefundSwap (RefundSwapRequest) returns (GetSwapInfoResponse);
/*
Claim swaps manually.
This is only required when no claim address has been set and the swap does not have an associated wallet.
*/
rpc ClaimSwaps (ClaimSwapsRequest) returns (ClaimSwapsResponse);
/*
Gets all available information about a swap from the database.
*/
rpc GetSwapInfo (GetSwapInfoRequest) returns (GetSwapInfoResponse);
/*
Returns the entire history of the swap if is still pending and streams updates in real time.
If the swap id is empty or "*" updates for all swaps will be streamed.
*/
rpc GetSwapInfoStream (GetSwapInfoRequest) returns (stream GetSwapInfoResponse);
/*
This is a wrapper for channel creation swaps. The daemon only returns the ID, timeout block height and lockup address.
The Boltz backend takes care of the rest. When an amount of onchain coins that is in the limits is sent to the address
before the timeout block height, the daemon creates a new lightning invoice, sends it to the Boltz backend which
will try to pay it and if that is not possible, create a new channel to make the swap succeed.
*/
rpc Deposit (DepositRequest) returns (DepositResponse) { option deprecated = true; }
/*
Creates a new swap from onchain to lightning.
*/
rpc CreateSwap (CreateSwapRequest) returns (CreateSwapResponse);
/*
Create a new swap from onchain to a new lightning channel. The daemon will only accept the invoice payment if the HTLCs
is coming trough a new channel channel opened by Boltz.
*/
rpc CreateChannel (CreateChannelRequest) returns (CreateSwapResponse) { option deprecated = true; };
/*
Creates a new reverse swap from lightning to onchain. If `accept_zero_conf` is set to true in the request, the daemon
will not wait until the lockup transaction from Boltz is confirmed in a block, but will claim it instantly.
*/
rpc CreateReverseSwap (CreateReverseSwapRequest) returns (CreateReverseSwapResponse);
/*
Creates a new chain swap from one chain to another. If `accept_zero_conf` is set to true in the request, the daemon
will not wait until the lockup transaction from Boltz is confirmed in a block, but will claim it instantly.
*/
rpc CreateChainSwap (CreateChainSwapRequest) returns (ChainSwapInfo);
/*
Creates a new liquid wallet and returns the mnemonic.
*/
rpc CreateWallet (CreateWalletRequest) returns (CreateWalletResponse);
/*
Imports an existing wallet.
*/
rpc ImportWallet (ImportWalletRequest) returns (Wallet);
/*
Sets the subaccount of a wallet. Not supported for readonly wallets.
*/
rpc SetSubaccount (SetSubaccountRequest) returns (Subaccount);
/*
Returns all subaccounts for a given wallet. Not supported for readonly wallets.
*/
rpc GetSubaccounts (GetSubaccountsRequest) returns (GetSubaccountsResponse);
/*
Returns all available wallets.
*/
rpc GetWallets (GetWalletsRequest) returns (Wallets);
/*
Returns the current balance and subaccount of a wallet.
*/
rpc GetWallet (GetWalletRequest) returns (Wallet);
/*
Returns the credentials of a wallet. The password will be required if the wallet is encrypted.
*/
rpc GetWalletCredentials (GetWalletCredentialsRequest) returns (WalletCredentials);
/*
Removes a wallet.
*/
rpc RemoveWallet (RemoveWalletRequest) returns (RemoveWalletResponse);
/*
Gracefully stops the daemon.
*/
rpc Stop(google.protobuf.Empty) returns (google.protobuf.Empty);
/*
Unlocks the server. This will be required on startup if there are any encrypted wallets.
*/
rpc Unlock(UnlockRequest) returns (google.protobuf.Empty);
/*
Check if the password is correct.
*/
rpc VerifyWalletPassword(VerifyWalletPasswordRequest) returns (VerifyWalletPasswordResponse);
/*
Changes the password for wallet encryption.
*/
rpc ChangeWalletPassword(ChangeWalletPasswordRequest) returns (google.protobuf.Empty);
/*
Creates a new tenant which can be used to bake restricted macaroons.
*/
rpc CreateTenant(CreateTenantRequest) returns (Tenant);
/*
Returns all tenants.
*/
rpc ListTenants(ListTenantsRequest) returns (ListTenantsResponse);
/*
Get a specifiy tenant.
*/
rpc GetTenant(GetTenantRequest) returns (Tenant);
/*
Bakes a new macaroon with the specified permissions.
The macaroon can also be restricted to a specific tenant. In this case,
- any swap or wallet created with the returned macaroon will belong to this tenant and can not be accessed by other tenants.
- the lightning node connected to the daemon can not be used to pay or create invoices for swaps.
*/
rpc BakeMacaroon(BakeMacaroonRequest) returns (BakeMacaroonResponse);
}
message CreateTenantRequest {
string name = 1;
}
message ListTenantsRequest {}
message ListTenantsResponse {
repeated Tenant tenants = 1;
}
message GetTenantRequest {
string name = 1;
}
message Tenant {
uint64 id = 1;
string name = 2;
}
enum MacaroonAction {
READ = 0;
WRITE = 1;
}
message MacaroonPermissions {
MacaroonAction action = 2;
}
message BakeMacaroonRequest {
optional uint64 tenant_id = 1;
repeated MacaroonPermissions permissions = 2;
}
message BakeMacaroonResponse {
string macaroon = 1;
}
enum SwapState {
PENDING = 0;
SUCCESSFUL= 1;
// Unknown client error. Check the error field of the message for more information
ERROR = 2;
// Unknown server error. Check the status field of the message for more information
SERVER_ERROR = 3;
// Client refunded locked coins after the HTLC timed out
REFUNDED = 4;
// Client noticed that the HTLC timed out but didn't find any outputs to refund
ABANDONED = 5;
}
enum Currency {
BTC = 0;
LBTC = 1;
}
message Pair {
Currency from = 1;
Currency to = 2;
}
message SwapInfo {
string id = 1;
Pair pair = 22;
SwapState state = 2;
string error = 3;
// Latest status message of the Boltz backend
string status = 4;
string private_key = 5;
string preimage = 6;
string redeem_script = 7;
string invoice = 8;
string lockup_address = 9;
uint64 expected_amount = 10;
uint32 timeout_block_height = 11;
string lockup_transaction_id = 12;
/*
If the swap times out or fails for some other reason, the damon will automatically refund the coins sent to the
`lockup_address` back to the configured wallet or the address specified in the `refund_address` field.
*/
string refund_transaction_id = 13;
optional string refund_address = 19;
repeated ChannelId chan_ids = 14;
optional string blinding_key = 15;
int64 created_at = 16;
optional uint64 service_fee = 17;
optional uint64 onchain_fee = 18;
// internal wallet which was used to pay the swap
optional uint64 wallet_id = 20;
uint64 tenant_id = 21;
}
enum SwapType {
SUBMARINE = 0;
REVERSE = 1;
CHAIN = 2;
}
message GetPairInfoRequest {
SwapType type = 1;
Pair pair = 2;
}
message PairInfo {
Pair pair = 1;
SwapFees fees = 2;
Limits limits = 3;
string hash = 4;
}
/*
Channel creations are an optional extension to a submarine swap in the data types of boltz-client.
*/
message ChannelCreationInfo {
option deprecated = true;
// ID of the swap to which this channel channel belongs
string swap_id = 1;
string status = 2;
uint32 inbound_liquidity = 3;
bool private = 4;
string funding_transaction_id = 5;
uint32 funding_transaction_vout = 6;
}
message CombinedChannelSwapInfo {
option deprecated = true;
SwapInfo swap = 1;
ChannelCreationInfo channel_creation = 2;
}
message ReverseSwapInfo {
string id = 1;
SwapState state = 2;
string error = 3;
// Latest status message of the Boltz backend
string status = 4;
string private_key = 5;
string preimage = 6;
string redeem_script = 7;
string invoice = 8;
string claim_address = 9;
int64 onchain_amount = 10;
uint32 timeout_block_height = 11;
string lockup_transaction_id = 12;
string claim_transaction_id = 13;
Pair pair = 14;
repeated ChannelId chan_ids = 15;
optional string blinding_key = 16;
int64 created_at = 17;
optional int64 paid_at = 23; // the time when the invoice was paid
optional uint64 service_fee = 18;
optional uint64 onchain_fee = 19;
optional uint64 routing_fee_msat = 20;
bool external_pay = 21;
uint64 tenant_id = 22;
}
message BlockHeights {
uint32 btc = 1;
optional uint32 liquid = 2;
}
message GetInfoRequest {}
message GetInfoResponse {
string version = 9;
string node = 10;
string network = 2;
string node_pubkey = 7;
// one of: running, disabled, error
string auto_swap_status = 11;
// mapping of the currency to the latest block height.
BlockHeights block_heights = 8;
// swaps that need a manual interaction to refund
repeated string refundable_swaps = 12;
// the currently authenticated tenant
optional Tenant tenant = 13;
// swaps that need a manual interaction to claim
repeated string claimable_swaps = 14;
string symbol = 1 [deprecated = true];
string lnd_pubkey = 3 [deprecated = true];
uint32 block_height = 4 [deprecated = true];
repeated string pending_swaps = 5 [deprecated = true];
repeated string pending_reverse_swaps = 6 [deprecated = true];
}
message Limits {
uint64 minimal = 1;
uint64 maximal = 2;
uint64 maximal_zero_conf_amount = 3;
}
message SwapFees {
double percentage = 1;
uint64 miner_fees = 2;
}
message GetPairsResponse {
repeated PairInfo submarine = 1;
repeated PairInfo reverse = 2;
repeated PairInfo chain = 3;
}
message MinerFees {
uint32 normal = 1;
uint32 reverse = 2;
}
message Fees {
float percentage = 1;
MinerFees miner = 2;
}
message GetServiceInfoRequest {}
message GetServiceInfoResponse {
Fees fees = 1;
Limits limits = 2;
}
enum IncludeSwaps {
ALL = 0;
MANUAL = 1;
AUTO = 2;
}
message ListSwapsRequest {
optional Currency from = 1;
optional Currency to = 2;
optional SwapState state = 4;
IncludeSwaps include = 5;
}
message ListSwapsResponse {
repeated SwapInfo swaps = 1;
repeated CombinedChannelSwapInfo channel_creations = 2;
repeated ReverseSwapInfo reverse_swaps = 3;
repeated ChainSwapInfo chain_swaps = 4;
}
message GetStatsRequest {
IncludeSwaps include = 1;
}
message GetStatsResponse {
SwapStats stats = 1;
}
message RefundSwapRequest {
string id = 1;
oneof destination {
string address = 2;
uint64 wallet_id = 3;
}
}
message ClaimSwapsRequest {
repeated string swap_ids = 1;
oneof destination {
string address = 2;
uint64 wallet_id = 3;
}
}
message ClaimSwapsResponse {
string transaction_id = 1;
}
message GetSwapInfoRequest {
string id = 1;
}
message GetSwapInfoResponse {
SwapInfo swap = 1;
ChannelCreationInfo channel_creation = 2;
ReverseSwapInfo reverse_swap = 3;
ChainSwapInfo chain_swap = 4;
}
message DepositRequest {
/*
Percentage of inbound liquidity the channel that is opened in case the invoice cannot be paid should have.
25 by default.
*/
uint32 inbound_liquidity = 1;
}
message DepositResponse {
string id = 1;
string address = 2;
uint32 timeout_block_height = 3;
}
message CreateSwapRequest {
uint64 amount = 1;
Pair pair = 2;
// not yet supported
// repeated string chan_ids = 3;
// the daemon will pay the swap using the onchain wallet specified in the `wallet` field or any wallet otherwise.
bool send_from_internal = 4;
// address where the coins should go if the swap fails. Refunds will go to any of the daemons wallets otherwise.
optional string refund_address = 5;
// wallet to pay swap from. only used if `send_from_internal` is set to true
optional uint64 wallet_id = 6;
// invoice to use for the swap. if not set, the daemon will get a new invoice from the lightning node
optional string invoice = 7;
// Boltz does not accept 0-conf for Liquid transactions with a fee of 0.01 sat/vByte;
// when `zero_conf` is enabled, a fee of 0.1 sat/vByte will be used for Liquid lockup transactions
optional bool zero_conf = 8;
}
message CreateSwapResponse {
string id = 1;
string address = 2;
uint64 expected_amount = 3;
string bip21 = 4;
// lockup transaction id. Only populated when `send_from_internal` was specified in the request
string tx_id = 5;
uint32 timeout_block_height = 6;
float timeout_hours = 7;
}
message CreateChannelRequest {
int64 amount = 1;
/*
Percentage of inbound liquidity the channel that is opened should have.
25 by default.
*/
uint32 inbound_liquidity = 2;
bool private = 3;
};
message CreateReverseSwapRequest {
// amount of satoshis to swap
uint64 amount = 1;
// If no value is set, the daemon will query a new address from the lightning node
string address = 2;
// Whether the daemon should broadcast the claim transaction immediately after the lockup transaction is in the mempool.
// Should only be used for smaller amounts as it involves trust in boltz.
bool accept_zero_conf = 3;
Pair pair = 4;
// a list of channel ids which are allowed for paying the invoice. can be in either cln or lnd style.
repeated string chan_ids = 5;
// wallet from which the onchain address should be generated - only considered if `address` is not set
optional uint64 wallet_id = 6;
// Whether the daemon should return immediately after creating the swap or wait until the swap is successful or failed.
// It will always return immediately if `accept_zero_conf` is not set.
optional bool return_immediately = 7;
// If set, the daemon will not pay the invoice of the swap and return the invoice to be paid. This implicitly sets `return_immediately` to true.
optional bool external_pay = 8;
// Description of the invoice which will be created for the swap
optional string description = 9;
}
message CreateReverseSwapResponse {
string id = 1;
string lockup_address = 2;
// Only populated when zero-conf is accepted and return_immediately is set to false
optional uint64 routing_fee_milli_sat = 3;
// Only populated when zero-conf is accepted and return_immediately is set to false
optional string claim_transaction_id = 4;
// Invoice to be paid. Only populated when `external_pay` is set to true
optional string invoice = 5;
}
message CreateChainSwapRequest {
// Amount of satoshis to swap. It is the amount expected to be sent to the lockup address.
uint64 amount = 1;
Pair pair = 2;
// Address where funds will be swept to if the swap succeeds
optional string to_address = 3;
// Address where the coins should be refunded to if the swap fails.
optional string refund_address = 4;
// Wallet from which the swap should be paid from. Ignored if `external_pay` is set to true.
// If the swap fails, funds will be refunded to this wallet as well.
optional uint64 from_wallet_id = 5;
// Wallet where the the funds will go if the swap succeeds.
optional uint64 to_wallet_id = 6;
// Whether the daemon should broadcast the claim transaction immediately after the lockup transaction is in the mempool.
// Should only be used for smaller amounts as it involves trust in Boltz.
optional bool accept_zero_conf = 7;
// If set, the daemon will not pay the swap from an internal wallet.
optional bool external_pay = 8;
// Boltz does not accept 0-conf for Liquid transactions with a fee of 0.01 sat/vByte;
// when `lockup_zero_conf` is enabled, a fee of 0.1 sat/vByte will be used for Liquid lockup transactions
optional bool lockup_zero_conf = 9;
}
message ChainSwapInfo {
string id = 1;
Pair pair = 2;
SwapState state = 3;
string error = 4;
string status = 5;
string preimage = 6;
bool is_auto = 8;
optional uint64 service_fee = 9;
double service_fee_percent = 10;
optional uint64 onchain_fee = 11;
int64 created_at = 12;
uint64 tenant_id = 13;
ChainSwapData from_data = 14;
ChainSwapData to_data = 15;
}
message ChainSwapData {
string id = 1;
Currency currency = 2;
string private_key = 3;
string their_public_key = 4;
uint64 amount = 6;
uint32 timeout_block_height = 7;
optional string lockup_transaction_id = 8;
optional string transaction_id = 9;
optional uint64 wallet_id = 20;
optional string address = 12;
optional string blinding_key = 13;
string lockup_address = 14;
}
message ChannelId {
// cln style: 832347x2473x1
string cln = 1;
// lnd style: 915175205006540801
uint64 lnd = 2;
}
message LightningChannel {
ChannelId id = 1;
uint64 capacity = 2;
uint64 outbound_sat = 3;
uint64 inbound_sat = 4;
string peer_id = 5;
}
message SwapStats {
uint64 total_fees = 1;
uint64 total_amount = 2;
uint64 avg_fees = 3;
uint64 avg_amount = 4;
uint64 count = 5;
uint64 success_count = 6;
}
message Budget {
uint64 total = 1;
int64 remaining = 2;
int64 start_date = 3;
int64 end_date = 4;
}
message WalletCredentials {
// only one of these is allowed to be present
optional string mnemonic = 1;
optional string xpub = 2;
optional string core_descriptor = 3;
// only used in combination with mnemonic
optional uint64 subaccount = 4;
}
message WalletParams {
string name = 1;
Currency currency = 2;
// the password to encrypt the wallet with. If there are existing encrypted wallets, the same password has to be used.
optional string password = 3;
}
message ImportWalletRequest {
WalletCredentials credentials = 1;
WalletParams params = 2;
}
message CreateWalletRequest {
WalletParams params = 2;
}
message CreateWalletResponse {
string mnemonic = 1;
Wallet wallet = 2;
}
message SetSubaccountRequest {
uint64 wallet_id = 1;
// The subaccount to use. If not set, a new one will be created.
optional uint64 subaccount = 2;
}
message GetSubaccountsRequest {
uint64 wallet_id = 1;
}
message GetSubaccountsResponse {
optional uint64 current = 1;
repeated Subaccount subaccounts = 2;
}
message ImportWalletResponse {}
message GetWalletsRequest {
optional Currency currency = 1;
optional bool include_readonly = 2;
}
message GetWalletRequest {
optional string name = 1;
optional uint64 id = 2;
}
message GetWalletCredentialsRequest {
uint64 id = 1;
optional string password = 2;
}
message RemoveWalletRequest {
uint64 id = 1;
}
message Wallet {
uint64 id = 1;
string name = 2;
Currency currency = 3;
bool readonly = 4;
Balance balance = 5;
uint64 tenant_id = 6;
}
message Wallets {
repeated Wallet wallets = 1;
}
message Balance {
uint64 total = 1;
uint64 confirmed = 2;
uint64 unconfirmed = 3;
}
message Subaccount {
Balance balance = 1;
uint64 pointer = 2;
string type = 3;
}
message RemoveWalletResponse {}
message UnlockRequest {
string password = 1;
}
message VerifyWalletPasswordRequest {
string password = 1;
}
message VerifyWalletPasswordResponse {
bool correct = 1;
}
message ChangeWalletPasswordRequest {
string old = 1;
string new = 2;
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
wget https://raw.githubusercontent.com/BoltzExchange/boltz-client/master/boltzrpc/boltzrpc.proto -O lnbits/wallets/boltz_grpc_files/boltzrpc.proto
poetry run python -m grpc_tools.protoc -I . --python_out=. --grpc_python_out=. --pyi_out=. lnbits/wallets/boltz_grpc_files/boltzrpc.proto
+281
View File
@@ -0,0 +1,281 @@
import base64
try:
import breez_sdk # type: ignore
BREEZ_SDK_INSTALLED = True
except ImportError:
BREEZ_SDK_INSTALLED = False
if not BREEZ_SDK_INSTALLED:
class BreezSdkWallet: # pyright: ignore
def __init__(self):
raise RuntimeError(
"Breez SDK is not installed. "
"Ask admin to run `poetry install -E breez` to install it."
)
else:
import asyncio
from pathlib import Path
from typing import AsyncGenerator, Optional
from loguru import logger
from lnbits import bolt11 as lnbits_bolt11
from lnbits.settings import settings
from .base import (
InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
PaymentSuccessStatus,
StatusResponse,
UnsupportedError,
Wallet,
)
breez_event_queue: asyncio.Queue = asyncio.Queue()
def load_bytes(source: str, extension: str) -> Optional[bytes]:
# first check if it can be read from a file
if source.split(".")[-1] == extension:
with open(source, "rb") as f:
source_bytes = f.read()
return source_bytes
else:
# else check the source string can be converted from hex
try:
return bytes.fromhex(source)
except ValueError:
pass
# else convert from base64
try:
return base64.b64decode(source)
except Exception:
pass
return None
def load_greenlight_credentials() -> (
Optional[
breez_sdk.GreenlightCredentials # pyright: ignore[reportUnboundVariable]
]
):
if (
settings.breez_greenlight_device_key
and settings.breez_greenlight_device_cert
):
device_key_bytes = load_bytes(settings.breez_greenlight_device_key, "pem")
device_cert_bytes = load_bytes(settings.breez_greenlight_device_cert, "crt")
if not device_key_bytes or not device_cert_bytes:
raise ValueError(
"cannot initialize BreezSdkWallet: "
"cannot decode breez_greenlight_device_key "
"or breez_greenlight_device_cert"
)
return breez_sdk.GreenlightCredentials( # pyright: ignore[reportUnboundVariable]
developer_key=list(device_key_bytes),
developer_cert=list(device_cert_bytes),
)
return None
class SDKListener(
breez_sdk.EventListener # pyright: ignore[reportUnboundVariable]
):
def on_event(self, event):
logger.debug(event)
breez_event_queue.put_nowait(event)
class BreezSdkWallet(Wallet): # type: ignore[no-redef]
def __init__(self):
if not settings.breez_greenlight_seed:
raise ValueError(
"cannot initialize BreezSdkWallet: missing breez_greenlight_seed"
)
if not settings.breez_api_key:
raise ValueError(
"cannot initialize BreezSdkWallet: missing breez_api_key"
)
if (
settings.breez_greenlight_device_key
and not settings.breez_greenlight_device_cert
):
raise ValueError(
"cannot initialize BreezSdkWallet: "
"missing breez_greenlight_device_cert"
)
if (
settings.breez_greenlight_device_cert
and not settings.breez_greenlight_device_key
):
raise ValueError(
"cannot initialize BreezSdkWallet: "
"missing breez_greenlight_device_key"
)
self.config = breez_sdk.default_config(
breez_sdk.EnvironmentType.PRODUCTION,
settings.breez_api_key,
breez_sdk.NodeConfig.GREENLIGHT(
config=breez_sdk.GreenlightNodeConfig(
partner_credentials=load_greenlight_credentials(),
invite_code=settings.breez_greenlight_invite_code,
)
),
)
breez_sdk_working_dir = Path(settings.lnbits_data_folder, "breez-sdk")
breez_sdk_working_dir.mkdir(parents=True, exist_ok=True)
self.config.working_dir = breez_sdk_working_dir.absolute().as_posix()
try:
seed = breez_sdk.mnemonic_to_seed(settings.breez_greenlight_seed)
connect_request = breez_sdk.ConnectRequest(self.config, seed)
self.sdk_services = breez_sdk.connect(connect_request, SDKListener())
except Exception as exc:
logger.warning(exc)
raise ValueError(f"cannot initialize BreezSdkWallet: {exc!s}") from exc
async def cleanup(self):
self.sdk_services.disconnect()
async def status(self) -> StatusResponse:
try:
node_info: breez_sdk.NodeState = self.sdk_services.node_info()
except Exception as exc:
return StatusResponse(f"Failed to connect to breez, got: '{exc}...'", 0)
return StatusResponse(None, int(node_info.channels_balance_msat))
async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse:
# if description_hash or unhashed_description:
# raise UnsupportedError("description_hash and unhashed_description")
try:
if description_hash and not unhashed_description:
raise UnsupportedError(
"'description_hash' unsupported by Greenlight, provide"
" 'unhashed_description'"
)
breez_invoice: breez_sdk.ReceivePaymentResponse = (
self.sdk_services.receive_payment(
breez_sdk.ReceivePaymentRequest(
amount * 1000, # breez uses msat
(
unhashed_description.decode()
if unhashed_description
else memo
),
preimage=kwargs.get("preimage"),
opening_fee_params=None,
use_description_hash=True if unhashed_description else None,
)
)
)
return InvoiceResponse(
True,
breez_invoice.ln_invoice.payment_hash,
breez_invoice.ln_invoice.bolt11,
None,
)
except Exception as e:
logger.warning(e)
return InvoiceResponse(False, None, None, str(e))
async def pay_invoice(
self, bolt11: str, fee_limit_msat: int
) -> PaymentResponse:
invoice = lnbits_bolt11.decode(bolt11)
try:
send_payment_request = breez_sdk.SendPaymentRequest(bolt11=bolt11)
send_payment_response: breez_sdk.SendPaymentResponse = (
self.sdk_services.send_payment(send_payment_request)
)
payment: breez_sdk.Payment = send_payment_response.payment
except Exception as exc:
logger.warning(exc)
try:
# try to report issue to Breez to improve LSP routing
self.sdk_services.report_issue(
breez_sdk.ReportIssueRequest.PAYMENT_FAILURE(
breez_sdk.ReportPaymentFailureDetails(invoice.payment_hash)
)
)
except Exception as ex:
logger.info(ex)
# assume that payment failed?
return PaymentResponse(
False, None, None, None, f"payment failed: {exc}"
)
if payment.status != breez_sdk.PaymentStatus.COMPLETE:
return PaymentResponse(False, None, None, None, "payment is pending")
# let's use the payment_hash as the checking_id
checking_id = invoice.payment_hash
return PaymentResponse(
True,
checking_id,
payment.fee_msat,
payment.details.data.payment_preimage,
None,
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
payment: breez_sdk.Payment = self.sdk_services.payment_by_hash(
checking_id
)
if payment is None:
return PaymentPendingStatus()
if payment.payment_type != breez_sdk.PaymentType.RECEIVED:
logger.warning(f"unexpected payment type: {payment.status}")
return PaymentPendingStatus()
if payment.status == breez_sdk.PaymentStatus.FAILED:
return PaymentFailedStatus()
if payment.status == breez_sdk.PaymentStatus.COMPLETE:
return PaymentSuccessStatus()
return PaymentPendingStatus()
except Exception as exc:
logger.warning(exc)
return PaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try:
payment: breez_sdk.Payment = self.sdk_services.payment_by_hash(
checking_id
)
if payment is None:
return PaymentPendingStatus()
if payment.payment_type != breez_sdk.PaymentType.SENT:
logger.warning(f"unexpected payment type: {payment.status}")
return PaymentPendingStatus()
if payment.status == breez_sdk.PaymentStatus.COMPLETE:
return PaymentSuccessStatus(
fee_msat=payment.fee_msat,
preimage=payment.details.data.payment_preimage,
)
if payment.status == breez_sdk.PaymentStatus.FAILED:
return PaymentFailedStatus()
return PaymentPendingStatus()
except Exception as exc:
logger.warning(exc)
return PaymentPendingStatus()
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True:
event = await breez_event_queue.get()
if event.is_invoice_paid():
yield event.details.payment_hash

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