Compare commits

..
11 Commits
Author SHA1 Message Date
calleandGitHub 41b08b3d84 Merge pull request #3 from lnbits/fix/lightning_prefix_parse
fix parsing of lightning: prefix
2023-02-21 14:49:18 +01:00
callebtc e38cde98ae fix parsing of lightning: prefix 2023-02-21 14:48:32 +01:00
calleandGitHub f6a9cdc578 Merge pull request #2 from lnbits/fix/migration
let undefined
2023-02-20 16:03:13 +01:00
callebtc f3b6b1949d let undefined 2023-02-20 04:24:29 +01:00
ArcandGitHub e387ae3271 Create manifest.json 2023-02-16 10:05:40 +00:00
callebtc a7bce3a636 simplify local storage sync 2023-02-15 10:57:48 +01:00
callebtc 0532e8a733 synchronize local storage 2023-02-15 02:25:28 +01:00
callebtc 2582b4ca17 update model 2023-02-15 02:05:22 +01:00
callebtc ee7201dc58 clean 2023-02-13 10:55:15 +01:00
callebtc 57c64b104f clean 2023-02-13 10:54:35 +01:00
callebtc d33735faa0 init 2023-02-13 10:53:29 +01:00
621 changed files with 5861 additions and 126730 deletions
-25
View File
@@ -1,25 +0,0 @@
.git
data
docker
docs
tests
venv
tools
lnbits/static/css/*
lnbits/static/bundle.js
lnbits/static/bundle.css
*.md
*.log
.env
.gitignore
.prettierrc
LICENSE
Makefile
mypy.ini
package-lock.json
package.json
pytest.ini
-89
View File
@@ -1,89 +0,0 @@
HOST=127.0.0.1
PORT=5000
DEBUG=false
LNBITS_ALLOWED_USERS=""
LNBITS_ADMIN_USERS=""
# Extensions only admin can access
LNBITS_ADMIN_EXTENSIONS="ngrok"
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
LNBITS_AD_SPACE="" # csv ad image filepaths or urls, extensions can choose to honor
LNBITS_HIDE_API=false # Hides wallet api, extensions can choose to honor
# Disable extensions for all users, use "all" to disable all extensions
LNBITS_DISABLED_EXTENSIONS="amilk"
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://...
# for both PostgreSQL and CockroachDB, you'll need to install
# psycopg2 as an additional dependency
LNBITS_DATA_FOLDER="./data"
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
LNBITS_FORCE_HTTPS=true
LNBITS_SERVICE_FEE="0.0"
LNBITS_RESERVE_FEE_MIN=2000 # value in millisats
LNBITS_RESERVE_FEE_PERCENT=1.0 # value in percent
# Change theme
LNBITS_SITE_TITLE="LNbits"
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'"
# Choose from mint, flamingo, freedom, salvador, autumn, monochrome, classic
LNBITS_THEME_OPTIONS="classic, bitcoin, freedom, mint, autumn, monochrome, salvador"
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, ClicheWallet
# LndRestWallet, CoreLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
# just so you can see the UI before dealing with this file.
# Set one of these blocks depending on the wallet kind you chose above:
# ClicheWallet
CLICHE_ENDPOINT=ws://127.0.0.1:12000
# SparkWallet
SPARK_URL=http://localhost:9737/rpc
SPARK_TOKEN=myaccesstoken
# CoreLightningWallet
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
# LnbitsWallet
LNBITS_ENDPOINT=https://legend.lnbits.com
LNBITS_KEY=LNBITS_ADMIN_KEY
# LndRestWallet
LND_REST_ENDPOINT=https://127.0.0.1:8080/
LND_REST_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
LND_REST_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon or HEXSTRING"
# To use an AES-encrypted macaroon, set
# LND_REST_MACAROON_ENCRYPTED="eNcRyPtEdMaCaRoOn"
# LNPayWallet
LNPAY_API_ENDPOINT=https://api.lnpay.co/v1/
# Secret API Key under developers tab
LNPAY_API_KEY=LNPAY_API_KEY
# Wallet Admin in Wallet Access Keys
LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
# LntxbotWallet
LNTXBOT_API_ENDPOINT=https://lntxbot.com/
LNTXBOT_KEY=LNTXBOT_ADMIN_KEY
# OpenNodeWallet
OPENNODE_API_ENDPOINT=https://api.opennode.com/
OPENNODE_KEY=OPENNODE_ADMIN_KEY
# FakeWallet
FAKE_WALLET_SECRET="ToTheMoon1"
LNBITS_DENOMINATION=sats
# EclairWallet
ECLAIR_URL=http://127.0.0.1:8283
ECLAIR_PASS=eclairpw
-1
View File
@@ -1 +0,0 @@
custom: https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK
-28
View File
@@ -1,28 +0,0 @@
name: codeql
on:
push:
branches: [main, ]
pull_request:
branches: [main]
schedule:
- cron: '0 12 * * 5'
jobs:
analyze:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
with:
fetch-depth: 2
- run: git checkout HEAD^2
if: ${{ github.event_name == 'pull_request' }}
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: javascript, python
- name: Autobuild
uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
-25
View File
@@ -1,25 +0,0 @@
name: formatting
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: abatilo/actions-poetry@v2.1.3
- name: Install packages
run: poetry install
- name: Check black
run: make checkblack
- name: Check isort
run: make checkisort
- uses: actions/setup-node@v3
- name: Check prettier
run: |
npm install prettier
make checkprettier
-39
View File
@@ -1,39 +0,0 @@
name: migrations
on: [pull_request]
jobs:
sqlite-to-postgres:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:latest
env:
POSTGRES_USER: lnbits
POSTGRES_PASSWORD: lnbits
POSTGRES_DB: migration
ports:
# maps tcp port 5432 on service container to the host
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
strategy:
matrix:
python-version: [3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3
- name: Install dependencies
run: |
poetry install
sudo apt install unzip
- name: Run migrations
run: |
make test-migration
-22
View File
@@ -1,22 +0,0 @@
name: mypy
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3
- name: Install dependencies
run: |
poetry install
- name: Run tests
run: poetry run mypy
-68
View File
@@ -1,68 +0,0 @@
name: Build and push Docker image on tag
env:
DOCKER_CLI_EXPERIMENTAL: enabled
on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+"
- "[0-9]+.[0-9]+.[0-9]+-*"
jobs:
build:
runs-on: ubuntu-20.04
name: Build and push lnbits image
steps:
- name: Login to Docker Hub
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
- name: Checkout project
uses: actions/checkout@v2
- name: Import environment variables
id: import-env
shell: bash
run: echo "TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
- name: Show set environment variables
run: |
printf " TAG: %s\n" "$TAG"
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
id: qemu
- name: Setup Docker buildx action
uses: docker/setup-buildx-action@v1
id: buildx
- name: Show available Docker buildx platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Cache Docker layers
uses: actions/cache@v2
id: cache
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Run Docker buildx against tag
run: |
docker buildx build \
--cache-from "type=local,src=/tmp/.buildx-cache" \
--cache-to "type=local,dest=/tmp/.buildx-cache" \
--platform linux/amd64,linux/arm64 \
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits-legend:${TAG} \
--output "type=registry" ./
- name: Run Docker buildx against latest
run: |
docker buildx build \
--cache-from "type=local,src=/tmp/.buildx-cache" \
--cache-to "type=local,dest=/tmp/.buildx-cache" \
--platform linux/amd64,linux/arm64 \
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits-legend:latest \
--output "type=registry" ./
-123
View File
@@ -1,123 +0,0 @@
name: regtest
on: [push, pull_request]
jobs:
LndRestWallet:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3
- name: Setup Regtest
run: |
docker build -t lnbitsdocker/lnbits-legend .
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker
chmod +x ./tests
./tests
sudo chmod -R a+rwx .
- name: Install dependencies
run: |
poetry install
- name: Run tests
env:
PYTHONUNBUFFERED: 1
PORT: 5123
LNBITS_DATA_FOLDER: ./data
LNBITS_BACKEND_WALLET_CLASS: LndRestWallet
LND_REST_ENDPOINT: https://localhost:8081/
LND_REST_CERT: ./docker/data/lnd-1/tls.cert
LND_REST_MACAROON: ./docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
run: |
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
make test-real-wallet
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
LndWallet:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.8]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3
- name: Setup Regtest
run: |
docker build -t lnbitsdocker/lnbits-legend .
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker
chmod +x ./tests
./tests
sudo chmod -R a+rwx .
- name: Install dependencies
run: |
poetry install
poetry add grpcio protobuf
- name: Run tests
env:
PYTHONUNBUFFERED: 1
PORT: 5123
LNBITS_DATA_FOLDER: ./data
LNBITS_BACKEND_WALLET_CLASS: LndWallet
LND_GRPC_ENDPOINT: localhost
LND_GRPC_PORT: 10009
LND_GRPC_CERT: docker/data/lnd-1/tls.cert
LND_GRPC_MACAROON: docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
run: |
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
make test-real-wallet
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
CoreLightningWallet:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3
- name: Setup Regtest
run: |
docker build -t lnbitsdocker/lnbits-legend .
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker
chmod +x ./tests
./tests
sudo chmod -R a+rwx .
- name: Install dependencies
run: |
poetry install
poetry add pyln-client
- name: Run tests
env:
PYTHONUNBUFFERED: 1
PORT: 5123
LNBITS_DATA_FOLDER: ./data
LNBITS_BACKEND_WALLET_CLASS: CoreLightningWallet
CORELIGHTNING_RPC: ./docker/data/clightning-1/regtest/lightning-rpc
run: |
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
make test-real-wallet
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
-85
View File
@@ -1,85 +0,0 @@
name: tests
on: [push, pull_request]
jobs:
venv-sqlite:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
env:
VIRTUAL_ENV: ./venv
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
run: |
python -m venv ${{ env.VIRTUAL_ENV }}
./venv/bin/python -m pip install --upgrade pip
./venv/bin/pip install -r requirements.txt
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
- name: Run tests
run: make test-venv
sqlite:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3
- name: Install dependencies
env:
VIRTUAL_ENV: ./venv
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
run: |
poetry install
- name: Run tests
run: make test
postgres:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:latest
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
# maps tcp port 5432 on service container to the host
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
strategy:
matrix:
python-version: [3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3
- name: Install dependencies
run: |
poetry install
- name: Run tests
env:
LNBITS_DATABASE_URL: postgres://postgres:postgres@0.0.0.0:5432/postgres
run: make test
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
-40
View File
@@ -1,40 +0,0 @@
.DS_Store
._*
__pycache__
*.py[cod]
*$py.class
.mypy_cache
.vscode
*-lock.json
*.egg
*.egg-info
.coverage
.pytest_cache
.webassets-cache
htmlcov
test-reports
tests/data/*.sqlite3
*.swo
*.swp
*.pyo
*.pyc
*.env
.env
.venv
venv
*.sqlite3
.pyre*
__bundle__
coverage.xml
node_modules
lnbits/static/bundle.*
docker
# Nix
*result*
-12
View File
@@ -1,12 +0,0 @@
{
"semi": false,
"arrowParens": "avoid",
"insertPragma": false,
"printWidth": 80,
"proseWrap": "preserve",
"singleQuote": true,
"trailingComma": "none",
"useTabs": false,
"jsxBracketSameLine": false,
"bracketSpacing": false
}
-12
View File
@@ -1,12 +0,0 @@
FROM python:3.9-slim
RUN apt-get update
RUN apt-get install -y curl
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH"
WORKDIR /app
COPY . .
RUN poetry config virtualenvs.create false
RUN poetry install --no-dev --no-root
RUN poetry run python build.py
EXPOSE 5000
CMD ["poetry", "run", "lnbits", "--port", "5000", "--host", "0.0.0.0"]
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2022 Arc
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-76
View File
@@ -1,76 +0,0 @@
.PHONY: test
all: format check requirements.txt
format: prettier isort black
check: mypy checkprettier checkisort checkblack
prettier: $(shell find lnbits -name "*.js" -name ".html")
./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
black:
poetry run black .
mypy:
poetry run mypy
isort:
poetry run isort .
checkprettier: $(shell find lnbits -name "*.js" -name ".html")
./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
checkblack:
poetry run black --check .
checkisort:
poetry run isort --check-only .
test:
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
FAKE_WALLET_SECRET="ToTheMoon1" \
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest
test-real-wallet:
BOLTZ_NETWORK="regtest" \
BOLTZ_URL="http://127.0.0.1:9001" \
BOLTZ_MEMPOOL_SPACE_URL="http://127.0.0.1:8080" \
BOLTZ_MEMPOOL_SPACE_URL_WS="ws://127.0.0.1:8080" \
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest
test-venv:
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
FAKE_WALLET_SECRET="ToTheMoon1" \
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
./venv/bin/pytest --durations=1 -s --cov=lnbits --cov-report=xml tests
test-migration:
rm -rf ./migration-data
mkdir -p ./migration-data
unzip tests/data/mock_data.zip -d ./migration-data
HOST=0.0.0.0 \
PORT=5002 \
LNBITS_DATA_FOLDER="./migration-data" \
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
HOST=0.0.0.0 \
PORT=5002 \
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
LNBITS_DATA_FOLDER="./migration-data" \
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
poetry run python tools/conv.py
migration:
poetry run python tools/conv.py
bak:
# LNBITS_DATABASE_URL=postgres://postgres:postgres@0.0.0.0:5432/postgres
+6 -76
View File
@@ -1,81 +1,11 @@
LNbits
======
# Cashu
[![license-badge]](LICENSE)
[![docs-badge]][docs]
## Create ecash mint for pegging in/out of ecash
![Lightning network wallet](https://i.imgur.com/EHvK6Lq.png)
# LNbits v0.9 BETA, free and open-source lightning-network wallet/accounts system
### Usage
(Join us on [https://t.me/lnbits](https://t.me/lnbits))
(LNbits is beta, for responsible disclosure of any concerns please contact lnbits@pm.me)
Use [legend.lnbits.com](https://legend.lnbits.com), or run your own LNbits server!
LNbits is a very simple Python server that sits on top of any funding source, and can be used as:
* Accounts system to mitigate the risk of exposing applications to your full balance, via unique API keys for each wallet
* Extendable platform for exploring lightning-network functionality via LNbits extension framework
* Part of a development stack via LNbits API
* Fallback wallet for the LNURL scheme
* Instant wallet for LN demonstrations
LNbits can run on top of any lightning-network funding source, currently there is support for LND, c-lightning, Spark, LNpay, OpenNode, lntxbot, with more being added regularly.
See [legend.lnbits.org](https://legend.lnbits.org) for more detailed documentation.
Checkout the LNbits [YouTube](https://www.youtube.com/playlist?list=PLPj3KCksGbSYG0ciIQUWJru1dWstPHshe) video series.
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 excellent funding sources for LNbits.
## Running LNbits
See the [install guide](docs/guide/installation.md) for details on installation and setup.
## LNbits as an account system
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv + more to come..
![Lightning network wallet](https://i.imgur.com/w8jdGpF.png)
Each wallet also comes with its own API keys, to help partition the exposure of your funding source.
(LNbits M5StackSats available here https://github.com/arcbtc/M5StackSats)
![lnurl ATM](https://i.imgur.com/WfCg8wY.png)
## LNbits as an LNURL-withdraw fallback
LNURL has a fallback scheme, so if scanned by a regular QR code reader it can default to a URL. LNbits exploits this to generate an instant wallet using the [LNURL-withdraw](https://github.com/btcontract/lnurl-rfc/blob/master/lnurl-withdraw.md).
![lnurl fallback](https://i.imgur.com/CPBKHIv.png)
Using **lnbits.com/?lightning="LNURL-withdraw"** will trigger a withdraw that builds an LNbits wallet.
Example use would be an ATM, which utilises LNURL, if the user scans the QR with a regular QR code scanner app, they will still be able to access the funds.
![lnurl ATM](https://i.imgur.com/Gi6bn3L.jpg)
## LNbits as an insta-wallet
Wallets can be easily generated and given out to people at events (one click multi-wallet generation to be added soon).
"Go to this website", has a lot less friction than "Download this app".
![lnurl ATM](https://i.imgur.com/xFWDnwy.png)
## Tip us
If you like this project and might even use or extend it, why not [send some tip love](https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)!
[docs]: https://legend.lnbits.org/
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
[github-mypy]: https://github.com/lnbits/lnbits/actions?query=workflow%3Amypy
[github-mypy-badge]: https://github.com/lnbits/lnbits/workflows/mypy/badge.svg
[github-tests]: https://github.com/lnbits/lnbits/actions?query=workflow%3Atests
[github-tests-badge]: https://github.com/lnbits/lnbits/workflows/tests/badge.svg
[codecov]: https://codecov.io/gh/lnbits/lnbits
[codecov-badge]: https://codecov.io/gh/lnbits/lnbits/branch/master/graph/badge.svg
[license-badge]: https://img.shields.io/badge/license-MIT-blue.svg
1. Enable extension
2. Create a Mint
3. Share wallet
+47
View File
@@ -0,0 +1,47 @@
import asyncio
from environs import Env
from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_cashu")
cashu_static_files = [
{
"path": "/cashu/static",
"app": StaticFiles(directory="lnbits/extensions/cashu/static"),
"name": "cashu_static",
}
]
from cashu.mint.ledger import Ledger
env = Env()
env.read_env()
ledger = Ledger(
db=db,
seed=env.str("CASHU_PRIVATE_KEY", default="SuperSecretPrivateKey"),
derivation_path="0/0/0/1",
)
cashu_ext: APIRouter = APIRouter(prefix="/cashu", tags=["cashu"])
def cashu_renderer():
return template_renderer(["lnbits/extensions/cashu/templates"])
from .tasks import startup_cashu_mint, wait_for_paid_invoices
from .views import * # noqa: F401,F403
from .views_api import * # noqa: F401,F403
def cashu_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(startup_cashu_mint))
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
-105
View File
@@ -1,105 +0,0 @@
import glob
import os
import subprocess
import warnings
from os import path
from pathlib import Path
from typing import Any, List, NamedTuple, Optional
LNBITS_PATH = path.dirname(path.realpath(__file__)) + "/lnbits"
def get_js_vendored(prefer_minified: bool = False) -> List[str]:
paths = get_vendored(".js", prefer_minified)
def sorter(key: str):
if "moment@" in key:
return 1
if "vue@" in key:
return 2
if "vue-router@" in key:
return 3
if "polyfills" in key:
return 4
return 9
return sorted(paths, key=sorter)
def get_css_vendored(prefer_minified: bool = False) -> List[str]:
paths = get_vendored(".css", prefer_minified)
def sorter(key: str):
if "quasar@" in key:
return 1
if "vue@" in key:
return 2
if "chart.js@" in key:
return 100
return 9
return sorted(paths, key=sorter)
def get_vendored(ext: str, prefer_minified: bool = False) -> List[str]:
paths: List[str] = []
for path in glob.glob(
os.path.join(LNBITS_PATH, "static/vendor/**"), recursive=True
):
if path.endswith(".min" + ext):
# path is minified
unminified = path.replace(".min" + ext, ext)
if prefer_minified:
paths.append(path)
if unminified in paths:
paths.remove(unminified)
elif unminified not in paths:
paths.append(path)
elif path.endswith(ext):
# path is not minified
minified = path.replace(ext, ".min" + ext)
if not prefer_minified:
paths.append(path)
if minified in paths:
paths.remove(minified)
elif minified not in paths:
paths.append(path)
return sorted(paths)
def url_for_vendored(abspath: str) -> str:
return "/" + os.path.relpath(abspath, LNBITS_PATH)
def transpile_scss():
with warnings.catch_warnings():
warnings.simplefilter("ignore")
from scss.compiler import compile_string # type: ignore
with open(os.path.join(LNBITS_PATH, "static/scss/base.scss")) as scss:
with open(os.path.join(LNBITS_PATH, "static/css/base.css"), "w") as css:
css.write(compile_string(scss.read()))
def bundle_vendored():
for getfiles, outputpath in [
(get_js_vendored, os.path.join(LNBITS_PATH, "static/bundle.js")),
(get_css_vendored, os.path.join(LNBITS_PATH, "static/bundle.css")),
]:
output = ""
for path in getfiles():
with open(path) as f:
output += "/* " + url_for_vendored(path) + " */\n" + f.read() + ";\n"
with open(outputpath, "w") as f:
f.write(output)
def build():
transpile_scss()
bundle_vendored()
if __name__ == "__main__":
build()
+7
View File
@@ -0,0 +1,7 @@
{
"name": "Cashu",
"short_description": "Ecash mint and wallet",
"tile": "/cashu/static/image/cashu.png",
"contributors": ["calle", "vlad", "arcbtc"],
"hidden": false
}
+51
View File
@@ -0,0 +1,51 @@
from typing import List, Optional, Union
from . import db
from .models import Cashu
async def create_cashu(
cashu_id: str, keyset_id: str, wallet_id: str, data: Cashu
) -> Cashu:
await db.execute(
"""
INSERT INTO cashu.cashu (id, wallet, name, tickershort, fraction, maxsats, coins, keyset_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
cashu_id,
wallet_id,
data.name,
data.tickershort,
data.fraction,
data.maxsats,
data.coins,
keyset_id,
),
)
cashu = await get_cashu(cashu_id)
assert cashu, "Newly created cashu couldn't be retrieved"
return cashu
async def get_cashu(cashu_id) -> Optional[Cashu]:
row = await db.fetchone("SELECT * FROM cashu.cashu WHERE id = ?", (cashu_id,))
return Cashu(**row) if row else None
async def get_cashus(wallet_ids: Union[str, List[str]]) -> List[Cashu]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM cashu.cashu WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Cashu(**row) for row in rows]
async def delete_cashu(cashu_id) -> None:
await db.execute("DELETE FROM cashu.cashu WHERE id = ?", (cashu_id,))
-5
View File
@@ -1,5 +0,0 @@
_site
.sass-cache
.jekyll-cache
.jekyll-metadata
vendor
-1
View File
@@ -1 +0,0 @@
legend.lnbits.org
-11
View File
@@ -1,11 +0,0 @@
title: "LNbits docs"
remote_theme: pmarsceill/just-the-docs
logo: "/logos/lnbits-full.png"
search_enabled: true
url: https://legend.lnbits.org
aux_links:
"LNbits on GitHub":
- "//github.com/lnbits/lnbits"
"lnbits.com":
- "//lnbits.com"
-12
View File
@@ -1,12 +0,0 @@
---
layout: default
parent: For developers
title: API reference
nav_order: 3
---
API reference
=============
[Swagger Docs](https://legend.lnbits.org/devs/swagger.html)
-42
View File
@@ -1,42 +0,0 @@
---
layout: default
title: For developers
nav_order: 4
has_children: true
---
For developers
==============
Thanks for contributing :)
Tests
=====
This project has unit tests that help prevent regressions. Before you can run the tests, you must install a few dependencies:
```bash
poetry install
npm i
```
Then to run the tests:
```bash
make test
```
Run formatting:
```bash
make format
```
Run mypy checks:
```bash
poetry run mypy
```
Run everything:
```bash
make all
```
-72
View File
@@ -1,72 +0,0 @@
---
layout: default
parent: For developers
title: Making extensions
nav_order: 2
---
Making extensions
=================
Start off by copying the example extension in `lnbits/extensions/example` into your own:
```sh
cp lnbits/extensions/example lnbits/extensions/mysuperplugin -r # Let's not use dashes or anything; it doesn't like those.
cd lnbits/extensions/mysuperplugin
find . -type f -print0 | xargs -0 sed -i 's/example/mysuperplugin/g' # Change all occurrences of 'example' to your plugin name 'mysuperplugin'.
```
- if you are on macOS and having difficulty with 'sed', consider `brew install gnu-sed` and use 'gsed', without -0 option after xargs.
Going over the example extension's structure:
* views_api.py: This is where your public API would go. It will be exposed at "$DOMAIN/$PLUGIN/$ROUTE". For example: https://lnbits.com/mysuperplugin/api/v1/tools.
* views.py: The `/` path will show up as your plugin's home page in lnbits' UI. Other pages you can define yourself. The `templates` folder should explain itself in relation to this.
* migrations.py: Create database tables for your plugin. They'll be created automatically when you start lnbits.
... This document is a work-in-progress. Send pull requests if you get stuck, so others don't.
Adding new dependencies
-----------------------
If for some reason your extensions needs a new python package to work, you can add a new package using `venv`, or `poerty`:
```sh
$ poetry add <package>
# or
$ ./venv/bin/pip install <package>
```
**But we need an extra step to make sure LNbits doesn't break in production.**
Dependencies need to be added to `pyproject.toml` and `requirements.txt`, then tested by running on `venv` and `poetry`.
`nix` compatability can be tested with `nix build .#checks.x86_64-linux.vmTest`.
SQLite to PostgreSQL migration
-----------------------
LNbits currently supports SQLite and PostgreSQL databases. There is a migration script `tools/conv.py` that helps users migrate from SQLite to PostgreSQL. This script also copies all extension databases to the new backend.
### Adding mock data to `mock_data.zip`
`mock_data.zip` contains a few lines of sample SQLite data and is used in automated GitHub test to see whether your migration in `conv.py` works. Run your extension and save a few lines of data into a SQLite `your_extension.sqlite3` file. Unzip `tests/data/mock_data.zip`, add `your_extension.sqlite3`, updated `database.sqlite3` and zip it again. Add the updated `mock_data.zip` to your PR.
### running migration locally
you will need a running postgres database
#### create lnbits user for migration database
```console
sudo su - postgres -c "psql -c 'CREATE ROLE lnbits LOGIN PASSWORD 'lnbits';'"
```
#### create migration database
```console
sudo su - postgres -c "psql -c 'CREATE DATABASE migration;'"
```
#### run the migration
```console
make test-migration
```
sudo su - postgres -c "psql -c 'CREATE ROLE lnbits LOGIN PASSWORD 'lnbits';'"
#### clean migration database afterwards, fails if you try again
```console
sudo su - postgres -c "psql -c 'DROP DATABASE IF EXISTS migration;'"
```
-29
View File
@@ -1,29 +0,0 @@
<html>
<head>
<!-- Load the latest Swagger UI code and style from npm using unpkg.com -->
<script src="https://unpkg.com/swagger-ui-dist@3/swagger-ui-bundle.js"></script>
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@3/swagger-ui.css"/>
<title>My New API</title>
</head>
<body>
<div id="swagger-ui"></div> <!-- Div to hold the UI component -->
<script>
window.onload = function () {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
url: "https://legend.lnbits.com/openapi.json", //Location of Open API spec in the repo
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
})
window.ui = ui
}
</script>
</body>
</html>
-122
View File
@@ -1,122 +0,0 @@
## Defining a route with path parameters
**old:**
```python
# with <>
@offlineshop_ext.route("/lnurl/<item_id>", methods=["GET"])
```
**new:**
```python
# with curly braces: {}
@offlineshop_ext.get("/lnurl/{item_id}")
```
## Check if a user exists and access user object
**old:**
```python
# decorators
@check_user_exists()
async def do_routing_stuff():
pass
```
**new:**
If user doesn't exist, `Depends(check_user_exists)` will raise an exception.
If user exists, `user` will be the user object
```python
# depends calls
@core_html_routes.get("/my_route")
async def extensions(user: User = Depends(check_user_exists)):
pass
```
## Returning data from API calls
**old:**
```python
return (
{
"id": wallet.wallet.id,
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat
},
HTTPStatus.OK,
)
```
FastAPI returns `HTTPStatus.OK` by default id no Exception is raised
**new:**
```python
return {
"id": wallet.wallet.id,
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat
}
```
To change the default HTTPStatus, add it to the path decorator
```python
@core_app.post("/api/v1/payments", status_code=HTTPStatus.CREATED)
async def payments():
pass
```
## Raise exceptions
**old:**
```python
return (
{"message": f"Failed to connect to {domain}."},
HTTPStatus.BAD_REQUEST,
)
# or the Quart way via abort function
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
```
**new:**
Raise an exception to return a status code other than the default status code.
```python
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to connect to {domain}."
)
```
## Extensions
**old:**
```python
from quart import Blueprint
amilk_ext: Blueprint = Blueprint(
"amilk", __name__, static_folder="static", template_folder="templates"
)
```
**new:**
```python
from fastapi import APIRouter
from lnbits.jinja2_templating import Jinja2Templates
from lnbits.helpers import template_renderer
from fastapi.staticfiles import StaticFiles
offlineshop_ext: APIRouter = APIRouter(
prefix="/Extension",
tags=["Offlineshop"]
)
offlineshop_ext.mount(
"lnbits/extensions/offlineshop/static",
StaticFiles("lnbits/extensions/offlineshop/static")
)
offlineshop_rndr = template_renderer([
"lnbits/extensions/offlineshop/templates",
])
```
## Possible optimizations
### Use Redis as a cache server
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.
Example:
* Get transactions for a wallet ID
* User data for a user id
* Wallet data for a Admin / Invoice key
-402
View File
@@ -1,402 +0,0 @@
---
layout: default
title: Basic installation
nav_order: 2
---
# Basic installation
You can choose between four package managers, `poetry`, `nix` and `venv`.
By default, LNbits will use SQLite as its database. You can also use PostgreSQL which is recommended for applications with a high load (see guide below).
## Option 1 (recommended): poetry
```sh
git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend/
# for making sure python 3.9 is installed, skip if installed
sudo apt update
sudo apt install software-properties-common
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt install python3.9 python3.9-distutils
curl -sSL https://install.python-poetry.org | python3 -
export PATH="/home/ubuntu/.local/bin:$PATH" # or whatever is suggested in the poetry install notes printed to terminal
poetry env use python3.9
poetry install --no-dev
mkdir data
cp .env.example .env
sudo nano .env # set funding source
```
#### Running the server
```sh
poetry run lnbits
# To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0'
```
## Option 2: Nix
```sh
git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend/
# Modern debian distros usually include Nix, however you can install with:
# 'sh <(curl -L https://nixos.org/nix/install) --daemon', or use setup here https://nixos.org/download.html#nix-verify-installation
nix build .#lnbits
mkdir data
```
#### Running the server
```sh
# .env variables are currently passed when running
LNBITS_DATA_FOLDER=data LNBITS_BACKEND_WALLET_CLASS=LNbitsWallet LNBITS_ENDPOINT=https://legend.lnbits.com LNBITS_KEY=7b1a78d6c78f48b09a202f2dcb2d22eb ./result/bin/lnbits --port 9000
```
## Option 3: venv
```sh
git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend/
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3-venv'
python3 -m venv venv
# If you have problems here, try `sudo apt install -y pkg-config libpq-dev`
./venv/bin/pip install -r requirements.txt
# create the data folder and the .env file
mkdir data && cp .env.example .env
# build the static files
./venv/bin/python build.py
```
#### Running the server
```sh
./venv/bin/uvicorn lnbits.__main__:app --port 5000
```
If you want to host LNbits on the internet, run with the option `--host 0.0.0.0`.
## Option 4: Docker
```sh
git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend
docker build -t lnbits-legend .
cp .env.example .env
mkdir data
docker run --detach --publish 5000:5000 --name lnbits-legend --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits-legend
```
## Option 5: Fly.io
Fly.io is a docker container hosting platform that has a generous free tier. You can host LNBits for free on Fly.io for personal use.
First, sign up for an account at [Fly.io](https://fly.io) (no credit card required).
Then, install the Fly.io CLI onto your device [here](https://fly.io/docs/getting-started/installing-flyctl/).
After install is complete, the command will output a command you should copy/paste/run to get `fly` into your `$PATH`. Something like:
```
flyctl was installed successfully to /home/ubuntu/.fly/bin/flyctl
Manually add the directory to your $HOME/.bash_profile (or similar)
export FLYCTL_INSTALL="/home/ubuntu/.fly"
export PATH="$FLYCTL_INSTALL/bin:$PATH"
```
You can either run those commands, then `source ~/.bash_profile` or, if you don't, you'll have to call Fly from `~/.fly/bin/flyctl`.
Once installed, run the following commands.
```
git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend
fly auth login
[complete login process]
fly launch
```
You'll be prompted to enter an app name, region, postgres (choose no), deploy now (choose no).
You'll now find a file in the directory called `fly.toml`. Open that file and modify/add the following settings.
Note: Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`. Environment variable strings should be quoted here, so if in `.env` you have `LNBITS_ENDPOINT=https://legend.lnbits.com` in `fly.toml` you should have `LNBITS_ENDPOINT="https://legend.lnbits.com"`.
Note: Don't enter secret environment variables here. Fly.io offers secrets (via the `fly secrets` command) that are exposed as environment variables in your runtime. So, for example, if using the LND_REST funding source, you can run `fly secrets set LND_REST_MACAROON=<hex_macaroon_data>`.
```
...
kill_timeout = 30
...
...
[mounts]
source="lnbits_data"
destination="/data"
...
...
[env]
HOST="127.0.0.1"
PORT=5000
LNBITS_FORCE_HTTPS=true
LNBITS_DATA_FOLDER="/data"
${PUT_YOUR_LNBITS_ENV_VARS_HERE}
...
...
[[services]]
internal_port = 5000
...
```
Next, create a volume to store the sqlite database for LNBits. Be sure to choose the same region for the volume that you chose earlier.
```
fly volumes create lnbits_data --size 1
```
You're ready to deploy! Run `fly deploy` and follow the steps to finish deployment. You'll select a `region` (up to you, choose the same as you did for the storage volume previously created), `postgres` (choose no), `deploy` (choose yes).
You can use `fly logs` to view the application logs, or `fly ssh console` to get a ssh shell in the running container.
### Troubleshooting
Problems installing? These commands have helped us install LNbits.
```sh
sudo apt install pkg-config libffi-dev libpq-dev
# if the secp256k1 build fails:
# if you used venv
./venv/bin/pip install setuptools wheel
# if you used poetry
poetry add setuptools wheel
# build essentials for debian/ubuntu
sudo apt install python3-dev gcc build-essential
```
### Optional: PostgreSQL database
If you want to use LNbits at scale, we recommend using PostgreSQL as the backend database. Install Postgres and setup a database for LNbits:
```sh
# on debian/ubuntu 'sudo apt-get -y install postgresql'
# or follow instructions at https://www.postgresql.org/download/linux/
# Postgres doesn't have a default password, so we'll create one.
sudo -i -u postgres
psql
# on psql
ALTER USER postgres PASSWORD 'myPassword'; # choose whatever password you want
\q
# on postgres user
createdb lnbits
exit
```
You need to edit the `.env` file.
```sh
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
# postgres://<user>:<myPassword>@<host>/<lnbits> - alter line bellow with your user, password and db name
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
# save and exit
```
# Using LNbits
Now you can visit your LNbits at http://localhost:5000/.
Now modify the `.env` file with any settings you prefer and add a proper [funding source](./wallets.md) by modifying the value of `LNBITS_BACKEND_WALLET_CLASS` and providing the extra information and credentials related to the chosen funding source.
Then you can restart it and it will be using the new settings.
You might also need to install additional packages or perform additional setup steps, depending on the chosen backend. See [the short guide](./wallets.md) on each different funding source.
Take a look at [Polar](https://lightningpolar.com/) for an excellent way of spinning up a Lightning Network dev environment.
# Additional guides
## SQLite to PostgreSQL migration
If you already have LNbits installed and running, on an SQLite database, we **highly** recommend you migrate to postgres if you are planning to run LNbits on scale.
There's a script included that can do the migration easy. You should have Postgres already installed and there should be a password for the user (see Postgres install guide above). Additionally, your LNbits instance should run once on postgres to implement the database schema before the migration works:
```sh
# STOP LNbits
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
# postgres://<user>:<password>@<host>/<database> - alter line bellow with your user, password and db name
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
# save and exit
# START LNbits
# STOP LNbits
poetry run python tools/conv.py
# or
make migration
```
Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly.
## LNbits as a systemd service
Systemd is great for taking care of your LNbits instance. It will start it on boot and restart it in case it crashes. If you want to run LNbits as a systemd service on your Debian/Ubuntu/Raspbian server, create a file at `/etc/systemd/system/lnbits.service` with the following content:
```
# Systemd unit for lnbits
# /etc/systemd/system/lnbits.service
[Unit]
Description=LNbits
# you can uncomment these lines if you know what you're doing
# it will make sure that lnbits starts after lnd (replace with your own backend service)
#Wants=lnd.service
#After=lnd.service
[Service]
# replace with the absolute path of your lnbits installation
WorkingDirectory=/home/lnbits/lnbits-legend
# same here. run `which poetry` if you can't find the poetry binary
ExecStart=/home/lnbits/.local/bin/poetry run lnbits
# replace with the user that you're running lnbits on
User=lnbits
Restart=always
TimeoutSec=120
RestartSec=30
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
```
Save the file and run the following commands:
```sh
sudo systemctl enable lnbits.service
sudo systemctl start lnbits.service
```
## Running behind an apache2 reverse proxy over https
Install apache2 and enable apache2 mods
```sh
apt-get install apache2 certbot
a2enmod headers ssl proxy proxy-http
```
create a ssl certificate with letsencrypt
```sh
certbot certonly --webroot --agree-tos --text --non-interactive --webroot-path /var/www/html -d lnbits.org
```
create a apache2 vhost at: /etc/apache2/sites-enabled/lnbits.conf
```sh
cat <<EOF > /etc/apache2/sites-enabled/lnbits.conf
<VirtualHost *:443>
ServerName lnbits.org
SSLEngine On
SSLProxyEngine On
SSLCertificateFile /etc/letsencrypt/live/lnbits.org/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/lnbits.org/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
LogLevel info
ErrorLog /var/log/apache2/lnbits.log
CustomLog /var/log/apache2/lnbits-access.log combined
RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME}
RequestHeader set "X-Forwarded-SSL" expr=%{HTTPS}
ProxyPreserveHost On
ProxyPass / http://localhost:5000/
ProxyPassReverse / http://localhost:5000/
<Proxy *>
Order deny,allow
Allow from all
</Proxy>
</VirtualHost>
EOF
```
restart apache2
```sh
service restart apache2
```
## Using https without reverse proxy
The most common way of using LNbits via https is to use a reverse proxy such as Caddy, nginx, or ngriok. However, you can also run LNbits via https without additional software. This is useful for development purposes or if you want to use LNbits in your local network.
We have to create a self-signed certificate using `mkcert`. Note that this certiciate is not "trusted" by most browsers but that's fine (since you know that you have created it) and encryption is always better than clear text.
#### Install mkcert
You can find the install instructions for `mkcert` [here](https://github.com/FiloSottile/mkcert).
Install mkcert on Ubuntu:
```sh
sudo apt install libnss3-tools
curl -JLO "https://dl.filippo.io/mkcert/latest?for=linux/amd64"
chmod +x mkcert-v*-linux-amd64
sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert
```
#### Create certificate
To create a certificate, first `cd` into your LNbits folder and execute the following command on Linux:
```sh
openssl req -new -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out cert.pem -keyout key.pem
```
This will create two new files (`key.pem` and `cert.pem `).
Alternatively, you can use mkcert ([more info](https://kifarunix.com/how-to-create-self-signed-ssl-certificate-with-mkcert-on-ubuntu-18-04/)):
```sh
# add your local IP (192.x.x.x) as well if you want to use it in your local network
mkcert localhost 127.0.0.1 ::1
```
You can then pass the certificate files to uvicorn when you start LNbits:
```sh
./venv/bin/uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./key.pem --ssl-certfile ./cert.pem
```
## LNbits running on Umbrel behind Tor
If you want to run LNbits on your Umbrel but want it to be reached through clearnet, _Uxellodunum_ made an extensive [guide](https://community.getumbrel.com/t/guide-lnbits-without-tor/604) on how to do it.
## Docker installation
To install using docker you first need to build the docker image as:
```
git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend
docker build -t lnbits-legend .
```
You can launch the docker in a different directory, but make sure to copy `.env.example` from lnbits there
```
cp <lnbits_repo>/.env.example .env
```
and change the configuration in `.env` as required.
Then create the data directory
```
mkdir data
```
Then the image can be run as:
```
docker run --detach --publish 5000:5000 --name lnbits-legend -e "LNBITS_BACKEND_WALLET_CLASS='FakeWallet'" --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits-legend
```
Finally you can access your lnbits on your machine at port 5000.
-85
View File
@@ -1,85 +0,0 @@
---
layout: default
title: Backend wallets
nav_order: 3
---
Backend wallets
===============
LNbits can run on top of many lightning-network funding sources. Currently there is support for CoreLightning, LND, LNbits, LNPay, lntxbot and OpenNode, with more being added regularly.
A backend wallet can be configured using the following LNbits environment variables:
### CoreLightning
Using this wallet requires the installation of the `pylightning` Python package.
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
- `CORELIGHTNING_RPC`: /file/path/lightning-rpc
### Spark (c-lightning)
- `LNBITS_BACKEND_WALLET_CLASS`: **SparkWallet**
- `SPARK_URL`: http://10.147.17.230:9737/rpc
- `SPARK_TOKEN`: secret_access_key
### LND (REST)
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
- `LND_REST_CERT`: /file/path/tls.cert
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
or
- `LND_REST_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
### LND (gRPC)
Using this wallet requires the installation of the `grpcio` and `protobuf` Python packages.
- `LNBITS_BACKEND_WALLET_CLASS`: **LndWallet**
- `LND_GRPC_ENDPOINT`: ip_address
- `LND_GRPC_PORT`: port
- `LND_GRPC_CERT`: /file/path/tls.cert
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
You can also use an AES-encrypted macaroon (more info) instead by using
- `LND_GRPC_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
To encrypt your macaroon, run `./venv/bin/python lnbits/wallets/macaroon/macaroon.py`.
### LNbits
- `LNBITS_BACKEND_WALLET_CLASS`: **LNbitsWallet**
- `LNBITS_ENDPOINT`: e.g. https://lnbits.com
- `LNBITS_KEY`: lnbitsAdminKey
### LNPay
For the invoice listener to work you have a publicly accessible URL in your LNbits and must set up [LNPay webhooks](https://dashboard.lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the "Wallet Receive" event and no secret. For example, `https://mylnbits/wallet/webhook` will be the Endpoint Url that gets notified about the payment.
- `LNBITS_BACKEND_WALLET_CLASS`: **LNPayWallet**
- `LNPAY_API_ENDPOINT`: https://api.lnpay.co/v1/
- `LNPAY_API_KEY`: sak_apiKey
- `LNPAY_WALLET_KEY`: waka_apiKey
### lntxbot
- `LNBITS_BACKEND_WALLET_CLASS`: **LntxbotWallet**
- `LNTXBOT_API_ENDPOINT`: https://lntxbot.com/
- `LNTXBOT_KEY`: lntxbotAdminApiKey
### OpenNode
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary.
- `LNBITS_BACKEND_WALLET_CLASS`: **OpenNodeWallet**
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
- `OPENNODE_KEY`: opennodeAdminApiKey
-24
View File
@@ -1,24 +0,0 @@
---
layout: default
title: Users Guide
nav_order: 1
---
LNbits, free and open-source lightning-network wallet/accounts system
=====================================================================
LNbits is a very simple Python application that sits on top of any funding source, and can be used as:
* Accounts system to mitigate the risk of exposing applications to your full balance, via unique API keys for each wallet
* Extendable platform for exploring lightning-network functionality via LNbits extension framework
* Part of a development stack via LNbits API
* Fallback wallet for the LNURL scheme
* Instant wallet for LN demonstrations
LNbits as an account system
---------------------------
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending,
export to csv + more to come...
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 101.041 30.0001"><defs><path id="a" d="M33.2619 148.1667h154.2143v68.7917H33.2619z"/></defs><g fill="#1f2234" aria-label="LNbits" font-family="sans-serif" font-size=".3095" font-weight="400" letter-spacing=".0031" style="line-height:1.25;white-space:pre;shape-inside:url(#a)" transform="matrix(72.4607 0 0 72.4607 -2399.2814 -10741.3589)"><g transform="matrix(.00244 0 0 .00244 33.0708 148.1594)"><circle cx="101.2976" cy="116.4167" r="84.6667" fill="#673ab7" fill-rule="evenodd"/><path fill="#eee" d="M79.1105 71.9667v49.0613h13.3803v40.141l31.2208-53.5213h-17.8404l17.8404-35.681z"/></g><g fill="#eee" font-family="roboto"><path d="M33.6755 148.518h.0962v.0364h-.1416v-.22h.0454zM33.9796 148.5544h-.0453l-.0883-.1448v.1448h-.0453v-.22h.0453l.0884.145v-.145h.0452z" font-weight="700" style="-inkscape-font-specification:'roboto Bold'"/><path d="M34.1625 148.4744q0 .0375-.0172.0603-.0172.0227-.0462.0227-.031 0-.048-.0219l-.0014.019h-.0256v-.2322h.028v.0866q.0168-.021.0466-.021.0298 0 .0467.0225.017.0225.017.0617zm-.028-.003q0-.0286-.011-.044-.011-.0157-.0317-.0157-.0277 0-.0398.0257v.0707q.0129.0257.04.0257.0202 0 .0314-.0156.0112-.0156.0112-.0469zM34.231 148.5544h-.028v-.1635h.028zm-.0302-.2069q0-.007.0041-.0115.0042-.005.0124-.005.0082 0 .0124.005.0042.005.0042.0115 0 .007-.0042.0113-.0042.005-.0124.005-.0082 0-.0124-.005-.004-.005-.004-.0113zM34.3167 148.3513v.0396h.0305v.0216h-.0305v.1014q0 .01.004.0148.0042.005.014.005.0048 0 .0133-.002v.0227q-.011.003-.0215.003-.0187 0-.0283-.0113-.0095-.0113-.0095-.0322v-.1014h-.0297v-.0216h.0297v-.0396zM34.478 148.511q0-.0113-.0086-.0175-.0085-.006-.0298-.0109-.0211-.005-.0337-.0109-.0124-.006-.0184-.015t-.006-.0209q0-.02.017-.034.017-.0139.0435-.0139.0278 0 .045.0144.0174.0144.0174.0367h-.028q0-.0115-.0099-.0198-.0097-.008-.0245-.008-.0152 0-.0238.007-.0086.007-.0086.0174 0 .0101.008.0153.008.005.0288.01.021.005.034.0112.013.007.0192.0157.0063.009.0063.0222 0 .022-.0175.0352-.0175.0131-.0455.0131-.0197 0-.0348-.007-.015-.007-.0237-.0193-.0085-.0125-.0085-.027h.028q.0007.014.0111.0223.0106.008.0279.008.0158 0 .0253-.006.0097-.006.0097-.0172z" style="-inkscape-font-specification:roboto"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 101.041 30.0001"><defs><path id="a" d="M33.2619 148.1667h154.2143v68.7917H33.2619z"/></defs><g fill="#1f2234" aria-label="LNbits" font-family="sans-serif" font-size=".3095" font-weight="400" letter-spacing=".0031" style="line-height:1.25;white-space:pre;shape-inside:url(#a)" transform="matrix(72.4607 0 0 72.4607 -2399.2814 -10741.3589)"><g transform="matrix(.00244 0 0 .00244 33.0708 148.1594)"><circle cx="101.2976" cy="116.4167" r="84.6667" fill="#673ab7" fill-rule="evenodd"/><path fill="#eee" d="M79.1105 71.9667v49.0613h13.3803v40.141l31.2208-53.5213h-17.8404l17.8404-35.681z"/></g><g font-family="roboto"><path d="M33.6755 148.518h.0962v.0364h-.1416v-.22h.0454zM33.9796 148.5544h-.0453l-.0883-.1448v.1448h-.0453v-.22h.0453l.0884.145v-.145h.0452z" font-weight="700" style="-inkscape-font-specification:'roboto Bold'"/><path d="M34.1625 148.4744q0 .0375-.0172.0603-.0172.0227-.0462.0227-.031 0-.048-.0219l-.0014.019h-.0256v-.2322h.028v.0866q.0168-.021.0466-.021.0298 0 .0467.0225.017.0225.017.0617zm-.028-.003q0-.0286-.011-.044-.011-.0157-.0317-.0157-.0277 0-.0398.0257v.0707q.0129.0257.04.0257.0202 0 .0314-.0156.0112-.0156.0112-.0469zM34.231 148.5544h-.028v-.1635h.028zm-.0302-.2069q0-.007.0041-.0115.0042-.005.0124-.005.0082 0 .0124.005.0042.005.0042.0115 0 .007-.0042.0113-.0042.005-.0124.005-.0082 0-.0124-.005-.004-.005-.004-.0113zM34.3167 148.3513v.0396h.0305v.0216h-.0305v.1014q0 .01.004.0148.0042.005.014.005.0048 0 .0133-.002v.0227q-.011.003-.0215.003-.0187 0-.0283-.0113-.0095-.0113-.0095-.0322v-.1014h-.0297v-.0216h.0297v-.0396zM34.478 148.511q0-.0113-.0086-.0175-.0085-.006-.0298-.0109-.0211-.005-.0337-.0109-.0124-.006-.0184-.015t-.006-.0209q0-.02.017-.034.017-.0139.0435-.0139.0278 0 .045.0144.0174.0144.0174.0367h-.028q0-.0115-.0099-.0198-.0097-.008-.0245-.008-.0152 0-.0238.007-.0086.007-.0086.0174 0 .0101.008.0153.008.005.0288.01.021.005.034.0112.013.007.0192.0157.0063.009.0063.0222 0 .022-.0175.0352-.0175.0131-.0455.0131-.0197 0-.0348-.007-.015-.007-.0237-.0193-.0085-.0125-.0085-.027h.028q.0007.014.0111.0223.0106.008.0279.008.0158 0 .0253-.006.0097-.006.0097-.0172z" style="-inkscape-font-specification:roboto"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><g transform="translate(-2.9464 -5.625) scale(.17717)"><circle cx="101.2976" cy="116.4167" r="84.6667" fill="#673ab7" fill-rule="evenodd"/><path fill="#eee" d="M79.1105 71.9667v49.0613h13.3803v40.141l31.2208-53.5213h-17.8404l17.8404-35.681z"/></g></svg>

Before

Width:  |  Height:  |  Size: 314 B

+28
View File
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCqdYhs1zok/3Es
uSycCn0YKGXaXO9Vbhb1Aol1vzYc0UkQseDrGCiEF/0D5Vol6n3aqsZabKfQC46e
ylEs+HWUYStsDApkSsyrlT+38nNYFtbCGNzf3lFqJxaOKrelCmhiYc3vzJc4yHpp
OaWtuWDlx3bWnJSMi8VF6UkqAtZrlLTrx45Wd/X2lPh5V3hHbwQy+BJ0kGOJuNWO
iMg4PUtaCVa8QQQSSdbXzLeMnkDXJRgGr6tEaFbZWCmQBOYIrmYcXTa4xSg+SaLa
fBqGGY5CFa2mnGarhQ9I6/Or7KES4CgyIewuJSC4VZQ9WNFTeWRJmwF59LQIB1B+
Su61Ds5vAgMBAAECggEAUTUJN4EZzEJ9smbNn9FHY1jrnSolQzfdbAA3aIcdyoDS
JEf59UZfFv8upMqBNgsOxjwRn0agc/btjSypm5kKuxPH0gOyd7FC7IOABaxV0yOe
UH3zzBtoAgB5OGWVc+UPcb9SkumPKZgo6TRzf94PEakDLp8ODh8QbowDNSaA7GL2
FXvSgcxtLFv24/wMj7MPtUJDPPzhzRafe097RAJKi9ig33GpiohVg7HWxBEt/m2q
WPJqV1izIYVuyUV1NN2qV/7KP35vqGu3sMyuKjmz33+7G5OUzxoixFp1hx1iQwNM
QmzrNjKgGKPOvI169K73LtT6GfcAcVyvudPioJi2oQKBgQDlvEg79bXuV72OMkDI
LINTk0Pncs5fJMhsPrbhvAbi8C9qH3j/e3uh4/f/RZVy8PZFe2YsmJo6cE60t445
oejGpj0L9gB+ptWQa+6tJ0vO2DFrGz7siBDQDK08bBfE0FRAYYH1oy81oZihAwh/
ypUWgxAAkFIDG1b8IYozUwlgaQKBgQC98mWG1ypNFDKU5ffFmxzCermXhe4sIOny
E9Na51lMuzp1HVOHqYio27KGWB9NhFnhCRdzq2csJrdL5GqNBzF3mVc7Wp1QA9Iz
GAN1BWYsObtffmePI73UOtdOf3vWRWt9xFDw07CuPUJoZLxCO8DE2R8TfBEZpMVx
L99xZ4ldFwKBgCCCZk5l1Nrne6F1pwksk2DuQ/uqmV2JXPtEXKjQ7MCMcIk57uto
U/RyyBNNi3p6cr8eWTUSWzDN17VnDC9HpCMIPzdEFonVnf8T5vCF0QpyOXwInPzn
vElUoecc/LgTKvj5U2SpNPMH7QtL3JYI2tQWs9sAkTpzUfnX3TCqHXeRAoGAGeEJ
phpwn/nNfm3qmRiuDW0tFhDgR1DgruO2mx8EEqk7qLMrK0OU4/Sy2QzujuAmYrub
X14N+3z24jcnsTk2qyXQNbz9y+YJYH64Edx/dC0Vc0hEkcL73Qt7RC2oUKQ6pfL2
WClvW/3qq1B2OcvsUqWGT2BK8CR75hTwq4HAvFcCgYB0Cuv/cHo0iLzvCX2LwIiq
N38udO9oYa2VV/Ny4lf/oWFyLuL2syNJnW6fhqZk3ACVbIxQ4t85IJ+kKSKgSBXV
9p3kAo93/sP+Qd3yWK/2kc8fwQXPQWhEynmS0c2LmfmA6h+JlPUG+HHhXwEMcplc
iuvTCsY3KzPtBZgm8hFIKg==
-----END PRIVATE KEY-----
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 828 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
background: #350a60;
margin: 0;
width: 380px;
height: 595px;
}
iframe {
width: 100%;
height: 100%;
border: 0;
}
</style>
</head>
<body>
<iframe
id="iframe"
src="https://cashu.calle.wtf:5001/cashu/wallet?mint_id=W358Nzvmb7iuQpt6mXArKx"
></iframe>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
{
"description": "Cashu ecash wallet",
"icons": {"128": "128.png", "32": "32.png", "16": "16.png"},
"manifest_version": 3,
"name": "Cashu",
"version": "0.1",
"action": {
"default_icon": "32.png",
"default_popup": "embedder.html",
"default_title": "Cashu"
},
"permissions": [
"unlimitedStorage",
"storage"
],
"content_security_policy": {
"extension_pages": "default-src 'self'"
}
}
Generated
-77
View File
@@ -1,77 +0,0 @@
{
"nodes": {
"flake-utils": {
"locked": {
"lastModified": 1656928814,
"narHash": "sha256-RIFfgBuKz6Hp89yRr7+NR5tzIAbn52h8vT6vXkYjZoM=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "7e2a3b3dfd9af950a856d66b0a7d01e3c18aa249",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1657114324,
"narHash": "sha256-fWuaUNXrHcz/ciHRHlcSO92dvV3EVS0GJQUSBO5JIB4=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "a5c867d9fe9e4380452628e8f171c26b69fa9d3d",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1657261001,
"narHash": "sha256-sUZeuRYfhG59uD6xafM07bc7bAIkpcGq84Vj4B+cyms=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0be91cefefde5701f8fa957904618a13e3bb51d8",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "nixpkgs",
"type": "github"
}
},
"poetry2nix": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1657149754,
"narHash": "sha256-iSnZoqwNDDVoO175whSuvl4sS9lAb/2zZ3Sa4ywo970=",
"owner": "nix-community",
"repo": "poetry2nix",
"rev": "fc1930e011dea149db81863aac22fe701f36f1b5",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "poetry2nix",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"poetry2nix": "poetry2nix"
}
}
},
"root": "root",
"version": 7
}
-55
View File
@@ -1,55 +0,0 @@
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
poetry2nix.url = "github:nix-community/poetry2nix";
};
outputs = { self, nixpkgs, poetry2nix }@inputs:
let
supportedSystems = [ "x86_64-linux" "aarch64-linux" ];
forSystems = systems: f:
nixpkgs.lib.genAttrs systems
(system: f system (import nixpkgs { inherit system; overlays = [ poetry2nix.overlay self.overlays.default ]; }));
forAllSystems = forSystems supportedSystems;
projectName = "lnbits";
in
{
devShells = forAllSystems (system: pkgs: {
default = pkgs.mkShell {
buildInputs = with pkgs; [
nodePackages.prettier
];
};
});
overlays = {
default = final: prev: {
${projectName} = self.packages.${final.hostPlatform.system}.${projectName};
};
};
packages = forAllSystems (system: pkgs: {
default = self.packages.${system}.${projectName};
${projectName} = pkgs.poetry2nix.mkPoetryApplication {
projectDir = ./.;
python = pkgs.python39;
};
});
nixosModules = {
default = { pkgs, lib, config, ... }: {
imports = [ "${./nix/modules/${projectName}-service.nix}" ];
nixpkgs.overlays = [ self.overlays.default ];
};
};
checks = forAllSystems (system: pkgs:
let
vmTests = import ./nix/tests {
makeTest = (import (nixpkgs + "/nixos/lib/testing-python.nix") { inherit system; }).makeTest;
inherit inputs pkgs;
};
in
pkgs.lib.optionalAttrs pkgs.stdenv.isLinux vmTests # vmTests can only be ran on Linux, so append them only if on Linux.
//
{
# Other checks here...
}
);
};
}
View File
-38
View File
@@ -1,38 +0,0 @@
import asyncio
import uvloop
from loguru import logger
from starlette.requests import Request
from .commands import migrate_databases
from .settings import (
DEBUG,
HOST,
LNBITS_COMMIT,
LNBITS_DATA_FOLDER,
LNBITS_DATABASE_URL,
LNBITS_SITE_TITLE,
PORT,
WALLET,
)
uvloop.install()
asyncio.create_task(migrate_databases())
from .app import create_app
app = create_app()
logger.info("Starting LNbits")
logger.info(f"Host: {HOST}")
logger.info(f"Port: {PORT}")
logger.info(f"Debug: {DEBUG}")
logger.info(f"Site title: {LNBITS_SITE_TITLE}")
logger.info(f"Funding source: {WALLET.__class__.__name__}")
logger.info(
f"Database: {'PostgreSQL' if LNBITS_DATABASE_URL and LNBITS_DATABASE_URL.startswith('postgres://') else 'CockroachDB' if LNBITS_DATABASE_URL and LNBITS_DATABASE_URL.startswith('cockroachdb://') else 'SQLite'}"
)
logger.info(f"Data folder: {LNBITS_DATA_FOLDER}")
logger.info(f"Git version: {LNBITS_COMMIT}")
# logger.info(f"Service fee: {SERVICE_FEE}")
-251
View File
@@ -1,251 +0,0 @@
import asyncio
import importlib
import logging
import signal
import sys
import traceback
import warnings
from http import HTTPStatus
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from loguru import logger
import lnbits.settings
from lnbits.core.tasks import register_task_listeners
from .core import core_app
from .core.views.generic import core_html_routes
from .helpers import (
get_css_vendored,
get_js_vendored,
get_valid_extensions,
template_renderer,
url_for_vendored,
)
from .requestvars import g
from .settings import WALLET
from .tasks import (
catch_everything_and_restart,
check_pending_payments,
internal_invoice_listener,
invoice_listener,
run_deferred_async,
webhook_handler,
)
def create_app(config_object="lnbits.settings") -> FastAPI:
"""Create application factory.
:param config_object: The configuration object to use.
"""
configure_logger()
app = FastAPI(
title="LNbits API",
description="API for LNbits, the free and open source bitcoin wallet and accounts system with plugins.",
license_info={
"name": "MIT License",
"url": "https://raw.githubusercontent.com/lnbits/lnbits-legend/main/LICENSE",
},
)
app.mount("/static", StaticFiles(packages=[("lnbits", "static")]), name="static")
app.mount(
"/core/static",
StaticFiles(packages=[("lnbits.core", "static")]),
name="core_static",
)
origins = ["*"]
app.add_middleware(
CORSMiddleware, allow_origins=origins, allow_methods=["*"], allow_headers=["*"]
)
g().config = lnbits.settings
g().base_url = f"http://{lnbits.settings.HOST}:{lnbits.settings.PORT}"
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
):
# Only the browser sends "text/html" request
# not fail proof, but everything else get's a JSON response
if (
request.headers
and "accept" in request.headers
and "text/html" in request.headers["accept"]
):
return template_renderer().TemplateResponse(
"error.html",
{"request": request, "err": f"{exc.errors()} is not a valid UUID."},
)
return JSONResponse(
status_code=HTTPStatus.NO_CONTENT,
content={"detail": exc.errors()},
)
app.add_middleware(GZipMiddleware, minimum_size=1000)
# app.add_middleware(ASGIProxyFix)
check_funding_source(app)
register_assets(app)
register_routes(app)
register_async_tasks(app)
register_exception_handlers(app)
return app
def check_funding_source(app: FastAPI) -> None:
@app.on_event("startup")
async def check_wallet_status():
original_sigint_handler = signal.getsignal(signal.SIGINT)
def signal_handler(signal, frame):
logger.debug(f"SIGINT received, terminating LNbits.")
sys.exit(1)
signal.signal(signal.SIGINT, signal_handler)
while True:
try:
error_message, balance = await WALLET.status()
if not error_message:
break
logger.error(
f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
RuntimeWarning,
)
except:
pass
logger.info("Retrying connection to backend in 5 seconds...")
await asyncio.sleep(5)
signal.signal(signal.SIGINT, original_sigint_handler)
logger.info(
f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat."
)
def register_routes(app: FastAPI) -> None:
"""Register FastAPI routes / LNbits extensions."""
app.include_router(core_app)
app.include_router(core_html_routes)
for ext in get_valid_extensions():
try:
ext_module = importlib.import_module(f"lnbits.extensions.{ext.code}")
ext_route = getattr(ext_module, f"{ext.code}_ext")
if hasattr(ext_module, f"{ext.code}_start"):
ext_start_func = getattr(ext_module, f"{ext.code}_start")
ext_start_func()
if hasattr(ext_module, f"{ext.code}_static_files"):
ext_statics = getattr(ext_module, f"{ext.code}_static_files")
for s in ext_statics:
app.mount(s["path"], s["app"], s["name"])
logger.trace(f"adding route for extension {ext_module}")
app.include_router(ext_route)
except Exception as e:
logger.error(str(e))
raise ImportError(
f"Please make sure that the extension `{ext.code}` follows conventions."
)
def register_assets(app: FastAPI):
"""Serve each vendored asset separately or a bundle."""
@app.on_event("startup")
async def vendored_assets_variable():
if g().config.DEBUG:
g().VENDORED_JS = map(url_for_vendored, get_js_vendored())
g().VENDORED_CSS = map(url_for_vendored, get_css_vendored())
else:
g().VENDORED_JS = ["/static/bundle.js"]
g().VENDORED_CSS = ["/static/bundle.css"]
def register_async_tasks(app):
@app.route("/wallet/webhook")
async def webhook_listener():
return await webhook_handler()
@app.on_event("startup")
async def listeners():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(check_pending_payments))
loop.create_task(catch_everything_and_restart(invoice_listener))
loop.create_task(catch_everything_and_restart(internal_invoice_listener))
await register_task_listeners()
await run_deferred_async()
@app.on_event("shutdown")
async def stop_listeners():
pass
def register_exception_handlers(app: FastAPI):
@app.exception_handler(Exception)
async def basic_error(request: Request, err):
logger.error("handled error", traceback.format_exc())
logger.error("ERROR:", err)
etype, _, tb = sys.exc_info()
traceback.print_exception(etype, err, tb)
exc = traceback.format_exc()
if (
request.headers
and "accept" in request.headers
and "text/html" in request.headers["accept"]
):
return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": err}
)
return JSONResponse(
status_code=HTTPStatus.NO_CONTENT,
content={"detail": err},
)
def configure_logger() -> None:
logger.remove()
log_level: str = "DEBUG" if lnbits.settings.DEBUG else "INFO"
formatter = Formatter()
logger.add(sys.stderr, level=log_level, format=formatter.format)
logging.getLogger("uvicorn").handlers = [InterceptHandler()]
logging.getLogger("uvicorn.access").handlers = [InterceptHandler()]
class Formatter:
def __init__(self):
self.padding = 0
self.minimal_fmt: str = "<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | <level>{level}</level> | <level>{message}</level>\n"
if lnbits.settings.DEBUG:
self.fmt: str = "<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | <level>{level: <4}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | <level>{message}</level>\n"
else:
self.fmt: str = self.minimal_fmt
def format(self, record):
function = "{function}".format(**record)
if function == "emit": # uvicorn logs
return self.minimal_fmt
return self.fmt
class InterceptHandler(logging.Handler):
def emit(self, record):
try:
level = logger.level(record.levelname).name
except ValueError:
level = record.levelno
logger.log(level, record.getMessage())
-371
View File
@@ -1,371 +0,0 @@
import hashlib
import re
import time
from binascii import unhexlify
from decimal import Decimal
from typing import List, NamedTuple, Optional
import bitstring # type: ignore
import embit
import secp256k1
from bech32 import CHARSET, bech32_decode, bech32_encode
from ecdsa import SECP256k1, VerifyingKey # type: ignore
from ecdsa.util import sigdecode_string # type: ignore
class Route(NamedTuple):
pubkey: str
short_channel_id: str
base_fee_msat: int
ppm_fee: int
cltv: int
class Invoice(object):
payment_hash: str
amount_msat: int = 0
description: Optional[str] = None
description_hash: Optional[str] = None
payee: Optional[str] = None
date: int
expiry: int = 3600
secret: Optional[str] = None
route_hints: List[Route] = []
min_final_cltv_expiry: int = 18
def decode(pr: str) -> Invoice:
"""bolt11 decoder,
based on https://github.com/rustyrussell/lightning-payencode/blob/master/lnaddr.py
"""
hrp, decoded_data = bech32_decode(pr)
if hrp is None or decoded_data is None:
raise ValueError("Bad bech32 checksum")
if not hrp.startswith("ln"):
raise ValueError("Does not start with ln")
bitarray = _u5_to_bitarray(decoded_data)
# final signature 65 bytes, split it off.
if len(bitarray) < 65 * 8:
raise ValueError("Too short to contain signature")
# extract the signature
signature = bitarray[-65 * 8 :].tobytes()
# the tagged fields as a bitstream
data = bitstring.ConstBitStream(bitarray[: -65 * 8])
# build the invoice object
invoice = Invoice()
# decode the amount from the hrp
m = re.search(r"[^\d]+", hrp[2:])
if m:
amountstr = hrp[2 + m.end() :]
if amountstr != "":
invoice.amount_msat = _unshorten_amount(amountstr)
# pull out date
invoice.date = data.read(35).uint
while data.pos != data.len:
tag, tagdata, data = _pull_tagged(data)
data_length = len(tagdata) / 5
if tag == "d":
invoice.description = _trim_to_bytes(tagdata).decode("utf-8")
elif tag == "h" and data_length == 52:
invoice.description_hash = _trim_to_bytes(tagdata).hex()
elif tag == "p" and data_length == 52:
invoice.payment_hash = _trim_to_bytes(tagdata).hex()
elif tag == "x":
invoice.expiry = tagdata.uint
elif tag == "n":
invoice.payee = _trim_to_bytes(tagdata).hex()
# this won't work in most cases, we must extract the payee
# from the signature
elif tag == "s":
invoice.secret = _trim_to_bytes(tagdata).hex()
elif tag == "r":
s = bitstring.ConstBitStream(tagdata)
while s.pos + 264 + 64 + 32 + 32 + 16 < s.len:
route = Route(
pubkey=s.read(264).tobytes().hex(),
short_channel_id=_readable_scid(s.read(64).intbe),
base_fee_msat=s.read(32).intbe,
ppm_fee=s.read(32).intbe,
cltv=s.read(16).intbe,
)
invoice.route_hints.append(route)
# BOLT #11:
# A reader MUST check that the `signature` is valid (see the `n` tagged
# field specified below).
# A reader MUST use the `n` field to validate the signature instead of
# performing signature recovery if a valid `n` field is provided.
message = bytearray([ord(c) for c in hrp]) + data.tobytes()
sig = signature[0:64]
if invoice.payee:
key = VerifyingKey.from_string(unhexlify(invoice.payee), curve=SECP256k1)
key.verify(sig, message, hashlib.sha256, sigdecode=sigdecode_string)
else:
keys = VerifyingKey.from_public_key_recovery(
sig, message, SECP256k1, hashlib.sha256
)
signaling_byte = signature[64]
key = keys[int(signaling_byte)]
invoice.payee = key.to_string("compressed").hex()
return invoice
def encode(options):
"""Convert options into LnAddr and pass it to the encoder"""
addr = LnAddr()
addr.currency = options["currency"]
addr.fallback = options["fallback"] if options["fallback"] else None
if options["amount"]:
addr.amount = options["amount"]
if options["timestamp"]:
addr.date = int(options["timestamp"])
addr.paymenthash = unhexlify(options["paymenthash"])
if options["description"]:
addr.tags.append(("d", options["description"]))
if options["description_hash"]:
addr.tags.append(("h", options["description_hash"]))
if options["expires"]:
addr.tags.append(("x", options["expires"]))
if options["fallback"]:
addr.tags.append(("f", options["fallback"]))
if options["route"]:
for r in options["route"]:
splits = r.split("/")
route = []
while len(splits) >= 5:
route.append(
(
unhexlify(splits[0]),
unhexlify(splits[1]),
int(splits[2]),
int(splits[3]),
int(splits[4]),
)
)
splits = splits[5:]
assert len(splits) == 0
addr.tags.append(("r", route))
return lnencode(addr, options["privkey"])
def lnencode(addr, privkey):
if addr.amount:
amount = Decimal(str(addr.amount))
# We can only send down to millisatoshi.
if amount * 10**12 % 10:
raise ValueError(
"Cannot encode {}: too many decimal places".format(addr.amount)
)
amount = addr.currency + shorten_amount(amount)
else:
amount = addr.currency if addr.currency else ""
hrp = "ln" + amount + "0n"
# Start with the timestamp
data = bitstring.pack("uint:35", addr.date)
# Payment hash
data += tagged_bytes("p", addr.paymenthash)
tags_set = set()
for k, v in addr.tags:
# BOLT #11:
#
# A writer MUST NOT include more than one `d`, `h`, `n` or `x` fields,
if k in ("d", "h", "n", "x"):
if k in tags_set:
raise ValueError("Duplicate '{}' tag".format(k))
if k == "r":
route = bitstring.BitArray()
for step in v:
pubkey, channel, feebase, feerate, cltv = step
route.append(
bitstring.BitArray(pubkey)
+ bitstring.BitArray(channel)
+ bitstring.pack("intbe:32", feebase)
+ bitstring.pack("intbe:32", feerate)
+ bitstring.pack("intbe:16", cltv)
)
data += tagged("r", route)
elif k == "f":
data += encode_fallback(v, addr.currency)
elif k == "d":
data += tagged_bytes("d", v.encode())
elif k == "x":
# Get minimal length by trimming leading 5 bits at a time.
expirybits = bitstring.pack("intbe:64", v)[4:64]
while expirybits.startswith("0b00000"):
expirybits = expirybits[5:]
data += tagged("x", expirybits)
elif k == "h":
data += tagged_bytes("h", v)
elif k == "n":
data += tagged_bytes("n", v)
else:
# FIXME: Support unknown tags?
raise ValueError("Unknown tag {}".format(k))
tags_set.add(k)
# BOLT #11:
#
# A writer MUST include either a `d` or `h` field, and MUST NOT include
# both.
if "d" in tags_set and "h" in tags_set:
raise ValueError("Cannot include both 'd' and 'h'")
if not "d" in tags_set and not "h" in tags_set:
raise ValueError("Must include either 'd' or 'h'")
# We actually sign the hrp, then data (padded to 8 bits with zeroes).
privkey = secp256k1.PrivateKey(bytes(unhexlify(privkey)))
sig = privkey.ecdsa_sign_recoverable(
bytearray([ord(c) for c in hrp]) + data.tobytes()
)
# This doesn't actually serialize, but returns a pair of values :(
sig, recid = privkey.ecdsa_recoverable_serialize(sig)
data += bytes(sig) + bytes([recid])
return bech32_encode(hrp, bitarray_to_u5(data))
class LnAddr(object):
def __init__(
self, paymenthash=None, amount=None, currency="bc", tags=None, date=None
):
self.date = int(time.time()) if not date else int(date)
self.tags = [] if not tags else tags
self.unknown_tags = []
self.paymenthash = paymenthash
self.signature = None
self.pubkey = None
self.currency = currency
self.amount = amount
def __str__(self):
return "LnAddr[{}, amount={}{} tags=[{}]]".format(
hexlify(self.pubkey.serialize()).decode("utf-8"),
self.amount,
self.currency,
", ".join([k + "=" + str(v) for k, v in self.tags]),
)
def shorten_amount(amount):
"""Given an amount in bitcoin, shorten it"""
# Convert to pico initially
amount = int(amount * 10**12)
units = ["p", "n", "u", "m", ""]
for unit in units:
if amount % 1000 == 0:
amount //= 1000
else:
break
return str(amount) + unit
def _unshorten_amount(amount: str) -> int:
"""Given a shortened amount, return millisatoshis"""
# BOLT #11:
# The following `multiplier` letters are defined:
#
# * `m` (milli): multiply by 0.001
# * `u` (micro): multiply by 0.000001
# * `n` (nano): multiply by 0.000000001
# * `p` (pico): multiply by 0.000000000001
units = {"p": 10**12, "n": 10**9, "u": 10**6, "m": 10**3}
unit = str(amount)[-1]
# BOLT #11:
# A reader SHOULD fail if `amount` contains a non-digit, or is followed by
# anything except a `multiplier` in the table above.
if not re.fullmatch(r"\d+[pnum]?", str(amount)):
raise ValueError("Invalid amount '{}'".format(amount))
if unit in units:
return int(int(amount[:-1]) * 100_000_000_000 / units[unit])
else:
return int(amount) * 100_000_000_000
def _pull_tagged(stream):
tag = stream.read(5).uint
length = stream.read(5).uint * 32 + stream.read(5).uint
return (CHARSET[tag], stream.read(length * 5), stream)
def is_p2pkh(currency, prefix):
return prefix == base58_prefix_map[currency][0]
def is_p2sh(currency, prefix):
return prefix == base58_prefix_map[currency][1]
# Tagged field containing BitArray
def tagged(char, l):
# Tagged fields need to be zero-padded to 5 bits.
while l.len % 5 != 0:
l.append("0b0")
return (
bitstring.pack(
"uint:5, uint:5, uint:5",
CHARSET.find(char),
(l.len / 5) / 32,
(l.len / 5) % 32,
)
+ l
)
def tagged_bytes(char, l):
return tagged(char, bitstring.BitArray(l))
def _trim_to_bytes(barr):
# Adds a byte if necessary.
b = barr.tobytes()
if barr.len % 8 != 0:
return b[:-1]
return b
def _readable_scid(short_channel_id: int) -> str:
return "{blockheight}x{transactionindex}x{outputindex}".format(
blockheight=((short_channel_id >> 40) & 0xFFFFFF),
transactionindex=((short_channel_id >> 16) & 0xFFFFFF),
outputindex=(short_channel_id & 0xFFFF),
)
def _u5_to_bitarray(arr: List[int]) -> bitstring.BitArray:
ret = bitstring.BitArray()
for a in arr:
ret += bitstring.pack("uint:5", a)
return ret
def bitarray_to_u5(barr):
assert barr.len % 5 == 0
ret = []
s = bitstring.ConstBitStream(barr)
while s.pos != s.len:
ret.append(s.read(5).uint)
return ret
-116
View File
@@ -1,116 +0,0 @@
import asyncio
import importlib
import os
import re
import warnings
import click
from loguru import logger
from .core import db as core_db
from .core import migrations as core_migrations
from .db import COCKROACH, POSTGRES, SQLITE
from .helpers import (
get_css_vendored,
get_js_vendored,
get_valid_extensions,
url_for_vendored,
)
from .settings import LNBITS_PATH
@click.command("migrate")
def db_migrate():
asyncio.create_task(migrate_databases())
@click.command("assets")
def handle_assets():
transpile_scss()
bundle_vendored()
def transpile_scss():
with warnings.catch_warnings():
warnings.simplefilter("ignore")
from scss.compiler import compile_string # type: ignore
with open(os.path.join(LNBITS_PATH, "static/scss/base.scss")) as scss:
with open(os.path.join(LNBITS_PATH, "static/css/base.css"), "w") as css:
css.write(compile_string(scss.read()))
def bundle_vendored():
for getfiles, outputpath in [
(get_js_vendored, os.path.join(LNBITS_PATH, "static/bundle.js")),
(get_css_vendored, os.path.join(LNBITS_PATH, "static/bundle.css")),
]:
output = ""
for path in getfiles():
with open(path) as f:
output += "/* " + url_for_vendored(path) + " */\n" + f.read() + ";\n"
with open(outputpath, "w") as f:
f.write(output)
async def migrate_databases():
"""Creates the necessary databases if they don't exist already; or migrates them."""
async def set_migration_version(conn, db_name, version):
await conn.execute(
"""
INSERT INTO dbversions (db, version) VALUES (?, ?)
ON CONFLICT (db) DO UPDATE SET version = ?
""",
(db_name, version, version),
)
async def run_migration(db, migrations_module):
db_name = migrations_module.__name__.split(".")[-2]
for key, migrate in migrations_module.__dict__.items():
match = match = matcher.match(key)
if match:
version = int(match.group(1))
if version > current_versions.get(db_name, 0):
logger.debug(f"running migration {db_name}.{version}")
await migrate(db)
if db.schema == None:
await set_migration_version(db, db_name, version)
else:
async with core_db.connect() as conn:
await set_migration_version(conn, db_name, version)
async with core_db.connect() as conn:
if conn.type == SQLITE:
exists = await conn.fetchone(
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
)
elif conn.type in {POSTGRES, COCKROACH}:
exists = await conn.fetchone(
"SELECT * FROM information_schema.tables WHERE table_name = 'dbversions'"
)
if not exists:
await core_migrations.m000_create_migrations_table(conn)
rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall()
current_versions = {row["db"]: row["version"] for row in rows}
matcher = re.compile(r"^m(\d\d\d)_")
await run_migration(conn, core_migrations)
for ext in get_valid_extensions():
try:
ext_migrations = importlib.import_module(
f"lnbits.extensions.{ext.code}.migrations"
)
ext_db = importlib.import_module(f"lnbits.extensions.{ext.code}").db
except ImportError:
raise ImportError(
f"Please make sure that the extension `{ext.code}` has a migrations file."
)
async with ext_db.connect() as ext_conn:
await run_migration(ext_conn, ext_migrations)
logger.info("✔️ All migrations done.")
-11
View File
@@ -1,11 +0,0 @@
from fastapi.routing import APIRouter
from lnbits.db import Database
db = Database("database")
core_app: APIRouter = APIRouter()
from .views.api import * # noqa
from .views.generic import * # noqa
from .views.public_api import * # noqa
-544
View File
@@ -1,544 +0,0 @@
import datetime
import json
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
from uuid import uuid4
from loguru import logger
from lnbits import bolt11
from lnbits.db import COCKROACH, POSTGRES, Connection
from lnbits.settings import DEFAULT_WALLET_NAME, LNBITS_ADMIN_USERS
from . import db
from .models import BalanceCheck, Payment, User, Wallet
# accounts
# --------
async def create_account(conn: Optional[Connection] = None) -> User:
user_id = uuid4().hex
await (conn or db).execute("INSERT INTO accounts (id) VALUES (?)", (user_id,))
new_account = await get_account(user_id=user_id, conn=conn)
assert new_account, "Newly created account couldn't be retrieved"
return new_account
async def get_account(
user_id: str, conn: Optional[Connection] = None
) -> Optional[User]:
row = await (conn or db).fetchone(
"SELECT id, email, pass as password FROM accounts WHERE id = ?", (user_id,)
)
return User(**row) if row else None
async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]:
user = await (conn or db).fetchone(
"SELECT id, email FROM accounts WHERE id = ?", (user_id,)
)
if user:
extensions = await (conn or db).fetchall(
"""SELECT extension FROM extensions WHERE "user" = ? AND active""",
(user_id,),
)
wallets = await (conn or db).fetchall(
"""
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
FROM wallets
WHERE "user" = ?
""",
(user_id,),
)
else:
return None
return User(
id=user["id"],
email=user["email"],
extensions=[e[0] for e in extensions],
wallets=[Wallet(**w) for w in wallets],
admin=user["id"] in [x.strip() for x in LNBITS_ADMIN_USERS]
if LNBITS_ADMIN_USERS
else False,
)
async def update_user_extension(
*, user_id: str, extension: str, active: bool, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"""
INSERT INTO extensions ("user", extension, active) VALUES (?, ?, ?)
ON CONFLICT ("user", extension) DO UPDATE SET active = ?
""",
(user_id, extension, active, active),
)
# wallets
# -------
async def create_wallet(
*,
user_id: str,
wallet_name: Optional[str] = None,
conn: Optional[Connection] = None,
) -> Wallet:
wallet_id = uuid4().hex
await (conn or db).execute(
"""
INSERT INTO wallets (id, name, "user", adminkey, inkey)
VALUES (?, ?, ?, ?, ?)
""",
(
wallet_id,
wallet_name or DEFAULT_WALLET_NAME,
user_id,
uuid4().hex,
uuid4().hex,
),
)
new_wallet = await get_wallet(wallet_id=wallet_id, conn=conn)
assert new_wallet, "Newly created wallet couldn't be retrieved"
return new_wallet
async def update_wallet(
wallet_id: str, new_name: str, conn: Optional[Connection] = None
) -> Optional[Wallet]:
return await (conn or db).execute(
"""
UPDATE wallets SET
name = ?
WHERE id = ?
""",
(new_name, wallet_id),
)
async def delete_wallet(
*, user_id: str, wallet_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"""
UPDATE wallets AS w
SET
"user" = 'del:' || w."user",
adminkey = 'del:' || w.adminkey,
inkey = 'del:' || w.inkey
WHERE id = ? AND "user" = ?
""",
(wallet_id, user_id),
)
async def get_wallet(
wallet_id: str, conn: Optional[Connection] = None
) -> Optional[Wallet]:
row = await (conn or db).fetchone(
"""
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
FROM wallets
WHERE id = ?
""",
(wallet_id,),
)
return Wallet(**row) if row else None
async def get_wallet_for_key(
key: str, key_type: str = "invoice", conn: Optional[Connection] = None
) -> Optional[Wallet]:
row = await (conn or db).fetchone(
"""
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
FROM wallets
WHERE adminkey = ? OR inkey = ?
""",
(key, key),
)
if not row:
return None
if key_type == "admin" and row["adminkey"] != key:
return None
return Wallet(**row)
# wallet payments
# ---------------
async def get_standalone_payment(
checking_id_or_hash: str,
conn: Optional[Connection] = None,
incoming: Optional[bool] = False,
wallet_id: Optional[str] = None,
) -> Optional[Payment]:
clause: str = "checking_id = ? OR hash = ?"
values = [checking_id_or_hash, checking_id_or_hash]
if incoming:
clause = f"({clause}) AND amount > 0"
if wallet_id:
clause = f"({clause}) AND wallet = ?"
values.append(wallet_id)
row = await (conn or db).fetchone(
f"""
SELECT *
FROM apipayments
WHERE {clause}
LIMIT 1
""",
tuple(values),
)
return Payment.from_row(row) if row else None
async def get_wallet_payment(
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> Optional[Payment]:
row = await (conn or db).fetchone(
"""
SELECT *
FROM apipayments
WHERE wallet = ? AND hash = ?
""",
(wallet_id, payment_hash),
)
return Payment.from_row(row) if row else None
async def get_payments(
*,
wallet_id: Optional[str] = None,
complete: bool = False,
pending: bool = False,
outgoing: bool = False,
incoming: bool = False,
since: Optional[int] = None,
exclude_uncheckable: bool = False,
limit: Optional[int] = None,
offset: Optional[int] = None,
conn: Optional[Connection] = None,
) -> List[Payment]:
"""
Filters payments to be returned by complete | pending | outgoing | incoming.
"""
args: List[Any] = []
clause: List[str] = []
if since != None:
if db.type == POSTGRES:
clause.append("time > to_timestamp(?)")
elif db.type == COCKROACH:
clause.append("time > cast(? AS timestamp)")
else:
clause.append("time > ?")
args.append(since)
if wallet_id:
clause.append("wallet = ?")
args.append(wallet_id)
if complete and pending:
pass
elif complete:
clause.append("((amount > 0 AND pending = false) OR amount < 0)")
elif pending:
clause.append("pending = true")
else:
pass
if outgoing and incoming:
pass
elif outgoing:
clause.append("amount < 0")
elif incoming:
clause.append("amount > 0")
else:
pass
if exclude_uncheckable: # checkable means it has a checking_id that isn't internal
clause.append("checking_id NOT LIKE 'temp_%'")
clause.append("checking_id NOT LIKE 'internal_%'")
limit_clause = f"LIMIT {limit}" if type(limit) == int and limit > 0 else ""
offset_clause = f"OFFSET {offset}" if type(offset) == int and offset > 0 else ""
# combine limit and offset clauses
limit_offset_clause = (
f"{limit_clause} {offset_clause}"
if limit_clause and offset_clause
else limit_clause or offset_clause
)
where = ""
if clause:
where = f"WHERE {' AND '.join(clause)}"
rows = await (conn or db).fetchall(
f"""
SELECT *
FROM apipayments
{where}
ORDER BY time DESC
{limit_offset_clause}
""",
tuple(args),
)
return [Payment.from_row(row) for row in rows]
async def delete_expired_invoices(
conn: Optional[Connection] = None,
) -> None:
# first we delete all invoices older than one month
await (conn or db).execute(
f"""
DELETE FROM apipayments
WHERE pending = true AND amount > 0
AND time < {db.timestamp_now} - {db.interval_seconds(2592000)}
"""
)
# then we delete all expired invoices, checking one by one
rows = await (conn or db).fetchall(
f"""
SELECT bolt11
FROM apipayments
WHERE pending = true
AND bolt11 IS NOT NULL
AND amount > 0 AND time < {db.timestamp_now} - {db.interval_seconds(86400)}
"""
)
logger.debug(f"Checking expiry of {len(rows)} invoices")
for (payment_request,) in rows:
try:
invoice = bolt11.decode(payment_request)
except:
continue
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
if expiration_date > datetime.datetime.utcnow():
continue
logger.debug(
f"Deleting expired invoice: {invoice.payment_hash} (expired: {expiration_date})"
)
await (conn or db).execute(
"""
DELETE FROM apipayments
WHERE pending = true AND hash = ?
""",
(invoice.payment_hash,),
)
# payments
# --------
async def create_payment(
*,
wallet_id: str,
checking_id: str,
payment_request: str,
payment_hash: str,
amount: int,
memo: str,
fee: int = 0,
preimage: Optional[str] = None,
pending: bool = True,
extra: Optional[Dict] = None,
webhook: Optional[str] = None,
conn: Optional[Connection] = None,
) -> Payment:
# todo: add this when tests are fixed
# previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
# assert previous_payment is None, "Payment already exists"
await (conn or db).execute(
"""
INSERT INTO apipayments
(wallet, checking_id, bolt11, hash, preimage,
amount, pending, memo, fee, extra, webhook)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
wallet_id,
checking_id,
payment_request,
payment_hash,
preimage,
amount,
pending,
memo,
fee,
json.dumps(extra)
if extra and extra != {} and type(extra) is dict
else None,
webhook,
),
)
new_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
assert new_payment, "Newly created payment couldn't be retrieved"
return new_payment
async def update_payment_status(
checking_id: str, pending: bool, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"UPDATE apipayments SET pending = ? WHERE checking_id = ?",
(pending, checking_id),
)
async def update_payment_details(
checking_id: str,
pending: Optional[bool] = None,
fee: Optional[int] = None,
preimage: Optional[str] = None,
new_checking_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> None:
set_clause: List[str] = []
set_variables: List[Any] = []
if new_checking_id is not None:
set_clause.append("checking_id = ?")
set_variables.append(new_checking_id)
if pending is not None:
set_clause.append("pending = ?")
set_variables.append(pending)
if fee is not None:
set_clause.append("fee = ?")
set_variables.append(fee)
if preimage is not None:
set_clause.append("preimage = ?")
set_variables.append(preimage)
set_variables.append(checking_id)
await (conn or db).execute(
f"UPDATE apipayments SET {', '.join(set_clause)} WHERE checking_id = ?",
tuple(set_variables),
)
return
async def delete_payment(checking_id: str, conn: Optional[Connection] = None) -> None:
await (conn or db).execute(
"DELETE FROM apipayments WHERE checking_id = ?", (checking_id,)
)
async def delete_wallet_payment(
checking_id: str, wallet_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"DELETE FROM apipayments WHERE checking_id = ? AND wallet = ?",
(checking_id, wallet_id),
)
async def check_internal(
payment_hash: str, conn: Optional[Connection] = None
) -> Optional[str]:
row = await (conn or db).fetchone(
"""
SELECT checking_id FROM apipayments
WHERE hash = ? AND pending AND amount > 0
""",
(payment_hash,),
)
if not row:
return None
else:
return row["checking_id"]
# balance_check
# -------------
async def save_balance_check(
wallet_id: str, url: str, conn: Optional[Connection] = None
):
domain = urlparse(url).netloc
await (conn or db).execute(
"""
INSERT INTO balance_check (wallet, service, url) VALUES (?, ?, ?)
ON CONFLICT (wallet, service) DO UPDATE SET url = ?
""",
(wallet_id, domain, url, url),
)
async def get_balance_check(
wallet_id: str, domain: str, conn: Optional[Connection] = None
) -> Optional[BalanceCheck]:
row = await (conn or db).fetchone(
"""
SELECT wallet, service, url
FROM balance_check
WHERE wallet = ? AND service = ?
""",
(wallet_id, domain),
)
return BalanceCheck.from_row(row) if row else None
async def get_balance_checks(conn: Optional[Connection] = None) -> List[BalanceCheck]:
rows = await (conn or db).fetchall("SELECT wallet, service, url FROM balance_check")
return [BalanceCheck.from_row(row) for row in rows]
# balance_notify
# --------------
async def save_balance_notify(
wallet_id: str, url: str, conn: Optional[Connection] = None
):
await (conn or db).execute(
"""
INSERT INTO balance_notify (wallet, url) VALUES (?, ?)
ON CONFLICT (wallet) DO UPDATE SET url = ?
""",
(wallet_id, url, url),
)
async def get_balance_notify(
wallet_id: str, conn: Optional[Connection] = None
) -> Optional[str]:
row = await (conn or db).fetchone(
"""
SELECT url
FROM balance_notify
WHERE wallet = ?
""",
(wallet_id,),
)
return row[0] if row else None
-190
View File
@@ -1,190 +0,0 @@
from sqlalchemy.exc import OperationalError # type: ignore
async def m000_create_migrations_table(db):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS dbversions (
db TEXT PRIMARY KEY,
version INT NOT NULL
)
"""
)
async def m001_initial(db):
"""
Initial LNbits tables.
"""
await db.execute(
"""
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
email TEXT,
pass TEXT
);
"""
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS extensions (
"user" TEXT NOT NULL,
extension TEXT NOT NULL,
active BOOLEAN DEFAULT false,
UNIQUE ("user", extension)
);
"""
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS wallets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
"user" TEXT NOT NULL,
adminkey TEXT NOT NULL,
inkey TEXT
);
"""
)
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS apipayments (
payhash TEXT NOT NULL,
amount INTEGER NOT NULL,
fee INTEGER NOT NULL DEFAULT 0,
wallet TEXT NOT NULL,
pending BOOLEAN NOT NULL,
memo TEXT,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
UNIQUE (wallet, payhash)
);
"""
)
await db.execute(
"""
CREATE VIEW balances AS
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
SELECT wallet, SUM(amount) AS s -- incoming
FROM apipayments
WHERE amount > 0 AND pending = false -- don't sum pending
GROUP BY wallet
UNION ALL
SELECT wallet, SUM(amount + fee) AS s -- outgoing, sum fees
FROM apipayments
WHERE amount < 0 -- do sum pending
GROUP BY wallet
)x
GROUP BY wallet;
"""
)
async def m002_add_fields_to_apipayments(db):
"""
Adding fields to apipayments for better accounting,
and renaming payhash to checking_id since that is what it really is.
"""
try:
await db.execute("ALTER TABLE apipayments RENAME COLUMN payhash TO checking_id")
await db.execute("ALTER TABLE apipayments ADD COLUMN hash TEXT")
await db.execute("CREATE INDEX by_hash ON apipayments (hash)")
await db.execute("ALTER TABLE apipayments ADD COLUMN preimage TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN bolt11 TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN extra TEXT")
import json
rows = await (await db.execute("SELECT * FROM apipayments")).fetchall()
for row in rows:
if not row["memo"] or not row["memo"].startswith("#"):
continue
for ext in ["withdraw", "events", "lnticket", "paywall", "tpos"]:
prefix = "#" + ext + " "
if row["memo"].startswith(prefix):
new = row["memo"][len(prefix) :]
await db.execute(
"""
UPDATE apipayments SET extra = ?, memo = ?
WHERE checking_id = ? AND memo = ?
""",
(
json.dumps({"tag": ext}),
new,
row["checking_id"],
row["memo"],
),
)
break
except OperationalError:
# this is necessary now because it may be the case that this migration will
# run twice in some environments.
# catching errors like this won't be necessary in anymore now that we
# keep track of db versions so no migration ever runs twice.
pass
async def m003_add_invoice_webhook(db):
"""
Special column for webhook endpoints that can be assigned
to each different invoice.
"""
await db.execute("ALTER TABLE apipayments ADD COLUMN webhook TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN webhook_status TEXT")
async def m004_ensure_fees_are_always_negative(db):
"""
Use abs() so wallet backends don't have to care about the sign of the fees.
"""
await db.execute("DROP VIEW balances")
await db.execute(
"""
CREATE VIEW balances AS
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
SELECT wallet, SUM(amount) AS s -- incoming
FROM apipayments
WHERE amount > 0 AND pending = false -- don't sum pending
GROUP BY wallet
UNION ALL
SELECT wallet, SUM(amount - abs(fee)) AS s -- outgoing, sum fees
FROM apipayments
WHERE amount < 0 -- do sum pending
GROUP BY wallet
)x
GROUP BY wallet;
"""
)
async def m005_balance_check_balance_notify(db):
"""
Keep track of balanceCheck-enabled lnurl-withdrawals to be consumed by an LNbits wallet and of balanceNotify URLs supplied by users to empty their wallets.
"""
await db.execute(
"""
CREATE TABLE IF NOT EXISTS balance_check (
wallet TEXT NOT NULL REFERENCES wallets (id),
service TEXT NOT NULL,
url TEXT NOT NULL,
UNIQUE(wallet, service)
);
"""
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS balance_notify (
wallet TEXT NOT NULL REFERENCES wallets (id),
url TEXT NOT NULL,
UNIQUE(wallet, url)
);
"""
)
-198
View File
@@ -1,198 +0,0 @@
import hashlib
import hmac
import json
from sqlite3 import Row
from typing import Dict, List, NamedTuple, Optional
from ecdsa import SECP256k1, SigningKey # type: ignore
from lnurl import encode as lnurl_encode # type: ignore
from loguru import logger
from pydantic import BaseModel
from lnbits.db import Connection
from lnbits.helpers import url_for
from lnbits.settings import WALLET
from lnbits.wallets.base import PaymentStatus
class Wallet(BaseModel):
id: str
name: str
user: str
adminkey: str
inkey: str
balance_msat: int
@property
def balance(self) -> int:
return self.balance_msat // 1000
@property
def withdrawable_balance(self) -> int:
from .services import fee_reserve
return self.balance_msat - fee_reserve(self.balance_msat)
@property
def lnurlwithdraw_full(self) -> str:
url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
try:
return lnurl_encode(url)
except:
return ""
def lnurlauth_key(self, domain: str) -> SigningKey:
hashing_key = hashlib.sha256(self.id.encode("utf-8")).digest()
linking_key = hmac.digest(hashing_key, domain.encode("utf-8"), "sha256")
return SigningKey.from_string(
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
)
async def get_payment(self, payment_hash: str) -> Optional["Payment"]:
from .crud import get_standalone_payment
return await get_standalone_payment(payment_hash)
class User(BaseModel):
id: str
email: Optional[str] = None
extensions: List[str] = []
wallets: List[Wallet] = []
password: Optional[str] = None
admin: bool = False
@property
def wallet_ids(self) -> List[str]:
return [wallet.id for wallet in self.wallets]
def get_wallet(self, wallet_id: str) -> Optional["Wallet"]:
w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
return w[0] if w else None
class Payment(BaseModel):
checking_id: str
pending: bool
amount: int
fee: int
memo: Optional[str]
time: int
bolt11: str
preimage: str
payment_hash: str
extra: Optional[Dict] = {}
wallet_id: str
webhook: Optional[str]
webhook_status: Optional[int]
@classmethod
def from_row(cls, row: Row):
return cls(
checking_id=row["checking_id"],
payment_hash=row["hash"] or "0" * 64,
bolt11=row["bolt11"] or "",
preimage=row["preimage"] or "0" * 64,
extra=json.loads(row["extra"] or "{}"),
pending=row["pending"],
amount=row["amount"],
fee=row["fee"],
memo=row["memo"],
time=row["time"],
wallet_id=row["wallet"],
webhook=row["webhook"],
webhook_status=row["webhook_status"],
)
@property
def tag(self) -> Optional[str]:
if self.extra is None:
return ""
return self.extra.get("tag")
@property
def msat(self) -> int:
return self.amount
@property
def sat(self) -> int:
return self.amount // 1000
@property
def is_in(self) -> bool:
return self.amount > 0
@property
def is_out(self) -> bool:
return self.amount < 0
@property
def is_uncheckable(self) -> bool:
return self.checking_id.startswith("internal_")
async def update_status(
self,
status: PaymentStatus,
conn: Optional[Connection] = None,
) -> None:
from .crud import update_payment_details
await update_payment_details(
checking_id=self.checking_id,
pending=status.pending,
fee=status.fee_msat,
preimage=status.preimage,
conn=conn,
)
async def set_pending(self, pending: bool) -> None:
from .crud import update_payment_status
await update_payment_status(self.checking_id, pending)
async def check_status(
self,
conn: Optional[Connection] = None,
) -> PaymentStatus:
if self.is_uncheckable:
return PaymentStatus(None)
logger.debug(
f"Checking {'outgoing' if self.is_out else 'incoming'} pending payment {self.checking_id}"
)
if self.is_out:
status = await WALLET.get_payment_status(self.checking_id)
else:
status = await WALLET.get_invoice_status(self.checking_id)
logger.debug(f"Status: {status}")
if self.is_out and status.failed:
logger.warning(
f"Deleting outgoing failed payment {self.checking_id}: {status}"
)
await self.delete(conn)
elif not status.pending:
logger.info(
f"Marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}"
)
await self.update_status(status, conn=conn)
return status
async def delete(self, conn: Optional[Connection] = None) -> None:
from .crud import delete_payment
await delete_payment(self.checking_id, conn=conn)
class BalanceCheck(BaseModel):
wallet: str
service: str
url: str
@classmethod
def from_row(cls, row: Row):
return cls(wallet=row["wallet"], service=row["service"], url=row["url"])
-384
View File
@@ -1,384 +0,0 @@
import asyncio
import json
from binascii import unhexlify
from io import BytesIO
from typing import Dict, Optional, Tuple
from urllib.parse import parse_qs, urlparse
import httpx
from fastapi import Depends
from lnurl import LnurlErrorResponse
from lnurl import decode as decode_lnurl # type: ignore
from loguru import logger
from lnbits import bolt11
from lnbits.db import Connection
from lnbits.decorators import (
WalletTypeInfo,
get_key_type,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import url_for, urlsafe_short_hash
from lnbits.requestvars import g
from lnbits.settings import FAKE_WALLET, RESERVE_FEE_MIN, RESERVE_FEE_PERCENT, WALLET
from lnbits.wallets.base import PaymentResponse, PaymentStatus
from . import db
from .crud import (
check_internal,
create_payment,
delete_wallet_payment,
get_wallet,
get_wallet_payment,
update_payment_details,
update_payment_status,
)
from .models import Payment
try:
from typing import TypedDict # type: ignore
except ImportError: # pragma: nocover
from typing_extensions import TypedDict
class PaymentFailure(Exception):
pass
class InvoiceFailure(Exception):
pass
async def create_invoice(
*,
wallet_id: str,
amount: int, # in satoshis
memo: str,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
extra: Optional[Dict] = None,
webhook: Optional[str] = None,
internal: Optional[bool] = False,
conn: Optional[Connection] = None,
) -> Tuple[str, str]:
invoice_memo = None if description_hash else memo
# use the fake wallet if the invoice is for internal use only
wallet = FAKE_WALLET if internal else WALLET
ok, checking_id, payment_request, error_message = await wallet.create_invoice(
amount=amount,
memo=invoice_memo,
description_hash=description_hash,
unhashed_description=unhashed_description,
)
if not ok:
raise InvoiceFailure(error_message or "unexpected backend error.")
invoice = bolt11.decode(payment_request)
amount_msat = amount * 1000
await create_payment(
wallet_id=wallet_id,
checking_id=checking_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=amount_msat,
memo=memo,
extra=extra,
webhook=webhook,
conn=conn,
)
return invoice.payment_hash, payment_request
async def pay_invoice(
*,
wallet_id: str,
payment_request: str,
max_sat: Optional[int] = None,
extra: Optional[Dict] = None,
description: str = "",
conn: Optional[Connection] = None,
) -> str:
"""
Pay a Lightning invoice.
First, we create a temporary payment in the database with fees set to the reserve fee.
We then check whether the balance of the payer would go negative.
We then attempt to pay the invoice through the backend.
If the payment is successful, we update the payment in the database with the payment details.
If the payment is unsuccessful, we delete the temporary payment.
If the payment is still in flight, we hope that some other process will regularly check for the payment.
"""
invoice = bolt11.decode(payment_request)
fee_reserve_msat = fee_reserve(invoice.amount_msat)
async with (db.reuse_conn(conn) if conn else db.connect()) as conn:
temp_id = invoice.payment_hash
internal_id = f"internal_{invoice.payment_hash}"
if invoice.amount_msat == 0:
raise ValueError("Amountless invoices not supported.")
if max_sat and invoice.amount_msat > max_sat * 1000:
raise ValueError("Amount in invoice is too high.")
# put all parameters that don't change here
class PaymentKwargs(TypedDict):
wallet_id: str
payment_request: str
payment_hash: str
amount: int
memo: str
extra: Optional[Dict]
payment_kwargs: PaymentKwargs = PaymentKwargs(
wallet_id=wallet_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=-invoice.amount_msat,
memo=description or invoice.description or "",
extra=extra,
)
# check_internal() returns the checking_id of the invoice we're waiting for
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn)
if internal_checking_id:
logger.debug(f"creating temporary internal payment with id {internal_id}")
# create a new payment from this wallet
await create_payment(
checking_id=internal_id,
fee=0,
pending=False,
conn=conn,
**payment_kwargs,
)
else:
logger.debug(f"creating temporary payment with id {temp_id}")
# create a temporary payment here so we can check if
# the balance is enough in the next step
await create_payment(
checking_id=temp_id,
fee=-fee_reserve_msat,
conn=conn,
**payment_kwargs,
)
# do the balance check
wallet = await get_wallet(wallet_id, conn=conn)
assert wallet
if wallet.balance_msat < 0:
logger.debug("balance is too low, deleting temporary payment")
if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat:
raise PaymentFailure(
f"You must reserve at least ({round(fee_reserve_msat/1000)} sat) to cover potential routing fees."
)
raise PermissionError("Insufficient balance.")
if internal_checking_id:
logger.debug(f"marking temporary payment as not pending {internal_checking_id}")
# mark the invoice from the other side as not pending anymore
# so the other side only has access to his new money when we are sure
# the payer has enough to deduct from
async with db.connect() as conn:
await update_payment_status(
checking_id=internal_checking_id, pending=False, conn=conn
)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(internal_checking_id)
else:
logger.debug(f"backend: sending payment {temp_id}")
# actually pay the external invoice
payment: PaymentResponse = await WALLET.pay_invoice(
payment_request, fee_reserve_msat
)
if payment.checking_id and payment.checking_id != temp_id:
logger.warning(
f"backend sent unexpected checking_id (expected: {temp_id} got: {payment.checking_id})"
)
logger.debug(f"backend: pay_invoice finished {temp_id}")
if payment.checking_id and payment.ok != False:
# payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {temp_id}")
async with db.connect() as conn:
await update_payment_details(
checking_id=temp_id,
pending=payment.ok != True,
fee=payment.fee_msat,
preimage=payment.preimage,
new_checking_id=payment.checking_id,
conn=conn,
)
logger.debug(f"payment successful {payment.checking_id}")
elif payment.checking_id is None and payment.ok == False:
# payment failed
logger.warning(f"backend sent payment failure")
async with db.connect() as conn:
logger.debug(f"deleting temporary payment {temp_id}")
await delete_wallet_payment(temp_id, wallet_id, conn=conn)
raise PaymentFailure(
f"payment failed: {payment.error_message}"
or "payment failed, but backend didn't give us an error message"
)
else:
logger.warning(
f"didn't receive checking_id from backend, payment may be stuck in database: {temp_id}"
)
return invoice.payment_hash
async def redeem_lnurl_withdraw(
wallet_id: str,
lnurl_request: str,
memo: Optional[str] = None,
extra: Optional[Dict] = None,
wait_seconds: int = 0,
conn: Optional[Connection] = None,
) -> None:
if not lnurl_request:
return None
res = {}
async with httpx.AsyncClient() as client:
lnurl = decode_lnurl(lnurl_request)
r = await client.get(str(lnurl))
res = r.json()
try:
_, payment_request = await create_invoice(
wallet_id=wallet_id,
amount=int(res["maxWithdrawable"] / 1000),
memo=memo or res["defaultDescription"] or "",
extra=extra,
conn=conn,
)
except:
logger.warning(
f"failed to create invoice on redeem_lnurl_withdraw from {lnurl}. params: {res}"
)
return None
if wait_seconds:
await asyncio.sleep(wait_seconds)
params = {"k1": res["k1"], "pr": payment_request}
try:
params["balanceNotify"] = url_for(
f"/withdraw/notify/{urlparse(lnurl_request).netloc}",
external=True,
wal=wallet_id,
)
except Exception:
pass
async with httpx.AsyncClient() as client:
try:
await client.get(res["callback"], params=params)
except Exception:
pass
async def perform_lnurlauth(
callback: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
conn: Optional[Connection] = None,
) -> Optional[LnurlErrorResponse]:
cb = urlparse(callback)
k1 = unhexlify(parse_qs(cb.query)["k1"][0])
key = wallet.wallet.lnurlauth_key(cb.netloc)
def int_to_bytes_suitable_der(x: int) -> bytes:
"""for strict DER we need to encode the integer with some quirks"""
b = x.to_bytes((x.bit_length() + 7) // 8, "big")
if len(b) == 0:
# ensure there's at least one byte when the int is zero
return bytes([0])
if b[0] & 0x80 != 0:
# ensure it doesn't start with a 0x80 and so it isn't
# interpreted as a negative number
return bytes([0]) + b
return b
def encode_strict_der(r_int, s_int, order):
# if s > order/2 verification will fail sometimes
# so we must fix it here (see https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147)
if s_int > order // 2:
s_int = order - s_int
# now we do the strict DER encoding copied from
# https://github.com/KiriKiri/bip66 (without any checks)
r = int_to_bytes_suitable_der(r_int)
s = int_to_bytes_suitable_der(s_int)
r_len = len(r)
s_len = len(s)
sign_len = 6 + r_len + s_len
signature = BytesIO()
signature.write(0x30.to_bytes(1, "big", signed=False))
signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(r_len.to_bytes(1, "big", signed=False))
signature.write(r)
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(s_len.to_bytes(1, "big", signed=False))
signature.write(s)
return signature.getvalue()
sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der)
async with httpx.AsyncClient() as client:
r = await client.get(
callback,
params={
"k1": k1.hex(),
"key": key.verifying_key.to_string("compressed").hex(),
"sig": sig.hex(),
},
)
try:
resp = json.loads(r.text)
if resp["status"] == "OK":
return None
return LnurlErrorResponse(reason=resp["reason"])
except (KeyError, json.decoder.JSONDecodeError):
return LnurlErrorResponse(
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text
)
async def check_transaction_status(
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> PaymentStatus:
payment: Optional[Payment] = await get_wallet_payment(
wallet_id, payment_hash, conn=conn
)
if not payment:
return PaymentStatus(None)
if not payment.pending:
# note: before, we still checked the status of the payment again
return PaymentStatus(True)
status: PaymentStatus = await payment.check_status()
return status
# WARN: this same value must be used for balance check and passed to WALLET.pay_invoice(), it may cause a vulnerability if the values differ
def fee_reserve(amount_msat: int) -> int:
return max(int(RESERVE_FEE_MIN), int(amount_msat * RESERVE_FEE_PERCENT / 100.0))
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

-36
View File
@@ -1,36 +0,0 @@
new Vue({
el: '#vue',
data: function () {
return {
searchTerm: '',
filteredExtensions: null
}
},
mounted() {
this.filteredExtensions = this.g.extensions
},
watch: {
searchTerm(term) {
// Reset the filter
this.filteredExtensions = this.g.extensions
if (term !== '') {
// Filter the extensions list
function extensionNameContains(searchTerm) {
return function (extension) {
return (
extension.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
extension.shortDescription
.toLowerCase()
.includes(searchTerm.toLowerCase())
)
}
}
this.filteredExtensions = this.filteredExtensions.filter(
extensionNameContains(term)
)
}
}
},
mixins: [windowMixin]
})
-25
View File
@@ -1,25 +0,0 @@
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
disclaimerDialog: {
show: false,
data: {}
},
walletName: ''
}
},
methods: {
createWallet: function () {
LNbits.href.createWallet(this.walletName)
},
processing: function () {
this.$q.notify({
timeout: 0,
message: 'Processing...',
icon: null
})
}
}
})
-51
View File
@@ -1,51 +0,0 @@
// the cache version gets updated every time there is a new deployment
const CACHE_VERSION = 1
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
const getApiKey = request => {
return request.headers.get('X-Api-Key') || 'none'
}
// on activation we clean up the previously registered service workers
self.addEventListener('activate', evt =>
evt.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
const currentCacheVersion = cacheName.split('-').slice(-2, 2)
if (currentCacheVersion !== CACHE_VERSION) {
return caches.delete(cacheName)
}
})
)
})
)
)
// The fetch handler serves responses for same-origin resources from a cache.
// If no response is found, it populates the runtime cache with the response
// from the network before returning it to the page.
self.addEventListener('fetch', event => {
// Skip cross-origin requests, like those for Google Analytics.
if (
event.request.url.startsWith(self.location.origin) &&
event.request.method == 'GET'
) {
// Open the cache
event.respondWith(
caches.open(CURRENT_CACHE + getApiKey(event.request)).then(cache => {
// Go to the network first
return fetch(event.request)
.then(fetchedResponse => {
cache.put(event.request, fetchedResponse.clone())
return fetchedResponse
})
.catch(() => {
// If the network is unavailable, get
return cache.match(event.request.url)
})
})
)
}
})
-726
View File
@@ -1,726 +0,0 @@
/* globals windowMixin, decode, Vue, VueQrcodeReader, VueQrcode, Quasar, LNbits, _, EventHub, Chart, decryptLnurlPayAES */
Vue.component(VueQrcode.name, VueQrcode)
Vue.use(VueQrcodeReader)
function generateChart(canvas, payments) {
var txs = []
var n = 0
var data = {
labels: [],
income: [],
outcome: [],
cumulative: []
}
_.each(
payments.filter(p => !p.pending).sort((a, b) => a.time - b.time),
tx => {
txs.push({
hour: Quasar.utils.date.formatDate(tx.date, 'YYYY-MM-DDTHH:00'),
sat: tx.sat
})
}
)
_.each(_.groupBy(txs, 'hour'), (value, day) => {
var income = _.reduce(
value,
(memo, tx) => (tx.sat >= 0 ? memo + tx.sat : memo),
0
)
var outcome = _.reduce(
value,
(memo, tx) => (tx.sat < 0 ? memo + Math.abs(tx.sat) : memo),
0
)
n = n + income - outcome
data.labels.push(day)
data.income.push(income)
data.outcome.push(outcome)
data.cumulative.push(n)
})
new Chart(canvas.getContext('2d'), {
type: 'bar',
data: {
labels: data.labels,
datasets: [
{
data: data.cumulative,
type: 'line',
label: 'balance',
backgroundColor: '#673ab7', // deep-purple
borderColor: '#673ab7',
borderWidth: 4,
pointRadius: 3,
fill: false
},
{
data: data.income,
type: 'bar',
label: 'in',
barPercentage: 0.75,
backgroundColor: window.Color('rgb(76,175,80)').alpha(0.5).rgbString() // green
},
{
data: data.outcome,
type: 'bar',
label: 'out',
barPercentage: 0.75,
backgroundColor: window.Color('rgb(233,30,99)').alpha(0.5).rgbString() // pink
}
]
},
options: {
title: {
text: 'Chart.js Combo Time Scale'
},
tooltips: {
mode: 'index',
intersect: false
},
scales: {
xAxes: [
{
type: 'time',
display: true,
offset: true,
time: {
minUnit: 'hour',
stepSize: 3
}
}
]
},
// performance tweaks
animation: {
duration: 0
},
elements: {
line: {
tension: 0
}
}
}
})
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
user: LNbits.map.user(window.user),
receive: {
show: false,
status: 'pending',
paymentReq: null,
paymentHash: null,
minMax: [0, 2100000000000000],
lnurl: null,
units: ['sat'],
unit: 'sat',
data: {
amount: null,
memo: ''
}
},
parse: {
show: false,
invoice: null,
lnurlpay: null,
lnurlauth: null,
data: {
request: '',
amount: 0,
comment: ''
},
paymentChecker: null,
camera: {
show: false,
camera: 'auto'
}
},
payments: [],
paymentsTable: {
columns: [
{
name: 'memo',
align: 'left',
label: 'Memo',
field: 'memo'
},
{
name: 'date',
align: 'left',
label: 'Date',
field: 'date',
sortable: true
},
{
name: 'sat',
align: 'right',
label: 'Amount (' + LNBITS_DENOMINATION + ')',
field: 'sat',
sortable: true
},
{
name: 'fee',
align: 'right',
label: 'Fee (m' + LNBITS_DENOMINATION + ')',
field: 'fee'
}
],
pagination: {
rowsPerPage: 10
},
filter: null
},
paymentsChart: {
show: false
},
disclaimerDialog: {
show: false,
location: window.location
},
balance: 0,
credit: 0,
newName: ''
}
},
computed: {
formattedBalance: function () {
if (LNBITS_DENOMINATION != 'sats') {
return this.balance / 100
} else {
return LNbits.utils.formatSat(this.balance || this.g.wallet.sat)
}
},
filteredPayments: function () {
var q = this.paymentsTable.filter
if (!q || q === '') return this.payments
return LNbits.utils.search(this.payments, q)
},
canPay: function () {
if (!this.parse.invoice) return false
return this.parse.invoice.sat <= this.balance
},
pendingPaymentsExist: function () {
return this.payments.findIndex(payment => payment.pending) !== -1
}
},
filters: {
msatoshiFormat: function (value) {
return LNbits.utils.formatSat(value / 1000)
}
},
methods: {
paymentTableRowKey: function (row) {
return row.payment_hash + row.amount
},
closeCamera: function () {
this.parse.camera.show = false
},
showCamera: function () {
this.parse.camera.show = true
},
showChart: function () {
this.paymentsChart.show = true
this.$nextTick(() => {
generateChart(this.$refs.canvas, this.payments)
})
},
focusInput(el) {
this.$nextTick(() => this.$refs[el].focus())
},
showReceiveDialog: function () {
this.receive.show = true
this.receive.status = 'pending'
this.receive.paymentReq = null
this.receive.paymentHash = null
this.receive.data.amount = null
this.receive.data.memo = null
this.receive.unit = 'sat'
this.receive.paymentChecker = null
this.receive.minMax = [0, 2100000000000000]
this.receive.lnurl = null
this.focusInput('setAmount')
},
showParseDialog: function () {
this.parse.show = true
this.parse.invoice = null
this.parse.lnurlpay = null
this.parse.lnurlauth = null
this.parse.data.request = ''
this.parse.data.comment = ''
this.parse.data.paymentChecker = null
this.parse.camera.show = false
},
updateBalance: function (credit) {
if (LNBITS_DENOMINATION != 'sats') {
credit = credit * 100
}
LNbits.api
.request('PUT', '/api/v1/wallet/balance/' + credit, this.g.wallet.inkey)
.catch(err => {
LNbits.utils.notifyApiError(err)
})
.then(response => {
let data = response.data
if (data.status === 'ERROR') {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: `Failed to update.`
})
return
}
this.balance = this.balance + data.balance
})
},
closeReceiveDialog: function () {
setTimeout(() => {
clearInterval(this.receive.paymentChecker)
}, 10000)
},
closeParseDialog: function () {
setTimeout(() => {
clearInterval(this.parse.paymentChecker)
}, 10000)
},
onPaymentReceived: function (paymentHash) {
this.fetchPayments()
this.fetchBalance()
if (this.receive.paymentHash === paymentHash) {
this.receive.show = false
this.receive.paymentHash = null
clearInterval(this.receive.paymentChecker)
}
},
createInvoice: function () {
this.receive.status = 'loading'
if (LNBITS_DENOMINATION != 'sats') {
this.receive.data.amount = this.receive.data.amount * 100
}
LNbits.api
.createInvoice(
this.g.wallet,
this.receive.data.amount,
this.receive.data.memo,
this.receive.unit,
this.receive.lnurl && this.receive.lnurl.callback
)
.then(response => {
this.receive.status = 'success'
this.receive.paymentReq = response.data.payment_request
this.receive.paymentHash = response.data.payment_hash
if (response.data.lnurl_response !== null) {
if (response.data.lnurl_response === false) {
response.data.lnurl_response = `Unable to connect`
}
if (typeof response.data.lnurl_response === 'string') {
// failure
this.$q.notify({
timeout: 5000,
type: 'warning',
message: `${this.receive.lnurl.domain} lnurl-withdraw call failed.`,
caption: response.data.lnurl_response
})
return
} else if (response.data.lnurl_response === true) {
// success
this.$q.notify({
timeout: 5000,
message: `Invoice sent to ${this.receive.lnurl.domain}!`,
spinner: true
})
}
}
clearInterval(this.receive.paymentChecker)
setTimeout(() => {
clearInterval(this.receive.paymentChecker)
}, 40000)
this.receive.paymentChecker = setInterval(() => {
let hash = response.data.payment_hash
LNbits.api.getPayment(this.g.wallet, hash).then(response => {
if (response.data.paid) {
this.onPaymentReceived(hash)
}
})
}, 5000)
})
.catch(err => {
LNbits.utils.notifyApiError(err)
this.receive.status = 'pending'
})
},
decodeQR: function (res) {
this.parse.data.request = res
this.decodeRequest()
this.parse.camera.show = false
},
decodeRequest: function () {
this.parse.show = true
let req = this.parse.data.request.toLowerCase()
if (this.parse.data.request.toLowerCase().startsWith('lightning:')) {
this.parse.data.request = this.parse.data.request.slice(10)
} else if (this.parse.data.request.toLowerCase().startsWith('lnurl:')) {
this.parse.data.request = this.parse.data.request.slice(6)
} else if (req.indexOf('lightning=lnurl1') !== -1) {
this.parse.data.request = this.parse.data.request
.split('lightning=')[1]
.split('&')[0]
}
if (
this.parse.data.request.toLowerCase().startsWith('lnurl1') ||
this.parse.data.request.match(/[\w.+-~_]+@[\w.+-~_]/)
) {
LNbits.api
.request(
'GET',
'/api/v1/lnurlscan/' + this.parse.data.request,
this.g.wallet.adminkey
)
.catch(err => {
LNbits.utils.notifyApiError(err)
})
.then(response => {
let data = response.data
if (data.status === 'ERROR') {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: `${data.domain} lnurl call failed.`,
caption: data.reason
})
return
}
if (data.kind === 'pay') {
this.parse.lnurlpay = Object.freeze(data)
this.parse.data.amount = data.minSendable / 1000
} else if (data.kind === 'auth') {
this.parse.lnurlauth = Object.freeze(data)
} else if (data.kind === 'withdraw') {
this.parse.show = false
this.receive.show = true
this.receive.status = 'pending'
this.receive.paymentReq = null
this.receive.paymentHash = null
this.receive.data.amount = data.maxWithdrawable / 1000
this.receive.data.memo = data.defaultDescription
this.receive.minMax = [
data.minWithdrawable / 1000,
data.maxWithdrawable / 1000
]
this.receive.lnurl = {
domain: data.domain,
callback: data.callback,
fixed: data.fixed
}
}
})
return
}
let invoice
try {
invoice = decode(this.parse.data.request)
} catch (error) {
this.$q.notify({
timeout: 3000,
type: 'warning',
message: error + '.',
caption: '400 BAD REQUEST'
})
this.parse.show = false
return
}
let cleanInvoice = {
msat: invoice.human_readable_part.amount,
sat: invoice.human_readable_part.amount / 1000,
fsat: LNbits.utils.formatSat(invoice.human_readable_part.amount / 1000)
}
_.each(invoice.data.tags, tag => {
if (_.isObject(tag) && _.has(tag, 'description')) {
if (tag.description === 'payment_hash') {
cleanInvoice.hash = tag.value
} else if (tag.description === 'description') {
cleanInvoice.description = tag.value
} else if (tag.description === 'expiry') {
var expireDate = new Date(
(invoice.data.time_stamp + tag.value) * 1000
)
cleanInvoice.expireDate = Quasar.utils.date.formatDate(
expireDate,
'YYYY-MM-DDTHH:mm:ss.SSSZ'
)
cleanInvoice.expired = false // TODO
}
}
})
this.parse.invoice = Object.freeze(cleanInvoice)
},
payInvoice: function () {
let dismissPaymentMsg = this.$q.notify({
timeout: 0,
message: 'Processing payment...'
})
LNbits.api
.payInvoice(this.g.wallet, this.parse.data.request)
.then(response => {
clearInterval(this.parse.paymentChecker)
setTimeout(() => {
clearInterval(this.parse.paymentChecker)
}, 40000)
this.parse.paymentChecker = setInterval(() => {
LNbits.api
.getPayment(this.g.wallet, response.data.payment_hash)
.then(res => {
if (res.data.paid) {
this.parse.show = false
clearInterval(this.parse.paymentChecker)
dismissPaymentMsg()
this.fetchPayments()
this.fetchBalance()
}
})
}, 2000)
})
.catch(err => {
dismissPaymentMsg()
LNbits.utils.notifyApiError(err)
})
},
payLnurl: function () {
let dismissPaymentMsg = this.$q.notify({
timeout: 0,
message: 'Processing payment...'
})
LNbits.api
.payLnurl(
this.g.wallet,
this.parse.lnurlpay.callback,
this.parse.lnurlpay.description_hash,
this.parse.data.amount * 1000,
this.parse.lnurlpay.description.slice(0, 120),
this.parse.data.comment
)
.then(response => {
this.parse.show = false
clearInterval(this.parse.paymentChecker)
setTimeout(() => {
clearInterval(this.parse.paymentChecker)
}, 40000)
this.parse.paymentChecker = setInterval(() => {
LNbits.api
.getPayment(this.g.wallet, response.data.payment_hash)
.then(res => {
if (res.data.paid) {
dismissPaymentMsg()
clearInterval(this.parse.paymentChecker)
this.fetchPayments()
this.fetchBalance()
// show lnurlpay success action
if (response.data.success_action) {
switch (response.data.success_action.tag) {
case 'url':
this.$q.notify({
message: `<a target="_blank" style="color: inherit" href="${response.data.success_action.url}">${response.data.success_action.url}</a>`,
caption: response.data.success_action.description,
html: true,
type: 'positive',
timeout: 0,
closeBtn: true
})
break
case 'message':
this.$q.notify({
message: response.data.success_action.message,
type: 'positive',
timeout: 0,
closeBtn: true
})
break
case 'aes':
LNbits.api
.getPayment(this.g.wallet, response.data.payment_hash)
.then(({data: payment}) =>
decryptLnurlPayAES(
response.data.success_action,
payment.preimage
)
)
.then(value => {
this.$q.notify({
message: value,
caption: response.data.success_action.description,
html: true,
type: 'positive',
timeout: 0,
closeBtn: true
})
})
break
}
}
}
})
}, 2000)
})
.catch(err => {
dismissPaymentMsg()
LNbits.utils.notifyApiError(err)
})
},
authLnurl: function () {
let dismissAuthMsg = this.$q.notify({
timeout: 10,
message: 'Performing authentication...'
})
LNbits.api
.authLnurl(this.g.wallet, this.parse.lnurlauth.callback)
.then(response => {
dismissAuthMsg()
this.$q.notify({
message: `Authentication successful.`,
type: 'positive',
timeout: 3500
})
this.parse.show = false
})
.catch(err => {
dismissAuthMsg()
if (err.response.data.reason) {
this.$q.notify({
message: `Authentication failed. ${this.parse.lnurlauth.domain} says:`,
caption: err.response.data.reason,
type: 'warning',
timeout: 5000
})
} else {
LNbits.utils.notifyApiError(err)
}
})
},
updateWalletName: function () {
let newName = this.newName
let adminkey = this.g.wallet.adminkey
if (!newName || !newName.length) return
LNbits.api
.request('PUT', '/api/v1/wallet/' + newName, adminkey, {})
.then(res => {
this.newName = ''
this.$q.notify({
message: `Wallet named updated.`,
type: 'positive',
timeout: 3500
})
LNbits.href.updateWallet(
res.data.name,
this.user.id,
this.g.wallet.id
)
})
.catch(err => {
this.newName = ''
LNbits.utils.notifyApiError(err)
})
},
deleteWallet: function (walletId, user) {
LNbits.utils
.confirmDialog('Are you sure you want to delete this wallet?')
.onOk(() => {
LNbits.href.deleteWallet(walletId, user)
})
},
fetchPayments: function () {
return LNbits.api.getPayments(this.g.wallet).then(response => {
this.payments = response.data
.map(obj => {
return LNbits.map.payment(obj)
})
.sort((a, b) => {
return b.time - a.time
})
})
},
fetchBalance: function () {
LNbits.api.getWallet(this.g.wallet).then(response => {
this.balance = Math.round(response.data.balance / 1000)
EventHub.$emit('update-wallet-balance', [
this.g.wallet.id,
this.balance
])
})
},
exportCSV: function () {
// status is important for export but it is not in paymentsTable
// because it is manually added with payment detail link and icons
// and would cause duplication in the list
let columns = this.paymentsTable.columns
columns.unshift({
name: 'pending',
align: 'left',
label: 'Pending',
field: 'pending'
})
LNbits.utils.exportCSV(columns, this.payments)
}
},
watch: {
payments: function () {
this.fetchBalance()
}
},
created: function () {
this.fetchBalance()
this.fetchPayments()
LNbits.api
.request('GET', '/api/v1/currencies')
.then(response => {
this.receive.units = ['sat', ...response.data]
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
mounted: function () {
// show disclaimer
if (!this.$q.localStorage.getItem('lnbits.disclaimerShown')) {
this.disclaimerDialog.show = true
this.$q.localStorage.set('lnbits.disclaimerShown', true)
}
// listen to incoming payments
LNbits.events.onInvoicePaid(this.g.wallet, payment =>
this.onPaymentReceived(payment.payment_hash)
)
}
})
if (navigator.serviceWorker != null) {
navigator.serviceWorker
.register('/service-worker.js')
.then(function (registration) {
console.log('Registered events at scope: ', registration.scope)
})
}
-71
View File
@@ -1,71 +0,0 @@
import asyncio
from typing import List
import httpx
from loguru import logger
from lnbits.tasks import register_invoice_listener
from . import db
from .crud import get_balance_notify
from .models import Payment
api_invoice_listeners: List[asyncio.Queue] = []
async def register_task_listeners():
invoice_paid_queue = asyncio.Queue(5)
register_invoice_listener(invoice_paid_queue)
asyncio.create_task(wait_for_paid_invoices(invoice_paid_queue))
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
while True:
payment = await invoice_paid_queue.get()
logger.debug("received invoice paid event")
# send information to sse channel
await dispatch_invoice_listener(payment)
# dispatch webhook
if payment.webhook and not payment.webhook_status:
await dispatch_webhook(payment)
# dispatch balance_notify
url = await get_balance_notify(payment.wallet_id)
if url:
async with httpx.AsyncClient() as client:
try:
r = await client.post(url, timeout=4)
await mark_webhook_sent(payment, r.status_code)
except (httpx.ConnectError, httpx.RequestError):
pass
async def dispatch_invoice_listener(payment: Payment):
for send_channel in api_invoice_listeners:
try:
send_channel.put_nowait(payment)
except asyncio.QueueFull:
logger.debug("removing sse listener", send_channel)
api_invoice_listeners.remove(send_channel)
async def dispatch_webhook(payment: Payment):
async with httpx.AsyncClient() as client:
data = payment.dict()
try:
logger.debug("sending webhook", payment.webhook)
r = await client.post(payment.webhook, json=data, timeout=40) # type: ignore
await mark_webhook_sent(payment, r.status_code)
except (httpx.ConnectError, httpx.RequestError):
await mark_webhook_sent(payment, -1)
async def mark_webhook_sent(payment: Payment, status: int) -> None:
await db.execute(
"""
UPDATE apipayments SET webhook_status = ?
WHERE hash = ?
""",
(status, payment.payment_hash),
)
-156
View File
@@ -1,156 +0,0 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-card-section>
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
</q-card-section>
<q-expansion-item
group="api"
dense
expand-separator
label="Get wallet details"
>
<q-card>
<q-card-section>
<code><span class="text-light-green">GET</span> /api/v1/wallet</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code
>{"id": &lt;string&gt;, "name": &lt;string&gt;, "balance":
&lt;int&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl {{ request.base_url }}api/v1/wallet -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Create an invoice (incoming)"
>
<q-card>
<q-card-section>
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;, "unit":
&lt;string&gt;, "webhook": &lt;url:string&gt;, "internal":
&lt;bool&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"payment_hash": &lt;string&gt;, "payment_request":
&lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": false,
"amount": &lt;int&gt;, "memo": &lt;string&gt;, "webhook":
&lt;url:string&gt;, "unit": &lt;string&gt;}' -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Pay an invoice (outgoing)"
>
<q-card>
<q-card-section>
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "{{ wallet.adminkey }}"}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"out": true, "bolt11": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>{"payment_hash": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": true,
"bolt11": &lt;string&gt;}' -H "X-Api-Key:
<i>{{ wallet.adminkey }}"</i> -H "Content-type:
application/json"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Decode an invoice"
>
<q-card>
<q-card-section>
<code
><span class="text-light-green">POST</span>
/api/v1/payments/decode</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"invoice": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}api/v1/payments/decode -d
'{"data": &lt;bolt11/lnurl, string&gt;}' -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Check an invoice (incoming or outgoing)"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-light-blue">GET</span>
/api/v1/payments/&lt;payment_hash&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "{{ wallet.inkey }}"}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{"paid": &lt;bool&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}api/v1/payments/&lt;payment_hash&gt; -H "X-Api-Key:
<i>{{ wallet.inkey }}"</i> -H "Content-type: application/json"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
@@ -1,67 +0,0 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user) }}
<script src="/core/static/js/extensions.js"></script>
{% endblock %} {% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-sm-3 col-xs-8 q-ml-auto">
<q-input v-model="searchTerm" label="Search extensions">
<q-icon
v-if="searchTerm !== ''"
name="close"
@click="searchTerm = ''"
class="cursor-pointer q-mt-lg"
/>
</q-input>
</div>
</div>
<div class="row q-col-gutter-md">
<div
class="col-6 col-md-4 col-lg-3"
v-for="extension in filteredExtensions"
:key="extension.code"
>
<q-card>
<q-card-section>
<q-icon
:name="extension.icon"
color="grey-5"
style="font-size: 4rem"
></q-icon>
{% raw %}
<h5 class="q-mt-lg q-mb-xs">{{ extension.name }}</h5>
<small>{{ extension.shortDescription }} </small>{% endraw %}
</q-card-section>
<q-separator></q-separator>
<q-card-actions>
<div v-if="extension.isEnabled">
<q-btn
flat
color="primary"
type="a"
:href="[extension.url, '?usr=', g.user.id].join('')"
>Open</q-btn
>
<q-btn
flat
color="grey-5"
type="a"
:href="['{{ url_for('core.extensions') }}', '?usr=', g.user.id, '&disable=', extension.code].join('')"
>
Disable</q-btn
>
</div>
<q-btn
v-else
flat
color="primary"
type="a"
:href="['{{ url_for('core.extensions') }}', '?usr=', g.user.id, '&enable=', extension.code].join('')"
>
Enable</q-btn
>
</q-card-actions>
</q-card>
</div>
</div>
{% endblock %}
-178
View File
@@ -1,178 +0,0 @@
{% extends "public.html" %} {% block scripts %}
<script src="/core/static/js/index.js"></script>
{% endblock %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section>
{% if lnurl %}
<q-btn
unelevated
color="primary"
@click="processing"
type="a"
href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}"
>
Press to claim bitcoin
</q-btn>
{% else %}
<q-form @submit="createWallet" class="q-gutter-md">
<q-input
filled
dense
v-model="walletName"
label="Name your {{SITE_TITLE}} wallet *"
></q-input>
<q-btn
unelevated
color="primary"
:disable="walletName == ''"
type="submit"
>Add a new wallet</q-btn
>
</q-form>
{% endif %}
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<h3 class="q-my-none">{{SITE_TITLE}}</h3>
<h5 class="q-my-md">{{SITE_TAGLINE}}</h5>
<div v-if="'{{SITE_TITLE}}' == 'LNbits'">
<p>
Easy to set up and lightweight, LNbits can run on any
lightning-network funding source, currently supporting LND,
c-lightning, OpenNode, lntxbot, LNPay and even LNbits itself!
</p>
<p>
You can run LNbits for yourself, or easily offer a custodian
solution for others.
</p>
<p>
Each wallet has its own API keys and there is no limit to the number
of wallets you can make. Being able to partition funds makes LNbits
a useful tool for money management and as a development tool.
</p>
<p>
Extensions add extra functionality to LNbits so you can experiment
with a range of cutting-edge technologies on the lightning network.
We have made developing extensions as easy as possible, and as a
free and open-source project, we encourage people to develop and
submit their own.
</p>
<div class="row q-mt-md q-gutter-sm">
<q-btn
outline
color="grey"
type="a"
href="https://github.com/lnbits/lnbits-legend"
target="_blank"
rel="noopener"
>View project in GitHub</q-btn
>
<q-btn
outline
color="grey"
type="a"
href="https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
target="_blank"
rel="noopener"
>Donate</q-btn
>
</div>
</div>
<p v-else>{{SITE_DESCRIPTION}}</p>
</q-card-section>
</q-card>
</div>
<!-- Ads -->
<div class="col-12 col-md-3 col-lg-3" v-if="'{{SITE_TITLE}}' == 'LNbits'">
<div class="row q-col-gutter-lg justify-center">
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
<q-btn
flat
color="secondary"
label="Runs on"
class="full-width"
></q-btn>
<div class="row">
<div class="col">
<a href="https://github.com/ElementsProject/lightning">
<q-img
contain
:src="($q.dark.isActive) ? '/static/images/cln.png' : '/static/images/clnl.png'"
></q-img>
</a>
</div>
<div class="col q-pl-md">
<a href="https://github.com/lightningnetwork/lnd">
<q-img
contain
:src="($q.dark.isActive) ? '/static/images/lnd.png' : '/static/images/lnd.png'"
></q-img>
</a>
</div>
</div>
<div class="row">
<div class="col">
<a href="https://opennode.com">
<q-img
contain
:src="($q.dark.isActive) ? '/static/images/opennode.png' : '/static/images/opennodel.png'"
></q-img>
</a>
</div>
<div class="col q-pl-md">
<a href="https://lnpay.co/">
<q-img
contain
:src="($q.dark.isActive) ? '/static/images/lnpay.png' : '/static/images/lnpayl.png'"
></q-img>
</a>
</div>
</div>
<div class="row">
<div class="col">
<a href="https://github.com/shesek/spark-wallet">
<q-img
contain
:src="($q.dark.isActive) ? '/static/images/spark.png' : '/static/images/sparkl.png'"
></q-img>
</a>
</div>
<div class="col q-pl-md">
<a href="https://t.me/lntxbot">
<q-img
contain
:src="($q.dark.isActive) ? '/static/images/lntxbot.png' : '/static/images/lntxbotl.png'"
></q-img>
</a>
</div>
</div>
<div class="row">
<div class="col">
<a href="https://github.com/rootzoll/raspiblitz">
<q-img
contain
:src="($q.dark.isActive) ? '/static/images/blitz.png' : '/static/images/blitzl.png'"
></q-img>
</a>
</div>
<div class="col q-pl-md">
<a href="https://getumbrel.com/">
<q-img
contain
:src="($q.dark.isActive) ? '/static/images/umbrel.png' : '/static/images/umbrell.png'"
></q-img>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
-741
View File
@@ -1,741 +0,0 @@
{% extends "base.html" %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user, wallet) }}
<script src="/core/static/js/wallet.js"></script>
{% endblock %}
<!---->
{% block title %} {{ wallet.name }} - {{ SITE_TITLE }} {% endblock %}
<!---->
{% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
<h3 class="q-my-none">
<strong>{% raw %}{{ formattedBalance }} {% endraw %}</strong>
{{LNBITS_DENOMINATION}}
<q-btn
v-if="'{{user.admin}}' == 'True'"
flat
round
color="primary"
icon="add"
size="md"
>
<q-popup-edit
class="bg-accent text-white"
v-slot="scope"
v-model="credit"
>
<q-input
v-if="'{{LNBITS_DENOMINATION}}' != 'sats'"
label="Amount to credit account"
v-model="scope.value"
dense
autofocus
mask="#.##"
fill-mask="0"
reverse-fill-mask
@keyup.enter="updateBalance(scope.value)"
>
<template v-slot:append>
<q-icon name="edit" />
</template>
</q-input>
<q-input
v-else
type="number"
label="Amount to credit account"
v-model="scope.value"
dense
autofocus
@keyup.enter="updateBalance(scope.value)"
>
<template v-slot:append>
<q-icon name="edit" />
</template>
</q-input>
</q-popup-edit>
</q-btn>
</h3>
</q-card-section>
<div class="row q-pb-md q-px-md q-col-gutter-md gt-sm">
<div class="col">
<q-btn
unelevated
color="primary"
class="full-width"
@click="showParseDialog"
>Paste Request</q-btn
>
</div>
<div class="col">
<q-btn
unelevated
color="primary"
class="full-width"
@click="showReceiveDialog"
>Create Invoice</q-btn
>
</div>
<div class="col">
<q-btn
unelevated
color="secondary"
icon="photo_camera"
@click="showCamera"
>scan
<q-tooltip>Use camera to scan an invoice/QR</q-tooltip>
</q-btn>
</div>
</div>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Transactions</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
<!--<q-btn v-if="pendingPaymentsExist" dense flat round icon="update" color="grey" @click="checkPendingPayments">
<q-tooltip>Check pending</q-tooltip>
</q-btn>-->
<q-btn
dense
flat
round
icon="show_chart"
color="grey"
@click="showChart"
>
<q-tooltip>Show chart</q-tooltip>
</q-btn>
</div>
</div>
<q-input
v-if="payments.length > 10"
filled
dense
clearable
v-model="paymentsTable.filter"
debounce="300"
placeholder="Search by tag, memo, amount"
class="q-mb-md"
>
</q-input>
<q-table
dense
flat
:data="filteredPayments"
:row-key="paymentTableRowKey"
:columns="paymentsTable.columns"
:pagination.sync="paymentsTable.pagination"
no-data-label="No transactions made yet"
:filter="paymentsTable.filter"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props"
>{{ col.label }}</q-th
>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width class="text-center">
<q-icon
v-if="props.row.isPaid"
size="14px"
:name="props.row.isOut ? 'call_made' : 'call_received'"
:color="props.row.isOut ? 'pink' : 'green'"
@click="props.expand = !props.expand"
></q-icon>
<q-icon
v-else
name="settings_ethernet"
color="grey"
@click="props.expand = !props.expand"
>
<q-tooltip>Pending</q-tooltip>
</q-icon>
</q-td>
<q-td
key="memo"
:props="props"
style="white-space: normal; word-break: break-all"
>
<q-badge v-if="props.row.tag" color="yellow" text-color="black">
<a
class="inherit"
:href="['/', props.row.tag, '/?usr=', user.id].join('')"
>
#{{ props.row.tag }}
</a>
</q-badge>
{{ props.row.memo }}
</q-td>
<q-td auto-width key="date" :props="props">
<q-tooltip>{{ props.row.date }}</q-tooltip>
{{ props.row.dateFrom }}
</q-td>
{% endraw %}
<q-td
auto-width
key="sat"
v-if="'{{LNBITS_DENOMINATION}}' != 'sats'"
:props="props"
>{% raw %} {{ parseFloat(String(props.row.fsat).replaceAll(",",
"")) / 100 }}
</q-td>
<q-td auto-width key="sat" v-else :props="props">
{{ props.row.fsat }}
</q-td>
<q-td auto-width key="fee" :props="props">
{{ props.row.fee }}
</q-td>
</q-tr>
<q-dialog v-model="props.expand" :props="props">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<div v-if="props.row.isIn && props.row.pending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
Invoice waiting to be paid
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
<div v-if="props.row.bolt11" class="text-center q-mb-lg">
<a :href="'lightning:' + props.row.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
:value="props.row.bolt11"
:options="{width: 340}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(props.row.bolt11)"
>Copy invoice</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Close</q-btn
>
</div>
</div>
<div v-else-if="props.row.isPaid && props.row.isIn">
<q-icon
size="18px"
:name="'call_received'"
:color="'green'"
></q-icon>
Payment Received
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isPaid && props.row.isOut">
<q-icon
size="18px"
:name="'call_made'"
:color="'pink'"
></q-icon>
Payment Sent
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isOut && props.row.pending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
Outgoing payment pending
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
</div>
</q-card>
</q-dialog>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
{% if HIDE_API %}
<div class="col-12 col-md-4 q-gutter-y-md">
{% else %}
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
{{ SITE_TITLE }} Wallet: <strong><em>{{ wallet.name }}</em></strong>
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "core/_api_docs.html" %}
<q-separator></q-separator>
{% if wallet.lnurlwithdraw_full %}
<q-expansion-item
group="extras"
icon="crop_free"
label="Drain Funds"
>
<q-card>
<q-card-section class="text-center">
<p>
This is an LNURL-withdraw QR code for slurping everything
from this wallet. Do not share with anyone.
</p>
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
<qrcode
value="{{wallet.lnurlwithdraw_full}}"
:options="{width:240}"
></qrcode>
</a>
<p>
It is compatible with <code>balanceCheck</code> and
<code>balanceNotify</code> so your wallet may keep pulling
the funds continuously from here after the first withdraw.
</p>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
{% endif %}
<q-expansion-item
group="extras"
icon="settings_cell"
label="Export to Phone with QR Code"
>
<q-card>
<q-card-section class="text-center">
<p>
This QR code contains your wallet URL with full access. You
can scan it from your phone to open your wallet from there.
</p>
<qrcode
:value="'{{request.base_url}}' +'wallet?usr={{user.id}}&wal={{wallet.id}}'"
:options="{width:240}"
></qrcode>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item group="extras" icon="edit" label="Rename wallet">
<q-card>
<q-card-section>
<div class="" style="max-width: 320px">
<q-input
filled
v-model.trim="newName"
label="Label"
dense="dense"
@update:model-value="(e) => console.log(e)"
/>
</div>
<q-btn
:disable="!newName.length"
unelevated
class="q-mt-sm"
color="primary"
@click="updateWalletName()"
>Update name</q-btn
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item
group="extras"
icon="remove_circle"
label="Delete wallet"
>
<q-card>
<q-card-section>
<p>
This whole wallet will be deleted, the funds will be
<strong>UNRECOVERABLE</strong>.
</p>
<q-btn
unelevated
color="red-10"
@click="deleteWallet('{{ wallet.id }}', '{{ user.id }}')"
>Delete wallet</q-btn
>
</q-card-section>
</q-card>
</q-expansion-item>
</q-list>
</q-card-section>
</q-card>
{% endif %} {% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD =
ADS.split(';') %}
<q-card>
<a href="{{ AD[0] }}"
><img width="100%" src="{{ AD[1] }}"
/></a> </q-card
>{% endfor %} {% endif %}
</div>
</div>
<q-dialog v-model="receive.show" @hide="closeReceiveDialog">
{% raw %}
<q-card
v-if="!receive.paymentReq"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<q-form @submit="createInvoice" class="q-gutter-md">
<p v-if="receive.lnurl" class="text-h6 text-center q-my-none">
<b>{{receive.lnurl.domain}}</b> is requesting an invoice:
</p>
{% endraw %} {% if LNBITS_DENOMINATION != 'sats' %}
<q-input
filled
dense
v-model.number="receive.data.amount"
label="Amount ({{LNBITS_DENOMINATION}}) *"
mask="#.##"
fill-mask="0"
reverse-fill-mask
:min="receive.minMax[0]"
:max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed"
></q-input>
{% else %}
<q-select
filled
dense
v-model="receive.unit"
type="text"
label="Unit"
:options="receive.units"
></q-select>
<q-input
ref="setAmount"
filled
dense
v-model.number="receive.data.amount"
:label="'Amount (' + receive.unit + ') *'"
:mask="receive.unit != 'sat' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
:step="receive.unit != 'sat' ? '0.01' : '1'"
:min="receive.minMax[0]"
:max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed"
></q-input>
{% endif %}
<q-input
filled
dense
v-model.trim="receive.data.memo"
label="Memo"
></q-input>
{% raw %}
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="receive.data.amount == null || receive.data.amount <= 0"
type="submit"
>
<span v-if="receive.lnurl">
Withdraw from {{receive.lnurl.domain}}
</span>
<span v-else> Create invoice </span>
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
<q-spinner
v-if="receive.status == 'loading'"
color="primary"
size="2.55em"
></q-spinner>
</q-form>
</q-card>
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<a :href="'lightning:' + receive.paymentReq">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
:value="receive.paymentReq"
:options="{width: 340}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(receive.paymentReq)"
>Copy invoice</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
{% endraw %}
</q-dialog>
<q-dialog v-model="parse.show" @hide="closeParseDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div v-if="parse.invoice">
<h6 v-if="'{{LNBITS_DENOMINATION}}' != 'sats'" class="q-my-none">
{% raw %} {{ parseFloat(String(parse.invoice.fsat).replaceAll(",",
"")) / 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
</h6>
<h6 v-else class="q-my-none">
{{ parse.invoice.fsat }}{% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
</h6>
<q-separator class="q-my-sm"></q-separator>
<p class="text-wrap">
<strong>Description:</strong> {{ parse.invoice.description }}<br />
<strong>Expire date:</strong> {{ parse.invoice.expireDate }}<br />
<strong>Hash:</strong> {{ parse.invoice.hash }}
</p>
{% endraw %}
<div v-if="canPay" class="row q-mt-lg">
<q-btn unelevated color="primary" @click="payInvoice">Pay</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
<div v-else class="row q-mt-lg">
<q-btn unelevated disabled color="yellow" text-color="black"
>Not enough funds!</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</div>
<div v-else-if="parse.lnurlauth">
{% raw %}
<q-form @submit="authLnurl" class="q-gutter-md">
<p class="q-my-none text-h6">
Authenticate with <b>{{ parse.lnurlauth.domain }}</b>?
</p>
<q-separator class="q-my-sm"></q-separator>
<p>
For every website and for every LNbits wallet, a new keypair will be
deterministically generated so your identity can't be tied to your
LNbits wallet or linked across websites. No other data will be
shared with {{ parse.lnurlauth.domain }}.
</p>
<p>Your public key for <b>{{ parse.lnurlauth.domain }}</b> is:</p>
<p class="q-mx-xl">
<code class="text-wrap"> {{ parse.lnurlauth.pubkey }} </code>
</p>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">Login</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
{% endraw %}
</div>
<div v-else-if="parse.lnurlpay">
{% raw %}
<q-form @submit="payLnurl" class="q-gutter-md">
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
<b>{{ parse.lnurlpay.domain }}</b> is requesting {{
parse.lnurlpay.maxSendable | msatoshiFormat }}
{{LNBITS_DENOMINATION}}
<span v-if="parse.lnurlpay.commentAllowed > 0">
<br />
and a {{parse.lnurlpay.commentAllowed}}-char comment
</span>
</p>
<p v-else class="q-my-none text-h6 text-center">
<b>{{ parse.lnurlpay.targetUser || parse.lnurlpay.domain }}</b> is
requesting <br />
between <b>{{ parse.lnurlpay.minSendable | msatoshiFormat }}</b> and
<b>{{ parse.lnurlpay.maxSendable | msatoshiFormat }}</b>
{% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
<span v-if="parse.lnurlpay.commentAllowed > 0">
<br />
and a {{parse.lnurlpay.commentAllowed}}-char comment
</span>
</p>
<q-separator class="q-my-sm"></q-separator>
<div class="row">
<p class="col text-justify text-italic">
{{ parse.lnurlpay.description }}
</p>
<p class="col-4 q-pl-md" v-if="parse.lnurlpay.image">
<q-img :src="parse.lnurlpay.image" />
</p>
</div>
<div class="row">
<div class="col">
{% endraw %}
<q-input
filled
dense
v-model.number="parse.data.amount"
type="number"
label="Amount ({{LNBITS_DENOMINATION}}) *"
:min="parse.lnurlpay.minSendable / 1000"
:max="parse.lnurlpay.maxSendable / 1000"
:readonly="parse.lnurlpay.fixed"
></q-input>
{% raw %}
</div>
<div class="col-8 q-pl-md" v-if="parse.lnurlpay.commentAllowed > 0">
<q-input
filled
dense
v-model="parse.data.comment"
:type="parse.lnurlpay.commentAllowed > 64 ? 'textarea' : 'text'"
label="Comment (optional)"
:maxlength="parse.lnurlpay.commentAllowed"
></q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit"
>Send {{LNBITS_DENOMINATION}}</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
{% endraw %}
</div>
<div v-else>
<q-form
v-if="!parse.camera.show"
@submit="decodeRequest"
class="q-gutter-md"
>
<q-input
filled
dense
v-model.trim="parse.data.request"
type="textarea"
label="Paste an invoice, payment request or lnurl code *"
>
</q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="parse.data.request == ''"
type="submit"
>Read</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
<div v-else>
<q-responsive :ratio="1">
<qrcode-stream
@decode="decodeQR"
class="rounded-borders"
></qrcode-stream>
</q-responsive>
<div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto">
Cancel
</q-btn>
</div>
</div>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="parse.camera.show">
<q-card class="q-pa-lg q-pt-xl">
<div class="text-center q-mb-lg">
<qrcode-stream
@decode="decodeQR"
class="rounded-borders"
></qrcode-stream>
</div>
<div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="paymentsChart.show">
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
<q-card-section>
<canvas ref="canvas" width="600" height="400"></canvas>
</q-card-section>
</q-card>
</q-dialog>
<q-tabs
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-top"
active-class="px-0"
indicator-color="transparent"
>
<q-tab
icon="account_balance_wallet"
label="Wallets"
@click="g.visibleDrawer = !g.visibleDrawer"
>
</q-tab>
<q-tab icon="content_paste" label="Paste" @click="showParseDialog"> </q-tab>
<q-tab icon="file_download" label="Receive" @click="showReceiveDialog">
</q-tab>
<q-tab icon="photo_camera" label="Scan" @click="showCamera"> </q-tab>
</q-tabs>
<q-dialog v-model="disclaimerDialog.show">
<q-card class="q-pa-lg">
<h6 class="q-my-md text-primary">Warning</h6>
<p>
Login functionality to be released in a future update, for now,
<strong
>make sure you bookmark this page for future access to your
wallet</strong
>!
</p>
<p>
This service is in BETA, and we hold no responsibility for people losing
access to funds. {% if service_fee > 0 %} To encourage you to run your
own LNbits installation, any balance on {% raw %}{{
disclaimerDialog.location.host }}{% endraw %} will incur a charge of
<strong>{{ service_fee }}% service fee</strong> per week. {% endif %}
</p>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(disclaimerDialog.location.href)"
>Copy wallet URL</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>I understand</q-btn
>
</div>
</q-card>
</q-dialog>
{% endblock %}
</div>
View File
-659
View File
@@ -1,659 +0,0 @@
import asyncio
import binascii
import hashlib
import json
from http import HTTPStatus
from io import BytesIO
from typing import Dict, List, Optional, Tuple, Union
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
import httpx
import pyqrcode
from fastapi import Depends, Header, Query, Request
from fastapi.exceptions import HTTPException
from fastapi.params import Body
from loguru import logger
from pydantic import BaseModel
from pydantic.fields import Field
from sse_starlette.sse import EventSourceResponse
from starlette.responses import HTMLResponse, StreamingResponse
from lnbits import bolt11, lnurl
from lnbits.core.models import Payment, Wallet
from lnbits.decorators import (
WalletTypeInfo,
get_key_type,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import url_for, urlsafe_short_hash
from lnbits.settings import LNBITS_ADMIN_USERS, LNBITS_SITE_TITLE
from lnbits.utils.exchange_rates import (
currencies,
fiat_amount_as_satoshis,
satoshis_amount_as_fiat,
)
from .. import core_app, db
from ..crud import (
create_payment,
get_payments,
get_standalone_payment,
get_wallet,
get_wallet_for_key,
save_balance_check,
update_payment_status,
update_wallet,
)
from ..services import (
InvoiceFailure,
PaymentFailure,
check_transaction_status,
create_invoice,
pay_invoice,
perform_lnurlauth,
)
from ..tasks import api_invoice_listeners
@core_app.get("/api/v1/wallet")
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
if wallet.wallet_type == 0:
return {
"id": wallet.wallet.id,
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat,
}
else:
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
@core_app.put("/api/v1/wallet/balance/{amount}")
async def api_update_balance(
amount: int, wallet: WalletTypeInfo = Depends(get_key_type)
):
if wallet.wallet.user not in LNBITS_ADMIN_USERS:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Not an admin user"
)
payHash = urlsafe_short_hash()
await create_payment(
wallet_id=wallet.wallet.id,
checking_id=payHash,
payment_request="selfPay",
payment_hash=payHash,
amount=amount * 1000,
memo="selfPay",
fee=0,
)
await update_payment_status(checking_id=payHash, pending=False)
updatedWallet = await get_wallet(wallet.wallet.id)
return {
"id": wallet.wallet.id,
"name": wallet.wallet.name,
"balance": amount,
}
@core_app.put("/api/v1/wallet/{new_name}")
async def api_update_wallet(
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
):
await update_wallet(wallet.wallet.id, new_name)
return {
"id": wallet.wallet.id,
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat,
}
@core_app.get("/api/v1/payments")
async def api_payments(
limit: Optional[int] = None,
offset: Optional[int] = None,
wallet: WalletTypeInfo = Depends(get_key_type),
):
pendingPayments = await get_payments(
wallet_id=wallet.wallet.id,
pending=True,
exclude_uncheckable=True,
limit=limit,
offset=offset,
)
for payment in pendingPayments:
await check_transaction_status(
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
)
return await get_payments(
wallet_id=wallet.wallet.id,
pending=True,
complete=True,
limit=limit,
offset=offset,
)
class CreateInvoiceData(BaseModel):
out: Optional[bool] = True
amount: float = Query(None, ge=0)
memo: Optional[str] = None
unit: Optional[str] = "sat"
description_hash: Optional[str] = None
unhashed_description: Optional[str] = None
lnurl_callback: Optional[str] = None
lnurl_balance_check: Optional[str] = None
extra: Optional[dict] = None
webhook: Optional[str] = None
internal: Optional[bool] = False
bolt11: Optional[str] = None
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
if data.description_hash:
try:
description_hash = binascii.unhexlify(data.description_hash)
except binascii.Error:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'description_hash' must be a valid hex string",
)
unhashed_description = b""
memo = ""
elif data.unhashed_description:
try:
unhashed_description = binascii.unhexlify(data.unhashed_description)
except binascii.Error:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'unhashed_description' must be a valid hex string",
)
description_hash = b""
memo = ""
else:
description_hash = b""
unhashed_description = b""
memo = data.memo or LNBITS_SITE_TITLE
if data.unit == "sat":
amount = int(data.amount)
else:
assert data.unit is not None, "unit not set"
price_in_sats = await fiat_amount_as_satoshis(data.amount, data.unit)
amount = price_in_sats
async with db.connect() as conn:
try:
payment_hash, payment_request = await create_invoice(
wallet_id=wallet.id,
amount=amount,
memo=memo,
description_hash=description_hash,
unhashed_description=unhashed_description,
extra=data.extra,
webhook=data.webhook,
internal=data.internal,
conn=conn,
)
except InvoiceFailure as e:
raise HTTPException(status_code=520, detail=str(e))
except Exception as exc:
raise exc
invoice = bolt11.decode(payment_request)
lnurl_response: Union[None, bool, str] = None
if data.lnurl_callback:
if data.lnurl_balance_check is not None:
await save_balance_check(wallet.id, data.lnurl_balance_check)
async with httpx.AsyncClient() as client:
try:
r = await client.get(
data.lnurl_callback,
params={
"pr": payment_request,
"balanceNotify": url_for(
f"/withdraw/notify/{urlparse(data.lnurl_callback).netloc}",
external=True,
wal=wallet.id,
),
},
timeout=10,
)
if r.is_error:
lnurl_response = r.text
else:
resp = json.loads(r.text)
if resp["status"] != "OK":
lnurl_response = resp["reason"]
else:
lnurl_response = True
except (httpx.ConnectError, httpx.RequestError):
lnurl_response = False
return {
"payment_hash": invoice.payment_hash,
"payment_request": payment_request,
# maintain backwards compatibility with API clients:
"checking_id": invoice.payment_hash,
"lnurl_response": lnurl_response,
}
async def api_payments_pay_invoice(bolt11: str, wallet: Wallet):
try:
payment_hash = await pay_invoice(wallet_id=wallet.id, payment_request=bolt11)
except ValueError as e:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
except PermissionError as e:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(e))
except PaymentFailure as e:
raise HTTPException(status_code=520, detail=str(e))
except Exception as exc:
raise exc
return {
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
@core_app.post(
"/api/v1/payments",
status_code=HTTPStatus.CREATED,
)
async def api_payments_create(
wallet: WalletTypeInfo = Depends(require_invoice_key),
invoiceData: CreateInvoiceData = Body(...), # type: ignore
):
if invoiceData.out is True and wallet.wallet_type == 0:
if not invoiceData.bolt11:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="BOLT11 string is invalid or not given",
)
return await api_payments_pay_invoice(
invoiceData.bolt11, wallet.wallet
) # admin key
elif not invoiceData.out:
# invoice key
return await api_payments_create_invoice(invoiceData, wallet.wallet)
else:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invoice (or Admin) key required.",
)
class CreateLNURLData(BaseModel):
description_hash: str
callback: str
amount: int
comment: Optional[str] = None
description: Optional[str] = None
@core_app.post("/api/v1/payments/lnurl")
async def api_payments_pay_lnurl(
data: CreateLNURLData, wallet: WalletTypeInfo = Depends(require_admin_key)
):
domain = urlparse(data.callback).netloc
async with httpx.AsyncClient() as client:
try:
r = await client.get(
data.callback,
params={"amount": data.amount, "comment": data.comment},
timeout=40,
)
if r.is_error:
raise httpx.ConnectError("LNURL callback connection error")
except (httpx.ConnectError, httpx.RequestError):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to connect to {domain}.",
)
params = json.loads(r.text)
if params.get("status") == "ERROR":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} said: '{params.get('reason', '')}'",
)
if not params.get("pr"):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} did not return a payment request.",
)
invoice = bolt11.decode(params["pr"])
if invoice.amount_msat != data.amount:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} returned an invalid invoice. Expected {data.amount} msat, got {invoice.amount_msat}.",
)
if invoice.description_hash != data.description_hash:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} returned an invalid invoice. Expected description_hash == {data.description_hash}, got {invoice.description_hash}.",
)
extra = {}
if params.get("successAction"):
extra["success_action"] = params["successAction"]
if data.comment:
extra["comment"] = data.comment
assert data.description is not None, "description is required"
payment_hash = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=params["pr"],
description=data.description,
extra=extra,
)
return {
"success_action": params.get("successAction"),
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
async def subscribe(request: Request, wallet: Wallet):
this_wallet_id = wallet.id
payment_queue: asyncio.Queue[Payment] = asyncio.Queue(0)
logger.debug("adding sse listener", payment_queue)
api_invoice_listeners.append(payment_queue)
send_queue: asyncio.Queue[Tuple[str, Payment]] = asyncio.Queue(0)
async def payment_received() -> None:
while True:
payment: Payment = await payment_queue.get()
if payment.wallet_id == this_wallet_id:
logger.debug("payment received", payment)
await send_queue.put(("payment-received", payment))
asyncio.create_task(payment_received())
try:
while True:
typ, data = await send_queue.get()
if data:
jdata = json.dumps(dict(data.dict(), pending=False))
# yield dict(id=1, event="this", data="1234")
# await asyncio.sleep(2)
yield dict(data=jdata, event=typ)
# yield dict(data=jdata.encode("utf-8"), event=typ.encode("utf-8"))
except asyncio.CancelledError:
return
@core_app.get("/api/v1/payments/sse")
async def api_payments_sse(
request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
):
return EventSourceResponse(
subscribe(request, wallet.wallet), ping=20, media_type="text/event-stream"
)
@core_app.get("/api/v1/payments/{payment_hash}")
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
# We use X_Api_Key here because we want this call to work with and without keys
# If a valid key is given, we also return the field "details", otherwise not
wallet = await get_wallet_for_key(X_Api_Key) if type(X_Api_Key) == str else None
# we have to specify the wallet id here, because postgres and sqlite return internal payments in different order
# and get_standalone_payment otherwise just fetches the first one, causing unpredictable results
payment = await get_standalone_payment(
payment_hash, wallet_id=wallet.id if wallet else None
)
if payment is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
await check_transaction_status(payment.wallet_id, payment_hash)
payment = await get_standalone_payment(
payment_hash, wallet_id=wallet.id if wallet else None
)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
elif not payment.pending:
if wallet and wallet.id == payment.wallet_id:
return {"paid": True, "preimage": payment.preimage, "details": payment}
return {"paid": True, "preimage": payment.preimage}
try:
await payment.check_status()
except Exception:
if wallet and wallet.id == payment.wallet_id:
return {"paid": False, "details": payment}
return {"paid": False}
if wallet and wallet.id == payment.wallet_id:
return {
"paid": not payment.pending,
"preimage": payment.preimage,
"details": payment,
}
return {"paid": not payment.pending, "preimage": payment.preimage}
@core_app.get("/api/v1/lnurlscan/{code}")
async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type)):
try:
url = lnurl.decode(code)
domain = urlparse(url).netloc
except:
# parse internet identifier (user@domain.com)
name_domain = code.split("@")
if len(name_domain) == 2 and len(name_domain[1].split(".")) == 2:
name, domain = name_domain
url = (
("http://" if domain.endswith(".onion") else "https://")
+ domain
+ "/.well-known/lnurlp/"
+ name
)
# will proceed with these values
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="invalid lnurl"
)
# params is what will be returned to the client
params: Dict = {"domain": domain}
if "tag=login" in url:
params.update(kind="auth")
params.update(callback=url) # with k1 already in it
lnurlauth_key = wallet.wallet.lnurlauth_key(domain)
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
else:
async with httpx.AsyncClient() as client:
r = await client.get(url, timeout=5)
if r.is_error:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={"domain": domain, "message": "failed to get parameters"},
)
try:
data = json.loads(r.text)
except json.decoder.JSONDecodeError:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={
"domain": domain,
"message": f"got invalid response '{r.text[:200]}'",
},
)
try:
tag: str = data.get("tag")
params.update(**data)
if tag == "channelRequest":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail={
"domain": domain,
"kind": "channel",
"message": "unsupported",
},
)
elif tag == "withdrawRequest":
params.update(kind="withdraw")
params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"])
# callback with k1 already in it
parsed_callback: ParseResult = urlparse(data["callback"])
qs: Dict = parse_qs(parsed_callback.query)
qs["k1"] = data["k1"]
# balanceCheck/balanceNotify
if "balanceCheck" in data:
params.update(balanceCheck=data["balanceCheck"])
# format callback url and send to client
parsed_callback = parsed_callback._replace(
query=urlencode(qs, doseq=True)
)
params.update(callback=urlunparse(parsed_callback))
elif tag == "payRequest":
params.update(kind="pay")
params.update(fixed=data["minSendable"] == data["maxSendable"])
params.update(
description_hash=hashlib.sha256(
data["metadata"].encode("utf-8")
).hexdigest()
)
metadata = json.loads(data["metadata"])
for [k, v] in metadata:
if k == "text/plain":
params.update(description=v)
if k == "image/jpeg;base64" or k == "image/png;base64":
data_uri = "data:" + k + "," + v
params.update(image=data_uri)
if k == "text/email" or k == "text/identifier":
params.update(targetUser=v)
params.update(commentAllowed=data.get("commentAllowed", 0))
except KeyError as exc:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={
"domain": domain,
"message": f"lnurl JSON response invalid: {exc}",
},
)
return params
class DecodePayment(BaseModel):
data: str
@core_app.post("/api/v1/payments/decode")
async def api_payments_decode(data: DecodePayment):
payment_str = data.data
try:
if payment_str[:5] == "LNURL":
url = lnurl.decode(payment_str)
return {"domain": url}
else:
invoice = bolt11.decode(payment_str)
return {
"payment_hash": invoice.payment_hash,
"amount_msat": invoice.amount_msat,
"description": invoice.description,
"description_hash": invoice.description_hash,
"payee": invoice.payee,
"date": invoice.date,
"expiry": invoice.expiry,
"secret": invoice.secret,
"route_hints": invoice.route_hints,
"min_final_cltv_expiry": invoice.min_final_cltv_expiry,
}
except:
return {"message": "Failed to decode"}
class Callback(BaseModel):
callback: str = Query(...)
@core_app.post("/api/v1/lnurlauth")
async def api_perform_lnurlauth(
callback: Callback, wallet: WalletTypeInfo = Depends(require_admin_key)
):
err = await perform_lnurlauth(callback.callback, wallet=wallet)
if err:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason
)
return ""
@core_app.get("/api/v1/currencies")
async def api_list_currencies_available():
return list(currencies.keys())
class ConversionData(BaseModel):
from_: str = Field("sat", alias="from")
amount: float
to: str = Query("usd")
@core_app.post("/api/v1/conversion")
async def api_fiat_as_sats(data: ConversionData):
output = {}
if data.from_ == "sat":
output["BTC"] = data.amount / 100000000
output["sats"] = int(data.amount)
for currency in data.to.split(","):
output[currency.strip().upper()] = await satoshis_amount_as_fiat(
data.amount, currency.strip()
)
return output
else:
output[data.from_.upper()] = data.amount
output["sats"] = await fiat_amount_as_satoshis(data.amount, data.from_)
output["BTC"] = output["sats"] / 100000000
return output
@core_app.get("/api/v1/qrcode/{data}", response_class=StreamingResponse)
async def img(request: Request, data):
qr = pyqrcode.create(data)
stream = BytesIO()
qr.svg(stream, scale=3)
stream.seek(0)
async def _generator(stream: BytesIO):
yield stream.getvalue()
return StreamingResponse(
_generator(stream),
headers={
"Content-Type": "image/svg+xml",
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
},
)
-313
View File
@@ -1,313 +0,0 @@
import asyncio
from http import HTTPStatus
from typing import Optional
from fastapi import Request, status
from fastapi.exceptions import HTTPException
from fastapi.params import Depends, Query
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.routing import APIRouter
from loguru import logger
from pydantic.types import UUID4
from starlette.responses import HTMLResponse, JSONResponse
from lnbits.core import db
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer, url_for
from lnbits.settings import (
LNBITS_ADMIN_USERS,
LNBITS_ALLOWED_USERS,
LNBITS_CUSTOM_LOGO,
LNBITS_SITE_TITLE,
SERVICE_FEE,
)
from ...helpers import get_valid_extensions
from ..crud import (
create_account,
create_wallet,
delete_wallet,
get_balance_check,
get_user,
save_balance_notify,
update_user_extension,
)
from ..services import pay_invoice, redeem_lnurl_withdraw
core_html_routes: APIRouter = APIRouter(tags=["Core NON-API Website Routes"])
@core_html_routes.get("/favicon.ico", response_class=FileResponse)
async def favicon():
return FileResponse("lnbits/core/static/favicon.ico")
@core_html_routes.get("/", response_class=HTMLResponse)
async def home(request: Request, lightning: str = None):
return template_renderer().TemplateResponse(
"core/index.html", {"request": request, "lnurl": lightning}
)
@core_html_routes.get(
"/extensions", name="core.extensions", response_class=HTMLResponse
)
async def extensions(
request: Request,
user: User = Depends(check_user_exists), # type: ignore
enable: str = Query(None), # type: ignore
disable: str = Query(None), # type: ignore
):
extension_to_enable = enable
extension_to_disable = disable
if extension_to_enable and extension_to_disable:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "You can either `enable` or `disable` an extension."
)
# check if extension exists
if extension_to_enable or extension_to_disable:
ext = extension_to_enable or extension_to_disable
if ext not in [e.code for e in get_valid_extensions()]:
raise HTTPException(
HTTPStatus.BAD_REQUEST, f"Extension '{ext}' doesn't exist."
)
if extension_to_enable:
logger.info(f"Enabling extension: {extension_to_enable} for user {user.id}")
await update_user_extension(
user_id=user.id, extension=extension_to_enable, active=True
)
elif extension_to_disable:
logger.info(f"Disabling extension: {extension_to_disable} for user {user.id}")
await update_user_extension(
user_id=user.id, extension=extension_to_disable, active=False
)
# Update user as his extensions have been updated
if extension_to_enable or extension_to_disable:
user = await get_user(user.id) # type: ignore
return template_renderer().TemplateResponse(
"core/extensions.html", {"request": request, "user": user.dict()}
)
@core_html_routes.get(
"/wallet",
response_class=HTMLResponse,
description="""
Args:
just **wallet_name**: create a new user, then create a new wallet for user with wallet_name<br>
just **user_id**: return the first user wallet or create one if none found (with default wallet_name)<br>
**user_id** and **wallet_name**: create a new wallet for user with wallet_name<br>
**user_id** and **wallet_id**: return that wallet if user is the owner<br>
nothing: create everything<br>
""",
)
async def wallet(
request: Request = Query(None), # type: ignore
nme: Optional[str] = Query(None), # type: ignore
usr: Optional[UUID4] = Query(None), # type: ignore
wal: Optional[UUID4] = Query(None), # type: ignore
):
user_id = usr.hex if usr else None
wallet_id = wal.hex if wal else None
wallet_name = nme
service_fee = int(SERVICE_FEE) if int(SERVICE_FEE) == SERVICE_FEE else SERVICE_FEE
if not user_id:
user = await get_user((await create_account()).id)
logger.info(f"Create user {user.id}") # type: ignore
else:
user = await get_user(user_id)
if not user:
return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": "User does not exist."}
)
if LNBITS_ALLOWED_USERS and user_id not in LNBITS_ALLOWED_USERS:
return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": "User not authorized."}
)
if LNBITS_ADMIN_USERS and user_id in LNBITS_ADMIN_USERS:
user.admin = True
if not wallet_id:
if user.wallets and not wallet_name: # type: ignore
wallet = user.wallets[0] # type: ignore
else:
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name) # type: ignore
logger.info(
f"Created new wallet {wallet_name if wallet_name else '(no name)'} for user {user.id}" # type: ignore
)
return RedirectResponse(
f"/wallet?usr={user.id}&wal={wallet.id}", # type: ignore
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
logger.debug(
f"Access {'user '+ user.id + ' ' if user else ''} {'wallet ' + wallet_name if wallet_name else ''}"
)
userwallet = user.get_wallet(wallet_id) # type: ignore
if not userwallet:
return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": "Wallet not found"}
)
return template_renderer().TemplateResponse(
"core/wallet.html",
{
"request": request,
"user": user.dict(), # type: ignore
"wallet": userwallet.dict(),
"service_fee": service_fee,
"web_manifest": f"/manifest/{user.id}.webmanifest", # type: ignore
},
)
@core_html_routes.get("/withdraw", response_class=JSONResponse)
async def lnurl_full_withdraw(request: Request):
user = await get_user(request.query_params.get("usr"))
if not user:
return {"status": "ERROR", "reason": "User does not exist."}
wallet = user.get_wallet(request.query_params.get("wal"))
if not wallet:
return {"status": "ERROR", "reason": "Wallet does not exist."}
return {
"tag": "withdrawRequest",
"callback": url_for("/withdraw/cb", external=True, usr=user.id, wal=wallet.id),
"k1": "0",
"minWithdrawable": 1000 if wallet.withdrawable_balance else 0,
"maxWithdrawable": wallet.withdrawable_balance,
"defaultDescription": f"{LNBITS_SITE_TITLE} balance withdraw from {wallet.id[0:5]}",
"balanceCheck": url_for("/withdraw", external=True, usr=user.id, wal=wallet.id),
}
@core_html_routes.get("/withdraw/cb", response_class=JSONResponse)
async def lnurl_full_withdraw_callback(request: Request):
user = await get_user(request.query_params.get("usr"))
if not user:
return {"status": "ERROR", "reason": "User does not exist."}
wallet = user.get_wallet(request.query_params.get("wal"))
if not wallet:
return {"status": "ERROR", "reason": "Wallet does not exist."}
pr = request.query_params.get("pr")
async def pay():
try:
await pay_invoice(wallet_id=wallet.id, payment_request=pr)
except:
pass
asyncio.create_task(pay())
balance_notify = request.query_params.get("balanceNotify")
if balance_notify:
await save_balance_notify(wallet.id, balance_notify)
return {"status": "OK"}
@core_html_routes.get("/deletewallet", response_class=RedirectResponse)
async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query(...)): # type: ignore
user = await get_user(usr)
user_wallet_ids = [u.id for u in user.wallets] # type: ignore
if wal not in user_wallet_ids:
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
else:
await delete_wallet(user_id=user.id, wallet_id=wal) # type: ignore
user_wallet_ids.remove(wal)
logger.debug("Deleted wallet {wal} of user {user.id}")
if user_wallet_ids:
return RedirectResponse(
url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]), # type: ignore
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
return RedirectResponse(
url_for("/"), status_code=status.HTTP_307_TEMPORARY_REDIRECT
)
@core_html_routes.get("/withdraw/notify/{service}")
async def lnurl_balance_notify(request: Request, service: str):
bc = await get_balance_check(request.query_params.get("wal"), service)
if bc:
await redeem_lnurl_withdraw(bc.wallet, bc.url)
@core_html_routes.get(
"/lnurlwallet", response_class=RedirectResponse, name="core.lnurlwallet"
)
async def lnurlwallet(request: Request):
async with db.connect() as conn:
account = await create_account(conn=conn)
user = await get_user(account.id, conn=conn)
wallet = await create_wallet(user_id=user.id, conn=conn) # type: ignore
asyncio.create_task(
redeem_lnurl_withdraw(
wallet.id,
request.query_params.get("lightning"),
"LNbits initial funding: voucher redeem.",
{"tag": "lnurlwallet"},
5, # wait 5 seconds before sending the invoice to the service
)
)
return RedirectResponse(
f"/wallet?usr={user.id}&wal={wallet.id}", # type: ignore
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
@core_html_routes.get("/service-worker.js", response_class=FileResponse)
async def service_worker():
return FileResponse("lnbits/core/static/js/service-worker.js")
@core_html_routes.get("/manifest/{usr}.webmanifest")
async def manifest(usr: str):
user = await get_user(usr)
if not user:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
return {
"short_name": LNBITS_SITE_TITLE,
"name": LNBITS_SITE_TITLE + " Wallet",
"icons": [
{
"src": LNBITS_CUSTOM_LOGO
if LNBITS_CUSTOM_LOGO
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png",
"type": "image/png",
"sizes": "900x900",
}
],
"start_url": "/wallet?usr=" + usr + "&wal=" + user.wallets[0].id,
"background_color": "#1F2234",
"description": "Bitcoin Lightning Wallet",
"display": "standalone",
"scope": "/",
"theme_color": "#1F2234",
"shortcuts": [
{
"name": wallet.name,
"short_name": wallet.name,
"description": wallet.name,
"url": "/wallet?usr=" + usr + "&wal=" + wallet.id,
}
for wallet in user.wallets
],
}
-71
View File
@@ -1,71 +0,0 @@
import asyncio
import datetime
from http import HTTPStatus
from urllib.parse import urlparse
from fastapi import HTTPException
from loguru import logger
from starlette.requests import Request
from starlette.responses import HTMLResponse
from lnbits import bolt11
from .. import core_app
from ..crud import get_standalone_payment
from ..tasks import api_invoice_listeners
@core_app.get("/.well-known/lnurlp/{username}")
async def lnaddress(username: str, request: Request):
from lnbits.extensions.lnaddress.lnurl import lnurl_response
domain = urlparse(str(request.url)).netloc
return await lnurl_response(username, domain, request)
@core_app.get("/public/v1/payment/{payment_hash}")
async def api_public_payment_longpolling(payment_hash):
payment = await get_standalone_payment(payment_hash)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
elif not payment.pending:
return {"status": "paid"}
try:
invoice = bolt11.decode(payment.bolt11)
expiration = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
if expiration < datetime.datetime.now():
return {"status": "expired"}
except:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid bolt11 invoice."
)
payment_queue = asyncio.Queue(0)
logger.debug("adding standalone invoice listener", payment_hash, payment_queue)
api_invoice_listeners.append(payment_queue)
response = None
async def payment_info_receiver(cancel_scope):
async for payment in payment_queue.get():
if payment.payment_hash == payment_hash:
nonlocal response
response = {"status": "paid"}
cancel_scope.cancel()
async def timeouter(cancel_scope):
await asyncio.sleep(45)
cancel_scope.cancel()
asyncio.create_task(payment_info_receiver())
asyncio.create_task(timeouter())
if response:
return response
else:
raise HTTPException(status_code=HTTPStatus.REQUEST_TIMEOUT, detail="timeout")
-193
View File
@@ -1,193 +0,0 @@
import asyncio
import datetime
import os
import time
from contextlib import asynccontextmanager
from typing import Optional
from loguru import logger
from sqlalchemy import create_engine
from sqlalchemy_aio.base import AsyncConnection
from sqlalchemy_aio.strategy import ASYNCIO_STRATEGY # type: ignore
from .settings import LNBITS_DATA_FOLDER, LNBITS_DATABASE_URL
POSTGRES = "POSTGRES"
COCKROACH = "COCKROACH"
SQLITE = "SQLITE"
class Compat:
type: Optional[str] = "<inherited>"
schema: Optional[str] = "<inherited>"
def interval_seconds(self, seconds: int) -> str:
if self.type in {POSTGRES, COCKROACH}:
return f"interval '{seconds} seconds'"
elif self.type == SQLITE:
return f"{seconds}"
return "<nothing>"
@property
def timestamp_now(self) -> str:
if self.type in {POSTGRES, COCKROACH}:
return "now()"
elif self.type == SQLITE:
return "(strftime('%s', 'now'))"
return "<nothing>"
@property
def serial_primary_key(self) -> str:
if self.type in {POSTGRES, COCKROACH}:
return "SERIAL PRIMARY KEY"
elif self.type == SQLITE:
return "INTEGER PRIMARY KEY AUTOINCREMENT"
return "<nothing>"
@property
def references_schema(self) -> str:
if self.type in {POSTGRES, COCKROACH}:
return f"{self.schema}."
elif self.type == SQLITE:
return ""
return "<nothing>"
class Connection(Compat):
def __init__(self, conn: AsyncConnection, txn, typ, name, schema):
self.conn = conn
self.txn = txn
self.type = typ
self.name = name
self.schema = schema
def rewrite_query(self, query) -> str:
if self.type in {POSTGRES, COCKROACH}:
query = query.replace("%", "%%")
query = query.replace("?", "%s")
return query
async def fetchall(self, query: str, values: tuple = ()) -> list:
result = await self.conn.execute(self.rewrite_query(query), values)
return await result.fetchall()
async def fetchone(self, query: str, values: tuple = ()):
result = await self.conn.execute(self.rewrite_query(query), values)
row = await result.fetchone()
await result.close()
return row
async def execute(self, query: str, values: tuple = ()):
return await self.conn.execute(self.rewrite_query(query), values)
class Database(Compat):
def __init__(self, db_name: str):
self.name = db_name
if LNBITS_DATABASE_URL:
database_uri = LNBITS_DATABASE_URL
if database_uri.startswith("cockroachdb://"):
self.type = COCKROACH
else:
self.type = POSTGRES
import psycopg2 # type: ignore
def _parse_timestamp(value, _):
f = "%Y-%m-%d %H:%M:%S.%f"
if not "." in value:
f = "%Y-%m-%d %H:%M:%S"
return time.mktime(datetime.datetime.strptime(value, f).timetuple())
psycopg2.extensions.register_type(
psycopg2.extensions.new_type(
psycopg2.extensions.DECIMAL.values,
"DEC2FLOAT",
lambda value, curs: float(value) if value is not None else None,
)
)
psycopg2.extensions.register_type(
psycopg2.extensions.new_type(
(1082, 1083, 1266),
"DATE2INT",
lambda value, curs: time.mktime(value.timetuple())
if value is not None
else None,
)
)
psycopg2.extensions.register_type(
psycopg2.extensions.new_type(
(1184, 1114),
"TIMESTAMP2INT",
_parse_timestamp
# lambda value, curs: time.mktime(
# datetime.datetime.strptime(
# value, "%Y-%m-%d %H:%M:%S.%f"
# ).timetuple()
# ),
)
)
else:
if os.path.isdir(LNBITS_DATA_FOLDER):
self.path = os.path.join(LNBITS_DATA_FOLDER, f"{self.name}.sqlite3")
database_uri = f"sqlite:///{self.path}"
self.type = SQLITE
else:
raise NotADirectoryError(
f"LNBITS_DATA_FOLDER named {LNBITS_DATA_FOLDER} was not created"
f" - please 'mkdir {LNBITS_DATA_FOLDER}' and try again"
)
logger.trace(f"database {self.type} added for {self.name}")
self.schema = self.name
if self.name.startswith("ext_"):
self.schema = self.name[4:]
else:
self.schema = None
self.engine = create_engine(database_uri, strategy=ASYNCIO_STRATEGY)
self.lock = asyncio.Lock()
@asynccontextmanager
async def connect(self):
await self.lock.acquire()
try:
async with self.engine.connect() as conn:
async with conn.begin() as txn:
wconn = Connection(conn, txn, self.type, self.name, self.schema)
if self.schema:
if self.type in {POSTGRES, COCKROACH}:
await wconn.execute(
f"CREATE SCHEMA IF NOT EXISTS {self.schema}"
)
elif self.type == SQLITE:
await wconn.execute(
f"ATTACH '{self.path}' AS {self.schema}"
)
yield wconn
finally:
self.lock.release()
async def fetchall(self, query: str, values: tuple = ()) -> list:
async with self.connect() as conn:
result = await conn.execute(query, values)
return await result.fetchall()
async def fetchone(self, query: str, values: tuple = ()):
async with self.connect() as conn:
result = await conn.execute(query, values)
row = await result.fetchone()
await result.close()
return row
async def execute(self, query: str, values: tuple = ()):
async with self.connect() as conn:
return await conn.execute(query, values)
@asynccontextmanager
async def reuse_conn(self, conn: Connection):
yield conn
-239
View File
@@ -1,239 +0,0 @@
from http import HTTPStatus
from typing import Union
from cerberus import Validator # type: ignore
from fastapi import status
from fastapi.exceptions import HTTPException
from fastapi.openapi.models import APIKey, APIKeyIn
from fastapi.params import Security
from fastapi.security.api_key import APIKeyHeader, APIKeyQuery
from fastapi.security.base import SecurityBase
from pydantic.types import UUID4
from starlette.requests import Request
from lnbits.core.crud import get_user, get_wallet_for_key
from lnbits.core.models import User, Wallet
from lnbits.requestvars import g
from lnbits.settings import (
LNBITS_ADMIN_EXTENSIONS,
LNBITS_ADMIN_USERS,
LNBITS_ALLOWED_USERS,
)
class KeyChecker(SecurityBase):
def __init__(
self, scheme_name: str = None, auto_error: bool = True, api_key: str = None
):
self.scheme_name = scheme_name or self.__class__.__name__
self.auto_error = auto_error
self._key_type = "invoice"
self._api_key = api_key
if api_key:
key = APIKey(
**{"in": APIKeyIn.query},
name="X-API-KEY",
description="Wallet API Key - QUERY",
)
else:
key = APIKey(
**{"in": APIKeyIn.header},
name="X-API-KEY",
description="Wallet API Key - HEADER",
)
self.wallet = None # type: ignore
self.model: APIKey = key
async def __call__(self, request: Request):
try:
key_value = (
self._api_key
if self._api_key
else request.headers.get("X-API-KEY") or request.query_params["api-key"]
)
# FIXME: Find another way to validate the key. A fetch from DB should be avoided here.
# Also, we should not return the wallet here - thats silly.
# Possibly store it in a Redis DB
self.wallet = await get_wallet_for_key(key_value, self._key_type) # type: ignore
if not self.wallet:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invalid key or expired key.",
)
except KeyError:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="`X-API-KEY` header missing."
)
class WalletInvoiceKeyChecker(KeyChecker):
"""
WalletInvoiceKeyChecker will ensure that the provided invoice
wallet key is correct and populate g().wallet with the wallet
for the key in `X-API-key`.
The checker will raise an HTTPException when the key is wrong in some ways.
"""
def __init__(
self, scheme_name: str = None, auto_error: bool = True, api_key: str = None
):
super().__init__(scheme_name, auto_error, api_key)
self._key_type = "invoice"
class WalletAdminKeyChecker(KeyChecker):
"""
WalletAdminKeyChecker will ensure that the provided admin
wallet key is correct and populate g().wallet with the wallet
for the key in `X-API-key`.
The checker will raise an HTTPException when the key is wrong in some ways.
"""
def __init__(
self, scheme_name: str = None, auto_error: bool = True, api_key: str = None
):
super().__init__(scheme_name, auto_error, api_key)
self._key_type = "admin"
class WalletTypeInfo:
wallet_type: int
wallet: Wallet
def __init__(self, wallet_type: int, wallet: Wallet) -> None:
self.wallet_type = wallet_type
self.wallet = wallet
api_key_header = APIKeyHeader(
name="X-API-KEY",
auto_error=False,
description="Admin or Invoice key for wallet API's",
)
api_key_query = APIKeyQuery(
name="api-key",
auto_error=False,
description="Admin or Invoice key for wallet API's",
)
async def get_key_type(
r: Request,
api_key_header: str = Security(api_key_header), # type: ignore
api_key_query: str = Security(api_key_query), # type: ignore
) -> WalletTypeInfo:
# 0: admin
# 1: invoice
# 2: invalid
pathname = r["path"].split("/")[1]
token = api_key_header or api_key_query
if not token:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invoice (or Admin) key required.",
)
for typenr, WalletChecker in zip(
[0, 1], [WalletAdminKeyChecker, WalletInvoiceKeyChecker]
):
try:
checker = WalletChecker(api_key=token)
await checker.__call__(r)
wallet = WalletTypeInfo(typenr, checker.wallet) # type: ignore
if wallet is None or wallet.wallet is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
)
if (
LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS
) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS):
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
)
return wallet
except HTTPException as e:
if e.status_code == HTTPStatus.BAD_REQUEST:
raise
if e.status_code == HTTPStatus.UNAUTHORIZED:
pass
except:
raise
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
)
async def require_admin_key(
r: Request,
api_key_header: str = Security(api_key_header), # type: ignore
api_key_query: str = Security(api_key_query), # type: ignore
):
token = api_key_header or api_key_query
if not token:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Admin key required.",
)
wallet = await get_key_type(r, token)
if wallet.wallet_type != 0:
# If wallet type is not admin then return the unauthorized status
# This also covers when the user passes an invalid key type
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin key required."
)
else:
return wallet
async def require_invoice_key(
r: Request,
api_key_header: str = Security(api_key_header), # type: ignore
api_key_query: str = Security(api_key_query), # type: ignore
):
token = api_key_header or api_key_query
if not token:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invoice (or Admin) key required.",
)
wallet = await get_key_type(r, token)
if wallet.wallet_type > 1:
# If wallet type is not invoice then return the unauthorized status
# This also covers when the user passes an invalid key type
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invoice (or Admin) key required.",
)
else:
return wallet
async def check_user_exists(usr: UUID4) -> User:
g().user = await get_user(usr.hex)
if not g().user:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
)
if LNBITS_ALLOWED_USERS and g().user.id not in LNBITS_ALLOWED_USERS:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
)
if LNBITS_ADMIN_USERS and g().user.id in LNBITS_ADMIN_USERS:
g().user.admin = True
return g().user
-21
View File
@@ -1,21 +0,0 @@
# Bleskomat Extension for lnbits
This extension allows you to connect a Bleskomat ATM to an lnbits wallet. It will work with both the [open-source DIY Bleskomat ATM project](https://github.com/samotari/bleskomat) as well as the [commercial Bleskomat ATM](https://www.bleskomat.com/).
## Connect Your Bleskomat ATM
* Click the "Add Bleskomat" button on this page to begin.
* Choose a wallet. This will be the wallet that is used to pay satoshis to your ATM customers.
* Choose the fiat currency. This should match the fiat currency that your ATM accepts.
* Pick an exchange rate provider. This is the API that will be used to query the fiat to satoshi exchange rate at the time your customer attempts to withdraw their funds.
* Set your ATM's fee percentage.
* Click the "Done" button.
* Find the new Bleskomat in the list and then click the export icon to download a new configuration file for your ATM.
* Copy the configuration file ("bleskomat.conf") to your ATM's SD card.
* Restart Your Bleskomat ATM. It should automatically reload the configurations from the SD card.
## How Does It Work?
Since the Bleskomat ATMs are designed to be offline, a cryptographic signing scheme is used to verify that the URL was generated by an authorized device. When one of your customers inserts fiat money into the device, a signed URL (lnurl-withdraw) is created and displayed as a QR code. Your customer scans the QR code with their lnurl-supporting mobile app, their mobile app communicates with the web API of lnbits to verify the signature, the fiat currency amount is converted to sats, the customer accepts the withdrawal, and finally lnbits will pay the customer from your lnbits wallet.
-26
View File
@@ -1,26 +0,0 @@
from fastapi import APIRouter
from starlette.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
db = Database("ext_bleskomat")
bleskomat_static_files = [
{
"path": "/bleskomat/static",
"app": StaticFiles(packages=[("lnbits", "extensions/bleskomat/static")]),
"name": "bleskomat_static",
}
]
bleskomat_ext: APIRouter = APIRouter(prefix="/bleskomat", tags=["Bleskomat"])
def bleskomat_renderer():
return template_renderer(["lnbits/extensions/bleskomat/templates"])
from .lnurl_api import * # noqa
from .views import * # noqa
from .views_api import * # noqa
-6
View File
@@ -1,6 +0,0 @@
{
"name": "Bleskomat",
"short_description": "Connect a Bleskomat ATM to an lnbits",
"icon": "money",
"contributors": ["chill117"]
}
-113
View File
@@ -1,113 +0,0 @@
import secrets
import time
from typing import List, Optional, Union
from uuid import uuid4
from . import db
from .helpers import generate_bleskomat_lnurl_hash
from .models import Bleskomat, BleskomatLnurl, CreateBleskomat
async def create_bleskomat(data: CreateBleskomat, wallet_id: str) -> Bleskomat:
bleskomat_id = uuid4().hex
api_key_id = secrets.token_hex(8)
api_key_secret = secrets.token_hex(32)
api_key_encoding = "hex"
await db.execute(
"""
INSERT INTO bleskomat.bleskomats (id, wallet, api_key_id, api_key_secret, api_key_encoding, name, fiat_currency, exchange_rate_provider, fee)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
bleskomat_id,
wallet_id,
api_key_id,
api_key_secret,
api_key_encoding,
data.name,
data.fiat_currency,
data.exchange_rate_provider,
data.fee,
),
)
bleskomat = await get_bleskomat(bleskomat_id)
assert bleskomat, "Newly created bleskomat couldn't be retrieved"
return bleskomat
async def get_bleskomat(bleskomat_id: str) -> Optional[Bleskomat]:
row = await db.fetchone(
"SELECT * FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,)
)
return Bleskomat(**row) if row else None
async def get_bleskomat_by_api_key_id(api_key_id: str) -> Optional[Bleskomat]:
row = await db.fetchone(
"SELECT * FROM bleskomat.bleskomats WHERE api_key_id = ?", (api_key_id,)
)
return Bleskomat(**row) if row else None
async def get_bleskomats(wallet_ids: Union[str, List[str]]) -> List[Bleskomat]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM bleskomat.bleskomats WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Bleskomat(**row) for row in rows]
async def update_bleskomat(bleskomat_id: str, **kwargs) -> Optional[Bleskomat]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE bleskomat.bleskomats SET {q} WHERE id = ?",
(*kwargs.values(), bleskomat_id),
)
row = await db.fetchone(
"SELECT * FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,)
)
return Bleskomat(**row) if row else None
async def delete_bleskomat(bleskomat_id: str) -> None:
await db.execute("DELETE FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,))
async def create_bleskomat_lnurl(
*, bleskomat: Bleskomat, secret: str, tag: str, params: str, uses: int = 1
) -> BleskomatLnurl:
bleskomat_lnurl_id = uuid4().hex
hash = generate_bleskomat_lnurl_hash(secret)
now = int(time.time())
await db.execute(
"""
INSERT INTO bleskomat.bleskomat_lnurls (id, bleskomat, wallet, hash, tag, params, api_key_id, initial_uses, remaining_uses, created_time, updated_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
bleskomat_lnurl_id,
bleskomat.id,
bleskomat.wallet,
hash,
tag,
params,
bleskomat.api_key_id,
uses,
uses,
now,
now,
),
)
bleskomat_lnurl = await get_bleskomat_lnurl(secret)
assert bleskomat_lnurl, "Newly created bleskomat LNURL couldn't be retrieved"
return bleskomat_lnurl
async def get_bleskomat_lnurl(secret: str) -> Optional[BleskomatLnurl]:
hash = generate_bleskomat_lnurl_hash(secret)
row = await db.fetchone(
"SELECT * FROM bleskomat.bleskomat_lnurls WHERE hash = ?", (hash,)
)
return BleskomatLnurl(**row) if row else None
@@ -1,81 +0,0 @@
import json
import os
import httpx
fiat_currencies = json.load(
open(
os.path.join(
os.path.dirname(os.path.realpath(__file__)), "fiat_currencies.json"
),
"r",
)
)
exchange_rate_providers = {
"bitfinex": {
"name": "Bitfinex",
"domain": "bitfinex.com",
"api_url": "https://api.bitfinex.com/v1/pubticker/{from}{to}",
"getter": lambda data, replacements: data["last_price"],
},
"bitstamp": {
"name": "Bitstamp",
"domain": "bitstamp.net",
"api_url": "https://www.bitstamp.net/api/v2/ticker/{from}{to}/",
"getter": lambda data, replacements: data["last"],
},
"coinbase": {
"name": "Coinbase",
"domain": "coinbase.com",
"api_url": "https://api.coinbase.com/v2/exchange-rates?currency={FROM}",
"getter": lambda data, replacements: data["data"]["rates"][replacements["TO"]],
},
"coinmate": {
"name": "CoinMate",
"domain": "coinmate.io",
"api_url": "https://coinmate.io/api/ticker?currencyPair={FROM}_{TO}",
"getter": lambda data, replacements: data["data"]["last"],
},
"kraken": {
"name": "Kraken",
"domain": "kraken.com",
"api_url": "https://api.kraken.com/0/public/Ticker?pair=XBT{TO}",
"getter": lambda data, replacements: data["result"][
"XXBTZ" + replacements["TO"]
]["c"][0],
},
}
exchange_rate_providers_serializable = {}
for ref, exchange_rate_provider in exchange_rate_providers.items():
exchange_rate_provider_serializable = {}
for key, value in exchange_rate_provider.items():
if not callable(value):
exchange_rate_provider_serializable[key] = value
exchange_rate_providers_serializable[ref] = exchange_rate_provider_serializable
async def fetch_fiat_exchange_rate(currency: str, provider: str):
replacements = {
"FROM": "BTC",
"from": "btc",
"TO": currency.upper(),
"to": currency.lower(),
}
url = exchange_rate_providers[provider]["api_url"]
if url:
for key in replacements.keys():
url = url.replace("{" + key + "}", replacements[key])
async with httpx.AsyncClient() as client:
r = await client.get(url)
r.raise_for_status()
data = r.json()
else:
data = {}
getter = exchange_rate_providers[provider]["getter"]
rate = float(getter(data, replacements))
return rate
@@ -1,166 +0,0 @@
{
"AED": "United Arab Emirates Dirham",
"AFN": "Afghan Afghani",
"ALL": "Albanian Lek",
"AMD": "Armenian Dram",
"ANG": "Netherlands Antillean Gulden",
"AOA": "Angolan Kwanza",
"ARS": "Argentine Peso",
"AUD": "Australian Dollar",
"AWG": "Aruban Florin",
"AZN": "Azerbaijani Manat",
"BAM": "Bosnia and Herzegovina Convertible Mark",
"BBD": "Barbadian Dollar",
"BDT": "Bangladeshi Taka",
"BGN": "Bulgarian Lev",
"BHD": "Bahraini Dinar",
"BIF": "Burundian Franc",
"BMD": "Bermudian Dollar",
"BND": "Brunei Dollar",
"BOB": "Bolivian Boliviano",
"BRL": "Brazilian Real",
"BSD": "Bahamian Dollar",
"BTN": "Bhutanese Ngultrum",
"BWP": "Botswana Pula",
"BYN": "Belarusian Ruble",
"BYR": "Belarusian Ruble",
"BZD": "Belize Dollar",
"CAD": "Canadian Dollar",
"CDF": "Congolese Franc",
"CHF": "Swiss Franc",
"CLF": "Unidad de Fomento",
"CLP": "Chilean Peso",
"CNH": "Chinese Renminbi Yuan Offshore",
"CNY": "Chinese Renminbi Yuan",
"COP": "Colombian Peso",
"CRC": "Costa Rican Colón",
"CUC": "Cuban Convertible Peso",
"CVE": "Cape Verdean Escudo",
"CZK": "Czech Koruna",
"DJF": "Djiboutian Franc",
"DKK": "Danish Krone",
"DOP": "Dominican Peso",
"DZD": "Algerian Dinar",
"EGP": "Egyptian Pound",
"ERN": "Eritrean Nakfa",
"ETB": "Ethiopian Birr",
"EUR": "Euro",
"FJD": "Fijian Dollar",
"FKP": "Falkland Pound",
"GBP": "British Pound",
"GEL": "Georgian Lari",
"GGP": "Guernsey Pound",
"GHS": "Ghanaian Cedi",
"GIP": "Gibraltar Pound",
"GMD": "Gambian Dalasi",
"GNF": "Guinean Franc",
"GTQ": "Guatemalan Quetzal",
"GYD": "Guyanese Dollar",
"HKD": "Hong Kong Dollar",
"HNL": "Honduran Lempira",
"HRK": "Croatian Kuna",
"HTG": "Haitian Gourde",
"HUF": "Hungarian Forint",
"IDR": "Indonesian Rupiah",
"ILS": "Israeli New Sheqel",
"IMP": "Isle of Man Pound",
"INR": "Indian Rupee",
"IQD": "Iraqi Dinar",
"ISK": "Icelandic Króna",
"JEP": "Jersey Pound",
"JMD": "Jamaican Dollar",
"JOD": "Jordanian Dinar",
"JPY": "Japanese Yen",
"KES": "Kenyan Shilling",
"KGS": "Kyrgyzstani Som",
"KHR": "Cambodian Riel",
"KMF": "Comorian Franc",
"KRW": "South Korean Won",
"KWD": "Kuwaiti Dinar",
"KYD": "Cayman Islands Dollar",
"KZT": "Kazakhstani Tenge",
"LAK": "Lao Kip",
"LBP": "Lebanese Pound",
"LKR": "Sri Lankan Rupee",
"LRD": "Liberian Dollar",
"LSL": "Lesotho Loti",
"LYD": "Libyan Dinar",
"MAD": "Moroccan Dirham",
"MDL": "Moldovan Leu",
"MGA": "Malagasy Ariary",
"MKD": "Macedonian Denar",
"MMK": "Myanmar Kyat",
"MNT": "Mongolian Tögrög",
"MOP": "Macanese Pataca",
"MRO": "Mauritanian Ouguiya",
"MUR": "Mauritian Rupee",
"MVR": "Maldivian Rufiyaa",
"MWK": "Malawian Kwacha",
"MXN": "Mexican Peso",
"MYR": "Malaysian Ringgit",
"MZN": "Mozambican Metical",
"NAD": "Namibian Dollar",
"NGN": "Nigerian Naira",
"NIO": "Nicaraguan Córdoba",
"NOK": "Norwegian Krone",
"NPR": "Nepalese Rupee",
"NZD": "New Zealand Dollar",
"OMR": "Omani Rial",
"PAB": "Panamanian Balboa",
"PEN": "Peruvian Sol",
"PGK": "Papua New Guinean Kina",
"PHP": "Philippine Peso",
"PKR": "Pakistani Rupee",
"PLN": "Polish Złoty",
"PYG": "Paraguayan Guaraní",
"QAR": "Qatari Riyal",
"RON": "Romanian Leu",
"RSD": "Serbian Dinar",
"RUB": "Russian Ruble",
"RWF": "Rwandan Franc",
"SAR": "Saudi Riyal",
"SBD": "Solomon Islands Dollar",
"SCR": "Seychellois Rupee",
"SEK": "Swedish Krona",
"SGD": "Singapore Dollar",
"SHP": "Saint Helenian Pound",
"SLL": "Sierra Leonean Leone",
"SOS": "Somali Shilling",
"SRD": "Surinamese Dollar",
"SSP": "South Sudanese Pound",
"STD": "São Tomé and Príncipe Dobra",
"SVC": "Salvadoran Colón",
"SZL": "Swazi Lilangeni",
"THB": "Thai Baht",
"TJS": "Tajikistani Somoni",
"TMT": "Turkmenistani Manat",
"TND": "Tunisian Dinar",
"TOP": "Tongan Paʻanga",
"TRY": "Turkish Lira",
"TTD": "Trinidad and Tobago Dollar",
"TWD": "New Taiwan Dollar",
"TZS": "Tanzanian Shilling",
"UAH": "Ukrainian Hryvnia",
"UGX": "Ugandan Shilling",
"USD": "US Dollar",
"UYU": "Uruguayan Peso",
"UZS": "Uzbekistan Som",
"VEF": "Venezuelan Bolívar",
"VES": "Venezuelan Bolívar Soberano",
"VND": "Vietnamese Đồng",
"VUV": "Vanuatu Vatu",
"WST": "Samoan Tala",
"XAF": "Central African Cfa Franc",
"XAG": "Silver (Troy Ounce)",
"XAU": "Gold (Troy Ounce)",
"XCD": "East Caribbean Dollar",
"XDR": "Special Drawing Rights",
"XOF": "West African Cfa Franc",
"XPD": "Palladium",
"XPF": "Cfp Franc",
"XPT": "Platinum",
"YER": "Yemeni Rial",
"ZAR": "South African Rand",
"ZMW": "Zambian Kwacha",
"ZWL": "Zimbabwean Dollar"
}
-154
View File
@@ -1,154 +0,0 @@
import base64
import hashlib
import hmac
import urllib
from binascii import unhexlify
from http import HTTPStatus
from typing import Dict
from starlette.requests import Request
def generate_bleskomat_lnurl_hash(secret: str):
m = hashlib.sha256()
m.update(f"{secret}".encode())
return m.hexdigest()
def generate_bleskomat_lnurl_signature(
payload: str, api_key_secret: str, api_key_encoding: str = "hex"
):
if api_key_encoding == "hex":
key = unhexlify(api_key_secret)
elif api_key_encoding == "base64":
key = base64.b64decode(api_key_secret)
else:
key = bytes(f"{api_key_secret}")
return hmac.new(key=key, msg=payload.encode(), digestmod=hashlib.sha256).hexdigest()
def generate_bleskomat_lnurl_secret(api_key_id: str, signature: str):
# The secret is not randomly generated by the server.
# Instead it is the hash of the API key ID and signature concatenated together.
m = hashlib.sha256()
m.update(f"{api_key_id}-{signature}".encode())
return m.hexdigest()
def get_callback_url(req: Request):
return req.url_for("bleskomat.api_bleskomat_lnurl")
def is_supported_lnurl_subprotocol(tag: str) -> bool:
return tag == "withdrawRequest"
class LnurlHttpError(Exception):
def __init__(
self,
message: str = "",
http_status: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR,
):
self.message = message
self.http_status = http_status
super().__init__(self.message)
class LnurlValidationError(Exception):
pass
def prepare_lnurl_params(tag: str, query: Dict[str, str]):
params = {}
if not is_supported_lnurl_subprotocol(tag):
raise LnurlValidationError(f'Unsupported subprotocol: "{tag}"')
if tag == "withdrawRequest":
params["minWithdrawable"] = float(query["minWithdrawable"])
params["maxWithdrawable"] = float(query["maxWithdrawable"])
params["defaultDescription"] = query["defaultDescription"]
if not params["minWithdrawable"] > 0:
raise LnurlValidationError('"minWithdrawable" must be greater than zero')
if not params["maxWithdrawable"] >= params["minWithdrawable"]:
raise LnurlValidationError(
'"maxWithdrawable" must be greater than or equal to "minWithdrawable"'
)
return params
encode_uri_component_safe_chars = (
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.!~*'()"
)
def query_to_signing_payload(query: Dict[str, str]) -> str:
# Sort the query by key, then stringify it to create the payload.
sorted_keys = sorted(query.keys(), key=str.lower)
payload = []
for key in sorted_keys:
if not key == "signature":
encoded_key = urllib.parse.quote(key, safe=encode_uri_component_safe_chars)
encoded_value = urllib.parse.quote(
query[key], safe=encode_uri_component_safe_chars
)
payload.append(f"{encoded_key}={encoded_value}")
return "&".join(payload)
unshorten_rules = {
"query": {"n": "nonce", "s": "signature", "t": "tag"},
"tags": {
"c": "channelRequest",
"l": "login",
"p": "payRequest",
"w": "withdrawRequest",
},
"params": {
"channelRequest": {"pl": "localAmt", "pp": "pushAmt"},
"login": {},
"payRequest": {"pn": "minSendable", "px": "maxSendable", "pm": "metadata"},
"withdrawRequest": {
"pn": "minWithdrawable",
"px": "maxWithdrawable",
"pd": "defaultDescription",
},
},
}
def unshorten_lnurl_query(query: Dict[str, str]) -> Dict[str, str]:
new_query = {}
rules = unshorten_rules
if "tag" in query:
tag = query["tag"]
elif "t" in query:
tag = query["t"]
else:
raise LnurlValidationError('Missing required query parameter: "tag"')
# Unshorten tag:
if tag in rules["tags"]:
long_tag = rules["tags"][tag]
new_query["tag"] = long_tag
tag = long_tag
if not tag in rules["params"]:
raise LnurlValidationError(f'Unknown tag: "{tag}"')
for key in query:
if key in rules["params"][tag]:
short_param_key = key
long_param_key = rules["params"][tag][short_param_key]
if short_param_key in query:
new_query[long_param_key] = query[short_param_key]
else:
new_query[long_param_key] = query[long_param_key]
elif key in rules["query"]:
# Unshorten general keys:
short_key = key
long_key = rules["query"][short_key]
if not long_key in new_query:
if short_key in query:
new_query[long_key] = query[short_key]
else:
new_query[long_key] = query[long_key]
else:
# Keep unknown key/value pairs unchanged:
new_query[key] = query[key]
return new_query
-129
View File
@@ -1,129 +0,0 @@
import json
import math
import traceback
from http import HTTPStatus
from loguru import logger
from starlette.requests import Request
from . import bleskomat_ext
from .crud import (
create_bleskomat_lnurl,
get_bleskomat_by_api_key_id,
get_bleskomat_lnurl,
)
from .exchange_rates import fetch_fiat_exchange_rate
from .helpers import (
LnurlHttpError,
LnurlValidationError,
generate_bleskomat_lnurl_secret,
generate_bleskomat_lnurl_signature,
prepare_lnurl_params,
query_to_signing_payload,
unshorten_lnurl_query,
)
# Handles signed URL from Bleskomat ATMs and "action" callback of auto-generated LNURLs.
@bleskomat_ext.get("/u", name="bleskomat.api_bleskomat_lnurl")
async def api_bleskomat_lnurl(req: Request):
try:
query = req.query_params
# Unshorten query if "s" is used instead of "signature".
if "s" in query:
query = unshorten_lnurl_query(query)
if "signature" in query:
# Signature provided.
# Use signature to verify that the URL was generated by an authorized device.
# Later validate parameters, auto-generate LNURL, reply with LNURL response object.
signature = query["signature"]
# The API key ID, nonce, and tag should be present in the query string.
for field in ["id", "nonce", "tag"]:
if not field in query:
raise LnurlHttpError(
f'Failed API key signature check: Missing "{field}"',
HTTPStatus.BAD_REQUEST,
)
# URL signing scheme is described here:
# https://github.com/chill117/lnurl-node#how-to-implement-url-signing-scheme
payload = query_to_signing_payload(query)
api_key_id = query["id"]
bleskomat = await get_bleskomat_by_api_key_id(api_key_id)
if not bleskomat:
raise LnurlHttpError("Unknown API key", HTTPStatus.BAD_REQUEST)
api_key_secret = bleskomat.api_key_secret
api_key_encoding = bleskomat.api_key_encoding
expected_signature = generate_bleskomat_lnurl_signature(
payload, api_key_secret, api_key_encoding
)
if signature != expected_signature:
raise LnurlHttpError("Invalid API key signature", HTTPStatus.FORBIDDEN)
# Signature is valid.
# In the case of signed URLs, the secret is deterministic based on the API key ID and signature.
secret = generate_bleskomat_lnurl_secret(api_key_id, signature)
lnurl = await get_bleskomat_lnurl(secret)
if not lnurl:
try:
tag = query["tag"]
params = prepare_lnurl_params(tag, query)
if "f" in query:
rate = await fetch_fiat_exchange_rate(
currency=query["f"],
provider=bleskomat.exchange_rate_provider,
)
# Convert fee (%) to decimal:
fee = float(bleskomat.fee) / 100
if tag == "withdrawRequest":
for key in ["minWithdrawable", "maxWithdrawable"]:
amount_sats = int(
math.floor((params[key] / rate) * 1e8)
)
fee_sats = int(math.floor(amount_sats * fee))
amount_sats_less_fee = amount_sats - fee_sats
# Convert to msats:
params[key] = int(amount_sats_less_fee * 1e3)
except LnurlValidationError as e:
raise LnurlHttpError(e.message, HTTPStatus.BAD_REQUEST)
# Create a new LNURL using the query parameters provided in the signed URL.
params = json.JSONEncoder().encode(params)
lnurl = await create_bleskomat_lnurl(
bleskomat=bleskomat, secret=secret, tag=tag, params=params, uses=1
)
# Reply with LNURL response object.
return lnurl.get_info_response_object(secret, req)
# No signature provided.
# Treat as "action" callback.
if not "k1" in query:
raise LnurlHttpError("Missing secret", HTTPStatus.BAD_REQUEST)
secret = query["k1"]
lnurl = await get_bleskomat_lnurl(secret)
if not lnurl:
raise LnurlHttpError("Invalid secret", HTTPStatus.BAD_REQUEST)
if not lnurl.has_uses_remaining():
raise LnurlHttpError(
"Maximum number of uses already reached", HTTPStatus.BAD_REQUEST
)
try:
await lnurl.execute_action(query)
except LnurlValidationError as e:
raise LnurlHttpError(str(e), HTTPStatus.BAD_REQUEST)
except LnurlHttpError as e:
return {"status": "ERROR", "reason": str(e)}
except Exception as e:
logger.error(str(e))
return {"status": "ERROR", "reason": "Unexpected error"}
return {"status": "OK"}
-37
View File
@@ -1,37 +0,0 @@
async def m001_initial(db):
await db.execute(
"""
CREATE TABLE bleskomat.bleskomats (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
api_key_id TEXT NOT NULL,
api_key_secret TEXT NOT NULL,
api_key_encoding TEXT NOT NULL,
name TEXT NOT NULL,
fiat_currency TEXT NOT NULL,
exchange_rate_provider TEXT NOT NULL,
fee TEXT NOT NULL,
UNIQUE(api_key_id)
);
"""
)
await db.execute(
"""
CREATE TABLE bleskomat.bleskomat_lnurls (
id TEXT PRIMARY KEY,
bleskomat TEXT NOT NULL,
wallet TEXT NOT NULL,
hash TEXT NOT NULL,
tag TEXT NOT NULL,
params TEXT NOT NULL,
api_key_id TEXT NOT NULL,
initial_uses INTEGER DEFAULT 1,
remaining_uses INTEGER DEFAULT 0,
created_time INTEGER,
updated_time INTEGER,
UNIQUE(hash)
);
"""
)
-143
View File
@@ -1,143 +0,0 @@
import json
import time
from typing import Dict
from fastapi.params import Query
from loguru import logger
from pydantic import BaseModel, validator
from starlette.requests import Request
from lnbits import bolt11
from lnbits.core.services import PaymentFailure, pay_invoice
from . import db
from .exchange_rates import exchange_rate_providers, fiat_currencies
from .helpers import LnurlValidationError, get_callback_url
class CreateBleskomat(BaseModel):
name: str = Query(...)
fiat_currency: str = Query(...)
exchange_rate_provider: str = Query(...)
fee: str = Query(...)
@validator("fiat_currency")
def allowed_fiat_currencies(cls, v):
if v not in fiat_currencies.keys():
raise ValueError("Not allowed currency")
return v
@validator("exchange_rate_provider")
def allowed_providers(cls, v):
if v not in exchange_rate_providers.keys():
raise ValueError("Not allowed provider")
return v
@validator("fee")
def fee_type(cls, v):
if not isinstance(v, (str, float, int)):
raise ValueError("Fee type not allowed")
return v
class Bleskomat(BaseModel):
id: str
wallet: str
api_key_id: str
api_key_secret: str
api_key_encoding: str
name: str
fiat_currency: str
exchange_rate_provider: str
fee: str
class BleskomatLnurl(BaseModel):
id: str
bleskomat: str
wallet: str
hash: str
tag: str
params: str
api_key_id: str
initial_uses: int
remaining_uses: int
created_time: int
updated_time: int
def has_uses_remaining(self) -> bool:
# When initial uses is 0 then the LNURL has unlimited uses.
return self.initial_uses == 0 or self.remaining_uses > 0
def get_info_response_object(self, secret: str, req: Request) -> Dict[str, str]:
tag = self.tag
params = json.loads(self.params)
response = {"tag": tag}
if tag == "withdrawRequest":
for key in ["minWithdrawable", "maxWithdrawable", "defaultDescription"]:
response[key] = params[key]
response["callback"] = get_callback_url(req)
response["k1"] = secret
return response
def validate_action(self, query: Dict[str, str]) -> None:
tag = self.tag
params = json.loads(self.params)
# Perform tag-specific checks.
if tag == "withdrawRequest":
for field in ["pr"]:
if not field in query:
raise LnurlValidationError(f'Missing required parameter: "{field}"')
# Check the bolt11 invoice(s) provided.
pr = query["pr"]
if "," in pr:
raise LnurlValidationError("Multiple payment requests not supported")
try:
invoice = bolt11.decode(pr)
except ValueError:
raise LnurlValidationError(
'Invalid parameter ("pr"): Lightning payment request expected'
)
if invoice.amount_msat < params["minWithdrawable"]:
raise LnurlValidationError(
'Amount in invoice must be greater than or equal to "minWithdrawable"'
)
if invoice.amount_msat > params["maxWithdrawable"]:
raise LnurlValidationError(
'Amount in invoice must be less than or equal to "maxWithdrawable"'
)
else:
raise LnurlValidationError(f'Unknown subprotocol: "{tag}"')
async def execute_action(self, query: Dict[str, str]):
self.validate_action(query)
used = False
async with db.connect() as conn:
if self.initial_uses > 0:
used = await self.use(conn)
if not used:
raise LnurlValidationError("Maximum number of uses already reached")
tag = self.tag
if tag == "withdrawRequest":
try:
await pay_invoice(
wallet_id=self.wallet, payment_request=query["pr"]
)
except (ValueError, PermissionError, PaymentFailure) as e:
raise LnurlValidationError("Failed to pay invoice: " + str(e))
except Exception as e:
logger.error(str(e))
raise LnurlValidationError("Unexpected error")
async def use(self, conn) -> bool:
now = int(time.time())
result = await conn.execute(
"""
UPDATE bleskomat.bleskomat_lnurls
SET remaining_uses = remaining_uses - 1, updated_time = ?
WHERE id = ?
AND remaining_uses > 0
""",
(now, self.id),
)
return result.rowcount > 0
@@ -1,216 +0,0 @@
/* global Vue, VueQrcode, _, Quasar, LOCALE, windowMixin, LNbits */
Vue.component(VueQrcode.name, VueQrcode)
var mapBleskomat = function (obj) {
obj._data = _.clone(obj)
return obj
}
var defaultValues = {
name: 'My Bleskomat',
fiat_currency: 'EUR',
exchange_rate_provider: 'coinbase',
fee: '0.00'
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
checker: null,
bleskomats: [],
bleskomatsTable: {
columns: [
{
name: 'api_key_id',
align: 'left',
label: 'API Key ID',
field: 'api_key_id'
},
{
name: 'name',
align: 'left',
label: 'Name',
field: 'name'
},
{
name: 'fiat_currency',
align: 'left',
label: 'Fiat Currency',
field: 'fiat_currency'
},
{
name: 'exchange_rate_provider',
align: 'left',
label: 'Exchange Rate Provider',
field: 'exchange_rate_provider'
},
{
name: 'fee',
align: 'left',
label: 'Fee (%)',
field: 'fee'
}
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
fiatCurrencies: _.keys(window.bleskomat_vars.fiat_currencies),
exchangeRateProviders: _.keys(
window.bleskomat_vars.exchange_rate_providers
),
data: _.clone(defaultValues)
}
}
},
computed: {
sortedBleskomats: function () {
return this.bleskomats.sort(function (a, b) {
// Sort by API Key ID alphabetically.
var apiKeyId_A = a.api_key_id.toLowerCase()
var apiKeyId_B = b.api_key_id.toLowerCase()
return apiKeyId_A < apiKeyId_B ? -1 : apiKeyId_A > apiKeyId_B ? 1 : 0
})
}
},
methods: {
getBleskomats: function () {
var self = this
LNbits.api
.request(
'GET',
'/bleskomat/api/v1/bleskomats?all_wallets=true',
this.g.user.wallets[0].adminkey
)
.then(function (response) {
self.bleskomats = response.data.map(function (obj) {
return mapBleskomat(obj)
})
})
.catch(function (error) {
clearInterval(self.checker)
LNbits.utils.notifyApiError(error)
})
},
closeFormDialog: function () {
this.formDialog.data = _.clone(defaultValues)
},
exportConfigFile: function (bleskomatId) {
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
var fieldToKey = {
api_key_id: 'apiKey.id',
api_key_secret: 'apiKey.key',
api_key_encoding: 'apiKey.encoding',
fiat_currency: 'fiatCurrency'
}
var lines = _.chain(bleskomat)
.map(function (value, field) {
var key = fieldToKey[field] || null
return key ? [key, value].join('=') : null
})
.compact()
.value()
lines.push('callbackUrl=' + window.bleskomat_vars.callback_url)
lines.push('shorten=true')
var content = lines.join('\n')
var status = Quasar.utils.exportFile(
'bleskomat.conf',
content,
'text/plain'
)
if (status !== true) {
Quasar.plugins.Notify.create({
message: 'Browser denied file download...',
color: 'negative',
icon: null
})
}
},
openUpdateDialog: function (bleskomatId) {
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
this.formDialog.data = _.clone(bleskomat._data)
this.formDialog.show = true
},
sendFormData: function () {
var wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet
})
var data = _.omit(this.formDialog.data, 'wallet')
if (data.id) {
this.updateBleskomat(wallet, data)
} else {
this.createBleskomat(wallet, data)
}
},
updateBleskomat: function (wallet, data) {
var self = this
LNbits.api
.request(
'PUT',
'/bleskomat/api/v1/bleskomat/' + data.id,
wallet.adminkey,
_.pick(data, 'name', 'fiat_currency', 'exchange_rate_provider', 'fee')
)
.then(function (response) {
self.bleskomats = _.reject(self.bleskomats, function (obj) {
return obj.id === data.id
})
self.bleskomats.push(mapBleskomat(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
createBleskomat: function (wallet, data) {
var self = this
LNbits.api
.request('POST', '/bleskomat/api/v1/bleskomat', wallet.adminkey, data)
.then(function (response) {
self.bleskomats.push(mapBleskomat(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteBleskomat: function (bleskomatId) {
var self = this
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
LNbits.utils
.confirmDialog(
'Are you sure you want to delete "' + bleskomat.name + '"?'
)
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/bleskomat/api/v1/bleskomat/' + bleskomatId,
_.findWhere(self.g.user.wallets, {id: bleskomat.wallet}).adminkey
)
.then(function (response) {
self.bleskomats = _.reject(self.bleskomats, function (obj) {
return obj.id === bleskomatId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
}
},
created: function () {
if (this.g.user.wallets.length) {
var getBleskomats = this.getBleskomats
getBleskomats()
this.checker = setInterval(function () {
getBleskomats()
}, 20000)
}
}
})
@@ -1,66 +0,0 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="Setup guide"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<p>
This extension allows you to connect a Bleskomat ATM to an lnbits
wallet. It will work with both the
<a href="https://github.com/samotari/bleskomat"
>open-source DIY Bleskomat ATM project</a
>
as well as the
<a href="https://www.bleskomat.com/">commercial Bleskomat ATM</a>.
</p>
<h5 class="text-subtitle1 q-my-none">Connect Your Bleskomat ATM</h5>
<div>
<ol>
<li>Click the "Add Bleskomat" button on this page to begin.</li>
<li>
Choose a wallet. This will be the wallet that is used to pay
satoshis to your ATM customers.
</li>
<li>
Choose the fiat currency. This should match the fiat currency that
your ATM accepts.
</li>
<li>
Pick an exchange rate provider. This is the API that will be used to
query the fiat to satoshi exchange rate at the time your customer
attempts to withdraw their funds.
</li>
<li>Set your ATM's fee percentage.</li>
<li>Click the "Done" button.</li>
<li>
Find the new Bleskomat in the list and then click the export icon to
download a new configuration file for your ATM.
</li>
<li>
Copy the configuration file ("bleskomat.conf") to your ATM's SD
card.
</li>
<li>
Restart Your Bleskomat ATM. It should automatically reload the
configurations from the SD card.
</li>
</ol>
</div>
<h5 class="text-subtitle1 q-my-none">How does it work?</h5>
<p>
Since the Bleskomat ATMs are designed to be offline, a cryptographic
signing scheme is used to verify that the URL was generated by an
authorized device. When one of your customers inserts fiat money into
the device, a signed URL (lnurl-withdraw) is created and displayed as a
QR code. Your customer scans the QR code with their lnurl-supporting
mobile app, their mobile app communicates with the web API of lnbits to
verify the signature, the fiat currency amount is converted to sats, the
customer accepts the withdrawal, and finally lnbits will pay the
customer from your lnbits wallet.
</p>
</q-card-section>
</q-card>
<q-btn flat label="Swagger API" type="a" href="../docs#/Bleskomat"></q-btn>
</q-expansion-item>
@@ -1,180 +0,0 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user) }}
<script>
{% if bleskomat_vars %}
window.bleskomat_vars = {{ bleskomat_vars | tojson | safe }}
{% endif %}
</script>
<script src="/bleskomat/static/js/index.js"></script>
{% endblock %} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true"
>Add Bleskomat</q-btn
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Bleskomats</h5>
</div>
</div>
<q-table
dense
flat
:data="sortedBleskomats"
row-key="id"
:columns="bleskomatsTable.columns"
:pagination.sync="bleskomatsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
flat
dense
size="xs"
icon="file_download"
color="orange"
@click="exportConfigFile(props.row.id)"
>
<q-tooltip content-class="bg-accent"
>Export Configuration</q-tooltip
>
</q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="openUpdateDialog(props.row.id)"
icon="edit"
color="light-blue"
>
<q-tooltip content-class="bg-accent">Edit</q-tooltip>
</q-btn>
<q-btn
flat
dense
size="xs"
@click="deleteBleskomat(props.row.id)"
icon="cancel"
color="pink"
>
<q-tooltip content-class="bg-accent">Delete</q-tooltip>
</q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} Bleskomat extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "bleskomat/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormData" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
<q-input
filled
dense
v-model.trim="formDialog.data.name"
type="text"
label="Name *"
></q-input>
<q-select
filled
dense
v-model="formDialog.data.fiat_currency"
:options="formDialog.fiatCurrencies"
label="Fiat Currency *"
>
</q-select>
<q-select
filled
dense
v-model="formDialog.data.exchange_rate_provider"
:options="formDialog.exchangeRateProviders"
label="Exchange Rate Provider *"
>
</q-select>
<q-input
filled
dense
v-model.number="formDialog.data.fee"
type="string"
:default="0.00"
label="Fee (%) *"
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="formDialog.data.id"
unelevated
color="primary"
type="submit"
>Update Bleskomat</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="
formDialog.data.wallet == null ||
formDialog.data.name == null ||
formDialog.data.fiat_currency == null ||
formDialog.data.exchange_rate_provider == null ||
formDialog.data.fee == null"
type="submit"
>Add Bleskomat</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %}
-26
View File
@@ -1,26 +0,0 @@
from fastapi import Request
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import bleskomat_ext, bleskomat_renderer
from .exchange_rates import exchange_rate_providers_serializable, fiat_currencies
from .helpers import get_callback_url
templates = Jinja2Templates(directory="templates")
@bleskomat_ext.get("/", response_class=HTMLResponse)
async def index(req: Request, user: User = Depends(check_user_exists)):
bleskomat_vars = {
"callback_url": get_callback_url(req),
"exchange_rate_providers": exchange_rate_providers_serializable,
"fiat_currencies": fiat_currencies,
}
return bleskomat_renderer().TemplateResponse(
"bleskomat/index.html",
{"request": req, "user": user.dict(), "bleskomat_vars": bleskomat_vars},
)
-98
View File
@@ -1,98 +0,0 @@
from http import HTTPStatus
from fastapi import Depends, Query
from loguru import logger
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user
from lnbits.decorators import WalletTypeInfo, require_admin_key
from lnbits.extensions.bleskomat.models import CreateBleskomat
from . import bleskomat_ext
from .crud import (
create_bleskomat,
delete_bleskomat,
get_bleskomat,
get_bleskomats,
update_bleskomat,
)
from .exchange_rates import fetch_fiat_exchange_rate
@bleskomat_ext.get("/api/v1/bleskomats")
async def api_bleskomats(
wallet: WalletTypeInfo = Depends(require_admin_key),
all_wallets: bool = Query(False),
):
wallet_ids = [wallet.wallet.id]
if all_wallets:
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
return [bleskomat.dict() for bleskomat in await get_bleskomats(wallet_ids)]
@bleskomat_ext.get("/api/v1/bleskomat/{bleskomat_id}")
async def api_bleskomat_retrieve(
bleskomat_id, wallet: WalletTypeInfo = Depends(require_admin_key)
):
bleskomat = await get_bleskomat(bleskomat_id)
if not bleskomat or bleskomat.wallet != wallet.wallet.id:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Bleskomat configuration not found.",
)
return bleskomat.dict()
@bleskomat_ext.post("/api/v1/bleskomat")
@bleskomat_ext.put("/api/v1/bleskomat/{bleskomat_id}")
async def api_bleskomat_create_or_update(
data: CreateBleskomat,
wallet: WalletTypeInfo = Depends(require_admin_key),
bleskomat_id=None,
):
try:
fiat_currency = data.fiat_currency
exchange_rate_provider = data.exchange_rate_provider
await fetch_fiat_exchange_rate(
currency=fiat_currency, provider=exchange_rate_provider
)
except Exception as e:
logger.error(e)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f'Failed to fetch BTC/{fiat_currency} currency pair from "{exchange_rate_provider}"',
)
if bleskomat_id:
bleskomat = await get_bleskomat(bleskomat_id)
if not bleskomat or bleskomat.wallet != wallet.wallet.id:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Bleskomat configuration not found.",
)
bleskomat = await update_bleskomat(bleskomat_id, **data.dict())
else:
bleskomat = await create_bleskomat(wallet_id=wallet.wallet.id, data=data)
return bleskomat.dict()
@bleskomat_ext.delete("/api/v1/bleskomat/{bleskomat_id}")
async def api_bleskomat_delete(
bleskomat_id, wallet: WalletTypeInfo = Depends(require_admin_key)
):
bleskomat = await get_bleskomat(bleskomat_id)
if not bleskomat or bleskomat.wallet != wallet.wallet.id:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Bleskomat configuration not found.",
)
await delete_bleskomat(bleskomat_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
-73
View File
@@ -1,73 +0,0 @@
# Bolt cards (NXP NTAG) Extension
This extension allows you to link your Bolt Card (or other compatible NXP NTAG device) with a LNbits instance and use it in a more secure way than a static LNURLw. A technology called [Secure Unique NFC](https://mishka-scan.com/blog/secure-unique-nfc) is utilized in this workflow.
<a href="https://www.youtube.com/watch?v=wJ7QLFTRjK0">Tutorial</a>
**Disclaimer:** ***Use this only if you either know what you are doing or are a reckless lightning pioneer. Only you are responsible for all your sats, cards and other devices. Always backup all your card keys!***
***In order to use this extension you need to be able to setup your own card.*** That means writing a URL template pointing to your LNBits instance, configuring some SUN (SDM) settings and optionally changing the card's keys. There's a [guide](https://www.whitewolftech.com/articles/payment-card/) to set it up with a card reader connected to your computer. It can be done (without setting the keys) with [TagWriter app by NXP](https://play.google.com/store/apps/details?id=com.nxp.nfc.tagwriter) Android app. Last but not least, an OSS android app by name [bolt-nfc-android-app](https://github.com/boltcard/bolt-nfc-android-app) is being developed for these purposes. It's available from Google Play [here](https://play.google.com/store/apps/details?id=com.lightningnfcapp).
## About the keys
Up to five 16-byte keys can be stored on the card, numbered from 00 to 04. In the empty state they all should be set to zeros (00000000000000000000000000000000). For this extension only two keys need to be set:
One for encrypting the card UID and the counter (p parameter), let's called it meta key, key #01 or K1.
One for calculating CMAC (c parameter), let's called it file key, key #02 or K2.
The key #00, K0 (also know as auth key) is skipped to be use as authentification key. Is not needed by this extension, but can be filled in order to write the keys in cooperation with bolt-nfc-android-app.
***Always backup all keys that you're trying to write on the card. Without them you may not be able to change them in the future!***
## Setting the card - bolt-nfc-android-app (easy way)
So far, regarding the keys, the app can only write a new key set on an empty card (with zero keys). **When you write non zero (and 'non debug') keys, they can't be rewrite with this app.** You have to do it on your computer.
- Read the card with the app. Note UID so you can fill it in the extension later.
- Write the link on the card. It shoud be like `YOUR_LNBITS_DOMAIN/boltcards/api/v1/scan/{external_id}`
- `{external_id}` should be replaced with the External ID found in the LNBits dialog.
- Add new card in the extension.
- Set a max sats per transaction. Any transaction greater than this amount will be rejected.
- Set a max sats per day. After the card spends this amount of sats in a day, additional transactions will be rejected.
- Set a card name. This is just for your reference inside LNBits.
- Set the card UID. This is the unique identifier on your NFC card and is 7 bytes.
- If on an Android device with a newish version of Chrome, you can click the icon next to the input and tap your card to autofill this field.
- Advanced Options
- Card Keys (k0, k1, k2) will be automatically generated if not explicitly set.
- Set to 16 bytes of 0s (00000000000000000000000000000000) to leave the keys in debug mode.
- GENERATE KEY button fill the keys randomly. If there is "debug" in the card name, a debug set of keys is filled instead.
- Click CREATE CARD button
- Click the QR code button next to a card to view its details. You can scan the QR code with the Android app to import the keys.
- Click the "KEYS / AUTH LINK" button to copy the auth URL to the clipboard. You can then paste this into the Android app to import the keys.
- Tap the NFC card to write the keys to the card.
## Setting the card - computer (hard way)
Follow the guide.
The URI should be `lnurlw://YOUR-DOMAIN.COM/boltcards/api/v1/scan/{YOUR_card_external_id}?p=00000000000000000000000000000000&c=0000000000000000`
Then fill up the card parameters in the extension. Card Auth key (K0) can be omitted. Initical counter can be 0.
## Setting the card - android NXP app (hard way)
- If you don't know the card ID, use NXP TagInfo app to find it out.
- In the TagWriter app tap Write tags
- New Data Set > Link
- Set URI type to Custom URL
- URL should look like lnurlw://YOUR_LNBITS_DOMAIN/boltcards/api/v1/scan/{YOUR_card_external_id}?p=00000000000000000000000000000000&c=0000000000000000
- click Configure mirroring options
- Select Card Type NTAG 424 DNA
- Check Enable SDM Mirroring
- Select SDM Meta Read Access Right to 01
- Check Enable UID Mirroring
- Check Enable Counter Mirroring
- Set SDM Counter Retrieval Key to 0E
- Set PICC Data Offset to immediately after e=
- Set Derivation Key for CMAC Calculation to 00
- Set SDM MAC Input Offset to immediately after c=
- Set SDM MAC Offset to immediately after c=
- Save & Write
- Scan with compatible Wallet
This app afaik cannot change the keys. If you cannot change them any other way, leave them empty in the extension dialog and remember you're not secure. Card Auth key (K0) can be omitted anyway. Initical counter can be 0.
-37
View File
@@ -1,37 +0,0 @@
import asyncio
from fastapi import APIRouter
from starlette.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_boltcards")
boltcards_static_files = [
{
"path": "/boltcards/static",
"app": StaticFiles(packages=[("lnbits", "extensions/boltcards/static")]),
"name": "boltcards_static",
}
]
boltcards_ext: APIRouter = APIRouter(prefix="/boltcards", tags=["boltcards"])
def boltcards_renderer():
return template_renderer(["lnbits/extensions/boltcards/templates"])
from .lnurl import * # noqa
from .tasks import * # noqa
def boltcards_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
from .views import * # noqa
from .views_api import * # noqa
-6
View File
@@ -1,6 +0,0 @@
{
"name": "Bolt Cards",
"short_description": "Self custody Bolt Cards with one time LNURLw",
"icon": "payment",
"contributors": ["iwarpbtc", "arcbtc", "leesalminen"]
}
-273
View File
@@ -1,273 +0,0 @@
import secrets
from datetime import date, datetime
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import Card, CreateCardData, Hit, Refund
async def create_card(data: CreateCardData, wallet_id: str) -> Card:
card_id = urlsafe_short_hash().upper()
extenal_id = urlsafe_short_hash().lower()
await db.execute(
"""
INSERT INTO boltcards.cards (
id,
uid,
external_id,
wallet,
card_name,
counter,
tx_limit,
daily_limit,
enable,
k0,
k1,
k2,
otp
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
card_id,
data.uid.upper(),
extenal_id,
wallet_id,
data.card_name,
data.counter,
data.tx_limit,
data.daily_limit,
True,
data.k0,
data.k1,
data.k2,
secrets.token_hex(16),
),
)
card = await get_card(card_id)
assert card, "Newly created card couldn't be retrieved"
return card
async def update_card(card_id: str, **kwargs) -> Optional[Card]:
if "is_unique" in kwargs:
kwargs["is_unique"] = int(kwargs["is_unique"])
if "uid" in kwargs:
kwargs["uid"] = kwargs["uid"].upper()
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE boltcards.cards SET {q} WHERE id = ?",
(*kwargs.values(), card_id),
)
row = await db.fetchone("SELECT * FROM boltcards.cards WHERE id = ?", (card_id,))
return Card(**row) if row else None
async def get_cards(wallet_ids: Union[str, List[str]]) -> List[Card]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM boltcards.cards WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Card(**row) for row in rows]
async def get_card(card_id: str) -> Optional[Card]:
row = await db.fetchone("SELECT * FROM boltcards.cards WHERE id = ?", (card_id,))
if not row:
return None
card = dict(**row)
return Card.parse_obj(card)
async def get_card_by_uid(card_uid: str) -> Optional[Card]:
row = await db.fetchone(
"SELECT * FROM boltcards.cards WHERE uid = ?", (card_uid.upper(),)
)
if not row:
return None
card = dict(**row)
return Card.parse_obj(card)
async def get_card_by_external_id(external_id: str) -> Optional[Card]:
row = await db.fetchone(
"SELECT * FROM boltcards.cards WHERE external_id = ?", (external_id.lower(),)
)
if not row:
return None
card = dict(**row)
return Card.parse_obj(card)
async def get_card_by_otp(otp: str) -> Optional[Card]:
row = await db.fetchone("SELECT * FROM boltcards.cards WHERE otp = ?", (otp,))
if not row:
return None
card = dict(**row)
return Card.parse_obj(card)
async def delete_card(card_id: str) -> None:
# Delete cards
card = await get_card(card_id)
await db.execute("DELETE FROM boltcards.cards WHERE id = ?", (card_id,))
# Delete hits
hits = await get_hits([card_id])
for hit in hits:
await db.execute("DELETE FROM boltcards.hits WHERE id = ?", (hit.id,))
# Delete refunds
refunds = await get_refunds([hit])
for refund in refunds:
await db.execute(
"DELETE FROM boltcards.refunds WHERE id = ?", (refund.hit_id,)
)
async def update_card_counter(counter: int, id: str):
await db.execute(
"UPDATE boltcards.cards SET counter = ? WHERE id = ?",
(counter, id),
)
async def enable_disable_card(enable: bool, id: str) -> Optional[Card]:
row = await db.execute(
"UPDATE boltcards.cards SET enable = ? WHERE id = ?",
(enable, id),
)
return await get_card(id)
async def update_card_otp(otp: str, id: str):
await db.execute(
"UPDATE boltcards.cards SET otp = ? WHERE id = ?",
(otp, id),
)
async def get_hit(hit_id: str) -> Optional[Hit]:
row = await db.fetchone(f"SELECT * FROM boltcards.hits WHERE id = ?", (hit_id))
if not row:
return None
hit = dict(**row)
return Hit.parse_obj(hit)
async def get_hits(cards_ids: Union[str, List[str]]) -> List[Hit]:
q = ",".join(["?"] * len(cards_ids))
rows = await db.fetchall(
f"SELECT * FROM boltcards.hits WHERE card_id IN ({q})", (*cards_ids,)
)
return [Hit(**row) for row in rows]
async def get_hits_today(card_id: str) -> Optional[Hit]:
rows = await db.fetchall(
f"SELECT * FROM boltcards.hits WHERE card_id = ?",
(card_id,),
)
updatedrow = []
for row in rows:
if datetime.now().date() == datetime.fromtimestamp(row.time).date():
updatedrow.append(row)
return [Hit(**row) for row in updatedrow]
async def spend_hit(id: str, amount: int):
await db.execute(
"UPDATE boltcards.hits SET spent = ?, amount = ? WHERE id = ?",
(True, amount, id),
)
return await get_hit(id)
async def create_hit(card_id, ip, useragent, old_ctr, new_ctr) -> Hit:
hit_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO boltcards.hits (
id,
card_id,
ip,
spent,
useragent,
old_ctr,
new_ctr,
amount
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
hit_id,
card_id,
ip,
False,
useragent,
old_ctr,
new_ctr,
0,
),
)
hit = await get_hit(hit_id)
assert hit, "Newly recorded hit couldn't be retrieved"
return hit
async def create_refund(hit_id, refund_amount) -> Refund:
refund_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO boltcards.refunds (
id,
hit_id,
refund_amount
)
VALUES (?, ?, ?)
""",
(
refund_id,
hit_id,
refund_amount,
),
)
refund = await get_refund(refund_id)
assert refund, "Newly recorded hit couldn't be retrieved"
return refund
async def get_refund(refund_id: str) -> Optional[Refund]:
row = await db.fetchone(
f"SELECT * FROM boltcards.refunds WHERE id = ?", (refund_id)
)
if not row:
return None
refund = dict(**row)
return Refund.parse_obj(refund)
async def get_refunds(hits_ids: Union[str, List[str]]) -> List[Refund]:
q = ",".join(["?"] * len(hits_ids))
rows = await db.fetchall(
f"SELECT * FROM boltcards.refunds WHERE hit_id IN ({q})", (*hits_ids,)
)
return [Refund(**row) for row in rows]
-219
View File
@@ -1,219 +0,0 @@
import base64
import hashlib
import hmac
import json
import secrets
from http import HTTPStatus
from io import BytesIO
from typing import Optional
from urllib.parse import urlparse
from embit import bech32, compact
from fastapi import Request
from fastapi.param_functions import Query
from fastapi.params import Depends, Query
from lnurl import Lnurl, LnurlWithdrawResponse
from lnurl import encode as lnurl_encode # type: ignore
from lnurl.types import LnurlPayMetadata # type: ignore
from loguru import logger
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import HTMLResponse
from lnbits import bolt11
from lnbits.core.services import create_invoice
from lnbits.core.views.api import pay_invoice
from . import boltcards_ext
from .crud import (
create_hit,
get_card,
get_card_by_external_id,
get_card_by_otp,
get_hit,
get_hits_today,
spend_hit,
update_card,
update_card_counter,
update_card_otp,
)
from .models import CreateCardData
from .nxp424 import decryptSUN, getSunMAC
###############LNURLWITHDRAW#################
# /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000
@boltcards_ext.get("/api/v1/scan/{external_id}")
async def api_scan(p, c, request: Request, external_id: str = None):
# some wallets send everything as lower case, no bueno
p = p.upper()
c = c.upper()
card = None
counter = b""
card = await get_card_by_external_id(external_id)
if not card:
return {"status": "ERROR", "reason": "No card."}
if not card.enable:
return {"status": "ERROR", "reason": "Card is disabled."}
try:
card_uid, counter = decryptSUN(bytes.fromhex(p), bytes.fromhex(card.k1))
if card.uid.upper() != card_uid.hex().upper():
return {"status": "ERROR", "reason": "Card UID mis-match."}
if c != getSunMAC(card_uid, counter, bytes.fromhex(card.k2)).hex().upper():
return {"status": "ERROR", "reason": "CMAC does not check."}
except:
return {"status": "ERROR", "reason": "Error decrypting card."}
ctr_int = int.from_bytes(counter, "little")
if ctr_int <= card.counter:
return {"status": "ERROR", "reason": "This link is already used."}
await update_card_counter(ctr_int, card.id)
# gathering some info for hit record
ip = request.client.host
if "x-real-ip" in request.headers:
ip = request.headers["x-real-ip"]
elif "x-forwarded-for" in request.headers:
ip = request.headers["x-forwarded-for"]
agent = request.headers["user-agent"] if "user-agent" in request.headers else ""
todays_hits = await get_hits_today(card.id)
hits_amount = 0
for hit in todays_hits:
hits_amount = hits_amount + hit.amount
if hits_amount > card.daily_limit:
return {"status": "ERROR", "reason": "Max daily limit spent."}
hit = await create_hit(card.id, ip, agent, card.counter, ctr_int)
lnurlpay = lnurl_encode(request.url_for("boltcards.lnurlp_response", hit_id=hit.id))
return {
"tag": "withdrawRequest",
"callback": request.url_for("boltcards.lnurl_callback", hitid=hit.id),
"k1": hit.id,
"minWithdrawable": 1 * 1000,
"maxWithdrawable": card.tx_limit * 1000,
"defaultDescription": f"Boltcard (refund address lnurl://{lnurlpay})",
}
@boltcards_ext.get(
"/api/v1/lnurl/cb/{hitid}",
status_code=HTTPStatus.OK,
name="boltcards.lnurl_callback",
)
async def lnurl_callback(
request: Request,
pr: str = Query(None),
k1: str = Query(None),
):
hit = await get_hit(k1)
card = await get_card(hit.card_id)
if not hit:
return {"status": "ERROR", "reason": f"LNURL-pay record not found."}
if hit.id != k1:
return {"status": "ERROR", "reason": "Bad K1"}
if hit.spent:
return {"status": "ERROR", "reason": f"Payment already claimed"}
invoice = bolt11.decode(pr)
hit = await spend_hit(id=hit.id, amount=int(invoice.amount_msat / 1000))
try:
await pay_invoice(
wallet_id=card.wallet,
payment_request=pr,
max_sat=card.tx_limit,
extra={"tag": "boltcard", "tag": hit.id},
)
return {"status": "OK"}
except:
return {"status": "ERROR", "reason": f"Payment failed"}
# /boltcards/api/v1/auth?a=00000000000000000000000000000000
@boltcards_ext.get("/api/v1/auth")
async def api_auth(a, request: Request):
if a == "00000000000000000000000000000000":
response = {"k0": "0" * 32, "k1": "1" * 32, "k2": "2" * 32}
return response
card = await get_card_by_otp(a)
if not card:
raise HTTPException(
detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND
)
new_otp = secrets.token_hex(16)
await update_card_otp(new_otp, card.id)
lnurlw_base = (
f"{urlparse(str(request.url)).netloc}/boltcards/api/v1/scan/{card.external_id}"
)
response = {
"card_name": card.card_name,
"id": 1,
"k0": card.k0,
"k1": card.k1,
"k2": card.k2,
"k3": card.k1,
"k4": card.k2,
"lnurlw_base": "lnurlw://" + lnurlw_base,
"protocol_name": "new_bolt_card_response",
"protocol_version": 1,
}
return response
###############LNURLPAY REFUNDS#################
@boltcards_ext.get(
"/api/v1/lnurlp/{hit_id}",
response_class=HTMLResponse,
name="boltcards.lnurlp_response",
)
async def lnurlp_response(req: Request, hit_id: str = Query(None)):
hit = await get_hit(hit_id)
card = await get_card(hit.card_id)
if not hit:
return {"status": "ERROR", "reason": f"LNURL-pay record not found."}
if not card.enable:
return {"status": "ERROR", "reason": "Card is disabled."}
payResponse = {
"tag": "payRequest",
"callback": req.url_for("boltcards.lnurlp_callback", hit_id=hit_id),
"metadata": LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])),
"minSendable": 1 * 1000,
"maxSendable": card.tx_limit * 1000,
}
return json.dumps(payResponse)
@boltcards_ext.get(
"/api/v1/lnurlp/cb/{hit_id}",
response_class=HTMLResponse,
name="boltcards.lnurlp_callback",
)
async def lnurlp_callback(
req: Request, hit_id: str = Query(None), amount: str = Query(None)
):
hit = await get_hit(hit_id)
card = await get_card(hit.card_id)
if not hit:
return {"status": "ERROR", "reason": f"LNURL-pay record not found."}
payment_hash, payment_request = await create_invoice(
wallet_id=card.wallet,
amount=int(amount) / 1000,
memo=f"Refund {hit_id}",
unhashed_description=LnurlPayMetadata(
json.dumps([["text/plain", "Refund"]])
).encode("utf-8"),
extra={"refund": hit_id},
)
payResponse = {"pr": payment_request, "routes": []}
return json.dumps(payResponse)
-60
View File
@@ -1,60 +0,0 @@
from lnbits.helpers import urlsafe_short_hash
async def m001_initial(db):
await db.execute(
"""
CREATE TABLE boltcards.cards (
id TEXT PRIMARY KEY UNIQUE,
wallet TEXT NOT NULL,
card_name TEXT NOT NULL,
uid TEXT NOT NULL UNIQUE,
external_id TEXT NOT NULL UNIQUE,
counter INT NOT NULL DEFAULT 0,
tx_limit TEXT NOT NULL,
daily_limit TEXT NOT NULL,
enable BOOL NOT NULL,
k0 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
k1 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
k2 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
prev_k0 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
prev_k1 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
prev_k2 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
otp TEXT NOT NULL DEFAULT '',
time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
);
"""
)
await db.execute(
"""
CREATE TABLE boltcards.hits (
id TEXT PRIMARY KEY UNIQUE,
card_id TEXT NOT NULL,
ip TEXT NOT NULL,
spent BOOL NOT NULL DEFAULT True,
useragent TEXT,
old_ctr INT NOT NULL DEFAULT 0,
new_ctr INT NOT NULL DEFAULT 0,
amount INT NOT NULL,
time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
);
"""
)
await db.execute(
"""
CREATE TABLE boltcards.refunds (
id TEXT PRIMARY KEY UNIQUE,
hit_id TEXT NOT NULL,
refund_amount INT NOT NULL,
time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
);
"""
)
-83
View File
@@ -1,83 +0,0 @@
from sqlite3 import Row
from typing import Optional
from fastapi import Request
from fastapi.params import Query
from lnurl import Lnurl
from lnurl import encode as lnurl_encode # type: ignore
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
from lnurl.types import LnurlPayMetadata # type: ignore
from pydantic import BaseModel
from pydantic.main import BaseModel
ZERO_KEY = "00000000000000000000000000000000"
class Card(BaseModel):
id: str
wallet: str
card_name: str
uid: str
external_id: str
counter: int
tx_limit: int
daily_limit: int
enable: bool
k0: str
k1: str
k2: str
prev_k0: str
prev_k1: str
prev_k2: str
otp: str
time: int
def from_row(cls, row: Row) -> "Card":
return cls(**dict(row))
def lnurl(self, req: Request) -> Lnurl:
url = req.url_for("boltcard.lnurl_response", device_id=self.id, _external=True)
return lnurl_encode(url)
async def lnurlpay_metadata(self) -> LnurlPayMetadata:
return LnurlPayMetadata(json.dumps([["text/plain", self.title]]))
class CreateCardData(BaseModel):
card_name: str = Query(...)
uid: str = Query(...)
counter: int = Query(0)
tx_limit: int = Query(0)
daily_limit: int = Query(0)
enable: bool = Query(True)
k0: str = Query(ZERO_KEY)
k1: str = Query(ZERO_KEY)
k2: str = Query(ZERO_KEY)
prev_k0: str = Query(ZERO_KEY)
prev_k1: str = Query(ZERO_KEY)
prev_k2: str = Query(ZERO_KEY)
class Hit(BaseModel):
id: str
card_id: str
ip: str
spent: bool
useragent: str
old_ctr: int
new_ctr: int
amount: int
time: int
def from_row(cls, row: Row) -> "Hit":
return cls(**dict(row))
class Refund(BaseModel):
id: str
hit_id: str
refund_amount: int
time: int
def from_row(cls, row: Row) -> "Refund":
return cls(**dict(row))
-36
View File
@@ -1,36 +0,0 @@
# https://www.nxp.com/docs/en/application-note/AN12196.pdf
from typing import Tuple
from Cryptodome.Cipher import AES
from Cryptodome.Hash import CMAC
SV2 = "3CC300010080"
def myCMAC(key: bytes, msg: bytes = b"") -> bytes:
cobj = CMAC.new(key, ciphermod=AES)
if msg != b"":
cobj.update(msg)
return cobj.digest()
def decryptSUN(sun: bytes, key: bytes) -> Tuple[bytes, bytes]:
IVbytes = b"\x00" * 16
cipher = AES.new(key, AES.MODE_CBC, IVbytes)
sun_plain = cipher.decrypt(sun)
UID = sun_plain[1:8]
counter = sun_plain[8:11]
return UID, counter
def getSunMAC(UID: bytes, counter: bytes, key: bytes) -> bytes:
sv2prefix = bytes.fromhex(SV2)
sv2bytes = sv2prefix + UID + counter
mac1 = myCMAC(key, sv2bytes)
mac2 = myCMAC(mac1)
return mac2[1::2]
@@ -1,435 +0,0 @@
Vue.component(VueQrcode.name, VueQrcode)
const mapCards = obj => {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
toggleAdvanced: false,
nfcTagReading: false,
lnurlLink: `${window.location.host}/boltcards/api/v1/scan/`,
cards: [],
hits: [],
refunds: [],
cardDialog: {
show: false,
data: {
counter: 1,
k0: '',
k1: '',
k2: '',
uid: '',
card_name: ''
},
temp: {}
},
cardsTable: {
columns: [
{
name: 'card_name',
align: 'left',
label: 'Card name',
field: 'card_name'
},
{
name: 'counter',
align: 'left',
label: 'Counter',
field: 'counter'
},
{
name: 'wallet',
align: 'left',
label: 'Wallet',
field: 'wallet'
},
{
name: 'tx_limit',
align: 'left',
label: 'Max tx',
field: 'tx_limit'
},
{
name: 'daily_limit',
align: 'left',
label: 'Daily tx limit',
field: 'daily_limit'
}
],
pagination: {
rowsPerPage: 10
}
},
refundsTable: {
columns: [
{
name: 'hit_id',
align: 'left',
label: 'Hit ID',
field: 'hit_id'
},
{
name: 'refund_amount',
align: 'left',
label: 'Refund Amount',
field: 'refund_amount'
},
{
name: 'date',
align: 'left',
label: 'Time',
field: 'date'
}
],
pagination: {
rowsPerPage: 10,
sortBy: 'date',
descending: true
}
},
hitsTable: {
columns: [
{
name: 'card_name',
align: 'left',
label: 'Card name',
field: 'card_name'
},
{
name: 'amount',
align: 'left',
label: 'Amount',
field: 'amount'
},
{
name: 'old_ctr',
align: 'left',
label: 'Old counter',
field: 'old_ctr'
},
{
name: 'new_ctr',
align: 'left',
label: 'New counter',
field: 'new_ctr'
},
{
name: 'date',
align: 'left',
label: 'Time',
field: 'date'
},
{
name: 'ip',
align: 'left',
label: 'IP',
field: 'ip'
},
{
name: 'useragent',
align: 'left',
label: 'User agent',
field: 'useragent'
}
],
pagination: {
rowsPerPage: 10,
sortBy: 'date',
descending: true
}
},
qrCodeDialog: {
show: false,
data: null
}
}
},
methods: {
readNfcTag: function () {
try {
const self = this
if (typeof NDEFReader == 'undefined') {
throw {
toString: function () {
return 'NFC not supported on this device or browser.'
}
}
}
const ndef = new NDEFReader()
const readerAbortController = new AbortController()
readerAbortController.signal.onabort = event => {
console.log('All NFC Read operations have been aborted.')
}
this.nfcTagReading = true
this.$q.notify({
message: 'Tap your NFC tag to copy its UID here.'
})
return ndef.scan({signal: readerAbortController.signal}).then(() => {
ndef.onreadingerror = () => {
self.nfcTagReading = false
this.$q.notify({
type: 'negative',
message: 'There was an error reading this NFC tag.'
})
readerAbortController.abort()
}
ndef.onreading = ({message, serialNumber}) => {
//Decode NDEF data from tag
var self = this
self.cardDialog.data.uid = serialNumber
.toUpperCase()
.replaceAll(':', '')
this.$q.notify({
type: 'positive',
message: 'NFC tag read successfully.'
})
}
})
} catch (error) {
this.nfcTagReading = false
this.$q.notify({
type: 'negative',
message: error
? error.toString()
: 'An unexpected error has occurred.'
})
}
},
getCards: function () {
var self = this
LNbits.api
.request(
'GET',
'/boltcards/api/v1/cards?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.cards = response.data.map(function (obj) {
return mapCards(obj)
})
})
.then(function () {
self.getHits()
})
},
getHits: function () {
var self = this
LNbits.api
.request(
'GET',
'/boltcards/api/v1/hits?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.hits = response.data.map(function (obj) {
obj.card_name = self.cards.find(d => d.id == obj.card_id).card_name
return mapCards(obj)
})
})
},
getRefunds: function () {
var self = this
LNbits.api
.request(
'GET',
'/boltcards/api/v1/refunds?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.refunds = response.data.map(function (obj) {
return mapCards(obj)
})
})
},
openQrCodeDialog(cardId) {
var card = _.findWhere(this.cards, {id: cardId})
this.qrCodeDialog.data = {
link: window.location.origin + '/boltcards/api/v1/auth?a=' + card.otp,
name: card.card_name,
uid: card.uid,
external_id: card.external_id,
k0: card.k0,
k1: card.k1,
k2: card.k2,
k3: card.k1,
k4: card.k2
}
this.qrCodeDialog.show = true
},
addCardOpen: function () {
this.cardDialog.show = true
this.generateKeys()
},
generateKeys: function () {
var self = this
const genRanHex = size =>
[...Array(size)]
.map(() => Math.floor(Math.random() * 16).toString(16))
.join('')
debugcard =
typeof this.cardDialog.data.card_name === 'string' &&
this.cardDialog.data.card_name.search('debug') > -1
self.cardDialog.data.k0 = debugcard
? '11111111111111111111111111111111'
: genRanHex(32)
self.cardDialog.data.k1 = debugcard
? '22222222222222222222222222222222'
: genRanHex(32)
self.cardDialog.data.k2 = debugcard
? '33333333333333333333333333333333'
: genRanHex(32)
},
closeFormDialog: function () {
this.cardDialog.data = {}
},
sendFormData: function () {
let wallet = _.findWhere(this.g.user.wallets, {
id: this.cardDialog.data.wallet
})
let data = this.cardDialog.data
if (data.id) {
this.updateCard(wallet, data)
} else {
this.createCard(wallet, data)
}
},
createCard: function (wallet, data) {
var self = this
LNbits.api
.request('POST', '/boltcards/api/v1/cards', wallet.adminkey, data)
.then(function (response) {
self.cards.push(mapCards(response.data))
self.cardDialog.show = false
self.cardDialog.data = {}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
updateCardDialog: function (formId) {
var card = _.findWhere(this.cards, {id: formId})
this.cardDialog.data = _.clone(card)
this.cardDialog.temp.k0 = this.cardDialog.data.k0
this.cardDialog.temp.k1 = this.cardDialog.data.k1
this.cardDialog.temp.k2 = this.cardDialog.data.k2
this.cardDialog.show = true
},
updateCard: function (wallet, data) {
var self = this
if (
this.cardDialog.temp.k0 != data.k0 ||
this.cardDialog.temp.k1 != data.k1 ||
this.cardDialog.temp.k2 != data.k2
) {
data.prev_k0 = this.cardDialog.temp.k0
data.prev_k1 = this.cardDialog.temp.k1
data.prev_k2 = this.cardDialog.temp.k2
}
LNbits.api
.request(
'PUT',
'/boltcards/api/v1/cards/' + data.id,
wallet.adminkey,
data
)
.then(function (response) {
self.cards = _.reject(self.cards, function (obj) {
return obj.id == data.id
})
self.cards.push(mapCards(response.data))
self.cardDialog.show = false
self.cardDialog.data = {}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
enableCard: function (wallet, card_id, enable) {
var self = this
let fullWallet = _.findWhere(self.g.user.wallets, {
id: wallet
})
LNbits.api
.request(
'GET',
'/boltcards/api/v1/cards/enable/' + card_id + '/' + enable,
fullWallet.adminkey
)
.then(function (response) {
console.log(response.data)
self.cards = _.reject(self.cards, function (obj) {
return obj.id == response.data.id
})
self.cards.push(mapCards(response.data))
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteCard: function (cardId) {
let self = this
let cards = _.findWhere(this.cards, {id: cardId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this card')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/boltcards/api/v1/cards/' + cardId,
_.findWhere(self.g.user.wallets, {id: cards.wallet}).adminkey
)
.then(function (response) {
self.cards = _.reject(self.cards, function (obj) {
return obj.id == cardId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportCardsCSV: function () {
LNbits.utils.exportCSV(this.cardsTable.columns, this.cards)
},
exportHitsCSV: function () {
LNbits.utils.exportCSV(this.hitsTable.columns, this.hits)
},
exportRefundsCSV: function () {
LNbits.utils.exportCSV(this.refundsTable.columns, this.refunds)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getCards()
this.getRefunds()
}
}
})
-47
View File
@@ -1,47 +0,0 @@
import asyncio
import json
import httpx
from lnbits.core import db as core_db
from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener
from .crud import create_refund, get_hit
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue)
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
if not payment.extra.get("refund"):
return
if payment.extra.get("wh_status"):
# this webhook has already been sent
return
hit = await get_hit(payment.extra.get("refund"))
if hit:
refund = await create_refund(
hit_id=hit.id, refund_amount=(payment.amount / 1000)
)
await mark_webhook_sent(payment, 1)
async def mark_webhook_sent(payment: Payment, status: int) -> None:
payment.extra["wh_status"] = status
await core_db.execute(
"""
UPDATE apipayments SET extra = ?
WHERE hash = ?
""",
(json.dumps(payment.extra), payment.payment_hash),
)
@@ -1,21 +0,0 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="About Bolt Cards"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<h5 class="text-subtitle1 q-my-none">Be your own card association</h5>
<p>
Manage your Bolt Cards self custodian way<br />
<a
href="https://github.com/lnbits/lnbits/tree/main/lnbits/extensions/boltcards"
>More details</a
>
<br />
</p>
</q-card-section>
</q-card>
</q-expansion-item>
@@ -1,405 +0,0 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<div class="row justify-start" style="width: 150px">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Cards</h5>
</div>
<div class="col">
<q-btn
round
size="sm"
icon="add"
unelevated
color="primary"
@click="addCardOpen"
>
<q-tooltip>Add card</q-tooltip>
</q-btn>
</div>
</div>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCardsCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:data="cards"
row-key="id"
:columns="cardsTable.columns"
:pagination.sync="cardsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
icon="qr_code"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openQrCodeDialog(props.row.id)"
>
<q-tooltip>Card key credentials</q-tooltip>
</q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
v-if="props.row.enable"
dense
@click="enableCard(props.row.wallet, props.row.id, false)"
color="pink"
>DISABLE</q-btn
>
<q-btn
v-else
dense
@click="enableCard(props.row.wallet, props.row.id, true)"
color="green"
>ENABLE
</q-btn>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="updateCardDialog(props.row.id)"
icon="edit"
color="light-blue"
>
<q-tooltip>Edit card</q-tooltip>
</q-btn>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteCard(props.row.id)"
icon="cancel"
color="pink"
>
<q-tooltip
>Deleting card will also delete all records</q-tooltip
>
</q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Hits</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCardsCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:data="hits"
row-key="id"
:columns="hitsTable.columns"
:pagination.sync="hitsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Refunds</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportRefundsCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:data="refunds"
row-key="id"
:columns="refundsTable.columns"
:pagination.sync="refundsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} Bolt Cards extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "boltcards/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="cardDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormData" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="cardDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
<div class="row">
<div class="col">
<q-input
filled
dense
emit-value
v-model.trim="cardDialog.data.tx_limit"
type="number"
label="Max transaction (sats)"
class="q-pr-sm"
></q-input>
</div>
<div class="col">
<q-input
filled
dense
emit-value
v-model.trim="cardDialog.data.daily_limit"
type="number"
label="Daily limit (sats)"
></q-input>
</div>
</div>
<q-input
filled
dense
emit-value
v-model.trim="cardDialog.data.card_name"
type="text"
label="Card name "
>
</q-input>
<div class="row">
<div class="col-10">
<q-input
filled
dense
emit-value
v-model.trim="cardDialog.data.uid"
type="text"
label="Card UID "
>
<q-tooltip
>Get from the card you'll use, using an NFC app</q-tooltip
>
</q-input>
</div>
<div class="col-2 q-pl-sm">
<q-btn
outline
disable
color="grey"
icon="nfc"
:disable="nfcTagReading"
@click="readNfcTag()"
>
<q-tooltip>Tap card to scan UID</q-tooltip>
</q-btn>
</div>
</div>
<q-toggle
v-model="toggleAdvanced"
label="Show advanced options"
></q-toggle>
<div v-show="toggleAdvanced">
<q-input
filled
dense
v-model.trim="cardDialog.data.k0"
type="text"
label="Card Auth key (K0)"
hint="Used to authentificate with the card (16 bytes in HEX). "
@randomkey
>
</q-input>
<q-input
filled
dense
v-model.trim="cardDialog.data.k1"
type="text"
label="Card Meta key (K1)"
hint="Used for encypting of the message (16 bytes in HEX)."
></q-input>
<q-input
filled
dense
v-model.trim="cardDialog.data.k2"
type="text"
label="Card File key (K2)"
hint="Used for CMAC of the message (16 bytes in HEX)."
>
</q-input>
<q-input
filled
dense
v-model.number="cardDialog.data.counter"
type="number"
label="Initial counter"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
>Zero if you don't know.</q-tooltip
>
</q-input>
<q-btn
unelevated
color="primary"
class="q-ml-auto"
v-on:click="generateKeys"
v-on:click.right="debugKeys"
>Generate keys</q-btn
>
</div>
<div class="row q-mt-lg">
<q-btn
v-if="cardDialog.data.id"
unelevated
color="primary"
type="submit"
>Update Card</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="cardDialog.data.uid == null"
type="submit"
>Create Card
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="qrCodeDialog.show" position="top">
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
{% raw %}
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="qrCodeDialog.data.link"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
<p style="word-break: break-all" class="text-center">
(Keys for
<a
href="https://play.google.com/store/apps/details?id=com.lightningnfcapp"
target="_blank"
>bolt-nfc-android-app</a
>)
</p>
<p style="word-break: break-all">
<strong>Name:</strong> {{ qrCodeDialog.data.name }}<br />
<strong>UID:</strong> {{ qrCodeDialog.data.uid }}<br />
<strong>External ID:</strong> {{ qrCodeDialog.data.external_id }}<br />
<strong>Lock key:</strong> {{ qrCodeDialog.data.k0 }}<br />
<strong>Meta key:</strong> {{ qrCodeDialog.data.k1 }}<br />
<strong>File key:</strong> {{ qrCodeDialog.data.k2 }}<br />
</p>
<br />
<q-btn
unelevated
outline
color="grey"
@click="copyText(qrCodeDialog.data.link)"
label="Keys/Auth link"
>
</q-btn>
<q-tooltip>Click to copy, then add to NFC card</q-tooltip>
{% endraw %}
<div class="row q-mt-lg q-gutter-sm">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="/boltcards/static/js/index.js"></script>
{% endblock %}
-18
View File
@@ -1,18 +0,0 @@
from fastapi import FastAPI, Request
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import boltcards_ext, boltcards_renderer
templates = Jinja2Templates(directory="templates")
@boltcards_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return boltcards_renderer().TemplateResponse(
"boltcards/index.html", {"request": request, "user": user.dict()}
)
-170
View File
@@ -1,170 +0,0 @@
import secrets
from http import HTTPStatus
from fastapi.params import Depends, Query
from loguru import logger
from starlette.exceptions import HTTPException
from starlette.requests import Request
from lnbits.core.crud import get_user
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from . import boltcards_ext
from .crud import (
create_card,
create_hit,
delete_card,
enable_disable_card,
get_card,
get_card_by_otp,
get_card_by_uid,
get_cards,
get_hits,
get_refunds,
update_card,
update_card_counter,
update_card_otp,
)
from .models import CreateCardData
from .nxp424 import decryptSUN, getSunMAC
@boltcards_ext.get("/api/v1/cards")
async def api_cards(
g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
):
wallet_ids = [g.wallet.id]
if all_wallets:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
return [card.dict() for card in await get_cards(wallet_ids)]
@boltcards_ext.post("/api/v1/cards", status_code=HTTPStatus.CREATED)
@boltcards_ext.put("/api/v1/cards/{card_id}", status_code=HTTPStatus.OK)
async def api_card_create_or_update(
# req: Request,
data: CreateCardData,
card_id: str = None,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
try:
if len(bytes.fromhex(data.uid)) != 7:
raise HTTPException(
detail="Invalid bytes for card uid.", status_code=HTTPStatus.BAD_REQUEST
)
if len(bytes.fromhex(data.k0)) != 16:
raise HTTPException(
detail="Invalid bytes for k0.", status_code=HTTPStatus.BAD_REQUEST
)
if len(bytes.fromhex(data.k1)) != 16:
raise HTTPException(
detail="Invalid bytes for k1.", status_code=HTTPStatus.BAD_REQUEST
)
if len(bytes.fromhex(data.k2)) != 16:
raise HTTPException(
detail="Invalid bytes for k2.", status_code=HTTPStatus.BAD_REQUEST
)
except:
raise HTTPException(
detail="Invalid byte data provided.", status_code=HTTPStatus.BAD_REQUEST
)
if card_id:
card = await get_card(card_id)
if not card:
raise HTTPException(
detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND
)
if card.wallet != wallet.wallet.id:
raise HTTPException(
detail="Not your card.", status_code=HTTPStatus.FORBIDDEN
)
checkUid = await get_card_by_uid(data.uid)
if checkUid and checkUid.id != card_id:
raise HTTPException(
detail="UID already registered. Delete registered card and try again.",
status_code=HTTPStatus.BAD_REQUEST,
)
card = await update_card(card_id, **data.dict())
else:
checkUid = await get_card_by_uid(data.uid)
if checkUid:
raise HTTPException(
detail="UID already registered. Delete registered card and try again.",
status_code=HTTPStatus.BAD_REQUEST,
)
card = await create_card(wallet_id=wallet.wallet.id, data=data)
return card.dict()
@boltcards_ext.get("/api/v1/cards/enable/{card_id}/{enable}", status_code=HTTPStatus.OK)
async def enable_card(
card_id,
enable,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
card = await get_card(card_id)
if not card:
raise HTTPException(detail="No card found.", status_code=HTTPStatus.NOT_FOUND)
if card.wallet != wallet.wallet.id:
raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
card = await enable_disable_card(enable=enable, id=card_id)
return card.dict()
@boltcards_ext.delete("/api/v1/cards/{card_id}")
async def api_card_delete(card_id, wallet: WalletTypeInfo = Depends(require_admin_key)):
card = await get_card(card_id)
if not card:
raise HTTPException(
detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND
)
if card.wallet != wallet.wallet.id:
raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
await delete_card(card_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
@boltcards_ext.get("/api/v1/hits")
async def api_hits(
g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
):
wallet_ids = [g.wallet.id]
if all_wallets:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
cards = await get_cards(wallet_ids)
cards_ids = []
for card in cards:
cards_ids.append(card.id)
return [hit.dict() for hit in await get_hits(cards_ids)]
@boltcards_ext.get("/api/v1/refunds")
async def api_hits(
g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
):
wallet_ids = [g.wallet.id]
if all_wallets:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
cards = await get_cards(wallet_ids)
cards_ids = []
for card in cards:
cards_ids.append(card.id)
hits = await get_hits(cards_ids)
hits_ids = []
for hit in hits:
hits_ids.append(hit.id)
return [refund.dict() for refund in await get_refunds(hits_ids)]

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