├── lnbits
├── __init__.py
├── utils
│ ├── __init__.py
│ ├── cache.py
│ └── crypto.py
├── core
│ ├── views
│ │ ├── __init__.py
│ │ ├── websocket_api.py
│ │ ├── public_api.py
│ │ ├── wallet_api.py
│ │ ├── webpush_api.py
│ │ └── tinyurl_api.py
│ ├── db.py
│ ├── templates
│ │ ├── users
│ │ │ ├── _createUserDialog.html
│ │ │ ├── _createWalletDialog.html
│ │ │ ├── _topupDialog.html
│ │ │ └── _walletDialog.html
│ │ ├── admin
│ │ │ ├── _tab_security_notifications.html
│ │ │ ├── _tab_users.html
│ │ │ ├── _tab_funding.html
│ │ │ └── index.html
│ │ ├── node
│ │ │ ├── _tab_dashboard.html
│ │ │ └── public.html
│ │ └── service-worker.js
│ ├── sso
│ │ └── keycloak.py
│ ├── __init__.py
│ └── extensions
│ │ ├── helpers.py
│ │ └── extension_manager.py
├── wallets
│ ├── boltz_grpc_files
│ │ ├── __init__.py
│ │ └── update.sh
│ ├── lnd_grpc_files
│ │ └── __init__.py
│ ├── macaroon
│ │ ├── __init__.py
│ │ └── macaroon.py
│ ├── void.py
│ ├── __init__.py
│ └── base.py
├── py.typed
├── static
│ ├── i18n
│ │ └── i18n.js
│ ├── favicon.ico
│ ├── images
│ │ ├── alby.png
│ │ ├── albyl.png
│ │ ├── blitz.png
│ │ ├── breez.png
│ │ ├── cln.png
│ │ ├── clnl.png
│ │ ├── lnd.png
│ │ ├── lnpay.png
│ │ ├── spark.png
│ │ ├── zbd.png
│ │ ├── zbdl.png
│ │ ├── blitzl.png
│ │ ├── breezl.png
│ │ ├── lnpayl.png
│ │ ├── mynode.png
│ │ ├── mynodel.png
│ │ ├── sparkl.png
│ │ ├── start9.png
│ │ ├── start9l.png
│ │ ├── umbrel.png
│ │ ├── umbrell.png
│ │ ├── voltage.png
│ │ ├── greenlight.png
│ │ ├── open-sats.png
│ │ ├── opennode.png
│ │ ├── opennodel.png
│ │ ├── phoenixd.png
│ │ ├── phoenixdl.png
│ │ ├── templatead.png
│ │ ├── voltagel.png
│ │ ├── github-logo.png
│ │ ├── google-logo.png
│ │ ├── greenlightl.png
│ │ ├── logos
│ │ │ ├── lnbits.png
│ │ │ ├── lnbits.svg
│ │ │ └── nostr.svg
│ │ ├── default_voucher.png
│ │ ├── keycloak-logo.png
│ │ ├── maskable_icon.png
│ │ ├── lnbits-shop-dark.png
│ │ ├── lnbits-shop-light.png
│ │ ├── maskable_icon_x96.png
│ │ ├── screenshot_phone.png
│ │ ├── bitcoin-shop-banner.png
│ │ ├── maskable_icon_x192.png
│ │ ├── maskable_icon_x512.png
│ │ ├── screenshot_desktop.png
│ │ ├── bitcoin-hardware-wallet.png
│ │ ├── voucher_template.svg
│ │ ├── boltzl.svg
│ │ └── boltz.svg
│ ├── fonts
│ │ └── material-icons-v50.woff2
│ ├── js
│ │ ├── init-app.js
│ │ └── components
│ │ │ ├── extension-settings.js
│ │ │ └── payment-chart.js
│ ├── vendor
│ │ └── Chart.css
│ └── vendor.json
├── __main__.py
├── bolt11.py
├── requestvars.py
├── nodes
│ └── __init__.py
├── templates
│ ├── macros.jinja
│ ├── public.html
│ ├── print.html
│ └── error.html
├── jinja2_templating.py
├── lnurl.py
└── server.py
├── tests
├── __init__.py
├── api
│ ├── __init__.py
│ ├── test_public_api.py
│ ├── test_admin_api.py
│ ├── test_generic.py
│ └── test_webpush_api.py
├── unit
│ ├── __init__.py
│ ├── test_helpers_query.py
│ ├── test_db.py
│ ├── test_services_wallet_limit.py
│ ├── test_cache.py
│ ├── test_services_fees.py
│ └── test_db_fetch_page.py
├── regtest
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_services_pay_invoice.py
│ ├── test_services_create_invoice.py
│ └── helpers.py
├── wallets
│ ├── fixtures
│ │ └── certificates
│ │ │ ├── breez.crt
│ │ │ └── cert.pem
│ └── test_rest_wallets.py
└── helpers.py
├── docs
├── CNAME
├── .gitignore
├── logos
│ ├── lnbits.png
│ ├── lnbits-full.png
│ ├── lnbits-full-inverse.png
│ ├── lnbits.svg
│ ├── lnbits-full.svg
│ └── lnbits-full-inverse.svg
├── devs
│ ├── api.md
│ ├── swagger.html
│ ├── websockets.md
│ └── development.md
├── _config.yml
├── index.md
└── guide
│ ├── fastapi_transition.md
│ └── admin_ui.md
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── something-else.md
│ ├── feature_request.md
│ └── bug_report.md
├── workflows
│ ├── codeql.yml
│ ├── release-rc.yml
│ ├── make.yml
│ ├── migration.yml
│ ├── nix.yml
│ ├── lint.yml
│ ├── docker.yml
│ ├── release.yml
│ ├── jmeter.yml
│ ├── tests.yml
│ ├── ci.yml
│ └── regtest.yml
└── actions
│ └── prepare
│ └── action.yml
├── tools
├── optipng.sh
├── preimage.py
├── create_fake_admin.py
└── i18n-check.py
├── nix
├── tests
│ ├── default.nix
│ └── nixos-module
│ │ └── default.nix
└── modules
│ └── lnbits-service.nix
├── .prettierrc
├── .prettierignore
├── .dockerignore
├── .editorconfig
├── .gitignore
├── .pre-commit-config.yaml
├── LICENSE
├── lnbits.sh
├── Dockerfile
├── flake.nix
├── Makefile
├── flake.lock
└── README.md
/lnbits/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/api/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/unit/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lnbits/utils/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/regtest/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/CNAME:
--------------------------------------------------------------------------------
1 | docs.lnbits.org
2 |
--------------------------------------------------------------------------------
/lnbits/core/views/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lnbits/wallets/boltz_grpc_files/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lnbits/wallets/lnd_grpc_files/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lnbits/py.typed:
--------------------------------------------------------------------------------
1 | # Marker file for PEP 561
2 |
--------------------------------------------------------------------------------
/lnbits/static/i18n/i18n.js:
--------------------------------------------------------------------------------
1 | window.localisation = {}
2 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | custom: https://demo.lnbits.com/lnurlp/link/fH59GD
2 |
--------------------------------------------------------------------------------
/lnbits/__main__.py:
--------------------------------------------------------------------------------
1 | from .app import create_app
2 |
3 | app = create_app()
4 |
--------------------------------------------------------------------------------
/docs/.gitignore:
--------------------------------------------------------------------------------
1 | _site
2 | .sass-cache
3 | .jekyll-cache
4 | .jekyll-metadata
5 | vendor
6 |
--------------------------------------------------------------------------------
/docs/logos/lnbits.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/docs/logos/lnbits.png
--------------------------------------------------------------------------------
/lnbits/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/favicon.ico
--------------------------------------------------------------------------------
/docs/logos/lnbits-full.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/docs/logos/lnbits-full.png
--------------------------------------------------------------------------------
/lnbits/static/images/alby.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/alby.png
--------------------------------------------------------------------------------
/lnbits/static/images/albyl.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/albyl.png
--------------------------------------------------------------------------------
/lnbits/static/images/blitz.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/blitz.png
--------------------------------------------------------------------------------
/lnbits/static/images/breez.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/breez.png
--------------------------------------------------------------------------------
/lnbits/static/images/cln.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/cln.png
--------------------------------------------------------------------------------
/lnbits/static/images/clnl.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/clnl.png
--------------------------------------------------------------------------------
/lnbits/static/images/lnd.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/lnd.png
--------------------------------------------------------------------------------
/lnbits/static/images/lnpay.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/lnpay.png
--------------------------------------------------------------------------------
/lnbits/static/images/spark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/spark.png
--------------------------------------------------------------------------------
/lnbits/static/images/zbd.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/zbd.png
--------------------------------------------------------------------------------
/lnbits/static/images/zbdl.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/zbdl.png
--------------------------------------------------------------------------------
/lnbits/static/images/blitzl.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/blitzl.png
--------------------------------------------------------------------------------
/lnbits/static/images/breezl.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/breezl.png
--------------------------------------------------------------------------------
/lnbits/static/images/lnpayl.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/lnpayl.png
--------------------------------------------------------------------------------
/lnbits/static/images/mynode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/mynode.png
--------------------------------------------------------------------------------
/lnbits/static/images/mynodel.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/mynodel.png
--------------------------------------------------------------------------------
/lnbits/static/images/sparkl.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/sparkl.png
--------------------------------------------------------------------------------
/lnbits/static/images/start9.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/start9.png
--------------------------------------------------------------------------------
/lnbits/static/images/start9l.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/start9l.png
--------------------------------------------------------------------------------
/lnbits/static/images/umbrel.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/umbrel.png
--------------------------------------------------------------------------------
/lnbits/static/images/umbrell.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/umbrell.png
--------------------------------------------------------------------------------
/lnbits/static/images/voltage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/voltage.png
--------------------------------------------------------------------------------
/lnbits/wallets/macaroon/__init__.py:
--------------------------------------------------------------------------------
1 | from .macaroon import load_macaroon
2 |
3 | __all__ = ["load_macaroon"]
4 |
--------------------------------------------------------------------------------
/tools/optipng.sh:
--------------------------------------------------------------------------------
1 | optipng -o 7 docs/logos/*.png lnbits/static/images/*.png lnbits/static/images/logos/*.png
2 |
--------------------------------------------------------------------------------
/docs/logos/lnbits-full-inverse.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/docs/logos/lnbits-full-inverse.png
--------------------------------------------------------------------------------
/lnbits/static/images/greenlight.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/greenlight.png
--------------------------------------------------------------------------------
/lnbits/static/images/open-sats.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/open-sats.png
--------------------------------------------------------------------------------
/lnbits/static/images/opennode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/opennode.png
--------------------------------------------------------------------------------
/lnbits/static/images/opennodel.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/opennodel.png
--------------------------------------------------------------------------------
/lnbits/static/images/phoenixd.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/phoenixd.png
--------------------------------------------------------------------------------
/lnbits/static/images/phoenixdl.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/phoenixdl.png
--------------------------------------------------------------------------------
/lnbits/static/images/templatead.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/templatead.png
--------------------------------------------------------------------------------
/lnbits/static/images/voltagel.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/voltagel.png
--------------------------------------------------------------------------------
/lnbits/static/images/github-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/github-logo.png
--------------------------------------------------------------------------------
/lnbits/static/images/google-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/google-logo.png
--------------------------------------------------------------------------------
/lnbits/static/images/greenlightl.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/greenlightl.png
--------------------------------------------------------------------------------
/lnbits/static/images/logos/lnbits.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/logos/lnbits.png
--------------------------------------------------------------------------------
/lnbits/static/images/default_voucher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/default_voucher.png
--------------------------------------------------------------------------------
/lnbits/static/images/keycloak-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/keycloak-logo.png
--------------------------------------------------------------------------------
/lnbits/static/images/maskable_icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/maskable_icon.png
--------------------------------------------------------------------------------
/lnbits/static/images/lnbits-shop-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/lnbits-shop-dark.png
--------------------------------------------------------------------------------
/lnbits/static/images/lnbits-shop-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/lnbits-shop-light.png
--------------------------------------------------------------------------------
/lnbits/static/images/maskable_icon_x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/maskable_icon_x96.png
--------------------------------------------------------------------------------
/lnbits/static/images/screenshot_phone.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/screenshot_phone.png
--------------------------------------------------------------------------------
/lnbits/static/fonts/material-icons-v50.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/fonts/material-icons-v50.woff2
--------------------------------------------------------------------------------
/lnbits/static/images/bitcoin-shop-banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/bitcoin-shop-banner.png
--------------------------------------------------------------------------------
/lnbits/static/images/maskable_icon_x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/maskable_icon_x192.png
--------------------------------------------------------------------------------
/lnbits/static/images/maskable_icon_x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/maskable_icon_x512.png
--------------------------------------------------------------------------------
/lnbits/static/images/screenshot_desktop.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/screenshot_desktop.png
--------------------------------------------------------------------------------
/nix/tests/default.nix:
--------------------------------------------------------------------------------
1 | { pkgs, makeTest, inputs }:
2 | {
3 | vmTest = import ./nixos-module { inherit pkgs makeTest inputs; };
4 | }
5 |
--------------------------------------------------------------------------------
/tests/wallets/fixtures/certificates/breez.crt:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/tests/wallets/fixtures/certificates/breez.crt
--------------------------------------------------------------------------------
/lnbits/static/images/bitcoin-hardware-wallet.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dethos/lnbits/dev/lnbits/static/images/bitcoin-hardware-wallet.png
--------------------------------------------------------------------------------
/lnbits/static/js/init-app.js:
--------------------------------------------------------------------------------
1 | window.app.use(VueQrcodeReader)
2 | window.app.use(Quasar)
3 | window.app.use(window.i18n)
4 | window.app.mount('#vue')
5 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/something-else.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Something else
3 | about: Anything else that you need to say
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 | ---
8 |
--------------------------------------------------------------------------------
/lnbits/bolt11.py:
--------------------------------------------------------------------------------
1 | from bolt11 import (
2 | Bolt11 as Invoice, # noqa: F401
3 | )
4 | from bolt11 import (
5 | decode, # noqa: F401
6 | encode, # noqa: F401
7 | )
8 |
--------------------------------------------------------------------------------
/lnbits/core/db.py:
--------------------------------------------------------------------------------
1 | from lnbits.core.models import CoreAppExtra
2 | from lnbits.db import Database
3 |
4 | db = Database("database")
5 | core_app_extra: CoreAppExtra = CoreAppExtra()
6 |
--------------------------------------------------------------------------------
/docs/devs/api.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | parent: For developers
4 | title: API reference
5 | nav_order: 3
6 | ---
7 |
8 | # API reference
9 |
10 | [Swagger Docs](https://demo.lnbits.com/docs)
11 |
--------------------------------------------------------------------------------
/tools/preimage.py:
--------------------------------------------------------------------------------
1 | import hashlib
2 | import os
3 |
4 | preimage = os.urandom(32)
5 | preimage_hash = hashlib.sha256(preimage).hexdigest()
6 |
7 | print(f"preimage hash: {preimage_hash}")
8 | print(f"preimage: {preimage.hex()}")
9 |
--------------------------------------------------------------------------------
/lnbits/requestvars.py:
--------------------------------------------------------------------------------
1 | import contextvars
2 | import types
3 |
4 | request_global = contextvars.ContextVar(
5 | "request_global", default=types.SimpleNamespace()
6 | )
7 |
8 |
9 | def g() -> types.SimpleNamespace:
10 | return request_global.get()
11 |
--------------------------------------------------------------------------------
/lnbits/nodes/__init__.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from .base import Node
4 |
5 |
6 | def get_node_class() -> Optional[Node]:
7 | return NODE
8 |
9 |
10 | def set_node_class(node: Node):
11 | global NODE
12 | NODE = node
13 |
14 |
15 | NODE: Optional[Node] = None
16 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "arrowParens": "avoid",
4 | "insertPragma": false,
5 | "printWidth": 80,
6 | "proseWrap": "preserve",
7 | "singleQuote": true,
8 | "trailingComma": "none",
9 | "useTabs": false,
10 | "bracketSameLine": false,
11 | "bracketSpacing": false
12 | }
13 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | **/.git
2 | **/.svn
3 | **/.hg
4 | **/node_modules
5 |
6 | *.yml
7 |
8 | **/lnbits/extensions/*
9 | **/lnbits/upgrades/*
10 |
11 | **/lnbits/static/vendor
12 | **/lnbits/static/bundle.*
13 | **/lnbits/static/bundle-components.*
14 | **/lnbits/static/css/*
15 |
16 | flake.lock
17 |
18 | .venv
19 |
--------------------------------------------------------------------------------
/docs/logos/lnbits.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/lnbits/static/images/logos/lnbits.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/lnbits/wallets/boltz_grpc_files/update.sh:
--------------------------------------------------------------------------------
1 |
2 | wget https://raw.githubusercontent.com/BoltzExchange/boltz-client/master/boltzrpc/boltzrpc.proto -O lnbits/wallets/boltz_grpc_files/boltzrpc.proto
3 | poetry run python -m grpc_tools.protoc -I . --python_out=. --grpc_python_out=. --pyi_out=. lnbits/wallets/boltz_grpc_files/boltzrpc.proto
4 |
--------------------------------------------------------------------------------
/docs/_config.yml:
--------------------------------------------------------------------------------
1 | title: "LNbits docs"
2 | remote_theme: pmarsceill/just-the-docs
3 | color_scheme: dark
4 | logo: "/logos/lnbits-full-inverse.png"
5 | search_enabled: true
6 | url: https://docs.lnbits.org
7 | aux_links:
8 | "LNbits on GitHub":
9 | - "//github.com/lnbits/lnbits"
10 | "lnbits.com":
11 | - "//lnbits.com"
12 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .git
2 | data
3 | docker
4 | docs
5 | tests
6 | node_modules
7 |
8 | lnbits/static/css/*
9 | lnbits/static/bundle.js
10 | lnbits/static/bundle.css
11 |
12 | *.md
13 | !README.md
14 | *.log
15 |
16 | .env
17 |
18 | .gitignore
19 | .prettierrc
20 | LICENSE
21 | Makefile
22 | mypy.ini
23 | package-lock.json
24 | package.json
25 | pytest.ini
26 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # Top-most EditorConfig file
2 | root = true
3 |
4 | # Unix-style newlines with a newline ending every file, utf-8 charset
5 | [*]
6 | end_of_line = lf
7 | insert_final_newline = true
8 | trim_trailing_whitespace = true
9 | charset = utf-8
10 |
11 | [/lnbits/static/vendor/*]
12 | end_of_line = unset
13 | insert_final_newline = unset
14 | trim_trailing_whitespace = unset
15 |
--------------------------------------------------------------------------------
/lnbits/templates/macros.jinja:
--------------------------------------------------------------------------------
1 | {% macro window_vars(user, wallet) -%}
2 |
14 | {%- endmacro %}
15 |
--------------------------------------------------------------------------------
/tests/regtest/conftest.py:
--------------------------------------------------------------------------------
1 | import pytest_asyncio
2 |
3 | from .helpers import get_hold_invoice, get_real_invoice
4 |
5 |
6 | @pytest_asyncio.fixture(scope="function")
7 | async def hold_invoice():
8 | invoice = get_hold_invoice(100)
9 | yield invoice
10 | del invoice
11 |
12 |
13 | @pytest_asyncio.fixture(scope="function")
14 | async def real_invoice():
15 | invoice = get_real_invoice(100)
16 | yield {"bolt11": invoice["payment_request"]}
17 | del invoice
18 |
19 |
20 | @pytest_asyncio.fixture(scope="function")
21 | async def real_amountless_invoice():
22 | invoice = get_real_invoice(0)
23 | yield invoice["payment_request"]
24 | del invoice
25 |
--------------------------------------------------------------------------------
/lnbits/templates/public.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %} {% block beta %}{% endblock %} {% block drawer_toggle
2 | %}{% endblock %} {% block drawer %}{% endblock %} {% block toolbar_title %}
3 |
8 | {% if USE_CUSTOM_LOGO %}
9 |
10 | {%else%} {% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {% else %}
11 | LN bits
12 | {% endif %} {% endif %}
13 |
14 | {% endblock %}
15 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: '[Feature request]'
5 | labels: feature request
6 | assignees: ''
7 | ---
8 |
9 | **Is your feature request related to a problem? Please describe.**
10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
11 |
12 | **Describe the solution you'd like**
13 | A clear and concise description of what you want to happen.
14 |
15 | **Describe alternatives you've considered**
16 | A clear and concise description of any alternative solutions or features you've considered.
17 |
18 | **Additional context**
19 | Add any other context or screenshots about the feature request here.
20 |
--------------------------------------------------------------------------------
/tests/unit/test_helpers_query.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from lnbits.helpers import (
4 | insert_query,
5 | update_query,
6 | )
7 | from tests.helpers import DbTestModel
8 |
9 | test = DbTestModel(id=1, name="test", value="yes")
10 |
11 |
12 | @pytest.mark.asyncio
13 | async def test_helpers_insert_query():
14 | q = insert_query("test_helpers_query", test)
15 | assert (
16 | q == "INSERT INTO test_helpers_query (id, name, value) "
17 | "VALUES (:id, :name, :value)"
18 | )
19 |
20 |
21 | @pytest.mark.asyncio
22 | async def test_helpers_update_query():
23 | q = update_query("test_helpers_query", test)
24 | assert (
25 | q == "UPDATE test_helpers_query "
26 | "SET id = :id, name = :name, value = :value "
27 | "WHERE id = :id"
28 | )
29 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | name: codeql
2 |
3 | on:
4 | push:
5 | branches: [main, dev]
6 | pull_request:
7 | branches: [main, dev]
8 | schedule:
9 | - cron: '0 12 * * 5'
10 |
11 | jobs:
12 | analyze:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Checkout repository
16 | uses: actions/checkout@v4
17 | with:
18 | fetch-depth: 2
19 | - run: git checkout HEAD^2
20 | if: ${{ github.event_name == 'pull_request' }}
21 | - name: Initialize CodeQL
22 | uses: github/codeql-action/init@v2
23 | with:
24 | languages: javascript, python
25 | - name: Autobuild
26 | uses: github/codeql-action/autobuild@v2
27 | - name: Perform CodeQL Analysis
28 | uses: github/codeql-action/analyze@v2
29 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: User’s Guide
4 | nav_order: 1
5 | ---
6 |
7 | # LNbits, free and open-source Lightning Network wallet/accounts system
8 |
9 | LNbits is a very simple Python application that sits on top of any funding source, and can be used as:
10 |
11 | - Accounts system to mitigate the risk of exposing applications to your full balance, via unique API keys for each wallet
12 | - Extendable platform for exploring Lightning Network functionality via LNbits extension framework
13 | - Part of a development stack via LNbits API
14 | - Fallback wallet for the LNURL scheme
15 | - Instant wallet for LN demonstrations
16 |
17 | ## LNbits as an account system
18 |
19 | LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending,
20 | export to csv + more to come...
21 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: '[BUG]'
5 | labels: bug
6 | assignees: ''
7 | ---
8 |
9 | **Describe the bug**
10 | A clear and concise description of what the bug is.
11 |
12 | **To Reproduce**
13 | Steps to reproduce the behavior:
14 |
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 |
28 | - LNbits version: [e.g. 0.9.2 or commit hash]
29 | - Database [e.g. sqlite, postgres]
30 |
31 | **Additional context**
32 | Add any other context about the problem here.
33 |
--------------------------------------------------------------------------------
/lnbits/core/templates/users/_createUserDialog.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Create User
4 |
5 |
6 |
7 |
11 |
12 | Create
15 | Cancel
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/lnbits/core/templates/users/_createWalletDialog.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Create Wallet
4 |
5 |
6 |
7 |
11 |
12 | Create
15 | Cancel
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/nix/tests/nixos-module/default.nix:
--------------------------------------------------------------------------------
1 | { pkgs, makeTest, inputs }:
2 | makeTest {
3 | name = "lnbits-nixos-module";
4 | nodes = {
5 | client = { config, pkgs, ... }: {
6 | environment.systemPackages = [ pkgs.curl ];
7 | };
8 | lnbits = { ... }: {
9 | imports = [ inputs.self.nixosModules.default ];
10 | services.lnbits = {
11 | enable = true;
12 | openFirewall = true;
13 | host = "0.0.0.0";
14 | };
15 | };
16 | };
17 | testScript = { nodes, ... }: ''
18 | start_all()
19 | lnbits.wait_for_open_port(${toString nodes.lnbits.config.services.lnbits.port})
20 | client.wait_for_unit("multi-user.target")
21 | with subtest("Check that the lnbits webserver can be reached."):
22 | assert "
LNbits " in client.succeed(
23 | "curl -sSf http:/lnbits:8231/ | grep title"
24 | )
25 | '';
26 | }
27 |
--------------------------------------------------------------------------------
/.github/workflows/release-rc.yml:
--------------------------------------------------------------------------------
1 | name: release-rc
2 |
3 | on:
4 | push:
5 | tags:
6 | - "*-rc[0-9]"
7 |
8 | permissions:
9 | contents: write
10 |
11 | jobs:
12 |
13 | docker:
14 | uses: ./.github/workflows/docker.yml
15 | with:
16 | tag: ${{ github.ref_name }}
17 | secrets:
18 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
19 | DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
20 |
21 | pypi:
22 | runs-on: ubuntu-latest
23 | steps:
24 | - name: Install dependencies for building secp256k1
25 | run: |
26 | sudo apt-get update
27 | sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
28 | - uses: actions/checkout@v4
29 | - name: Build and publish to pypi
30 | uses: JRubics/poetry-publish@v1.15
31 | with:
32 | pypi_token: ${{ secrets.PYPI_API_KEY }}
33 |
--------------------------------------------------------------------------------
/tests/helpers.py:
--------------------------------------------------------------------------------
1 | import random
2 | import string
3 | from typing import Optional
4 |
5 | from lnbits.db import FromRowModel
6 | from lnbits.wallets import get_funding_source, set_funding_source
7 |
8 |
9 | class FakeError(Exception):
10 | pass
11 |
12 |
13 | class DbTestModel(FromRowModel):
14 | id: int
15 | name: str
16 | value: Optional[str] = None
17 |
18 |
19 | def get_random_string(iterations: int = 10):
20 | return "".join(
21 | random.SystemRandom().choice(string.ascii_uppercase + string.digits)
22 | for _ in range(iterations)
23 | )
24 |
25 |
26 | async def get_random_invoice_data():
27 | return {"out": False, "amount": 10, "memo": f"test_memo_{get_random_string(10)}"}
28 |
29 |
30 | set_funding_source()
31 | funding_source = get_funding_source()
32 | is_fake: bool = funding_source.__class__.__name__ == "FakeWallet"
33 | is_regtest: bool = not is_fake
34 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | ._*
3 |
4 | __pycache__
5 | *.py[cod]
6 | *$py.class
7 | .mypy_cache
8 | .vscode
9 | *-lock.json
10 |
11 | *.egg
12 | *.egg-info
13 | .coverage
14 | .coverage.*
15 | .pytest_cache
16 | .webassets-cache
17 | htmlcov
18 | test-reports
19 | tests/data/*.sqlite3
20 |
21 | *.swo
22 | *.swp
23 | *.pyo
24 | *.pyc
25 | *.env
26 | .env
27 | .pre-commit-config.yaml
28 |
29 | data
30 | *.sqlite3
31 | .pyre*
32 |
33 | __bundle__
34 |
35 | coverage.xml
36 | node_modules
37 | lnbits/static/bundle.js
38 | lnbits/static/bundle-components.js
39 | lnbits/static/bundle.css
40 | lnbits/static/bundle.min.js.old
41 | lnbits/static/bundle.min.css.old
42 | docker
43 |
44 | # Nix
45 | *result*
46 |
47 | # fly.io
48 | fly.toml
49 |
50 | lnbits-backup.zip
51 |
52 | # Ignore extensions (post installable extension PR)
53 | /lnbits/extensions
54 | /upgrades/
55 |
56 | # builded python package
57 | dist
58 |
59 | # jetbrains
60 | .idea
61 |
--------------------------------------------------------------------------------
/.github/workflows/make.yml:
--------------------------------------------------------------------------------
1 | name: make
2 |
3 | on:
4 | workflow_call:
5 | inputs:
6 | make:
7 | description: "make command that is run"
8 | required: true
9 | type: string
10 | npm:
11 | description: "use npm install"
12 | default: false
13 | type: boolean
14 | python-version:
15 | description: "python version"
16 | type: string
17 | default: "3.10"
18 |
19 | jobs:
20 | make:
21 | name: ${{ inputs.make }} (${{ inputs.python-version }})
22 | strategy:
23 | matrix:
24 | os-version: ["ubuntu-latest"]
25 | node-version: ["18.x"]
26 | runs-on: ${{ matrix.os-version }}
27 | steps:
28 | - uses: actions/checkout@v4
29 | - uses: lnbits/lnbits/.github/actions/prepare@dev
30 | with:
31 | python-version: ${{ inputs.python-version }}
32 | node-version: ${{ matrix.node-version }}
33 | npm: ${{ inputs.npm }}
34 | - run: make ${{ inputs.make }}
35 |
--------------------------------------------------------------------------------
/tests/api/test_public_api.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 |
4 | # check if the client is working
5 | @pytest.mark.asyncio
6 | async def test_core_views_generic(client):
7 | response = await client.get("/")
8 | assert response.status_code == 200
9 |
10 |
11 | # check GET /public/v1/payment/{payment_hash}: correct hash [should pass]
12 | @pytest.mark.asyncio
13 | async def test_api_public_payment_longpolling(client, invoice):
14 | response = await client.get(f"/public/v1/payment/{invoice['payment_hash']}")
15 | assert response.status_code < 300
16 | assert response.json()["status"] == "paid"
17 |
18 |
19 | # check GET /public/v1/payment/{payment_hash}: wrong hash [should fail]
20 | @pytest.mark.asyncio
21 | async def test_api_public_payment_longpolling_wrong_hash(client, invoice):
22 | response = await client.get(
23 | f"/public/v1/payment/{invoice['payment_hash'] + '0'*64}"
24 | )
25 | assert response.status_code == 404
26 | assert response.json()["detail"] == "Payment does not exist."
27 |
--------------------------------------------------------------------------------
/.github/workflows/migration.yml:
--------------------------------------------------------------------------------
1 | name: migration
2 |
3 | on:
4 | workflow_call:
5 | inputs:
6 | python-version:
7 | description: "python version"
8 | type: string
9 | default: "3.10"
10 |
11 | jobs:
12 | make:
13 | name: migration (${{ inputs.python-version }})
14 | strategy:
15 | matrix:
16 | os-version: ["ubuntu-latest"]
17 | runs-on: ${{ matrix.os-version }}
18 | services:
19 | postgres:
20 | image: postgres:latest
21 | env:
22 | POSTGRES_USER: lnbits
23 | POSTGRES_PASSWORD: lnbits
24 | POSTGRES_DB: migration
25 | ports:
26 | - 5432:5432
27 | options: >-
28 | --health-cmd pg_isready
29 | --health-interval 10s
30 | --health-timeout 5s
31 | --health-retries 5
32 | steps:
33 | - uses: actions/checkout@v4
34 | - uses: ./.github/actions/prepare
35 | with:
36 | python-version: ${{ inputs.python-version }}
37 | - run: make test-migration
38 |
--------------------------------------------------------------------------------
/lnbits/static/images/voucher_template.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | exclude: '^lnbits/static/bundle.*|^docs/.*|^lnbits/static/vendor/.*|^lnbits/extensions/.*|^lnbits/upgrades/.*|^lnbits/wallets/lnd_grpc_files/.*|^package-lock.json$'
2 |
3 | repos:
4 | - repo: https://github.com/pre-commit/pre-commit-hooks
5 | rev: v4.3.0
6 | hooks:
7 | - id: trailing-whitespace
8 | - id: end-of-file-fixer
9 | - id: check-yaml
10 | - id: check-added-large-files
11 | - id: check-docstring-first
12 | - id: check-json
13 | - id: debug-statements
14 | - id: mixed-line-ending
15 | - id: check-case-conflict
16 | - repo: https://github.com/psf/black
17 | rev: 24.2.0
18 | hooks:
19 | - id: black
20 | - repo: https://github.com/astral-sh/ruff-pre-commit
21 | rev: v0.3.2
22 | hooks:
23 | - id: ruff
24 | args: [ --fix, --exit-non-zero-on-fix ]
25 | - repo: https://github.com/pre-commit/mirrors-prettier
26 | rev: "v4.0.0-alpha.8"
27 | hooks:
28 | - id: prettier
29 | types_or: [css, javascript, html, json]
30 | args: ['lnbits']
31 |
--------------------------------------------------------------------------------
/lnbits/static/vendor/Chart.css:
--------------------------------------------------------------------------------
1 | /*
2 | * DOM element rendering detection
3 | * https://davidwalsh.name/detect-node-insertion
4 | */
5 | @keyframes chartjs-render-animation {
6 | from { opacity: 0.99; }
7 | to { opacity: 1; }
8 | }
9 |
10 | .chartjs-render-monitor {
11 | animation: chartjs-render-animation 0.001s;
12 | }
13 |
14 | /*
15 | * DOM element resizing detection
16 | * https://github.com/marcj/css-element-queries
17 | */
18 | .chartjs-size-monitor,
19 | .chartjs-size-monitor-expand,
20 | .chartjs-size-monitor-shrink {
21 | position: absolute;
22 | direction: ltr;
23 | left: 0;
24 | top: 0;
25 | right: 0;
26 | bottom: 0;
27 | overflow: hidden;
28 | pointer-events: none;
29 | visibility: hidden;
30 | z-index: -1;
31 | }
32 |
33 | .chartjs-size-monitor-expand > div {
34 | position: absolute;
35 | width: 1000000px;
36 | height: 1000000px;
37 | left: 0;
38 | top: 0;
39 | }
40 |
41 | .chartjs-size-monitor-shrink > div {
42 | position: absolute;
43 | width: 200%;
44 | height: 200%;
45 | left: 0;
46 | top: 0;
47 | }
48 |
--------------------------------------------------------------------------------
/docs/devs/swagger.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
10 | My New API
11 |
12 |
13 |
14 |
15 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Arc
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/tests/unit/test_db.py:
--------------------------------------------------------------------------------
1 | from datetime import date
2 |
3 | import pytest
4 |
5 | from lnbits.core.crud import (
6 | create_wallet,
7 | delete_wallet,
8 | get_wallet,
9 | get_wallet_for_key,
10 | )
11 | from lnbits.db import POSTGRES
12 |
13 |
14 | @pytest.mark.asyncio
15 | async def test_date_conversion(db):
16 | if db.type == POSTGRES:
17 | row = await db.fetchone("SELECT now()::date as now")
18 | assert row and isinstance(row.get("now"), date)
19 |
20 |
21 | # make test to create wallet and delete wallet
22 | @pytest.mark.asyncio
23 | async def test_create_wallet_and_delete_wallet(app, to_user):
24 | # create wallet
25 | wallet = await create_wallet(user_id=to_user.id, wallet_name="test_wallet_delete")
26 | assert wallet
27 |
28 | # delete wallet
29 | await delete_wallet(user_id=to_user.id, wallet_id=wallet.id)
30 |
31 | # check if wallet is deleted
32 | del_wallet = await get_wallet(wallet.id)
33 | assert del_wallet is not None
34 | assert del_wallet.deleted is True
35 |
36 | del_wallet = await get_wallet_for_key(wallet.inkey)
37 | assert del_wallet is None
38 |
--------------------------------------------------------------------------------
/.github/workflows/nix.yml:
--------------------------------------------------------------------------------
1 | name: LNbits CI / nix
2 |
3 | # - run : on main, dev, nix and cachix branches when relevant files change
4 | # - cache : on main, dev and cachix branches when relevant files change
5 |
6 | on:
7 | push:
8 | branches:
9 | - main
10 | - dev
11 | - nix
12 | - cachix
13 | paths:
14 | - 'flake.nix'
15 | - 'flake.lock'
16 | - 'pyproject.toml'
17 | - 'poetry.lock'
18 | - '.github/workflows/nix.yml'
19 | pull_request:
20 | paths:
21 | - 'flake.nix'
22 | - 'flake.lock'
23 | - 'pyproject.toml'
24 | - 'poetry.lock'
25 |
26 | jobs:
27 | nix:
28 | runs-on: ubuntu-latest
29 | steps:
30 | - uses: actions/checkout@v4
31 | - uses: cachix/install-nix-action@v27
32 | with:
33 | nix_path: nixpkgs=channel:nixos-24.05
34 | - uses: cachix/cachix-action@v15
35 | with:
36 | name: lnbits
37 | authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
38 | - run: nix build -L
39 | - run: cachix push lnbits ./result
40 | if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/cachix'
41 |
--------------------------------------------------------------------------------
/lnbits/jinja2_templating.py:
--------------------------------------------------------------------------------
1 | import typing
2 |
3 | from jinja2 import BaseLoader, Environment, pass_context
4 | from starlette.datastructures import QueryParams
5 | from starlette.requests import Request
6 | from starlette.templating import Jinja2Templates as SuperJinja2Templates
7 |
8 |
9 | class Jinja2Templates(SuperJinja2Templates):
10 | def __init__(self, loader: BaseLoader) -> None:
11 | self.env = self.get_environment(loader)
12 | super().__init__(env=self.env)
13 |
14 | def get_environment(self, loader: BaseLoader) -> Environment:
15 | @pass_context
16 | def url_for(context: dict, name: str, **path_params: typing.Any) -> str:
17 | request: Request = context["request"]
18 | return request.app.url_path_for(name, **path_params)
19 |
20 | def url_params_update(init: QueryParams, **new: typing.Any) -> QueryParams:
21 | values = dict(init)
22 | values.update(new)
23 | return QueryParams(**values)
24 |
25 | env = Environment(loader=loader, autoescape=True)
26 | env.globals["url_for"] = url_for
27 | env.globals["url_params_update"] = url_params_update
28 | return env
29 |
--------------------------------------------------------------------------------
/tests/unit/test_services_wallet_limit.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from lnbits.core.services import check_wallet_daily_withdraw_limit
4 | from lnbits.settings import settings
5 |
6 |
7 | @pytest.mark.asyncio
8 | async def test_no_wallet_limit():
9 | settings.lnbits_wallet_limit_daily_max_withdraw = 0
10 | result = await check_wallet_daily_withdraw_limit(
11 | conn=None, wallet_id="333333", amount_msat=0
12 | )
13 |
14 | assert result is None, "No limit set."
15 |
16 |
17 | @pytest.mark.asyncio
18 | async def test_wallet_limit_but_no_payments():
19 | settings.lnbits_wallet_limit_daily_max_withdraw = 5
20 | result = await check_wallet_daily_withdraw_limit(
21 | conn=None, wallet_id="333333", amount_msat=0
22 | )
23 |
24 | assert result is None, "Limit not reqached."
25 |
26 |
27 | @pytest.mark.asyncio
28 | async def test_no_wallet_spend_allowed():
29 | settings.lnbits_wallet_limit_daily_max_withdraw = -1
30 |
31 | with pytest.raises(
32 | ValueError, match="It is not allowed to spend funds from this server."
33 | ):
34 | await check_wallet_daily_withdraw_limit(
35 | conn=None, wallet_id="333333", amount_msat=0
36 | )
37 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: lint
2 | on:
3 | workflow_call:
4 |
5 | jobs:
6 |
7 | black:
8 | uses: ./.github/workflows/make.yml
9 | strategy:
10 | matrix:
11 | python-version: ["3.9", "3.10"]
12 | with:
13 | make: checkblack
14 | python-version: ${{ matrix.python-version }}
15 |
16 | ruff:
17 | uses: ./.github/workflows/make.yml
18 | strategy:
19 | matrix:
20 | python-version: ["3.9", "3.10"]
21 | with:
22 | make: checkruff
23 | python-version: ${{ matrix.python-version }}
24 |
25 | mypy:
26 | uses: ./.github/workflows/make.yml
27 | strategy:
28 | matrix:
29 | python-version: ["3.9", "3.10"]
30 | with:
31 | make: mypy
32 | python-version: ${{ matrix.python-version }}
33 |
34 | pyright:
35 | uses: ./.github/workflows/make.yml
36 | strategy:
37 | matrix:
38 | python-version: ["3.9", "3.10"]
39 | with:
40 | make: pyright
41 | python-version: ${{ matrix.python-version }}
42 | npm: true
43 |
44 |
45 | prettier:
46 | uses: ./.github/workflows/make.yml
47 | with:
48 | make: checkprettier
49 | npm: true
50 |
51 | bundle:
52 | uses: ./.github/workflows/make.yml
53 | with:
54 | make: checkbundle
55 | npm: true
56 |
--------------------------------------------------------------------------------
/lnbits/core/sso/keycloak.py:
--------------------------------------------------------------------------------
1 | """Keycloak SSO Login Helper
2 | """
3 |
4 | from typing import Optional
5 |
6 | import httpx
7 | from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase
8 |
9 |
10 | class KeycloakSSO(SSOBase):
11 | """Class providing login via Keycloak OAuth"""
12 |
13 | provider = "keycloak"
14 | scope = ["openid", "email", "profile"]
15 | discovery_url = ""
16 |
17 | async def openid_from_response(
18 | self, response: dict, session: Optional["httpx.AsyncClient"] = None
19 | ) -> OpenID:
20 | """Return OpenID from user information provided by Keycloak"""
21 | return OpenID(
22 | email=response.get("email", ""),
23 | provider=self.provider,
24 | id=response.get("sub"),
25 | first_name=response.get("given_name"),
26 | last_name=response.get("family_name"),
27 | display_name=response.get("name"),
28 | picture=response.get("picture"),
29 | )
30 |
31 | async def get_discovery_document(self) -> DiscoveryDocument:
32 | """Get document containing handy urls"""
33 | async with httpx.AsyncClient() as session:
34 | response = await session.get(self.discovery_url)
35 | content = response.json()
36 |
37 | return content
38 |
--------------------------------------------------------------------------------
/lnbits/core/views/websocket_api.py:
--------------------------------------------------------------------------------
1 | from fastapi import (
2 | APIRouter,
3 | WebSocket,
4 | WebSocketDisconnect,
5 | )
6 |
7 | from lnbits.settings import settings
8 |
9 | from ..services import (
10 | websocket_manager,
11 | websocket_updater,
12 | )
13 |
14 | websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"])
15 |
16 |
17 | @websocket_router.websocket("/{item_id}")
18 | async def websocket_connect(websocket: WebSocket, item_id: str):
19 | await websocket_manager.connect(websocket, item_id)
20 | try:
21 | while settings.lnbits_running:
22 | await websocket.receive_text()
23 | except WebSocketDisconnect:
24 | websocket_manager.disconnect(websocket)
25 |
26 |
27 | @websocket_router.post("/{item_id}")
28 | async def websocket_update_post(item_id: str, data: str):
29 | try:
30 | await websocket_updater(item_id, data)
31 | return {"sent": True, "data": data}
32 | except Exception:
33 | return {"sent": False, "data": data}
34 |
35 |
36 | @websocket_router.get("/{item_id}/{data}")
37 | async def websocket_update_get(item_id: str, data: str):
38 | try:
39 | await websocket_updater(item_id, data)
40 | return {"sent": True, "data": data}
41 | except Exception:
42 | return {"sent": False, "data": data}
43 |
--------------------------------------------------------------------------------
/lnbits/core/templates/users/_topupDialog.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
16 |
17 |
18 |
19 |
20 |
21 |
28 |
29 |
30 |
31 |
32 |
38 |
39 |
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/tests/regtest/test_services_pay_invoice.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from lnbits.core.crud import (
4 | get_standalone_payment,
5 | )
6 | from lnbits.core.services import (
7 | PaymentError,
8 | pay_invoice,
9 | )
10 |
11 | description = "test pay invoice"
12 |
13 |
14 | @pytest.mark.asyncio
15 | async def test_services_pay_invoice(to_wallet, real_invoice):
16 | payment_hash = await pay_invoice(
17 | wallet_id=to_wallet.id,
18 | payment_request=real_invoice.get("bolt11"),
19 | description=description,
20 | )
21 | assert payment_hash
22 | payment = await get_standalone_payment(payment_hash)
23 | assert payment
24 | assert not payment.pending
25 | assert payment.memo == description
26 |
27 |
28 | @pytest.mark.asyncio
29 | async def test_services_pay_invoice_invalid_bolt11(to_wallet):
30 | with pytest.raises(PaymentError):
31 | await pay_invoice(
32 | wallet_id=to_wallet.id,
33 | payment_request="lnbcr1123123n",
34 | )
35 |
36 |
37 | @pytest.mark.asyncio
38 | async def test_services_pay_invoice_0_amount_invoice(
39 | to_wallet, real_amountless_invoice
40 | ):
41 | with pytest.raises(PaymentError):
42 | await pay_invoice(
43 | wallet_id=to_wallet.id,
44 | payment_request=real_amountless_invoice,
45 | )
46 |
--------------------------------------------------------------------------------
/lnbits/static/vendor.json:
--------------------------------------------------------------------------------
1 | {
2 | "js": [
3 | "vendor/moment.js",
4 | "vendor/underscore.js",
5 | "vendor/axios.js",
6 | "vendor/vue.global.prod.js",
7 | "vendor/quasar.umd.prod.js",
8 | "vendor/vuex.global.js",
9 | "vendor/vue-i18n.global.prod.js",
10 | "vendor/vue-router.global.js",
11 | "vendor/vue-qrcode-reader.umd.js",
12 | "vendor/qrcode.vue.browser.js",
13 | "vendor/chart.umd.js",
14 | "vendor/showdown.js",
15 | "vendor/nostr.bundle.js",
16 | "i18n/i18n.js",
17 | "i18n/de.js",
18 | "i18n/en.js",
19 | "i18n/es.js",
20 | "i18n/fr.js",
21 | "i18n/it.js",
22 | "i18n/jp.js",
23 | "i18n/cn.js",
24 | "i18n/nl.js",
25 | "i18n/pi.js",
26 | "i18n/pl.js",
27 | "i18n/fr.js",
28 | "i18n/nl.js",
29 | "i18n/we.js",
30 | "i18n/pt.js",
31 | "i18n/br.js",
32 | "i18n/cs.js",
33 | "i18n/sk.js",
34 | "i18n/kr.js",
35 | "i18n/fi.js",
36 | "js/base.js",
37 | "js/event-reactions.js",
38 | "js/bolt11-decoder.js"
39 | ],
40 | "components": [
41 | "js/components/lnbits-funding-sources.js",
42 | "js/components/extension-settings.js",
43 | "js/components/payment-list.js",
44 | "js/components/payment-chart.js",
45 | "js/components.js",
46 | "js/init-app.js"
47 | ],
48 | "css": ["vendor/quasar.css", "css/base.css"]
49 | }
50 |
--------------------------------------------------------------------------------
/lnbits/templates/print.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {% for url in INCLUDED_CSS %}
6 |
11 | {% endfor %}
12 |
26 | {% block styles %}{% endblock %}
27 | {% block title %}{{ SITE_TITLE }}{% endblock %}
28 |
29 |
33 | {% block head_scripts %}{% endblock %}
34 |
35 |
36 |
37 |
38 |
39 |
40 | {% block page %}{% endblock %}
41 |
42 |
43 |
44 |
45 | {% for url in INCLUDED_JS %}
46 |
47 | {% endfor %}
48 |
49 | {% block scripts %}{% endblock %}
50 |
51 |
52 |
--------------------------------------------------------------------------------
/tests/api/test_admin_api.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from lnbits.settings import settings
4 |
5 |
6 | @pytest.mark.asyncio
7 | async def test_admin_get_settings_permission_denied(client, from_user):
8 | response = await client.get(f"/admin/api/v1/settings?usr={from_user.id}")
9 | assert response.status_code == 401
10 |
11 |
12 | @pytest.mark.asyncio
13 | async def test_admin_get_settings(client, superuser):
14 | response = await client.get(f"/admin/api/v1/settings?usr={superuser.id}")
15 | assert response.status_code == 200
16 | result = response.json()
17 | assert "super_user" not in result
18 |
19 |
20 | @pytest.mark.asyncio
21 | async def test_admin_update_settings(client, superuser):
22 | new_site_title = "UPDATED SITETITLE"
23 | response = await client.put(
24 | f"/admin/api/v1/settings?usr={superuser.id}",
25 | json={"lnbits_site_title": new_site_title},
26 | )
27 | assert response.status_code == 200
28 | result = response.json()
29 | assert "status" in result
30 | assert result.get("status") == "Success"
31 | assert settings.lnbits_site_title == new_site_title
32 |
33 |
34 | @pytest.mark.asyncio
35 | async def test_admin_update_noneditable_settings(client, superuser):
36 | response = await client.put(
37 | f"/admin/api/v1/settings?usr={superuser.id}",
38 | json={"super_user": "UPDATED"},
39 | )
40 | assert response.status_code == 400
41 |
--------------------------------------------------------------------------------
/lnbits/wallets/void.py:
--------------------------------------------------------------------------------
1 | from typing import AsyncGenerator
2 |
3 | from loguru import logger
4 |
5 | from .base import (
6 | InvoiceResponse,
7 | PaymentPendingStatus,
8 | PaymentResponse,
9 | PaymentStatus,
10 | StatusResponse,
11 | Wallet,
12 | )
13 |
14 |
15 | class VoidWallet(Wallet):
16 |
17 | async def cleanup(self):
18 | pass
19 |
20 | async def create_invoice(self, *_, **__) -> InvoiceResponse:
21 | return InvoiceResponse(
22 | ok=False, error_message="VoidWallet cannot create invoices."
23 | )
24 |
25 | async def status(self) -> StatusResponse:
26 | logger.warning(
27 | "This backend does nothing, it is here just as a placeholder, you must"
28 | " configure an actual backend before being able to do anything useful with"
29 | " LNbits."
30 | )
31 | return StatusResponse(None, 0)
32 |
33 | async def pay_invoice(self, *_, **__) -> PaymentResponse:
34 | return PaymentResponse(
35 | ok=False, error_message="VoidWallet cannot pay invoices."
36 | )
37 |
38 | async def get_invoice_status(self, *_, **__) -> PaymentStatus:
39 | return PaymentPendingStatus()
40 |
41 | async def get_payment_status(self, *_, **__) -> PaymentStatus:
42 | return PaymentPendingStatus()
43 |
44 | async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
45 | yield ""
46 |
--------------------------------------------------------------------------------
/docs/devs/websockets.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | parent: For developers
4 | title: Websockets
5 | nav_order: 2
6 | ---
7 |
8 | # Websockets
9 |
10 | `websockets` are a great way to add a two way instant data channel between server and client.
11 |
12 | LNbits has a useful in built websocket tool. With a websocket client connect to (obv change `somespecificid`) `wss://demo.lnbits.com/api/v1/ws/somespecificid` (you can use an online websocket tester). Now make a get to `https://demo.lnbits.com/api/v1/ws/somespecificid/somedata`. You can send data to that websocket by using `from lnbits.core.services import websocketUpdater` and the function `websocketUpdater("somespecificid", "somdata")`.
13 |
14 | Example vue-js function for listening to the websocket:
15 |
16 | ```
17 | initWs: async function () {
18 | if (location.protocol !== 'http:') {
19 | localUrl =
20 | 'wss://' +
21 | document.domain +
22 | ':' +
23 | location.port +
24 | '/api/v1/ws/' +
25 | self.item.id
26 | } else {
27 | localUrl =
28 | 'ws://' +
29 | document.domain +
30 | ':' +
31 | location.port +
32 | '/api/v1/ws/' +
33 | self.item.id
34 | }
35 | this.ws = new WebSocket(localUrl)
36 | this.ws.addEventListener('message', async ({data}) => {
37 | const res = JSON.parse(data.toString())
38 | console.log(res)
39 | })
40 | },
41 | ```
42 |
--------------------------------------------------------------------------------
/.github/workflows/docker.yml:
--------------------------------------------------------------------------------
1 | name: docker
2 |
3 | on:
4 | workflow_dispatch:
5 | inputs:
6 | tag:
7 | default: latest
8 | type: string
9 | workflow_call:
10 | inputs:
11 | tag:
12 | default: latest
13 | type: string
14 | secrets:
15 | DOCKER_USERNAME:
16 | required: true
17 | DOCKER_PASSWORD:
18 | required: true
19 |
20 | jobs:
21 | push_to_dockerhub:
22 | runs-on: ubuntu-latest
23 | steps:
24 | - name: Checkout
25 | uses: actions/checkout@v4
26 |
27 | - name: Login to Docker Hub
28 | uses: docker/login-action@v3
29 | with:
30 | username: ${{ secrets.DOCKER_USERNAME }}
31 | password: ${{ secrets.DOCKER_PASSWORD }}
32 |
33 | - name: Set up Docker Buildx
34 | uses: docker/setup-buildx-action@v3
35 |
36 | - name: Cache Docker layers
37 | uses: actions/cache@v4
38 | id: cache
39 | with:
40 | path: /tmp/.buildx-cache
41 | key: ${{ runner.os }}-buildx-${{ github.sha }}
42 | restore-keys: |
43 | ${{ runner.os }}-buildx-
44 |
45 | - name: Build and push
46 | uses: docker/build-push-action@v5
47 | with:
48 | context: .
49 | push: true
50 | tags: ${{ secrets.DOCKER_USERNAME }}/lnbits:${{ inputs.tag }}
51 | platforms: linux/amd64,linux/arm64
52 | cache-from: type=local,src=/tmp/.buildx-cache
53 | cache-to: type=local,dest=/tmp/.buildx-cache
54 |
--------------------------------------------------------------------------------
/lnbits/wallets/macaroon/macaroon.py:
--------------------------------------------------------------------------------
1 | import base64
2 |
3 | from loguru import logger
4 |
5 | from lnbits.utils.crypto import AESCipher
6 |
7 |
8 | def load_macaroon(macaroon: str) -> str:
9 | """Returns hex version of a macaroon encoded in base64 or the file path.
10 |
11 | :param macaroon: Macaroon encoded in base64 or file path.
12 | :type macaroon: str
13 | :return: Hex version of macaroon.
14 | :rtype: str
15 | """
16 |
17 | # if the macaroon is a file path, load it and return hex version
18 | if macaroon.split(".")[-1] == "macaroon":
19 | with open(macaroon, "rb") as f:
20 | macaroon_bytes = f.read()
21 | return macaroon_bytes.hex()
22 | else:
23 | # if macaroon is a provided string
24 | # check if it is hex, if so, return
25 | try:
26 | bytes.fromhex(macaroon)
27 | return macaroon
28 | except ValueError:
29 | pass
30 | # convert the bas64 macaroon to hex
31 | try:
32 | macaroon = base64.b64decode(macaroon).hex()
33 | except Exception:
34 | pass
35 | return macaroon
36 |
37 |
38 | # todo: move to its own (crypto.py) file
39 | # if this file is executed directly, ask for a macaroon and encrypt it
40 | if __name__ == "__main__":
41 | macaroon = input("Enter macaroon: ")
42 | macaroon = load_macaroon(macaroon)
43 | macaroon = AESCipher(description="encryption").encrypt(macaroon.encode())
44 | logger.info("Encrypted macaroon:")
45 | logger.info(macaroon)
46 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: release
2 |
3 | on:
4 | push:
5 | tags:
6 | - "v[0-9]+.[0-9]+.[0-9]+"
7 | - "[0-9]+.[0-9]+.[0-9]+"
8 |
9 | permissions:
10 | contents: write
11 |
12 | jobs:
13 |
14 | release:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - uses: actions/checkout@v4
18 | - name: Create github release
19 | env:
20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
21 | tag: ${{ github.ref_name }}
22 | run: |
23 | gh release create "$tag" --generate-notes --draft
24 |
25 | docker:
26 | needs: [ release ]
27 | uses: ./.github/workflows/docker.yml
28 | with:
29 | tag: ${{ github.ref_name }}
30 | secrets:
31 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
32 | DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
33 |
34 | docker-latest:
35 | needs: [ release ]
36 | uses: ./.github/workflows/docker.yml
37 | with:
38 | tag: latest
39 | secrets:
40 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
41 | DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
42 |
43 | pypi:
44 | runs-on: ubuntu-latest
45 | steps:
46 | - name: Install dependencies for building secp256k1
47 | run: |
48 | sudo apt-get update
49 | sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
50 | - uses: actions/checkout@v4
51 | - name: Build and publish to pypi
52 | uses: JRubics/poetry-publish@v1.15
53 | with:
54 | pypi_token: ${{ secrets.PYPI_API_KEY }}
55 |
--------------------------------------------------------------------------------
/docs/devs/development.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: For developers
4 | nav_order: 4
5 | has_children: true
6 | ---
7 |
8 | # For developers
9 |
10 | Thanks for contributing :)
11 |
12 | # Run
13 |
14 | Follow the [Basic installation: Option 1 (recommended): poetry](https://docs.lnbits.org/guide/installation.html#option-1-recommended-poetry)
15 | guide to install poetry and other dependencies.
16 |
17 | Then you can start LNbits uvicorn server with:
18 |
19 | ```bash
20 | poetry run lnbits
21 | ```
22 |
23 | Or you can use the following to start uvicorn with hot reloading enabled:
24 |
25 | ```bash
26 | make dev
27 | # or
28 | poetry run lnbits --reload
29 | ```
30 |
31 | You might need the following extra dependencies on clean installation of Debian:
32 |
33 | ```
34 | sudo apt install nodejs
35 | sudo apt install npm
36 | npm install
37 | sudo apt-get install autoconf libtool libpg-dev
38 | ```
39 |
40 | # Precommit hooks
41 |
42 | This ensures that all commits adhere to the formatting and linting rules.
43 |
44 | ```bash
45 | make install-pre-commit-hook
46 | ```
47 |
48 | # Tests
49 |
50 | This project has unit tests that help prevent regressions. Before you can run the tests, you must install a few dependencies:
51 |
52 | ```bash
53 | poetry install
54 | npm i
55 | ```
56 |
57 | Then to run the tests:
58 |
59 | ```bash
60 | make test
61 | ```
62 |
63 | Run formatting:
64 |
65 | ```bash
66 | make format
67 | ```
68 |
69 | Run mypy checks:
70 |
71 | ```bash
72 | poetry run mypy
73 | ```
74 |
75 | Run everything:
76 |
77 | ```bash
78 | make all
79 | ```
80 |
--------------------------------------------------------------------------------
/.github/workflows/jmeter.yml:
--------------------------------------------------------------------------------
1 | name: JMeter Extension Tests
2 |
3 | on:
4 | workflow_call:
5 | inputs:
6 | python-version:
7 | description: "Python Version"
8 | required: true
9 | default: "3.9"
10 | type: string
11 | poetry-version:
12 | description: "Poetry Version"
13 | required: true
14 | default: "1.5.1"
15 | type: string
16 |
17 | jobs:
18 | jmeter:
19 | runs-on: ubuntu-latest
20 | steps:
21 | - uses: actions/checkout@v4
22 |
23 | - uses: ./.github/actions/prepare
24 | with:
25 | python-version: ${{ inputs.python-version }}
26 |
27 | - name: run LNbits
28 | env:
29 | LNBITS_ADMIN_UI: true
30 | LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
31 | LNBITS_BACKEND_WALLET_CLASS: FakeWallet
32 | run: |
33 | poetry run lnbits &
34 | sleep 5
35 |
36 | - name: clone lnbits-extensions, install jmeter and run tests
37 | run: |
38 | git clone https://github.com/lnbits/lnbits-extensions
39 | cd lnbits-extensions
40 | mkdir logs
41 | mkdir reports
42 | make install-jmeter
43 | make start-mirror-server
44 | make test
45 |
46 | - name: upload jmeter test results
47 | uses: actions/upload-artifact@v4
48 | if: ${{ always() }}
49 | with:
50 | name: jmeter-extension-test-results
51 | path: |
52 | lnbits-extensions/reports/
53 | lnbits-extensions/logs/
54 |
--------------------------------------------------------------------------------
/tests/regtest/test_services_create_invoice.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from bolt11 import decode
3 |
4 | from lnbits.core.services import (
5 | PaymentStatus,
6 | create_invoice,
7 | )
8 | from lnbits.wallets import get_funding_source
9 |
10 | description = "test create invoice"
11 |
12 |
13 | @pytest.mark.asyncio
14 | async def test_create_invoice(from_wallet):
15 | payment_hash, pr = await create_invoice(
16 | wallet_id=from_wallet.id,
17 | amount=1000,
18 | memo=description,
19 | )
20 | invoice = decode(pr)
21 | assert invoice.payment_hash == payment_hash
22 | assert invoice.amount_msat == 1000000
23 | assert invoice.description == description
24 |
25 | funding_source = get_funding_source()
26 | status = await funding_source.get_invoice_status(payment_hash)
27 | assert isinstance(status, PaymentStatus)
28 | assert status.pending
29 |
30 |
31 | @pytest.mark.asyncio
32 | async def test_create_internal_invoice(from_wallet):
33 | payment_hash, pr = await create_invoice(
34 | wallet_id=from_wallet.id, amount=1000, memo=description, internal=True
35 | )
36 | invoice = decode(pr)
37 | assert invoice.payment_hash == payment_hash
38 | assert invoice.amount_msat == 1000000
39 | assert invoice.description == description
40 |
41 | # Internal invoices are not on fundingsource. so we should get some kind of error
42 | # that the invoice is not found, but we get status pending
43 | funding_source = get_funding_source()
44 | status = await funding_source.get_invoice_status(payment_hash)
45 | assert isinstance(status, PaymentStatus)
46 | assert status.pending
47 |
--------------------------------------------------------------------------------
/tests/unit/test_cache.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | import pytest
4 |
5 | from lnbits.utils.cache import Cache
6 | from tests.conftest import pytest_asyncio
7 |
8 | key = "foo"
9 | value = "bar"
10 |
11 |
12 | @pytest_asyncio.fixture
13 | async def cache():
14 | cache = Cache(interval=0.1)
15 |
16 | task = asyncio.create_task(cache.invalidate_forever())
17 | yield cache
18 | task.cancel()
19 |
20 |
21 | @pytest.mark.asyncio
22 | async def test_cache_get_set(cache):
23 | cache.set(key, value)
24 | assert cache.get(key) == value
25 | assert cache.get(key, default="default") == value
26 | assert cache.get("i-dont-exist", default="default") == "default"
27 |
28 |
29 | @pytest.mark.asyncio
30 | async def test_cache_expiry(cache):
31 | # gets expired by `get` call
32 | cache.set(key, value, expiry=0.01)
33 | await asyncio.sleep(0.02)
34 | assert not cache.get(key)
35 |
36 | # gets expired by invalidation task
37 | cache.set(key, value, expiry=0.1)
38 | await asyncio.sleep(0.2)
39 | assert key not in cache._values
40 | assert not cache.get(key)
41 |
42 |
43 | @pytest.mark.asyncio
44 | async def test_cache_pop(cache):
45 | cache.set(key, value)
46 | assert cache.pop(key) == value
47 | assert not cache.get(key)
48 | assert cache.pop(key, default="a") == "a"
49 |
50 |
51 | @pytest.mark.asyncio
52 | async def test_cache_coro(cache):
53 | called = 0
54 |
55 | async def test():
56 | nonlocal called
57 | called += 1
58 | return called
59 |
60 | await cache.save_result(test, key="test")
61 | result = await cache.save_result(test, key="test")
62 | assert result == called == 1
63 |
--------------------------------------------------------------------------------
/lnbits/core/__init__.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, FastAPI
2 |
3 | from .db import core_app_extra, db
4 | from .views.admin_api import admin_router
5 | from .views.api import api_router
6 | from .views.auth_api import auth_router
7 | from .views.extension_api import extension_router
8 |
9 | # this compat is needed for usermanager extension
10 | from .views.generic import generic_router
11 | from .views.node_api import node_router, public_node_router, super_node_router
12 | from .views.payment_api import payment_router
13 | from .views.public_api import public_router
14 | from .views.tinyurl_api import tinyurl_router
15 | from .views.user_api import users_router
16 | from .views.wallet_api import wallet_router
17 | from .views.webpush_api import webpush_router
18 | from .views.websocket_api import websocket_router
19 |
20 | # backwards compatibility for extensions
21 | core_app = APIRouter(tags=["Core"])
22 |
23 |
24 | def init_core_routers(app: FastAPI):
25 | app.include_router(core_app)
26 | app.include_router(generic_router)
27 | app.include_router(auth_router)
28 | app.include_router(admin_router)
29 | app.include_router(node_router)
30 | app.include_router(extension_router)
31 | app.include_router(super_node_router)
32 | app.include_router(public_node_router)
33 | app.include_router(public_router)
34 | app.include_router(payment_router)
35 | app.include_router(wallet_router)
36 | app.include_router(api_router)
37 | app.include_router(websocket_router)
38 | app.include_router(tinyurl_router)
39 | app.include_router(webpush_router)
40 | app.include_router(users_router)
41 |
42 |
43 | __all__ = ["core_app", "core_app_extra", "db"]
44 |
--------------------------------------------------------------------------------
/lnbits.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Check install has not already run
4 | if [ ! -d lnbits/data ]; then
5 |
6 | # Update package list and install prerequisites non-interactively
7 | sudo apt update -y
8 | sudo apt install -y software-properties-common
9 |
10 | # Add the deadsnakes PPA repository non-interactively
11 | sudo add-apt-repository -y ppa:deadsnakes/ppa
12 |
13 | # Install Python 3.9 and distutils non-interactively
14 | sudo apt install -y python3.9 python3.9-distutils
15 |
16 | # Install Poetry
17 | curl -sSL https://install.python-poetry.org | python3.9 -
18 |
19 | # Add Poetry to PATH for the current session
20 | export PATH="/home/$USER/.local/bin:$PATH"
21 |
22 | if [ ! -d lnbits/wallets ]; then
23 | # Clone the LNbits repository
24 | git clone https://github.com/lnbits/lnbits.git
25 | if [ $? -ne 0 ]; then
26 | echo "Failed to clone the repository ... FAIL"
27 | exit 1
28 | fi
29 | # Ensure we are in the lnbits directory
30 | cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
31 | fi
32 |
33 | git checkout main
34 | # Make data folder
35 | mkdir data
36 |
37 | # Copy the .env.example to .env
38 | cp .env.example .env
39 |
40 | elif [ ! -d lnbits/wallets ]; then
41 | # cd into lnbits
42 | cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
43 | fi
44 |
45 | # Set path for running after install
46 | export PATH="/home/$USER/.local/bin:$PATH"
47 |
48 | # Install the dependencies using Poetry
49 | poetry env use python3.9
50 | poetry install --only main
51 |
52 | # Set environment variables for LNbits
53 | export LNBITS_ADMIN_UI=true
54 | export HOST=0.0.0.0
55 |
56 | # Run LNbits
57 | poetry run lnbits
--------------------------------------------------------------------------------
/lnbits/templates/error.html:
--------------------------------------------------------------------------------
1 | {% extends "public.html" %} {% block page %}
2 |
3 |
4 |
5 |
6 |
7 | Error
8 |
9 |
14 |
15 | {{ err }}
16 |
17 |
18 |
19 |
20 |
27 |
28 |
29 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | {% endblock %} {% block scripts %}
44 |
45 |
62 |
63 | {% endblock %}
64 |
65 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.10-slim-bookworm AS builder
2 |
3 | RUN apt-get clean
4 | RUN apt-get update
5 | RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
6 |
7 | RUN curl -sSL https://install.python-poetry.org | python3 -
8 | ENV PATH="/root/.local/bin:$PATH"
9 |
10 | WORKDIR /app
11 |
12 | # Only copy the files required to install the dependencies
13 | COPY pyproject.toml poetry.lock ./
14 |
15 | RUN mkdir data
16 |
17 | ENV POETRY_NO_INTERACTION=1 \
18 | POETRY_VIRTUALENVS_IN_PROJECT=1 \
19 | POETRY_VIRTUALENVS_CREATE=1 \
20 | POETRY_CACHE_DIR=/tmp/poetry_cache
21 |
22 | RUN poetry install --only main
23 |
24 | FROM python:3.10-slim-bookworm
25 |
26 | # needed for backups postgresql-client version 14 (pg_dump)
27 | RUN apt-get update && apt-get -y upgrade && \
28 | apt-get -y install gnupg2 curl lsb-release && \
29 | sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' && \
30 | curl -s https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
31 | apt-get update && \
32 | apt-get -y install postgresql-client-14 postgresql-client-common && \
33 | apt-get clean all && rm -rf /var/lib/apt/lists/*
34 |
35 | RUN curl -sSL https://install.python-poetry.org | python3 -
36 | ENV PATH="/root/.local/bin:$PATH"
37 |
38 | ENV POETRY_NO_INTERACTION=1 \
39 | POETRY_VIRTUALENVS_IN_PROJECT=1 \
40 | POETRY_VIRTUALENVS_CREATE=1 \
41 | VIRTUAL_ENV=/app/.venv \
42 | PATH="/app/.venv/bin:$PATH"
43 |
44 | WORKDIR /app
45 |
46 | COPY . .
47 | COPY --from=builder /app/.venv .venv
48 |
49 | RUN poetry install --only main
50 |
51 | ENV LNBITS_PORT="5000"
52 | ENV LNBITS_HOST="0.0.0.0"
53 |
54 | EXPOSE 5000
55 |
56 | CMD ["sh", "-c", "poetry run lnbits --port $LNBITS_PORT --host $LNBITS_HOST"]
57 |
--------------------------------------------------------------------------------
/lnbits/core/extensions/helpers.py:
--------------------------------------------------------------------------------
1 | import hashlib
2 | from typing import Any, Optional
3 | from urllib import request
4 |
5 | import httpx
6 | from loguru import logger
7 | from packaging import version
8 |
9 | from lnbits.settings import settings
10 |
11 |
12 | def version_parse(v: str):
13 | """
14 | Wrapper for version.parse() that does not throw if the version is invalid.
15 | Instead it return the lowest possible version ("0.0.0")
16 | """
17 | try:
18 | return version.parse(v)
19 | except Exception:
20 | return version.parse("0.0.0")
21 |
22 |
23 | async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
24 | headers = {"User-Agent": settings.user_agent}
25 | if settings.lnbits_ext_github_token:
26 | headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
27 | async with httpx.AsyncClient(headers=headers) as client:
28 | resp = await client.get(url)
29 | if resp.status_code != 200:
30 | logger.warning(f"{error_msg} ({url}): {resp.text}")
31 | resp.raise_for_status()
32 | return resp.json()
33 |
34 |
35 | def download_url(url, save_path):
36 | with request.urlopen(url, timeout=60) as dl_file:
37 | with open(save_path, "wb") as out_file:
38 | out_file.write(dl_file.read())
39 |
40 |
41 | def file_hash(filename):
42 | h = hashlib.sha256()
43 | b = bytearray(128 * 1024)
44 | mv = memoryview(b)
45 | with open(filename, "rb", buffering=0) as f:
46 | while n := f.readinto(mv):
47 | h.update(mv[:n])
48 | return h.hexdigest()
49 |
50 |
51 | def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
52 | if not path:
53 | return ""
54 | _, _, *rest = path.split("/")
55 | tail = "/".join(rest)
56 | return f"https://github.com/{source_repo}/raw/main/{tail}"
57 |
--------------------------------------------------------------------------------
/lnbits/static/images/boltzl.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/lnbits/core/views/public_api.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | from http import HTTPStatus
3 |
4 | from fastapi import APIRouter, HTTPException
5 | from loguru import logger
6 |
7 | from lnbits import bolt11
8 |
9 | from ..crud import get_standalone_payment
10 | from ..tasks import api_invoice_listeners
11 |
12 | public_router = APIRouter(tags=["Core"])
13 |
14 |
15 | @public_router.get("/public/v1/payment/{payment_hash}")
16 | async def api_public_payment_longpolling(payment_hash):
17 | payment = await get_standalone_payment(payment_hash)
18 |
19 | if not payment:
20 | raise HTTPException(
21 | status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
22 | )
23 | # TODO: refactor to use PaymentState
24 | if payment.success:
25 | return {"status": "paid"}
26 |
27 | try:
28 | invoice = bolt11.decode(payment.bolt11)
29 | if invoice.has_expired():
30 | return {"status": "expired"}
31 | except Exception as exc:
32 | raise HTTPException(
33 | status_code=HTTPStatus.BAD_REQUEST, detail="Invalid bolt11 invoice."
34 | ) from exc
35 |
36 | payment_queue = asyncio.Queue(0)
37 |
38 | logger.debug(f"adding standalone invoice listener for hash: {payment_hash}")
39 | api_invoice_listeners[payment_hash] = payment_queue
40 |
41 | response = None
42 |
43 | async def payment_info_receiver():
44 | for payment in await payment_queue.get():
45 | if payment.payment_hash == payment_hash:
46 | nonlocal response
47 | response = {"status": "paid"}
48 |
49 | async def timeouter(cancel_scope):
50 | await asyncio.sleep(45)
51 | cancel_scope.cancel()
52 |
53 | cancel_scope = asyncio.create_task(payment_info_receiver())
54 | asyncio.create_task(timeouter(cancel_scope)) # noqa: RUF006
55 |
56 | if response:
57 | return response
58 | else:
59 | raise HTTPException(status_code=HTTPStatus.REQUEST_TIMEOUT, detail="timeout")
60 |
--------------------------------------------------------------------------------
/lnbits/static/images/boltz.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: tests
2 |
3 | on:
4 | workflow_call:
5 | inputs:
6 | custom-pytest:
7 | description: "Custom pytest arguments"
8 | required: true
9 | type: string
10 | python-version:
11 | default: "3.9"
12 | type: string
13 | os-version:
14 | default: "ubuntu-latest"
15 | type: string
16 | db-url:
17 | default: ""
18 | type: string
19 | db-name:
20 | default: "lnbits"
21 | type: string
22 | secrets:
23 | CODECOV_TOKEN:
24 | required: true
25 |
26 | jobs:
27 | tests:
28 | runs-on: ${{ inputs.os-version }}
29 |
30 | services:
31 | postgres:
32 | image: postgres:latest
33 | env:
34 | POSTGRES_USER: lnbits
35 | POSTGRES_PASSWORD: lnbits
36 | POSTGRES_DB: ${{ inputs.db-name }}
37 | ports:
38 | - 5432:5432
39 | options: >-
40 | --health-cmd pg_isready
41 | --health-interval 10s
42 | --health-timeout 5s
43 | --health-retries 5
44 |
45 | steps:
46 | - uses: actions/checkout@v4
47 |
48 | - uses: ./.github/actions/prepare
49 | with:
50 | python-version: ${{ inputs.python-version }}
51 |
52 | - name: Run pytest
53 | uses: pavelzw/pytest-action@v2
54 | env:
55 | LNBITS_DATABASE_URL: ${{ inputs.db-url }}
56 | LNBITS_BACKEND_WALLET_CLASS: FakeWallet
57 | PYTHONUNBUFFERED: 1
58 | DEBUG: true
59 | with:
60 | verbose: true
61 | job-summary: true
62 | emoji: false
63 | click-to-expand: true
64 | custom-pytest: ${{ inputs.custom-pytest }}
65 | report-title: "test (${{ inputs.python-version }}, ${{ inputs.db-url }})"
66 |
67 | - name: Upload coverage to Codecov
68 | uses: codecov/codecov-action@v4
69 | with:
70 | file: ./coverage.xml
71 | token: ${{ secrets.CODECOV_TOKEN }}
72 | verbose: false
73 |
--------------------------------------------------------------------------------
/tests/wallets/fixtures/certificates/cert.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN CERTIFICATE-----
2 | MIIFbzCCA1egAwIBAgIUfkee1G4E8QAadd517sY/9+6xr0AwDQYJKoZIhvcNAQEL
3 | BQAwRjELMAkGA1UEBhMCU1YxFDASBgNVBAgMC0VsIFNhbHZhZG9yMSEwHwYDVQQK
4 | DBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwIBcNMjQwNDAzMTMyMTM5WhgPMjA1
5 | MTA4MjAxMzIxMzlaMEYxCzAJBgNVBAYTAlNWMRQwEgYDVQQIDAtFbCBTYWx2YWRv
6 | cjEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIICIjANBgkqhkiG
7 | 9w0BAQEFAAOCAg8AMIICCgKCAgEAnW4MKs2Y3qZnn2+J/Bp21aUuJ7oE8ll82Q2C
8 | uh8VAlsNnGDpTyOSRLHLmxV+cu82umvVPBpOVwAl17/VuxcLjFVSk7YOMj3MWoF5
9 | hm+oBtetouSDt3H0+BoDuXN3eVsLI4b+e1F6ag7JIwsDQvRUbGTFiyHVvXolTZPb
10 | wtFzlwQSB5i6KHKRQ+W6Q+cz4khIRO79IhaEiu5TWDrmx+6WkZxWYYO/g/I/S1gX
11 | l1JP6gXQFabwUFn+CBAxPsi7f+igi6gIepXBQOIG1dkZ5ojJPabtvblO7mWJTsec
12 | 2D4Vb3L7OfboIYC85gY1cudWBX3oAASIVh9m9YoCZW2WOMNr6apnJSXx36ueJXAS
13 | rPq3C2haPWO8z+0nYkaYTcTAxeCvs0ux2DGIniinC+u1cELg6REK2X1K8YsSsXrc
14 | U1T8rNs2azyzTxglIHHac6ScG+Ac1nlY54C9UfZZcztE8nUBqJi+Eowpyr+y3QvT
15 | zNdulc80xpi5arbzt85BNi+xX+NZC07QjgUJ/eexRglP3flfTbbnG8Pphe/M/l04
16 | IfBWBqK2cF9Fd+1J+Zf7fXZrw+41QF8WukLoQ4JQEMqIIhDFzaoTi5ogsnhiGu0Z
17 | iaCATfCLMsWvAPHw6afFw2/utdvCd2Dr22H16hj0xEkNOw702/AoNWMFmzIzuC9m
18 | VjkH1KUCAwEAAaNTMFEwHQYDVR0OBBYEFJAQIGLZNVRwGIgb3cmPTAiduzreMB8G
19 | A1UdIwQYMBaAFJAQIGLZNVRwGIgb3cmPTAiduzreMA8GA1UdEwEB/wQFMAMBAf8w
20 | DQYJKoZIhvcNAQELBQADggIBAFOaWcLZSU46Zr43kQU+w+A70r+unmRfsANeREDi
21 | Qvjg1ihJLO8g1l7Cu74QUqLwx8BG3KO7ZbDcN6uTeCrYgyERSVUxNAwu5hf2LnEr
22 | MQ/L4h0j/8flj9oowTDCit/6YXTJ1Mf8OaKkSliUYVsoZCaIISZ2pvcZbU1cXCeX
23 | JBM4Zr1ijM8qbghPoG6O7Ep/A3VHTozuAU9C7uREH+XJFepr9BXjrFqyzx/ArEZa
24 | 5HIO9nOqWqtwMFDE2jX3Ios3tjbU275ez2Xd7meDn0iPWMEgNbXX6b+FFlNkajR2
25 | NchPmBigBpk9bt63HeIQb2t/VU7X9FvMTqCbp1R2MGiHTMyQ9IjeoYKNy/mur/GG
26 | DQkG7rq52oPGI06CJ7uuMEhCm6jNVtIbnCTl2jRnkD1fqKVmQa9Cn7jqDqR2dhqX
27 | AxTk01Vhinxhik0ckhcgViRgiBWSnnx4Vzk7wyV6O4EdtLTywkywTR/+WEisBVUV
28 | LOXZEmxj+AVARARUds+a/IgdANFGr/yWI6WBOibjoEFZMEZqzwlcEErgxLRinUvb
29 | 9COmr6ig+zC1570V2ktmn1P/qodOD4tOL0ICSkKoTQLFPfevM2y0DdN48T2kxzZ5
30 | TruiKHuAnOhvwKwUpF+TRFMUWft3VG9GJXm/4A9FWm/ALLrqw2oSXGrl5z8pq29z
31 | SN2A
32 | -----END CERTIFICATE-----
33 |
--------------------------------------------------------------------------------
/lnbits/static/js/components/extension-settings.js:
--------------------------------------------------------------------------------
1 | window.app.component('lnbits-extension-settings-form', {
2 | name: 'lnbits-extension-settings-form',
3 | template: '#lnbits-extension-settings-form',
4 | props: ['options', 'adminkey', 'endpoint'],
5 | methods: {
6 | async updateSettings() {
7 | if (!this.settings) {
8 | return Quasar.Notify.create({
9 | message: 'No settings to update',
10 | type: 'negative'
11 | })
12 | }
13 | try {
14 | const {data} = await LNbits.api.request(
15 | 'PUT',
16 | this.endpoint,
17 | this.adminkey,
18 | this.settings
19 | )
20 | this.settings = data
21 | } catch (error) {
22 | LNbits.utils.notifyApiError(error)
23 | }
24 | },
25 | getSettings: async function () {
26 | try {
27 | const {data} = await LNbits.api.request(
28 | 'GET',
29 | this.endpoint,
30 | this.adminkey
31 | )
32 | this.settings = data
33 | } catch (error) {
34 | LNbits.utils.notifyApiError(error)
35 | }
36 | },
37 | resetSettings: async function () {
38 | LNbits.utils
39 | .confirmDialog('Are you sure you want to reset the settings?')
40 | .onOk(async () => {
41 | try {
42 | await LNbits.api.request('DELETE', this.endpoint, this.adminkey)
43 | await this.getSettings()
44 | } catch (error) {
45 | LNbits.utils.notifyApiError(error)
46 | }
47 | })
48 | }
49 | },
50 | created: async function () {
51 | await this.getSettings()
52 | },
53 | data: function () {
54 | return {
55 | settings: undefined
56 | }
57 | }
58 | })
59 |
60 | window.app.component('lnbits-extension-settings-btn-dialog', {
61 | template: '#lnbits-extension-settings-btn-dialog',
62 | name: 'lnbits-extension-settings-btn-dialog',
63 | props: ['options', 'adminkey', 'endpoint'],
64 | data: function () {
65 | return {
66 | show: false
67 | }
68 | }
69 | })
70 |
--------------------------------------------------------------------------------
/tests/api/test_generic.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 |
4 | @pytest.mark.asyncio
5 | async def test_core_views_generic(client):
6 | response = await client.get("/")
7 | assert response.status_code == 200, f"{response.url} {response.status_code}"
8 |
9 |
10 | # check GET /wallet: wrong user, expect 400
11 | @pytest.mark.asyncio
12 | async def test_get_wallet_with_nonexistent_user(client):
13 | response = await client.get("wallet", params={"usr": "1"})
14 | assert response.status_code == 400, f"{response.url} {response.status_code}"
15 |
16 |
17 | # check GET /wallet: wallet and user
18 | @pytest.mark.asyncio
19 | async def test_get_wallet_with_user_and_wallet(client, to_user, to_wallet):
20 | response = await client.get(
21 | "wallet", params={"usr": to_user.id, "wal": to_wallet.id}
22 | )
23 | assert response.status_code == 200, f"{response.url} {response.status_code}"
24 |
25 |
26 | # check GET /wallet: wrong wallet and user, expect 400
27 | @pytest.mark.asyncio
28 | async def test_get_wallet_with_user_and_wrong_wallet(client, to_user):
29 | response = await client.get("wallet", params={"usr": to_user.id, "wal": "1"})
30 | assert response.status_code == 400, f"{response.url} {response.status_code}"
31 |
32 |
33 | # check GET /extensions: extensions list
34 | @pytest.mark.asyncio
35 | async def test_get_extensions(client, to_user):
36 | response = await client.get("extensions", params={"usr": to_user.id})
37 | assert response.status_code == 200, f"{response.url} {response.status_code}"
38 |
39 |
40 | # check GET /extensions: extensions list wrong user, expect 400
41 | @pytest.mark.asyncio
42 | async def test_get_extensions_wrong_user(client):
43 | response = await client.get("extensions", params={"usr": "1"})
44 | assert response.status_code == 400, f"{response.url} {response.status_code}"
45 |
46 |
47 | # check GET /extensions: no user given, expect code 400 bad request
48 | @pytest.mark.asyncio
49 | async def test_get_extensions_no_user(client):
50 | response = await client.get("extensions")
51 | # bad request
52 | assert response.status_code == 401, f"{response.url} {response.status_code}"
53 |
--------------------------------------------------------------------------------
/lnbits/core/views/wallet_api.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from fastapi import (
4 | APIRouter,
5 | Body,
6 | Depends,
7 | )
8 |
9 | from lnbits.core.models import (
10 | CreateWallet,
11 | KeyType,
12 | Wallet,
13 | )
14 | from lnbits.decorators import (
15 | WalletTypeInfo,
16 | require_admin_key,
17 | require_invoice_key,
18 | )
19 |
20 | from ..crud import (
21 | create_wallet,
22 | delete_wallet,
23 | update_wallet,
24 | )
25 |
26 | wallet_router = APIRouter(prefix="/api/v1/wallet", tags=["Wallet"])
27 |
28 |
29 | @wallet_router.get("")
30 | async def api_wallet(wallet: WalletTypeInfo = Depends(require_invoice_key)):
31 | res = {
32 | "name": wallet.wallet.name,
33 | "balance": wallet.wallet.balance_msat,
34 | }
35 | if wallet.key_type == KeyType.admin:
36 | res["id"] = wallet.wallet.id
37 | return res
38 |
39 |
40 | @wallet_router.put("/{new_name}")
41 | async def api_update_wallet_name(
42 | new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
43 | ):
44 | await update_wallet(wallet.wallet.id, new_name)
45 | return {
46 | "id": wallet.wallet.id,
47 | "name": wallet.wallet.name,
48 | "balance": wallet.wallet.balance_msat,
49 | }
50 |
51 |
52 | @wallet_router.patch("", response_model=Wallet)
53 | async def api_update_wallet(
54 | name: Optional[str] = Body(None),
55 | currency: Optional[str] = Body(None),
56 | wallet: WalletTypeInfo = Depends(require_admin_key),
57 | ):
58 | return await update_wallet(wallet.wallet.id, name, currency)
59 |
60 |
61 | @wallet_router.delete("")
62 | async def api_delete_wallet(
63 | wallet: WalletTypeInfo = Depends(require_admin_key),
64 | ) -> None:
65 | await delete_wallet(
66 | user_id=wallet.wallet.user,
67 | wallet_id=wallet.wallet.id,
68 | )
69 |
70 |
71 | @wallet_router.post("", response_model=Wallet)
72 | async def api_create_wallet(
73 | data: CreateWallet,
74 | wallet: WalletTypeInfo = Depends(require_admin_key),
75 | ) -> Wallet:
76 | return await create_wallet(user_id=wallet.wallet.user, wallet_name=data.name)
77 |
--------------------------------------------------------------------------------
/.github/actions/prepare/action.yml:
--------------------------------------------------------------------------------
1 | name: prepare
2 |
3 | inputs:
4 | python-version:
5 | description: "Python Version"
6 | required: true
7 | default: "3.9"
8 | poetry-version:
9 | description: "Poetry Version"
10 | default: "1.7.0"
11 | node-version:
12 | description: "Node Version"
13 | default: "20.x"
14 | npm:
15 | description: "use npm"
16 | default: false
17 | type: boolean
18 |
19 |
20 | runs:
21 | using: "composite"
22 | steps:
23 | - name: Set up Python ${{ inputs.python-version }}
24 | uses: actions/setup-python@v5
25 | with:
26 | python-version: ${{ inputs.python-version }}
27 | # cache poetry install via pip
28 | cache: "pip"
29 |
30 | - name: Set up Poetry ${{ inputs.poetry-version }}
31 | uses: abatilo/actions-poetry@v2
32 | with:
33 | poetry-version: ${{ inputs.poetry-version }}
34 |
35 | - name: Setup a local virtual environment (if no poetry.toml file)
36 | shell: bash
37 | run: |
38 | poetry config virtualenvs.create true --local
39 | poetry config virtualenvs.in-project true --local
40 |
41 | - uses: actions/cache@v4
42 | name: Define a cache for the virtual environment based on the dependencies lock file
43 | with:
44 | path: ./.venv
45 | key: venv-${{ hashFiles('poetry.lock') }}
46 |
47 | - name: Install the project dependencies
48 | shell: bash
49 | run: |
50 | poetry install
51 | # needed for conv tests
52 | poetry add psycopg2-binary
53 |
54 | - name: Use Node.js ${{ inputs.node-version }}
55 | if: ${{ (inputs.npm == 'true') }}
56 | uses: actions/setup-node@v4
57 | with:
58 | node-version: ${{ inputs.node-version }}
59 |
60 | - uses: actions/cache@v4
61 | if: ${{ (inputs.npm == 'true') }}
62 | name: Define a cache for the npm based on the dependencies lock file
63 | with:
64 | path: ./node_modules
65 | key: npm-${{ hashFiles('package-lock.json') }}
66 |
67 | - name: Install npm packages
68 | if: ${{ (inputs.npm == 'true') }}
69 | shell: bash
70 | run: npm install
71 |
--------------------------------------------------------------------------------
/lnbits/utils/cache.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import asyncio
4 | from time import time
5 | from typing import Any, NamedTuple, Optional
6 |
7 | from loguru import logger
8 |
9 | from lnbits.settings import settings
10 |
11 |
12 | class Cached(NamedTuple):
13 | value: Any
14 | expiry: float
15 |
16 |
17 | class Cache:
18 | """
19 | Small caching utility providing simple get/set interface (very much like redis)
20 | """
21 |
22 | def __init__(self, interval: float = 10) -> None:
23 | self.interval = interval
24 | self._values: dict[Any, Cached] = {}
25 |
26 | def get(self, key: str, default=None) -> Optional[Any]:
27 | cached = self._values.get(key)
28 | if cached is not None:
29 | if cached.expiry > time():
30 | return cached.value
31 | else:
32 | self._values.pop(key)
33 | return default
34 |
35 | def set(self, key: str, value: Any, expiry: float = 10):
36 | self._values[key] = Cached(value, time() + expiry)
37 |
38 | def pop(self, key: str, default=None) -> Optional[Any]:
39 | cached = self._values.pop(key, None)
40 | if cached and cached.expiry > time():
41 | return cached.value
42 | return default
43 |
44 | async def save_result(self, coro, key: str, expiry: float = 10):
45 | """
46 | If `key` exists, return its value, otherwise call coro and cache its result
47 | """
48 | cached = self.get(key)
49 | if cached:
50 | return cached
51 | else:
52 | value = await coro()
53 | self.set(key, value, expiry=expiry)
54 | return value
55 |
56 | async def invalidate_forever(self):
57 | while settings.lnbits_running:
58 | try:
59 | await asyncio.sleep(self.interval)
60 | ts = time()
61 | expired = [k for k, v in self._values.items() if v.expiry < ts]
62 | for k in expired:
63 | self._values.pop(k)
64 | except Exception:
65 | logger.error("Error invalidating cache")
66 |
67 |
68 | cache = Cache()
69 |
--------------------------------------------------------------------------------
/tests/unit/test_services_fees.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from lnbits.core.services import (
4 | fee_reserve,
5 | fee_reserve_total,
6 | service_fee,
7 | )
8 | from lnbits.settings import settings
9 |
10 |
11 | @pytest.mark.asyncio
12 | async def test_fee_reserve_internal():
13 | fee = fee_reserve(10_000, internal=True)
14 | assert fee == 0
15 |
16 |
17 | @pytest.mark.asyncio
18 | async def test_fee_reserve_min():
19 | settings.lnbits_reserve_fee_percent = 2
20 | settings.lnbits_reserve_fee_min = 500
21 | fee = fee_reserve(10000)
22 | assert fee == 500
23 |
24 |
25 | @pytest.mark.asyncio
26 | async def test_fee_reserve_percent():
27 | settings.lnbits_reserve_fee_percent = 1
28 | settings.lnbits_reserve_fee_min = 100
29 | fee = fee_reserve(100000)
30 | assert fee == 1000
31 |
32 |
33 | @pytest.mark.asyncio
34 | async def test_service_fee_no_wallet():
35 | settings.lnbits_service_fee_wallet = ""
36 | fee = service_fee(10000)
37 | assert fee == 0
38 |
39 |
40 | @pytest.mark.asyncio
41 | async def test_service_fee_internal():
42 | settings.lnbits_service_fee_wallet = "wallet_id"
43 | settings.lnbits_service_fee_ignore_internal = True
44 | fee = service_fee(10000, internal=True)
45 | assert fee == 0
46 |
47 |
48 | @pytest.mark.asyncio
49 | async def test_service_fee():
50 | settings.lnbits_service_fee_wallet = "wallet_id"
51 | settings.lnbits_service_fee = 2
52 | fee = service_fee(10000)
53 | assert fee == 200
54 |
55 |
56 | @pytest.mark.asyncio
57 | async def test_service_fee_max():
58 | settings.lnbits_service_fee_wallet = "wallet_id"
59 | settings.lnbits_service_fee = 2
60 | settings.lnbits_service_fee_max = 199
61 | fee = service_fee(100_000_000)
62 | assert fee / 1000 == 199
63 |
64 |
65 | @pytest.mark.asyncio
66 | async def test_fee_reserve_total():
67 | settings.lnbits_reserve_fee_percent = 1
68 | settings.lnbits_reserve_fee_min = 100
69 | settings.lnbits_service_fee = 2
70 | settings.lnbits_service_fee_wallet = "wallet_id"
71 | amount = 100_000
72 | fee = service_fee(amount)
73 | reserve = fee_reserve(amount)
74 | total = fee_reserve_total(amount)
75 | assert fee + reserve == total
76 |
--------------------------------------------------------------------------------
/tools/create_fake_admin.py:
--------------------------------------------------------------------------------
1 | # Python script to create a fake admin user for sqlite3,
2 | # for regtest setup as LNbits funding source
3 |
4 | import os
5 | import sqlite3
6 | import sys
7 | import time
8 | from uuid import uuid4
9 |
10 | import shortuuid
11 |
12 | adminkey = "d08a3313322a4514af75d488bcc27eee"
13 | sqfolder = "./data"
14 |
15 | if not sqfolder or not os.path.isdir(sqfolder):
16 | print("missing LNBITS_DATA_FOLDER")
17 | sys.exit(1)
18 |
19 | file = os.path.join(sqfolder, "database.sqlite3")
20 | conn = sqlite3.connect(file)
21 | cursor = conn.cursor()
22 |
23 | old_account = cursor.execute(
24 | "SELECT * FROM accounts WHERE id = :id", {"id": adminkey}
25 | ).fetchone()
26 | if old_account:
27 | print("fake admin does already exist")
28 | sys.exit(1)
29 |
30 |
31 | cursor.execute("INSERT INTO accounts (id) VALUES (:adminkey)", {"adminkey": adminkey})
32 |
33 | wallet_id = uuid4().hex
34 | cursor.execute(
35 | """
36 | INSERT INTO wallets (id, name, "user", adminkey, inkey)
37 | VALUES (:wallet_id, :name, :user, :adminkey, :inkey)
38 | """,
39 | {
40 | "wallet_id": wallet_id,
41 | "name": "TEST WALLET",
42 | "user": adminkey,
43 | "adminkey": adminkey,
44 | "inkey": uuid4().hex, # invoice key is not important
45 | },
46 | )
47 |
48 | expiration_date = time.time() + 420
49 |
50 | # 1 btc in sats
51 | amount = 100_000_000
52 | payment_hash = shortuuid.uuid()
53 | internal_id = f"internal_{payment_hash}"
54 |
55 | cursor.execute(
56 | """
57 | INSERT INTO apipayments
58 | (wallet, checking_id, hash, amount, status, memo, fee, expiry, pending)
59 | VALUES
60 | (:wallet_id, :checking_id, :payment_hash, :amount,
61 | :status, :memo, :fee, :expiry, :pending)
62 | """,
63 | {
64 | "wallet_id": wallet_id,
65 | "checking_id": internal_id,
66 | "payment_hash": payment_hash,
67 | "amount": amount * 1000,
68 | "status": "success",
69 | "memo": "fake admin",
70 | "fee": 0,
71 | "expiry": expiration_date,
72 | "pending": False,
73 | },
74 | )
75 |
76 | print(f"created test admin: {adminkey} with {amount} sats")
77 |
78 | conn.commit()
79 | cursor.close()
80 |
--------------------------------------------------------------------------------
/tests/unit/test_db_fetch_page.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | import pytest_asyncio
3 |
4 | from tests.helpers import DbTestModel
5 |
6 |
7 | @pytest_asyncio.fixture(scope="session")
8 | async def fetch_page(db):
9 | await db.execute("DROP TABLE IF EXISTS test_db_fetch_page")
10 | await db.execute(
11 | """
12 | CREATE TABLE test_db_fetch_page (
13 | id TEXT PRIMARY KEY,
14 | value TEXT NOT NULL,
15 | name TEXT NOT NULL
16 | )
17 | """
18 | )
19 | await db.execute(
20 | """
21 | INSERT INTO test_db_fetch_page (id, name, value) VALUES
22 | ('1', 'Alice', 'foo'),
23 | ('2', 'Bob', 'bar'),
24 | ('3', 'Carol', 'bar'),
25 | ('4', 'Dave', 'bar'),
26 | ('5', 'Dave', 'foo')
27 | """
28 | )
29 | yield
30 | await db.execute("DROP TABLE test_db_fetch_page")
31 |
32 |
33 | @pytest.mark.asyncio
34 | async def test_db_fetch_page_simple(fetch_page, db):
35 | row = await db.fetch_page(
36 | query="select * from test_db_fetch_page",
37 | model=DbTestModel,
38 | )
39 |
40 | assert row
41 | assert row.total == 5
42 | assert len(row.data) == 5
43 |
44 |
45 | @pytest.mark.asyncio
46 | async def test_db_fetch_page_group_by(fetch_page, db):
47 | row = await db.fetch_page(
48 | query="select max(id) as id, name from test_db_fetch_page",
49 | model=DbTestModel,
50 | group_by=["name"],
51 | )
52 | assert row
53 | assert row.total == 4
54 |
55 |
56 | @pytest.mark.asyncio
57 | async def test_db_fetch_page_group_by_multiple(fetch_page, db):
58 | row = await db.fetch_page(
59 | query="select max(id) as id, name, value from test_db_fetch_page",
60 | model=DbTestModel,
61 | group_by=["value", "name"],
62 | )
63 | assert row
64 | assert row.total == 5
65 |
66 |
67 | @pytest.mark.asyncio
68 | async def test_db_fetch_page_group_by_evil(fetch_page, db):
69 | with pytest.raises(ValueError, match="Value for GROUP BY is invalid"):
70 | await db.fetch_page(
71 | query="select * from test_db_fetch_page",
72 | model=DbTestModel,
73 | group_by=["name;"],
74 | )
75 |
--------------------------------------------------------------------------------
/lnbits/static/images/logos/nostr.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/lnbits/lnurl.py:
--------------------------------------------------------------------------------
1 | from typing import Callable
2 |
3 | from fastapi import HTTPException, Request, Response
4 | from fastapi.responses import JSONResponse
5 | from fastapi.routing import APIRoute
6 | from lnurl import LnurlErrorResponse, decode, encode, handle
7 | from loguru import logger
8 |
9 | from lnbits.exceptions import InvoiceError, PaymentError
10 |
11 |
12 | class LnurlErrorResponseHandler(APIRoute):
13 | """
14 | Custom APIRoute class to handle LNURL errors.
15 | LNURL errors always return with status 200 and
16 | a JSON response with `status="ERROR"` and a `reason` key.
17 | Helps to catch HTTPException and return a valid lnurl error response
18 |
19 | Example:
20 | withdraw_lnurl_router = APIRouter(prefix="/api/v1/lnurl")
21 | withdraw_lnurl_router.route_class = LnurlErrorResponseHandler
22 | """
23 |
24 | def get_route_handler(self) -> Callable:
25 | original_route_handler = super().get_route_handler()
26 |
27 | async def lnurl_route_handler(request: Request) -> Response:
28 | try:
29 | response = await original_route_handler(request)
30 | return response
31 | except (InvoiceError, PaymentError) as exc:
32 | logger.debug(f"Wallet Error: {exc}")
33 | response = JSONResponse(
34 | status_code=200,
35 | content={"status": "ERROR", "reason": f"{exc.message}"},
36 | )
37 | return response
38 | except HTTPException as exc:
39 | logger.debug(f"HTTPException: {exc}")
40 | response = JSONResponse(
41 | status_code=200,
42 | content={"status": "ERROR", "reason": f"{exc.detail}"},
43 | )
44 | return response
45 | except Exception as exc:
46 | logger.error("Unknown Error:", exc)
47 | response = JSONResponse(
48 | status_code=200,
49 | content={
50 | "status": "ERROR",
51 | "reason": f"UNKNOWN ERROR: {exc!s}",
52 | },
53 | )
54 | return response
55 |
56 | return lnurl_route_handler
57 |
58 |
59 | __all__ = [
60 | "decode",
61 | "encode",
62 | "handle",
63 | "LnurlErrorResponse",
64 | "LnurlErrorResponseHandler",
65 | ]
66 |
--------------------------------------------------------------------------------
/lnbits/core/views/webpush_api.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import json
3 | from http import HTTPStatus
4 | from urllib.parse import unquote, urlparse
5 |
6 | from fastapi import (
7 | APIRouter,
8 | Depends,
9 | HTTPException,
10 | Request,
11 | )
12 | from loguru import logger
13 |
14 | from lnbits.core.models import (
15 | CreateWebPushSubscription,
16 | WebPushSubscription,
17 | )
18 | from lnbits.decorators import (
19 | WalletTypeInfo,
20 | require_admin_key,
21 | )
22 |
23 | from ..crud import (
24 | create_webpush_subscription,
25 | delete_webpush_subscription,
26 | get_webpush_subscription,
27 | )
28 |
29 | webpush_router = APIRouter(prefix="/api/v1/webpush", tags=["Webpush"])
30 |
31 |
32 | @webpush_router.post("", status_code=HTTPStatus.CREATED)
33 | async def api_create_webpush_subscription(
34 | request: Request,
35 | data: CreateWebPushSubscription,
36 | wallet: WalletTypeInfo = Depends(require_admin_key),
37 | ) -> WebPushSubscription:
38 | try:
39 | subscription = json.loads(data.subscription)
40 | endpoint = subscription["endpoint"]
41 | host = urlparse(str(request.url)).netloc
42 |
43 | subscription = await get_webpush_subscription(endpoint, wallet.wallet.user)
44 | if subscription:
45 | return subscription
46 | else:
47 | return await create_webpush_subscription(
48 | endpoint,
49 | wallet.wallet.user,
50 | data.subscription,
51 | host,
52 | )
53 | except Exception as exc:
54 | logger.debug(exc)
55 | raise HTTPException(
56 | HTTPStatus.INTERNAL_SERVER_ERROR,
57 | "Cannot create webpush notification",
58 | ) from exc
59 |
60 |
61 | @webpush_router.delete("", status_code=HTTPStatus.OK)
62 | async def api_delete_webpush_subscription(
63 | request: Request,
64 | wallet: WalletTypeInfo = Depends(require_admin_key),
65 | ):
66 | try:
67 | endpoint = unquote(
68 | base64.b64decode(str(request.query_params.get("endpoint"))).decode("utf-8")
69 | )
70 | count = await delete_webpush_subscription(endpoint, wallet.wallet.user)
71 | return {"count": count}
72 | except Exception as exc:
73 | logger.debug(exc)
74 | raise HTTPException(
75 | HTTPStatus.INTERNAL_SERVER_ERROR,
76 | "Cannot delete webpush notification",
77 | ) from exc
78 |
--------------------------------------------------------------------------------
/lnbits/wallets/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import importlib
4 | from typing import Optional
5 |
6 | from lnbits.nodes import set_node_class
7 | from lnbits.settings import settings
8 | from lnbits.wallets.base import Wallet
9 |
10 | from .alby import AlbyWallet
11 | from .blink import BlinkWallet
12 | from .boltz import BoltzWallet
13 | from .breez import BreezSdkWallet
14 | from .cliche import ClicheWallet
15 | from .corelightning import CoreLightningWallet
16 |
17 | # The following import is intentional to keep backwards compatibility
18 | # for old configs that called it CLightningWallet. Do not remove.
19 | from .corelightning import CoreLightningWallet as CLightningWallet
20 | from .corelightningrest import CoreLightningRestWallet
21 | from .eclair import EclairWallet
22 | from .fake import FakeWallet
23 | from .lnbits import LNbitsWallet
24 | from .lndgrpc import LndWallet
25 | from .lndrest import LndRestWallet
26 | from .lnpay import LNPayWallet
27 | from .lntips import LnTipsWallet
28 | from .nwc import NWCWallet
29 | from .opennode import OpenNodeWallet
30 | from .phoenixd import PhoenixdWallet
31 | from .spark import SparkWallet
32 | from .void import VoidWallet
33 | from .zbd import ZBDWallet
34 |
35 |
36 | def set_funding_source(class_name: Optional[str] = None):
37 | backend_wallet_class = class_name or settings.lnbits_backend_wallet_class
38 | funding_source_constructor = getattr(wallets_module, backend_wallet_class)
39 | global funding_source
40 | funding_source = funding_source_constructor()
41 | if funding_source.__node_cls__:
42 | set_node_class(funding_source.__node_cls__(funding_source))
43 |
44 |
45 | def get_funding_source() -> Wallet:
46 | return funding_source
47 |
48 |
49 | wallets_module = importlib.import_module("lnbits.wallets")
50 | fake_wallet = FakeWallet()
51 |
52 | # initialize as fake wallet
53 | funding_source: Wallet = fake_wallet
54 |
55 |
56 | __all__ = [
57 | "AlbyWallet",
58 | "BlinkWallet",
59 | "BoltzWallet",
60 | "BreezSdkWallet",
61 | "ClicheWallet",
62 | "CoreLightningWallet",
63 | "CLightningWallet",
64 | "CoreLightningRestWallet",
65 | "EclairWallet",
66 | "FakeWallet",
67 | "LNbitsWallet",
68 | "LndWallet",
69 | "LndRestWallet",
70 | "LNPayWallet",
71 | "LnTipsWallet",
72 | "NWCWallet",
73 | "OpenNodeWallet",
74 | "PhoenixdWallet",
75 | "SparkWallet",
76 | "VoidWallet",
77 | "ZBDWallet",
78 | ]
79 |
--------------------------------------------------------------------------------
/flake.nix:
--------------------------------------------------------------------------------
1 | {
2 | description = "LNbits, free and open-source Lightning wallet and accounts system";
3 |
4 | inputs = {
5 | nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05";
6 | poetry2nix = {
7 | url = "github:nix-community/poetry2nix";
8 | inputs.nixpkgs.follows = "nixpkgs";
9 | };
10 | };
11 | outputs = { self, nixpkgs, poetry2nix }@inputs:
12 | let
13 | supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
14 | forSystems = systems: f:
15 | nixpkgs.lib.genAttrs systems
16 | (system: f system (import nixpkgs { inherit system; overlays = [ poetry2nix.overlays.default self.overlays.default ]; }));
17 | forAllSystems = forSystems supportedSystems;
18 | projectName = "lnbits";
19 | in
20 | {
21 | overlays = {
22 | default = final: prev: {
23 | ${projectName} = self.packages.${prev.stdenv.hostPlatform.system}.${projectName};
24 | };
25 | };
26 | packages = forAllSystems (system: pkgs: {
27 | default = self.packages.${system}.${projectName};
28 | ${projectName} = pkgs.poetry2nix.mkPoetryApplication {
29 | projectDir = ./.;
30 | meta.rev = self.dirtyRev or self.rev;
31 | meta.mainProgram = projectName;
32 | overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
33 | coincurve = prev.coincurve.override { preferWheel = true; };
34 | protobuf = prev.protobuf.override { preferWheel = true; };
35 | ruff = prev.ruff.override { preferWheel = true; };
36 | wallycore = prev.wallycore.override { preferWheel = true; };
37 | });
38 | };
39 | });
40 | nixosModules = {
41 | default = { pkgs, lib, config, ... }: {
42 | imports = [ "${./nix/modules/${projectName}-service.nix}" ];
43 | nixpkgs.overlays = [ self.overlays.default ];
44 | };
45 | };
46 | checks = forAllSystems (system: pkgs:
47 | let
48 | vmTests = import ./nix/tests {
49 | makeTest = (import (nixpkgs + "/nixos/lib/testing-python.nix") { inherit system; }).makeTest;
50 | inherit inputs pkgs;
51 | };
52 | in
53 | pkgs.lib.optionalAttrs pkgs.stdenv.isLinux vmTests # vmTests can only be ran on Linux, so append them only if on Linux.
54 | //
55 | {
56 | # Other checks here...
57 | }
58 | );
59 | };
60 | }
61 |
--------------------------------------------------------------------------------
/lnbits/core/templates/admin/_tab_security_notifications.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
29 |
30 |
31 |
32 |
38 |
39 |
40 |
41 |
42 |
43 |
49 |
55 |
56 |
62 |
63 |
73 |
74 |
75 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/lnbits/core/templates/admin/_tab_users.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | User Management
4 |
5 |
6 |
7 |
Admin Users
8 |
16 |
17 |
18 |
19 |
28 |
29 |
30 |
31 |
32 |
33 |
Allowed Users
34 |
42 |
43 |
44 |
45 |
54 |
55 |
56 |
57 |
58 |
59 | Allow creation of new users
60 | Allow creation of new users on the index page
63 |
64 |
65 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/lnbits/core/templates/node/_tab_dashboard.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
22 |
23 |
24 |
25 |
29 |
30 |
31 |
32 |
36 |
37 |
38 |
39 |
40 |
41 |
45 |
46 |
47 |
51 |
52 |
53 |
57 |
58 |
59 |
60 |
61 |
62 |
65 |
66 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/lnbits/server.py:
--------------------------------------------------------------------------------
1 | import multiprocessing as mp
2 | import time
3 | from pathlib import Path
4 |
5 | import click
6 | import uvicorn
7 | from uvicorn.supervisors import ChangeReload
8 |
9 | from lnbits.settings import set_cli_settings, settings
10 |
11 |
12 | @click.command(
13 | context_settings={
14 | "ignore_unknown_options": True,
15 | "allow_extra_args": True,
16 | }
17 | )
18 | @click.option("--port", default=settings.port, help="Port to listen on")
19 | @click.option("--host", default=settings.host, help="Host to run LNbits on")
20 | @click.option(
21 | "--forwarded-allow-ips",
22 | default=settings.forwarded_allow_ips,
23 | help="Allowed proxy servers",
24 | )
25 | @click.option("--ssl-keyfile", default=None, help="Path to SSL keyfile")
26 | @click.option("--ssl-certfile", default=None, help="Path to SSL certificate")
27 | @click.option(
28 | "--reload", is_flag=True, default=False, help="Enable auto-reload for development"
29 | )
30 | def main(
31 | port: int,
32 | host: str,
33 | forwarded_allow_ips: str,
34 | ssl_keyfile: str,
35 | ssl_certfile: str,
36 | reload: bool,
37 | ):
38 | """Launched with `poetry run lnbits` at root level"""
39 |
40 | # create data dir if it does not exist
41 | Path(settings.lnbits_data_folder).mkdir(parents=True, exist_ok=True)
42 | Path(settings.lnbits_data_folder, "logs").mkdir(parents=True, exist_ok=True)
43 |
44 | # create `extensions`` dir if it does not exist
45 | Path(settings.lnbits_extensions_path, "extensions").mkdir(
46 | parents=True, exist_ok=True
47 | )
48 |
49 | set_cli_settings(host=host, port=port, forwarded_allow_ips=forwarded_allow_ips)
50 |
51 | while True:
52 | config = uvicorn.Config(
53 | "lnbits.__main__:app",
54 | loop="uvloop",
55 | port=port,
56 | host=host,
57 | forwarded_allow_ips=forwarded_allow_ips,
58 | ssl_keyfile=ssl_keyfile,
59 | ssl_certfile=ssl_certfile,
60 | reload=reload or False,
61 | )
62 |
63 | server = uvicorn.Server(config=config)
64 |
65 | if config.should_reload:
66 | sock = config.bind_socket()
67 | run = ChangeReload(config, target=server.run, sockets=[sock]).run
68 | else:
69 | run = server.run
70 |
71 | process = mp.Process(target=run)
72 | process.start()
73 | server_restart.wait()
74 | server_restart.clear()
75 | server.should_exit = True
76 | server.force_exit = True
77 | time.sleep(3)
78 | process.terminate()
79 | process.join()
80 | time.sleep(1)
81 |
82 |
83 | server_restart = mp.Event()
84 |
85 | if __name__ == "__main__":
86 | main()
87 |
--------------------------------------------------------------------------------
/lnbits/core/templates/service-worker.js:
--------------------------------------------------------------------------------
1 | // update cache version every time there is a new deployment
2 | // so the service worker reinitializes the cache
3 | const CURRENT_CACHE = 'lnbits-{{ cache_version }}-'
4 |
5 | const getApiKey = request => {
6 | let api_key = request.headers.get('X-Api-Key')
7 | if (!api_key || api_key == 'undefined') {
8 | api_key = 'no_api_key'
9 | }
10 | return api_key
11 | }
12 |
13 | // on activation we clean up the previously registered service workers
14 | self.addEventListener('activate', evt =>
15 | evt.waitUntil(
16 | caches.keys().then(cacheNames => {
17 | return Promise.all(
18 | cacheNames.map(cacheName => {
19 | if (!cacheName.startsWith(CURRENT_CACHE)) {
20 | return caches.delete(cacheName)
21 | }
22 | })
23 | )
24 | })
25 | )
26 | )
27 |
28 | // The fetch handler serves responses for same-origin resources from a cache.
29 | // If no response is found, it populates the runtime cache with the response
30 | // from the network before returning it to the page.
31 | self.addEventListener('fetch', event => {
32 | if (
33 | !event.request.url.startsWith(
34 | self.location.origin + '/api/v1/payments/sse'
35 | ) &&
36 | event.request.url.startsWith(self.location.origin) &&
37 | event.request.method == 'GET'
38 | ) {
39 | // Open the cache
40 | event.respondWith(
41 | caches.open(CURRENT_CACHE + getApiKey(event.request)).then(cache => {
42 | // Go to the network first
43 | return fetch(event.request)
44 | .then(fetchedResponse => {
45 | cache.put(event.request, fetchedResponse.clone())
46 |
47 | return fetchedResponse
48 | })
49 | .catch(() => {
50 | // If the network is unavailable, get
51 | return cache.match(event.request.url)
52 | })
53 | })
54 | )
55 | }
56 | })
57 |
58 | // Handle and show incoming push notifications
59 | self.addEventListener('push', function (event) {
60 | if (!(self.Notification && self.Notification.permission === 'granted')) {
61 | return
62 | }
63 |
64 | let data = event.data.json()
65 | const title = data.title
66 | const body = data.body
67 | const url = data.url
68 |
69 | event.waitUntil(
70 | self.registration.showNotification(title, {
71 | body: body,
72 | icon: '/favicon.ico',
73 | data: {
74 | url: url
75 | }
76 | })
77 | )
78 | })
79 |
80 | // User can click on the notification message to open wallet
81 | // Installed app will open when `url_handlers` in web app manifest is supported
82 | self.addEventListener('notificationclick', function (event) {
83 | event.notification.close()
84 | event.waitUntil(clients.openWindow(event.notification.data.url))
85 | })
86 |
--------------------------------------------------------------------------------
/lnbits/utils/crypto.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import getpass
3 | from hashlib import md5
4 |
5 | from Cryptodome import Random
6 | from Cryptodome.Cipher import AES
7 |
8 | BLOCK_SIZE = 16
9 |
10 |
11 | class AESCipher:
12 | """This class is compatible with crypto-js/aes.js
13 |
14 | Encrypt and decrypt in Javascript using:
15 | import AES from "crypto-js/aes.js";
16 | import Utf8 from "crypto-js/enc-utf8.js";
17 | AES.encrypt(decrypted, password).toString()
18 | AES.decrypt(encrypted, password).toString(Utf8);
19 |
20 | """
21 |
22 | def __init__(self, key=None, description=""):
23 | self.key = key
24 | self.description = description + " "
25 |
26 | def pad(self, data):
27 | length = BLOCK_SIZE - (len(data) % BLOCK_SIZE)
28 | return data + (chr(length) * length).encode()
29 |
30 | def unpad(self, data):
31 | return data[: -(data[-1] if isinstance(data[-1], int) else ord(data[-1]))]
32 |
33 | @property
34 | def passphrase(self):
35 | passphrase = self.key if self.key is not None else None
36 | if passphrase is None:
37 | passphrase = getpass.getpass(f"Enter {self.description}password:")
38 | return passphrase
39 |
40 | def bytes_to_key(self, data, salt, output=48):
41 | # extended from https://gist.github.com/gsakkis/4546068
42 | assert len(salt) == 8, len(salt)
43 | data += salt
44 | key = md5(data).digest()
45 | final_key = key
46 | while len(final_key) < output:
47 | key = md5(key + data).digest()
48 | final_key += key
49 | return final_key[:output]
50 |
51 | def decrypt(self, encrypted: str) -> str: # type: ignore
52 | """Decrypts a string using AES-256-CBC."""
53 | passphrase = self.passphrase
54 | encrypted = base64.b64decode(encrypted) # type: ignore
55 | assert encrypted[0:8] == b"Salted__"
56 | salt = encrypted[8:16]
57 | key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16)
58 | key = key_iv[:32]
59 | iv = key_iv[32:]
60 | aes = AES.new(key, AES.MODE_CBC, iv)
61 | try:
62 | return self.unpad(aes.decrypt(encrypted[16:])).decode() # type: ignore
63 | except UnicodeDecodeError as exc:
64 | raise ValueError("Wrong passphrase") from exc
65 |
66 | def encrypt(self, message: bytes) -> str:
67 | passphrase = self.passphrase
68 | salt = Random.new().read(8)
69 | key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16)
70 | key = key_iv[:32]
71 | iv = key_iv[32:]
72 | aes = AES.new(key, AES.MODE_CBC, iv)
73 | return base64.b64encode(
74 | b"Salted__" + salt + aes.encrypt(self.pad(message))
75 | ).decode()
76 |
--------------------------------------------------------------------------------
/tools/i18n-check.py:
--------------------------------------------------------------------------------
1 | import os
2 | import re
3 |
4 |
5 | def get_translation_ids_from_source():
6 | # find all HTML files in selected directories
7 | files = []
8 | for start in ["lnbits/core/templates", "lnbits/templates", "lnbits/static/js"]:
9 | for check_dir, _, filenames in os.walk(start):
10 | for filename in filenames:
11 | if filename.endswith(".html") or filename.endswith(".js"):
12 | fn = os.path.join(check_dir, filename)
13 | files.append(fn)
14 | # find all $t('...') and $t("...") calls in HTML files
15 | # and extract the string inside the quotes
16 | p1 = re.compile(r"\$t\('([^']*)'")
17 | p2 = re.compile(r'\$t\("([^"]*)"')
18 | ids = []
19 | for fn in files:
20 | with open(fn) as f:
21 | text = f.read()
22 | m1 = re.findall(p1, text)
23 | m2 = re.findall(p2, text)
24 | for m in m1:
25 | ids.append(m)
26 | for m in m2:
27 | ids.append(m)
28 | return ids
29 |
30 |
31 | def get_translation_ids_for_language(language):
32 | ids = []
33 | for line in open(f"lnbits/static/i18n/{language}.js"):
34 | # extract ids from lines like that start with exactly 2 spaces
35 | if line.startswith(" ") and not line.startswith(" "):
36 | m = line[2:].split(":")[0]
37 | ids.append(m)
38 | return ids
39 |
40 |
41 | src_ids = get_translation_ids_from_source()
42 | print(f"Number of ids from source: {len(src_ids)}")
43 |
44 | en_ids = get_translation_ids_for_language("en")
45 | missing = set(src_ids) - set(en_ids)
46 | extra = set(en_ids) - set(src_ids)
47 | if len(missing) > 0:
48 | print()
49 | print(f'Missing ids in language "en": {len(missing)}')
50 | for i in sorted(missing):
51 | print(f" {i}")
52 | if len(extra) > 0:
53 | print()
54 | print(f'Extraneous ids in language "en": {len(extra)}')
55 | for i in sorted(extra):
56 | print(f" {i}")
57 |
58 | languages = []
59 |
60 | for *_, filenames in os.walk("lnbits/static/i18n"):
61 | for filename in filenames:
62 | if filename.endswith(".js") and filename not in ["i18n.js", "en.js"]:
63 | languages.append(filename.split(".")[0])
64 |
65 | for lang in sorted(languages):
66 | ids = get_translation_ids_for_language(lang)
67 | missing = set(en_ids) - set(ids)
68 | extra = set(ids) - set(en_ids)
69 | if len(missing) > 0:
70 | print()
71 | print(f'Missing ids in language "{lang}": {len(missing)}')
72 | for i in sorted(missing):
73 | print(f" {i}")
74 | if len(extra) > 0:
75 | print()
76 | print(f'Extraneous ids in language "{lang}": {len(extra)}')
77 | for i in sorted(extra):
78 | print(f" {i}")
79 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: LNbits CI
2 | on:
3 | push:
4 | branches:
5 | - main
6 | - dev
7 | pull_request:
8 |
9 |
10 | jobs:
11 |
12 | lint:
13 | uses: ./.github/workflows/lint.yml
14 |
15 | test-api:
16 | needs: [ lint ]
17 | strategy:
18 | matrix:
19 | python-version: ["3.9", "3.10"]
20 | db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
21 | uses: ./.github/workflows/tests.yml
22 | with:
23 | custom-pytest: "poetry run pytest tests/api"
24 | python-version: ${{ matrix.python-version }}
25 | db-url: ${{ matrix.db-url }}
26 | secrets:
27 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
28 |
29 | test-wallets:
30 | needs: [ lint ]
31 | strategy:
32 | matrix:
33 | python-version: ["3.9", "3.10"]
34 | db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
35 | uses: ./.github/workflows/tests.yml
36 | with:
37 | custom-pytest: "poetry run pytest tests/wallets"
38 | python-version: ${{ matrix.python-version }}
39 | db-url: ${{ matrix.db-url }}
40 | secrets:
41 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
42 |
43 | test-unit:
44 | needs: [ lint ]
45 | strategy:
46 | matrix:
47 | python-version: ["3.9", "3.10"]
48 | db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
49 | uses: ./.github/workflows/tests.yml
50 | with:
51 | custom-pytest: "poetry run pytest tests/unit"
52 | python-version: ${{ matrix.python-version }}
53 | db-url: ${{ matrix.db-url }}
54 | secrets:
55 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
56 |
57 | migration:
58 | needs: [ lint ]
59 | strategy:
60 | matrix:
61 | python-version: ["3.9", "3.10"]
62 | uses: ./.github/workflows/migration.yml
63 | with:
64 | python-version: ${{ matrix.python-version }}
65 |
66 | openapi:
67 | needs: [ lint ]
68 | uses: ./.github/workflows/make.yml
69 | with:
70 | make: openapi
71 |
72 | regtest:
73 | needs: [ lint ]
74 | uses: ./.github/workflows/regtest.yml
75 | strategy:
76 | matrix:
77 | python-version: ["3.9"]
78 | backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
79 | with:
80 | custom-pytest: "poetry run pytest tests/regtest"
81 | python-version: ${{ matrix.python-version }}
82 | backend-wallet-class: ${{ matrix.backend-wallet-class }}
83 | secrets:
84 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
85 |
86 | jmeter:
87 | needs: [ lint ]
88 | strategy:
89 | matrix:
90 | python-version: ["3.9"]
91 | poetry-version: ["1.5.1"]
92 | uses: ./.github/workflows/jmeter.yml
93 | with:
94 | python-version: ${{ matrix.python-version }}
95 | poetry-version: ${{ matrix.poetry-version }}
96 |
--------------------------------------------------------------------------------
/tests/api/test_webpush_api.py:
--------------------------------------------------------------------------------
1 | from http import HTTPStatus
2 |
3 | import pytest
4 |
5 |
6 | @pytest.mark.asyncio
7 | async def test_create___bad_body(client, adminkey_headers_from):
8 | response = await client.post(
9 | "/api/v1/webpush",
10 | headers=adminkey_headers_from,
11 | json={"subscription": "bad_json"},
12 | )
13 | assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
14 |
15 |
16 | @pytest.mark.asyncio
17 | async def test_create___missing_fields(client, adminkey_headers_from):
18 | response = await client.post(
19 | "/api/v1/webpush",
20 | headers=adminkey_headers_from,
21 | json={"subscription": """{"a": "x"}"""},
22 | )
23 | assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
24 |
25 |
26 | @pytest.mark.asyncio
27 | async def test_create___bad_access_key(client, inkey_headers_from):
28 | response = await client.post(
29 | "/api/v1/webpush",
30 | headers=inkey_headers_from,
31 | json={"subscription": """{"a": "x"}"""},
32 | )
33 | assert response.status_code == HTTPStatus.UNAUTHORIZED
34 |
35 |
36 | @pytest.mark.asyncio
37 | async def test_delete__bad_endpoint_format(client, adminkey_headers_from):
38 | response = await client.delete(
39 | "/api/v1/webpush",
40 | params={"endpoint": "https://this.should.be.base64.com"},
41 | headers=adminkey_headers_from,
42 | )
43 | assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
44 |
45 |
46 | @pytest.mark.asyncio
47 | async def test_delete__no_endpoint_param(client, adminkey_headers_from):
48 | response = await client.delete(
49 | "/api/v1/webpush",
50 | headers=adminkey_headers_from,
51 | )
52 | assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
53 |
54 |
55 | @pytest.mark.asyncio
56 | async def test_delete__no_endpoint_found(client, adminkey_headers_from):
57 | response = await client.delete(
58 | "/api/v1/webpush",
59 | params={"endpoint": "aHR0cHM6Ly9kZW1vLmxuYml0cy5jb20="},
60 | headers=adminkey_headers_from,
61 | )
62 | assert response.status_code == HTTPStatus.OK
63 | assert response.json()["count"] == 0
64 |
65 |
66 | @pytest.mark.asyncio
67 | async def test_delete__bad_access_key(client, inkey_headers_from):
68 | response = await client.delete(
69 | "/api/v1/webpush",
70 | headers=inkey_headers_from,
71 | )
72 | assert response.status_code == HTTPStatus.UNAUTHORIZED
73 |
74 |
75 | @pytest.mark.asyncio
76 | async def test_create_and_delete(client, adminkey_headers_from):
77 | response = await client.post(
78 | "/api/v1/webpush",
79 | headers=adminkey_headers_from,
80 | json={"subscription": """{"endpoint": "https://demo.lnbits.com"}"""},
81 | )
82 | assert response.status_code == HTTPStatus.CREATED
83 | response = await client.delete(
84 | "/api/v1/webpush",
85 | params={"endpoint": "aHR0cHM6Ly9kZW1vLmxuYml0cy5jb20="},
86 | headers=adminkey_headers_from,
87 | )
88 | assert response.status_code == HTTPStatus.OK
89 | assert response.json()["count"] == 1
90 |
--------------------------------------------------------------------------------
/.github/workflows/regtest.yml:
--------------------------------------------------------------------------------
1 | name: regtest
2 |
3 | on:
4 | workflow_call:
5 | inputs:
6 | custom-pytest:
7 | description: "Custom pytest arguments"
8 | required: true
9 | type: string
10 | python-version:
11 | default: "3.9"
12 | type: string
13 | os-version:
14 | default: "ubuntu-latest"
15 | type: string
16 | backend-wallet-class:
17 | required: true
18 | type: string
19 | secrets:
20 | CODECOV_TOKEN:
21 | required: true
22 |
23 | jobs:
24 | regtest:
25 | runs-on: ${{ inputs.os-version }}
26 | timeout-minutes: 10
27 | steps:
28 | - uses: actions/checkout@v4
29 |
30 | - name: docker build
31 | if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
32 | run: |
33 | docker build -t lnbits/lnbits .
34 |
35 | - name: Setup Regtest
36 | run: |
37 | git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
38 | cd docker
39 | chmod +x ./tests
40 | ./tests
41 | sudo chmod -R a+rwx .
42 |
43 | - uses: ./.github/actions/prepare
44 | with:
45 | python-version: ${{ inputs.python-version }}
46 |
47 | - name: Run pytest
48 | uses: pavelzw/pytest-action@v2
49 | env:
50 | LNBITS_DATABASE_URL: ${{ inputs.db-url }}
51 | LNBITS_BACKEND_WALLET_CLASS: ${{ inputs.backend-wallet-class }}
52 | LND_REST_ENDPOINT: https://localhost:8081/
53 | LND_REST_CERT: ./docker/data/lnd-3/tls.cert
54 | LND_REST_MACAROON: ./docker/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
55 | LND_GRPC_ENDPOINT: localhost
56 | LND_GRPC_PORT: 10009
57 | LND_GRPC_CERT: docker/data/lnd-3/tls.cert
58 | LND_GRPC_MACAROON: docker/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
59 | CORELIGHTNING_RPC: ./docker/data/clightning-1/regtest/lightning-rpc
60 | CORELIGHTNING_REST_URL: https://localhost:3001
61 | CORELIGHTNING_REST_MACAROON: ./docker/data/clightning-2-rest/access.macaroon
62 | CORELIGHTNING_REST_CERT: ./docker/data/clightning-2-rest/certificate.pem
63 | LNBITS_ENDPOINT: http://localhost:5001
64 | LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee"
65 | ECLAIR_URL: http://127.0.0.1:8082
66 | ECLAIR_PASS: lnbits
67 | PYTHONUNBUFFERED: 1
68 | DEBUG: true
69 | with:
70 | verbose: true
71 | job-summary: true
72 | emoji: false
73 | click-to-expand: true
74 | custom-pytest: ${{ inputs.custom-pytest }}
75 | report-title: "regtest (${{ inputs.python-version }}, ${{ inputs.backend-wallet-class }}"
76 |
77 | - name: Upload coverage to Codecov
78 | uses: codecov/codecov-action@v4
79 | with:
80 | file: ./coverage.xml
81 | token: ${{ secrets.CODECOV_TOKEN }}
82 | verbose: true
83 |
84 | - name: docker lnbits logs
85 | if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
86 | run: |
87 | docker logs lnbits-lnbits-1
88 |
--------------------------------------------------------------------------------
/lnbits/core/views/tinyurl_api.py:
--------------------------------------------------------------------------------
1 | from http import HTTPStatus
2 |
3 | from fastapi import (
4 | APIRouter,
5 | Depends,
6 | HTTPException,
7 | )
8 | from starlette.responses import RedirectResponse
9 |
10 | from lnbits.decorators import (
11 | WalletTypeInfo,
12 | require_admin_key,
13 | require_invoice_key,
14 | )
15 |
16 | from ..crud import (
17 | create_tinyurl,
18 | delete_tinyurl,
19 | get_tinyurl,
20 | get_tinyurl_by_url,
21 | )
22 |
23 | tinyurl_router = APIRouter(tags=["Tinyurl"])
24 |
25 |
26 | @tinyurl_router.post(
27 | "/api/v1/tinyurl",
28 | name="Tinyurl",
29 | description="creates a tinyurl",
30 | )
31 | async def api_create_tinyurl(
32 | url: str, endless: bool = False, wallet: WalletTypeInfo = Depends(require_admin_key)
33 | ):
34 | tinyurls = await get_tinyurl_by_url(url)
35 | try:
36 | for tinyurl in tinyurls:
37 | if tinyurl:
38 | if tinyurl.wallet == wallet.wallet.id:
39 | return tinyurl
40 | return await create_tinyurl(url, endless, wallet.wallet.id)
41 | except Exception as exc:
42 | raise HTTPException(
43 | status_code=HTTPStatus.BAD_REQUEST, detail="Unable to create tinyurl"
44 | ) from exc
45 |
46 |
47 | @tinyurl_router.get(
48 | "/api/v1/tinyurl/{tinyurl_id}",
49 | name="Tinyurl",
50 | description="get a tinyurl by id",
51 | )
52 | async def api_get_tinyurl(
53 | tinyurl_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
54 | ):
55 | try:
56 | tinyurl = await get_tinyurl(tinyurl_id)
57 | if tinyurl:
58 | if tinyurl.wallet == wallet.wallet.id:
59 | return tinyurl
60 | raise HTTPException(
61 | status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
62 | )
63 | except Exception as exc:
64 | raise HTTPException(
65 | status_code=HTTPStatus.NOT_FOUND, detail="Unable to fetch tinyurl"
66 | ) from exc
67 |
68 |
69 | @tinyurl_router.delete(
70 | "/api/v1/tinyurl/{tinyurl_id}",
71 | name="Tinyurl",
72 | description="delete a tinyurl by id",
73 | )
74 | async def api_delete_tinyurl(
75 | tinyurl_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
76 | ):
77 | try:
78 | tinyurl = await get_tinyurl(tinyurl_id)
79 | if tinyurl:
80 | if tinyurl.wallet == wallet.wallet.id:
81 | await delete_tinyurl(tinyurl_id)
82 | return {"deleted": True}
83 | raise HTTPException(
84 | status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
85 | )
86 | except Exception as exc:
87 | raise HTTPException(
88 | status_code=HTTPStatus.BAD_REQUEST, detail="Unable to delete"
89 | ) from exc
90 |
91 |
92 | @tinyurl_router.get(
93 | "/t/{tinyurl_id}",
94 | name="Tinyurl",
95 | description="redirects a tinyurl by id",
96 | )
97 | async def api_tinyurl(tinyurl_id: str):
98 | tinyurl = await get_tinyurl(tinyurl_id)
99 | if tinyurl:
100 | response = RedirectResponse(url=tinyurl.url)
101 | return response
102 | else:
103 | raise HTTPException(
104 | status_code=HTTPStatus.NOT_FOUND, detail="unable to find tinyurl"
105 | )
106 |
--------------------------------------------------------------------------------
/tests/wallets/test_rest_wallets.py:
--------------------------------------------------------------------------------
1 | import json
2 | from typing import Dict, Union
3 | from urllib.parse import urlencode
4 |
5 | import pytest
6 | from loguru import logger
7 | from pytest_httpserver import HTTPServer
8 | from werkzeug.wrappers import Response
9 |
10 | from tests.wallets.fixtures.models import Mock
11 | from tests.wallets.helpers import (
12 | WalletTest,
13 | build_test_id,
14 | check_assertions,
15 | load_funding_source,
16 | wallet_fixtures_from_json,
17 | )
18 |
19 | # todo:
20 | # - tests for extra fields
21 | # - tests for paid_invoices_stream
22 | # - test particular validations
23 |
24 |
25 | # specify where the server should bind to
26 | @pytest.fixture(scope="session")
27 | def httpserver_listen_address():
28 | return ("127.0.0.1", 8555)
29 |
30 |
31 | @pytest.mark.asyncio
32 | @pytest.mark.parametrize(
33 | "test_data",
34 | wallet_fixtures_from_json("tests/wallets/fixtures/json/fixtures_rest.json"),
35 | ids=build_test_id,
36 | )
37 | async def test_rest_wallet(httpserver: HTTPServer, test_data: WalletTest):
38 | test_id = build_test_id(test_data)
39 | logger.info(f"[{test_id}]: test start")
40 | try:
41 | if test_data.skip:
42 | logger.info(f"[{test_id}]: test skip")
43 | pytest.skip()
44 |
45 | logger.info(f"[{test_id}]: apply {len(test_data.mocks)} mocks")
46 | for mock in test_data.mocks:
47 | _apply_mock(httpserver, mock)
48 |
49 | logger.info(f"[{test_id}]: load funding source")
50 | wallet = load_funding_source(test_data.funding_source)
51 |
52 | logger.info(f"[{test_id}]: check assertions")
53 | await check_assertions(wallet, test_data)
54 | except Exception as exc:
55 | logger.info(f"[{test_id}]: test failed: {exc}")
56 | raise exc
57 | finally:
58 | logger.info(f"[{test_id}]: test end")
59 |
60 |
61 | def _apply_mock(httpserver: HTTPServer, mock: Mock):
62 | request_data: Dict[str, Union[str, dict, list]] = {}
63 | request_type = getattr(mock.dict(), "request_type", None)
64 | # request_type = mock.request_type <--- this des not work for whatever reason!!!
65 |
66 | if request_type == "data":
67 | assert isinstance(mock.response, dict), "request data must be JSON"
68 | request_data["data"] = urlencode(mock.response)
69 | elif request_type == "json":
70 | request_data["json"] = mock.response
71 |
72 | if mock.query_params:
73 | request_data["query_string"] = mock.query_params
74 |
75 | assert mock.uri, "Missing URI for HTTP mock."
76 | assert mock.method, "Missing method for HTTP mock."
77 | req = httpserver.expect_request(
78 | uri=mock.uri,
79 | headers=mock.headers,
80 | method=mock.method,
81 | **request_data, # type: ignore
82 | )
83 |
84 | server_response: Union[str, dict, list, Response] = mock.response
85 | response_type = mock.response_type
86 | if response_type == "response":
87 | assert isinstance(server_response, dict), "server response must be JSON"
88 | server_response = Response(**server_response)
89 | elif response_type == "stream":
90 | response_type = "response"
91 | server_response = Response(iter(json.dumps(server_response).splitlines()))
92 |
93 | respond_with = f"respond_with_{response_type}"
94 |
95 | getattr(req, respond_with)(server_response)
96 |
--------------------------------------------------------------------------------
/lnbits/core/templates/admin/_tab_funding.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Wallets Management
4 |
5 |
6 |
7 |
8 |
Funding Source Info
9 |
26 |
27 |
28 |
29 | {% if LNBITS_NODE_UI_AVAILABLE %}
30 |
Node Management
31 |
35 |
40 |
41 |
46 | {% else %}
47 |
Node Management not supported by active funding source
48 | {% endif %}
49 |
50 |
51 |
52 |
53 |
Invoice Expiry
54 |
61 |
62 |
63 |
64 |
Fee reserve
65 |
66 |
67 |
73 |
74 |
75 |
76 |
84 |
85 |
86 |
87 |
88 |
89 |
93 |
94 |
95 |
96 |
97 |
--------------------------------------------------------------------------------
/docs/guide/fastapi_transition.md:
--------------------------------------------------------------------------------
1 | ## Defining a route with path parameters
2 |
3 | **old:**
4 |
5 | ```python
6 | # with <>
7 | @offlineshop_ext.route("/lnurl/", methods=["GET"])
8 | ```
9 |
10 | **new:**
11 |
12 | ```python
13 | # with curly braces: {}
14 | @offlineshop_ext.get("/lnurl/{item_id}")
15 | ```
16 |
17 | ## Check if a user exists and access user object
18 |
19 | **old:**
20 |
21 | ```python
22 | # decorators
23 | @check_user_exists()
24 | async def do_routing_stuff():
25 | pass
26 | ```
27 |
28 | **new:**
29 | If user doesn't exist, `Depends(check_user_exists)` will raise an exception.
30 | If user exists, `user` will be the user object
31 |
32 | ```python
33 | # depends calls
34 | @core_html_routes.get("/my_route")
35 | async def extensions(user: User = Depends(check_user_exists)):
36 | pass
37 | ```
38 |
39 | ## Returning data from API calls
40 |
41 | **old:**
42 |
43 | ```python
44 | return (
45 | {
46 | "id": wallet.wallet.id,
47 | "name": wallet.wallet.name,
48 | "balance": wallet.wallet.balance_msat
49 | },
50 | HTTPStatus.OK,
51 | )
52 | ```
53 |
54 | FastAPI returns `HTTPStatus.OK` by default id no Exception is raised
55 |
56 | **new:**
57 |
58 | ```python
59 | return {
60 | "id": wallet.wallet.id,
61 | "name": wallet.wallet.name,
62 | "balance": wallet.wallet.balance_msat
63 | }
64 | ```
65 |
66 | To change the default HTTPStatus, add it to the path decorator
67 |
68 | ```python
69 | @core_app.post("/api/v1/payments", status_code=HTTPStatus.CREATED)
70 | async def payments():
71 | pass
72 | ```
73 |
74 | ## Raise exceptions
75 |
76 | **old:**
77 |
78 | ```python
79 | return (
80 | {"message": f"Failed to connect to {domain}."},
81 | HTTPStatus.BAD_REQUEST,
82 | )
83 | # or the Quart way via abort function
84 | abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
85 | ```
86 |
87 | **new:**
88 |
89 | Raise an exception to return a status code other than the default status code.
90 |
91 | ```python
92 | raise HTTPException(
93 | status_code=HTTPStatus.BAD_REQUEST,
94 | detail=f"Failed to connect to {domain}."
95 | )
96 | ```
97 |
98 | ## Extensions
99 |
100 | **old:**
101 |
102 | ```python
103 | from quart import Blueprint
104 |
105 | amilk_ext: Blueprint = Blueprint(
106 | "amilk", __name__, static_folder="static", template_folder="templates"
107 | )
108 | ```
109 |
110 | **new:**
111 |
112 | ```python
113 | from fastapi import APIRouter
114 | from lnbits.jinja2_templating import Jinja2Templates
115 | from lnbits.helpers import template_renderer
116 | from fastapi.staticfiles import StaticFiles
117 |
118 | offlineshop_ext: APIRouter = APIRouter(
119 | prefix="/Extension",
120 | tags=["Offlineshop"]
121 | )
122 |
123 | offlineshop_ext.mount(
124 | "lnbits/extensions/offlineshop/static",
125 | StaticFiles("lnbits/extensions/offlineshop/static")
126 | )
127 |
128 | offlineshop_rndr = template_renderer([
129 | "lnbits/extensions/offlineshop/templates",
130 | ])
131 | ```
132 |
133 | ## Possible optimizations
134 |
135 | ### Use Redis as a cache server
136 |
137 | Instead of hitting the database over and over again, we can store a short lived object in [Redis](https://redis.io) for an arbitrary key.
138 | Example:
139 |
140 | - Get transactions for a wallet ID
141 | - User data for a user id
142 | - Wallet data for a Admin / Invoice key
143 |
--------------------------------------------------------------------------------
/lnbits/core/extensions/extension_manager.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import importlib
3 |
4 | from loguru import logger
5 |
6 | from lnbits.core.crud import (
7 | add_installed_extension,
8 | delete_installed_extension,
9 | get_dbversions,
10 | get_installed_extension,
11 | update_installed_extension_state,
12 | )
13 | from lnbits.core.db import core_app_extra
14 | from lnbits.core.helpers import migrate_extension_database
15 | from lnbits.settings import settings
16 |
17 | from .models import Extension, InstallableExtension
18 |
19 |
20 | async def install_extension(ext_info: InstallableExtension) -> Extension:
21 | extension = Extension.from_installable_ext(ext_info)
22 | installed_ext = await get_installed_extension(ext_info.id)
23 | ext_info.payments = installed_ext.payments if installed_ext else []
24 |
25 | await ext_info.download_archive()
26 |
27 | ext_info.extract_archive()
28 |
29 | db_version = (await get_dbversions()).get(ext_info.id, 0)
30 | await migrate_extension_database(extension, db_version)
31 |
32 | await add_installed_extension(ext_info)
33 |
34 | if extension.is_upgrade_extension:
35 | # call stop while the old routes are still active
36 | await stop_extension_background_work(ext_info.id)
37 |
38 | return extension
39 |
40 |
41 | async def uninstall_extension(ext_id: str):
42 | await stop_extension_background_work(ext_id)
43 |
44 | settings.deactivate_extension_paths(ext_id)
45 |
46 | extension = await get_installed_extension(ext_id)
47 | if extension:
48 | extension.clean_extension_files()
49 | await delete_installed_extension(ext_id=ext_id)
50 |
51 |
52 | async def activate_extension(ext: Extension):
53 | core_app_extra.register_new_ext_routes(ext)
54 | await update_installed_extension_state(ext_id=ext.code, active=True)
55 |
56 |
57 | async def deactivate_extension(ext_id: str):
58 | settings.deactivate_extension_paths(ext_id)
59 | await update_installed_extension_state(ext_id=ext_id, active=False)
60 |
61 |
62 | async def stop_extension_background_work(ext_id: str) -> bool:
63 | """
64 | Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
65 | Extensions SHOULD expose a `api_stop()` function.
66 | """
67 | upgrade_hash = settings.lnbits_upgraded_extensions.get(ext_id, "")
68 | ext = Extension(ext_id, True, False, upgrade_hash=upgrade_hash)
69 |
70 | try:
71 | logger.info(f"Stopping background work for extension '{ext.module_name}'.")
72 | old_module = importlib.import_module(ext.module_name)
73 |
74 | # Extensions must expose an `{ext_id}_stop()` function at the module level
75 | # The `api_stop()` function is for backwards compatibility (will be deprecated)
76 | stop_fns = [f"{ext_id}_stop", "api_stop"]
77 | stop_fn_name = next((fn for fn in stop_fns if hasattr(old_module, fn)), None)
78 | assert stop_fn_name, f"No stop function found for '{ext.module_name}'."
79 |
80 | stop_fn = getattr(old_module, stop_fn_name)
81 | if stop_fn:
82 | if asyncio.iscoroutinefunction(stop_fn):
83 | await stop_fn()
84 | else:
85 | stop_fn()
86 |
87 | logger.info(f"Stopped background work for extension '{ext.module_name}'.")
88 | except Exception as ex:
89 | logger.warning(f"Failed to stop background work for '{ext.module_name}'.")
90 | logger.warning(ex)
91 | return False
92 |
93 | return True
94 |
--------------------------------------------------------------------------------
/docs/guide/admin_ui.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: default
3 | title: Admin UI
4 | nav_order: 4
5 | ---
6 |
7 | # Admin UI
8 |
9 | The LNbits Admin UI lets you change LNbits settings via the LNbits frontend.
10 | It is disabled by default and the first time you set the environment variable `LNBITS_ADMIN_UI=true`
11 | the settings are initialized and saved to the database and will be used from there as long the UI is enabled.
12 | From there on the settings from the database are used.
13 |
14 | # Super User
15 |
16 | With the Admin UI we introduced the super user, it is created with the initialisation of the Admin UI and will be shown with a success message in the server logs.
17 | The super user has access to the server and can change settings that may crash the server and make it unresponsive via the frontend and api, like changing funding sources.
18 |
19 | Also only the super user can brrrr satoshis to different wallets.
20 |
21 | The super user is only stored inside the settings table of the database and after the settings are "reset to defaults" and a restart happened,
22 | a new super user is created.
23 |
24 | The super user is never sent over the api and the frontend only receives a bool if you are super user or not.
25 |
26 | We also added a decorator for the API routes to check for super user.
27 |
28 | There is also the possibility of posting the super user via webhook to another service when it is created. you can look it up here https://github.com/lnbits/lnbits/blob/main/lnbits/settings.py `class SaaSSettings`
29 |
30 | # Admin Users
31 |
32 | environment variable: `LNBITS_ADMIN_USERS`, comma-separated list of user ids
33 | Admin Users can change settings in the admin ui as well, with the exception of funding source settings, because they require e server restart and could potentially make the server inaccessible. Also they have access to all the extension defined in `LNBITS_ADMIN_EXTENSIONS`.
34 |
35 | # Allowed Users
36 |
37 | environment variable: `LNBITS_ALLOWED_USERS`, comma-separated list of user ids
38 | By defining this users, LNbits will no longer be usable by the public, only defined users and admins can then access the LNbits frontend.
39 |
40 | Setting this environment variable also disables account creation.
41 | Account creation can be also disabled by setting `LNBITS_ALLOW_NEW_ACCOUNTS=false`
42 |
43 | # How to activate
44 |
45 | ```
46 | $ sudo systemctl stop lnbits.service
47 | $ cd ~/lnbits
48 | $ sudo nano .env
49 | ```
50 |
51 | -> set: `LNBITS_ADMIN_UI=true`
52 |
53 | Now start LNbits once in the terminal window
54 |
55 | ```
56 | $ poetry run lnbits
57 | ```
58 |
59 | You can now `cat` the Super User ID:
60 |
61 | ```
62 | $ cat data/.super_user
63 | 123de4bfdddddbbeb48c8bc8382fe123
64 | ```
65 |
66 | You can access your super user account at `/wallet?usr=super_user_id`. You just have to append it to your normal LNbits web domain.
67 |
68 | After that you will find the **`Admin` / `Manage Server`** between `Wallets` and `Extensions`
69 |
70 | Here you can design the interface, it has TOPUP to fill wallets and you can restrict access rights to extensions only for admins or generally deactivated for everyone. You can make users admins or set up Allowed Users if you want to restrict access. And of course the classic settings of the .env file, e.g. to change the funding source wallet or set a charge fee.
71 |
72 | Do not forget
73 |
74 | ```
75 | sudo systemctl start lnbits.service
76 | ```
77 |
78 | A little hint, if you set `RESET TO DEFAULTS`, then a new Super User Account will also be created. The old one is then no longer valid.
79 |
--------------------------------------------------------------------------------
/lnbits/core/templates/admin/index.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %} {% from "macros.jinja" import window_vars with context
2 | %} {% block page %}
3 |
4 |
5 |
11 |
12 |
13 |
14 |
15 |
22 |
23 |
24 |
31 |
32 |
33 |
34 |
35 |
42 |
43 |
44 |
45 |
46 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | tab = val.name"
71 | >
72 |
73 | tab = val.name"
77 | >
78 |
79 | tab = val.name"
83 | >
84 |
85 | tab = val.name"
89 | >
90 |
91 | tab = val.name"
95 | >
96 |
97 |
98 |
99 |
100 |
101 |
102 | {% include "admin/_tab_funding.html" %} {% include
103 | "admin/_tab_users.html" %} {% include "admin/_tab_server.html" %} {%
104 | include "admin/_tab_security.html" %} {% include
105 | "admin/_tab_theme.html" %}
106 |
107 |
108 |
109 |
110 |
111 |
112 | {% endblock %} {% block scripts %} {{ window_vars(user) }}
113 |
114 | {% endblock %}
115 |
--------------------------------------------------------------------------------
/lnbits/static/js/components/payment-chart.js:
--------------------------------------------------------------------------------
1 | function generateChart(canvas, rawData) {
2 | const data = rawData.reduce(
3 | (previous, current) => {
4 | previous.labels.push(current.date)
5 | previous.income.push(current.income)
6 | previous.spending.push(current.spending)
7 | previous.cumulative.push(current.balance)
8 | return previous
9 | },
10 | {
11 | labels: [],
12 | income: [],
13 | spending: [],
14 | cumulative: []
15 | }
16 | )
17 |
18 | return new Chart(canvas.getContext('2d'), {
19 | type: 'bar',
20 | data: {
21 | labels: data.labels,
22 | datasets: [
23 | {
24 | data: data.cumulative,
25 | type: 'line',
26 | label: 'balance',
27 | backgroundColor: '#673ab7', // deep-purple
28 | borderColor: '#673ab7',
29 | borderWidth: 4,
30 | pointRadius: 3,
31 | fill: false
32 | },
33 | {
34 | data: data.income,
35 | type: 'bar',
36 | label: 'in',
37 | barPercentage: 0.75,
38 | backgroundColor: 'rgba(76, 175, 80, 0.5)' // green
39 | },
40 | {
41 | data: data.spending,
42 | type: 'bar',
43 | label: 'out',
44 | barPercentage: 0.75,
45 | backgroundColor: 'rgba(233, 30, 99, 0.5)' // pink
46 | }
47 | ]
48 | },
49 | options: {
50 | title: {
51 | text: 'Chart.js Combo Time Scale'
52 | },
53 | tooltips: {
54 | mode: 'index',
55 | intersect: false
56 | },
57 | scales: {
58 | xAxes: [
59 | {
60 | type: 'time',
61 | display: true,
62 | //offset: true,
63 | time: {
64 | minUnit: 'hour',
65 | stepSize: 3
66 | }
67 | }
68 | ]
69 | },
70 | // performance tweaks
71 | animation: {
72 | duration: 0
73 | },
74 | elements: {
75 | line: {
76 | tension: 0
77 | }
78 | }
79 | }
80 | })
81 | }
82 |
83 | window.app.component('payment-chart', {
84 | template: '#payment-chart',
85 | name: 'payment-chart',
86 | props: ['wallet'],
87 | mixins: [window.windowMixin],
88 | data: function () {
89 | return {
90 | paymentsChart: {
91 | show: false,
92 | group: {
93 | value: 'hour',
94 | label: 'Hour'
95 | },
96 | groupOptions: [
97 | {value: 'hour', label: 'Hour'},
98 | {value: 'day', label: 'Day'},
99 | {value: 'week', label: 'Week'},
100 | {value: 'month', label: 'Month'},
101 | {value: 'year', label: 'Year'}
102 | ],
103 | instance: null
104 | }
105 | }
106 | },
107 | methods: {
108 | showChart: function () {
109 | this.paymentsChart.show = true
110 | LNbits.api
111 | .request(
112 | 'GET',
113 | '/api/v1/payments/history?group=' + this.paymentsChart.group.value,
114 | this.wallet.adminkey
115 | )
116 | .then(response => {
117 | this.$nextTick(() => {
118 | if (this.paymentsChart.instance) {
119 | this.paymentsChart.instance.destroy()
120 | }
121 | this.paymentsChart.instance = generateChart(
122 | this.$refs.canvas,
123 | response.data
124 | )
125 | })
126 | })
127 | .catch(err => {
128 | LNbits.utils.notifyApiError(err)
129 | this.paymentsChart.show = false
130 | })
131 | }
132 | }
133 | })
134 |
--------------------------------------------------------------------------------
/nix/modules/lnbits-service.nix:
--------------------------------------------------------------------------------
1 | { config, pkgs, lib, ... }:
2 |
3 | let
4 | defaultUser = "lnbits";
5 | cfg = config.services.lnbits;
6 | inherit (lib) mkOption mkIf types optionalAttrs literalExpression;
7 | in
8 |
9 | {
10 | options = {
11 | services.lnbits = {
12 | enable = mkOption {
13 | default = false;
14 | type = types.bool;
15 | description = ''
16 | Whether to enable the lnbits service
17 | '';
18 | };
19 | openFirewall = mkOption {
20 | type = types.bool;
21 | default = false;
22 | description = ''
23 | Whether to open the ports used by lnbits in the firewall for the server
24 | '';
25 | };
26 | package = mkOption {
27 | type = types.package;
28 | defaultText = literalExpression "pkgs.lnbits";
29 | default = pkgs.lnbits;
30 | description = ''
31 | The lnbits package to use.
32 | '';
33 | };
34 | stateDir = mkOption {
35 | type = types.path;
36 | default = "/var/lib/lnbits";
37 | description = ''
38 | The lnbits state directory which LNBITS_DATA_FOLDER will be set to
39 | '';
40 | };
41 | host = mkOption {
42 | type = types.str;
43 | default = "127.0.0.1";
44 | description = ''
45 | The host to bind to
46 | '';
47 | };
48 | port = mkOption {
49 | type = types.port;
50 | default = 8231;
51 | description = ''
52 | The port to run on
53 | '';
54 | };
55 | user = mkOption {
56 | type = types.str;
57 | default = "lnbits";
58 | description = "user to run lnbits as";
59 | };
60 | group = mkOption {
61 | type = types.str;
62 | default = "lnbits";
63 | description = "group to run lnbits as";
64 | };
65 | env = mkOption {
66 | type = types.attrsOf types.str;
67 | default = {};
68 | description = ''
69 | Additional environment variables that are passed to lnbits.
70 | Reference Variables: https://github.com/lnbits/lnbits/blob/dev/.env.example
71 | '';
72 | example = {
73 | LNBITS_ADMIN_UI = "true";
74 | };
75 | };
76 | };
77 | };
78 |
79 | config = mkIf cfg.enable {
80 | users.users = optionalAttrs (cfg.user == defaultUser) {
81 | ${defaultUser} = {
82 | isSystemUser = true;
83 | group = defaultUser;
84 | };
85 | };
86 |
87 | users.groups = optionalAttrs (cfg.group == defaultUser) {
88 | ${defaultUser} = { };
89 | };
90 |
91 | systemd.tmpfiles.rules = [
92 | "d ${cfg.stateDir} 0700 ${cfg.user} ${cfg.group} - -"
93 | ];
94 |
95 | systemd.services.lnbits = {
96 | enable = true;
97 | description = "lnbits";
98 | wantedBy = [ "multi-user.target" ];
99 | after = [ "network-online.target" ];
100 | environment = lib.mkMerge [
101 | {
102 | LNBITS_DATA_FOLDER = "${cfg.stateDir}";
103 | LNBITS_EXTENSIONS_PATH = "${cfg.stateDir}/extensions";
104 | LNBITS_PATH = "${cfg.package.src}";
105 | }
106 | cfg.env
107 | ];
108 | serviceConfig = {
109 | User = cfg.user;
110 | Group = cfg.group;
111 | WorkingDirectory = "${cfg.package.src}";
112 | StateDirectory = "${cfg.stateDir}";
113 | ExecStart = "${lib.getExe cfg.package} --port ${toString cfg.port} --host ${cfg.host}";
114 | Restart = "always";
115 | PrivateTmp = true;
116 | };
117 | };
118 | networking.firewall = mkIf cfg.openFirewall {
119 | allowedTCPPorts = [ cfg.port ];
120 | };
121 | };
122 | }
123 |
--------------------------------------------------------------------------------
/lnbits/core/templates/users/_walletDialog.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Wallets
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
20 |
21 |
22 |
23 |
24 |
25 |
32 | Show Payments
33 |
34 |
42 | Copy Wallet ID
43 |
44 |
49 |
57 | Copy Admin Key
58 |
59 |
67 | Copy Invoice Key
68 |
69 |
77 | Undelete Wallet
78 |
79 |
86 | Delete Wallet
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
104 |
105 |
106 |
107 |
--------------------------------------------------------------------------------
/tests/regtest/helpers.py:
--------------------------------------------------------------------------------
1 | import hashlib
2 | import json
3 | import os
4 | import time
5 | from subprocess import PIPE, Popen, TimeoutExpired
6 | from typing import Tuple
7 |
8 | from loguru import logger
9 |
10 | docker_lightning_cli = [
11 | "docker",
12 | "exec",
13 | "lnbits-lnd-1-1",
14 | "lncli",
15 | "--network",
16 | "regtest",
17 | "--rpcserver=lnd-1",
18 | ]
19 |
20 | docker_bitcoin_cli = [
21 | "docker",
22 | "exec",
23 | "lnbits-bitcoind-1-1" "bitcoin-cli",
24 | "-rpcuser=lnbits",
25 | "-rpcpassword=lnbits",
26 | "-regtest",
27 | ]
28 |
29 |
30 | docker_lightning_unconnected_cli = [
31 | "docker",
32 | "exec",
33 | "lnbits-lnd-2-1",
34 | "lncli",
35 | "--network",
36 | "regtest",
37 | "--rpcserver=lnd-2",
38 | ]
39 |
40 |
41 | def run_cmd(cmd: list) -> str:
42 | timeout = 20
43 | process = Popen(cmd, stdout=PIPE, stderr=PIPE)
44 |
45 | def process_communication(comm):
46 | stdout, stderr = comm
47 | output = stdout.decode("utf-8").strip()
48 | error = stderr.decode("utf-8").strip()
49 | return output, error
50 |
51 | try:
52 | now = time.time()
53 | output, error = process_communication(process.communicate(timeout=timeout))
54 | took = time.time() - now
55 | logger.debug(f"ran command output: {output}, error: {error}, took: {took}s")
56 | return output
57 | except TimeoutExpired:
58 | process.kill()
59 | output, error = process_communication(process.communicate())
60 | logger.error(f"timeout command: {cmd}, output: {output}, error: {error}")
61 | raise
62 |
63 |
64 | def run_cmd_json(cmd: list) -> dict:
65 | output = run_cmd(cmd)
66 | try:
67 | return json.loads(output) if output else {}
68 | except json.decoder.JSONDecodeError:
69 | logger.error(f"failed to decode json from cmd `{cmd}`: {output}")
70 | raise
71 |
72 |
73 | def get_hold_invoice(sats: int) -> Tuple[str, dict]:
74 | preimage = os.urandom(32)
75 | preimage_hash = hashlib.sha256(preimage).hexdigest()
76 | cmd = docker_lightning_cli.copy()
77 | cmd.extend(["addholdinvoice", preimage_hash, str(sats)])
78 | json = run_cmd_json(cmd)
79 | return preimage.hex(), json
80 |
81 |
82 | def settle_invoice(preimage: str) -> str:
83 | cmd = docker_lightning_cli.copy()
84 | cmd.extend(["settleinvoice", preimage])
85 | return run_cmd(cmd)
86 |
87 |
88 | def cancel_invoice(preimage_hash: str) -> str:
89 | cmd = docker_lightning_cli.copy()
90 | cmd.extend(["cancelinvoice", preimage_hash])
91 | return run_cmd(cmd)
92 |
93 |
94 | def get_real_invoice(sats: int) -> dict:
95 | cmd = docker_lightning_cli.copy()
96 | cmd.extend(["addinvoice", str(sats)])
97 | return run_cmd_json(cmd)
98 |
99 |
100 | def pay_real_invoice(invoice: str) -> str:
101 | cmd = docker_lightning_cli.copy()
102 | cmd.extend(["payinvoice", "--force", invoice])
103 | return run_cmd(cmd)
104 |
105 |
106 | def mine_blocks(blocks: int = 1) -> str:
107 | cmd = docker_bitcoin_cli.copy()
108 | cmd.extend(["-generate", str(blocks)])
109 | return run_cmd(cmd)
110 |
111 |
112 | def get_unconnected_node_uri() -> str:
113 | cmd = docker_lightning_unconnected_cli.copy()
114 | cmd.append("getinfo")
115 | info = run_cmd_json(cmd)
116 | pubkey = info["identity_pubkey"]
117 | return f"{pubkey}@lnd-2:9735"
118 |
119 |
120 | def create_onchain_address(address_type: str = "bech32") -> str:
121 | cmd = docker_bitcoin_cli.copy()
122 | cmd.extend(["getnewaddress", address_type])
123 | return run_cmd(cmd)
124 |
125 |
126 | def pay_onchain(address: str, sats: int) -> str:
127 | btc = sats * 0.00000001
128 | cmd = docker_bitcoin_cli.copy()
129 | cmd.extend(["sendtoaddress", address, str(btc)])
130 | return run_cmd(cmd)
131 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: test
2 |
3 | all: format check
4 |
5 | format: prettier black ruff
6 |
7 | check: mypy pyright checkblack checkruff checkprettier checkbundle
8 |
9 | test: test-unit test-wallets test-api test-regtest
10 |
11 | prettier:
12 | poetry run ./node_modules/.bin/prettier --write .
13 |
14 | pyright:
15 | poetry run ./node_modules/.bin/pyright
16 |
17 | mypy:
18 | poetry run mypy
19 |
20 | black:
21 | poetry run black .
22 |
23 | ruff:
24 | poetry run ruff check . --fix
25 |
26 | checkruff:
27 | poetry run ruff check .
28 |
29 | checkprettier:
30 | poetry run ./node_modules/.bin/prettier --check .
31 |
32 | checkblack:
33 | poetry run black --check .
34 |
35 | checkeditorconfig:
36 | editorconfig-checker
37 |
38 | dev:
39 | poetry run lnbits --reload
40 |
41 | docker:
42 | docker build -t lnbits/lnbits .
43 |
44 | test-wallets:
45 | LNBITS_DATA_FOLDER="./tests/data" \
46 | LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
47 | PYTHONUNBUFFERED=1 \
48 | DEBUG=true \
49 | poetry run pytest tests/wallets
50 |
51 | test-unit:
52 | LNBITS_DATA_FOLDER="./tests/data" \
53 | LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
54 | PYTHONUNBUFFERED=1 \
55 | DEBUG=true \
56 | poetry run pytest tests/unit
57 |
58 | test-api:
59 | LNBITS_DATA_FOLDER="./tests/data" \
60 | LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
61 | PYTHONUNBUFFERED=1 \
62 | DEBUG=true \
63 | poetry run pytest tests/api
64 |
65 | test-regtest:
66 | LNBITS_DATA_FOLDER="./tests/data" \
67 | PYTHONUNBUFFERED=1 \
68 | DEBUG=true \
69 | poetry run pytest tests/regtest
70 |
71 | test-migration:
72 | LNBITS_ADMIN_UI=True \
73 | make test-api
74 | HOST=0.0.0.0 \
75 | PORT=5002 \
76 | LNBITS_DATA_FOLDER="./tests/data" \
77 | timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
78 | HOST=0.0.0.0 \
79 | PORT=5002 \
80 | LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
81 | timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
82 | LNBITS_DATA_FOLDER="./tests/data" \
83 | LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
84 | poetry run python tools/conv.py
85 |
86 | migration:
87 | poetry run python tools/conv.py
88 |
89 | openapi:
90 | LNBITS_ADMIN_UI=False \
91 | LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
92 | LNBITS_DATA_FOLDER="./tests/data" \
93 | PYTHONUNBUFFERED=1 \
94 | HOST=0.0.0.0 \
95 | PORT=5003 \
96 | poetry run lnbits &
97 | sleep 15
98 | curl -s http://0.0.0.0:5003/openapi.json | poetry run openapi-spec-validator --errors=all -
99 | # kill -9 %1
100 |
101 | bak:
102 | # LNBITS_DATABASE_URL=postgres://postgres:postgres@0.0.0.0:5432/postgres
103 | #
104 |
105 | sass:
106 | npm run sass
107 |
108 | bundle:
109 | npm install
110 | npm run bundle
111 | poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
112 |
113 | checkbundle:
114 | cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
115 | cp lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old
116 | cp lnbits/static/bundle-components.min.js lnbits/static/bundle-components.min.js.old
117 | make bundle
118 | diff -q lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old || exit 1
119 | diff -q lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old || exit 1
120 | diff -q lnbits/static/bundle-components.min.js lnbits/static/bundle-components.min.js.old || exit 1
121 | @echo "Bundle is OK"
122 | rm lnbits/static/bundle.min.js.old
123 | rm lnbits/static/bundle.min.css.old
124 | rm lnbits/static/bundle-components.min.js.old
125 |
126 | install-pre-commit-hook:
127 | @echo "Installing pre-commit hook to git"
128 | @echo "Uninstall the hook with poetry run pre-commit uninstall"
129 | poetry run pre-commit install
130 |
131 | pre-commit:
132 | poetry run pre-commit run --all-files
133 |
--------------------------------------------------------------------------------
/lnbits/wallets/base.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from abc import ABC, abstractmethod
4 | from typing import TYPE_CHECKING, AsyncGenerator, Coroutine, NamedTuple, Optional
5 |
6 | if TYPE_CHECKING:
7 | from lnbits.nodes.base import Node
8 |
9 |
10 | class StatusResponse(NamedTuple):
11 | error_message: Optional[str]
12 | balance_msat: int
13 |
14 |
15 | class InvoiceResponse(NamedTuple):
16 | ok: bool
17 | checking_id: Optional[str] = None # payment_hash, rpc_id
18 | payment_request: Optional[str] = None
19 | error_message: Optional[str] = None
20 |
21 | @property
22 | def success(self) -> bool:
23 | return self.ok is True
24 |
25 | @property
26 | def pending(self) -> bool:
27 | return self.ok is None
28 |
29 | @property
30 | def failed(self) -> bool:
31 | return self.ok is False
32 |
33 |
34 | class PaymentResponse(NamedTuple):
35 | # when ok is None it means we don't know if this succeeded
36 | ok: Optional[bool] = None
37 | checking_id: Optional[str] = None # payment_hash, rcp_id
38 | fee_msat: Optional[int] = None
39 | preimage: Optional[str] = None
40 | error_message: Optional[str] = None
41 |
42 | @property
43 | def success(self) -> bool:
44 | return self.ok is True
45 |
46 | @property
47 | def pending(self) -> bool:
48 | return self.ok is None
49 |
50 | @property
51 | def failed(self) -> bool:
52 | return self.ok is False
53 |
54 |
55 | class PaymentStatus(NamedTuple):
56 | paid: Optional[bool] = None
57 | fee_msat: Optional[int] = None
58 | preimage: Optional[str] = None
59 |
60 | @property
61 | def success(self) -> bool:
62 | return self.paid is True
63 |
64 | @property
65 | def pending(self) -> bool:
66 | return self.paid is not True
67 |
68 | @property
69 | def failed(self) -> bool:
70 | return self.paid is False
71 |
72 | def __str__(self) -> str:
73 | if self.success:
74 | return "success"
75 | if self.failed:
76 | return "failed"
77 | return "pending"
78 |
79 |
80 | class PaymentSuccessStatus(PaymentStatus):
81 | paid = True
82 |
83 |
84 | class PaymentFailedStatus(PaymentStatus):
85 | paid = False
86 |
87 |
88 | class PaymentPendingStatus(PaymentStatus):
89 | paid = None
90 |
91 |
92 | class Wallet(ABC):
93 |
94 | __node_cls__: Optional[type[Node]] = None
95 |
96 | @abstractmethod
97 | async def cleanup(self):
98 | pass
99 |
100 | @abstractmethod
101 | def status(self) -> Coroutine[None, None, StatusResponse]:
102 | pass
103 |
104 | @abstractmethod
105 | def create_invoice(
106 | self,
107 | amount: int,
108 | memo: Optional[str] = None,
109 | description_hash: Optional[bytes] = None,
110 | unhashed_description: Optional[bytes] = None,
111 | **kwargs,
112 | ) -> Coroutine[None, None, InvoiceResponse]:
113 | pass
114 |
115 | @abstractmethod
116 | def pay_invoice(
117 | self, bolt11: str, fee_limit_msat: int
118 | ) -> Coroutine[None, None, PaymentResponse]:
119 | pass
120 |
121 | @abstractmethod
122 | def get_invoice_status(
123 | self, checking_id: str
124 | ) -> Coroutine[None, None, PaymentStatus]:
125 | pass
126 |
127 | @abstractmethod
128 | def get_payment_status(
129 | self, checking_id: str
130 | ) -> Coroutine[None, None, PaymentStatus]:
131 | pass
132 |
133 | @abstractmethod
134 | def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
135 | pass
136 |
137 | def normalize_endpoint(self, endpoint: str, add_proto=True) -> str:
138 | endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
139 | if add_proto:
140 | if endpoint.startswith("ws://") or endpoint.startswith("wss://"):
141 | return endpoint
142 | endpoint = (
143 | f"https://{endpoint}" if not endpoint.startswith("http") else endpoint
144 | )
145 | return endpoint
146 |
147 |
148 | class UnsupportedError(Exception):
149 | pass
150 |
--------------------------------------------------------------------------------
/flake.lock:
--------------------------------------------------------------------------------
1 | {
2 | "nodes": {
3 | "flake-utils": {
4 | "inputs": {
5 | "systems": "systems"
6 | },
7 | "locked": {
8 | "lastModified": 1710146030,
9 | "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
10 | "owner": "numtide",
11 | "repo": "flake-utils",
12 | "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
13 | "type": "github"
14 | },
15 | "original": {
16 | "owner": "numtide",
17 | "repo": "flake-utils",
18 | "type": "github"
19 | }
20 | },
21 | "nix-github-actions": {
22 | "inputs": {
23 | "nixpkgs": [
24 | "poetry2nix",
25 | "nixpkgs"
26 | ]
27 | },
28 | "locked": {
29 | "lastModified": 1703863825,
30 | "narHash": "sha256-rXwqjtwiGKJheXB43ybM8NwWB8rO2dSRrEqes0S7F5Y=",
31 | "owner": "nix-community",
32 | "repo": "nix-github-actions",
33 | "rev": "5163432afc817cf8bd1f031418d1869e4c9d5547",
34 | "type": "github"
35 | },
36 | "original": {
37 | "owner": "nix-community",
38 | "repo": "nix-github-actions",
39 | "type": "github"
40 | }
41 | },
42 | "nixpkgs": {
43 | "locked": {
44 | "lastModified": 1723938990,
45 | "narHash": "sha256-9tUadhnZQbWIiYVXH8ncfGXGvkNq3Hag4RCBEMUk7MI=",
46 | "owner": "nixos",
47 | "repo": "nixpkgs",
48 | "rev": "c42fcfbdfeae23e68fc520f9182dde9f38ad1890",
49 | "type": "github"
50 | },
51 | "original": {
52 | "owner": "nixos",
53 | "ref": "nixos-24.05",
54 | "repo": "nixpkgs",
55 | "type": "github"
56 | }
57 | },
58 | "poetry2nix": {
59 | "inputs": {
60 | "flake-utils": "flake-utils",
61 | "nix-github-actions": "nix-github-actions",
62 | "nixpkgs": [
63 | "nixpkgs"
64 | ],
65 | "systems": "systems_2",
66 | "treefmt-nix": "treefmt-nix"
67 | },
68 | "locked": {
69 | "lastModified": 1724134185,
70 | "narHash": "sha256-nDqpGjz7cq3ThdC98BPe1ANCNlsJds/LLZ3/MdIXjA0=",
71 | "owner": "nix-community",
72 | "repo": "poetry2nix",
73 | "rev": "5ee730a8752264e463c0eaf06cc060fd07f6dae9",
74 | "type": "github"
75 | },
76 | "original": {
77 | "owner": "nix-community",
78 | "repo": "poetry2nix",
79 | "type": "github"
80 | }
81 | },
82 | "root": {
83 | "inputs": {
84 | "nixpkgs": "nixpkgs",
85 | "poetry2nix": "poetry2nix"
86 | }
87 | },
88 | "systems": {
89 | "locked": {
90 | "lastModified": 1681028828,
91 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
92 | "owner": "nix-systems",
93 | "repo": "default",
94 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
95 | "type": "github"
96 | },
97 | "original": {
98 | "owner": "nix-systems",
99 | "repo": "default",
100 | "type": "github"
101 | }
102 | },
103 | "systems_2": {
104 | "locked": {
105 | "lastModified": 1681028828,
106 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
107 | "owner": "nix-systems",
108 | "repo": "default",
109 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
110 | "type": "github"
111 | },
112 | "original": {
113 | "id": "systems",
114 | "type": "indirect"
115 | }
116 | },
117 | "treefmt-nix": {
118 | "inputs": {
119 | "nixpkgs": [
120 | "poetry2nix",
121 | "nixpkgs"
122 | ]
123 | },
124 | "locked": {
125 | "lastModified": 1719749022,
126 | "narHash": "sha256-ddPKHcqaKCIFSFc/cvxS14goUhCOAwsM1PbMr0ZtHMg=",
127 | "owner": "numtide",
128 | "repo": "treefmt-nix",
129 | "rev": "8df5ff62195d4e67e2264df0b7f5e8c9995fd0bd",
130 | "type": "github"
131 | },
132 | "original": {
133 | "owner": "numtide",
134 | "repo": "treefmt-nix",
135 | "type": "github"
136 | }
137 | }
138 | },
139 | "root": "root",
140 | "version": 7
141 | }
142 |
--------------------------------------------------------------------------------
/docs/logos/lnbits-full.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/docs/logos/lnbits-full-inverse.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/lnbits/core/templates/node/public.html:
--------------------------------------------------------------------------------
1 | {% extends "public.html" %} {% from "macros.jinja" import window_vars with
2 | context %} {% block page %}
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
25 |
26 |
27 |
31 |
32 |
33 |
37 |
38 |
39 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | {% endblock %} {% block scripts %} {{ window_vars(user) }}
54 |
55 |
132 |
133 | {% endblock %}
134 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |  [![license-badge]](LICENSE) [![docs-badge]][docs]  [ ](https://t.me/lnbits) [ ](https://opensats.org)
7 | 
8 |
9 | # The world's most powerful suite of bitcoin tools.
10 |
11 | ## Run for yourself, for others, or as part of a stack.
12 |
13 | LNbits is beta, for responsible disclosure of any concerns please contact an admin in the community chat.
14 |
15 | LNbits is a Python server that sits on top of any funding source. It can be used as:
16 |
17 | - Accounts system to mitigate the risk of exposing applications to your full balance via unique API keys for each wallet
18 | - Extendable platform for exploring Lightning network functionality via the LNbits extension framework
19 | - Part of a development stack via LNbits API
20 | - Fallback wallet for the LNURL scheme
21 | - Instant wallet for LN demonstrations
22 |
23 | LNbits can run on top of almost all Lightning funding sources.
24 |
25 | See [LNbits manual](https://docs.lnbits.org/guide/wallets.html) for more detailed documentation about each funding source.
26 |
27 | Checkout the LNbits [YouTube](https://www.youtube.com/playlist?list=PLPj3KCksGbSYG0ciIQUWJru1dWstPHshe) video series.
28 |
29 | LNbits is inspired by all the great work of [opennode.com](https://www.opennode.com/), and in particular [lnpay.co](https://lnpay.co/). Both work as funding sources for LNbits.
30 |
31 | ## Running LNbits
32 |
33 | Test on our demo server [demo.lnbits.com](https://demo.lnbits.com), or on [lnbits.com](https://lnbits.com) software as a service, where you can spin up an LNbits instance for 21sats per hr.
34 |
35 | See the [install guide](https://github.com/lnbits/lnbits/blob/main/docs/guide/installation.md) for details on installation and setup.
36 |
37 | ## LNbits account system
38 |
39 | LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv. Each wallet also comes with its own API keys, to help partition the exposure of your funding source.
40 |
41 |
42 |
43 | ## LNbits extension universe
44 |
45 | Extend YOUR LNbits to meet YOUR needs.
46 |
47 | All non-core features are installed as extensions, reducing your code base and making your LNbits unique to you. Extend your LNbits install in any direction, and even create and share your own extensions.
48 |
49 |
50 |
51 | ## LNbits API
52 |
53 | LNbits has a powerful API, many projects use LNbits to do the heavy lifting for their bitcoin/lightning services.
54 |
55 |
56 |
57 | ## LNbits node manager
58 |
59 | LNbits comes packaged with a light node management UI, to make running your node that much easier.
60 |
61 |
62 |
63 | ## LNbits across all your devices
64 |
65 | As well as working great in a browser, LNbits has native IoS and Android apps as well as a chrome extension. So you can enjoy the same UI across ALL your devices.
66 |
67 |
68 |
69 | ## Tip us
70 |
71 | If you like this project [send some tip love](https://demo.lnbits.com/lnurlp/link/fH59GD)!
72 |
73 | [docs]: https://github.com/lnbits/lnbits/wiki
74 | [docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
75 | [github-mypy]: https://github.com/lnbits/lnbits/actions?query=workflow%3Amypy
76 | [github-mypy-badge]: https://github.com/lnbits/lnbits/workflows/mypy/badge.svg
77 | [github-tests]: https://github.com/lnbits/lnbits/actions?query=workflow%3Atests
78 | [github-tests-badge]: https://github.com/lnbits/lnbits/workflows/tests/badge.svg
79 | [codecov]: https://codecov.io/gh/lnbits/lnbits
80 | [codecov-badge]: https://codecov.io/gh/lnbits/lnbits/branch/master/graph/badge.svg
81 | [license-badge]: https://img.shields.io/badge/license-MIT-blue.svg
82 |
--------------------------------------------------------------------------------