├── 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 | Logo 10 | {%else%} {% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {% else %} 11 | LNbits 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 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 29 | 40 | 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 |
    10 |
  • 13 |
  • 17 |
  • 21 |
  • 25 |
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 | 72 | 73 | 78 | 79 | 84 | 85 | 90 | 91 | 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 | 22 | 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 | ![phase: beta](https://img.shields.io/badge/phase-beta-C41E3A) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-08A04B) [](https://t.me/lnbits) [](https://opensats.org) 7 | ![Lightning network wallet](https://i.imgur.com/DeIiO0y.png) 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 | --------------------------------------------------------------------------------