Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41a9689014 | ||
|
|
76edfa2134 | ||
|
|
d7b3a28950 | ||
|
|
2c161a079d | ||
|
|
88df4266b7 | ||
|
|
6496fac35f | ||
|
|
10492ec3d0 | ||
|
|
302d79a30f | ||
|
|
b88abd1a46 | ||
|
|
573c4cf5ea | ||
|
|
e9065833c0 | ||
|
|
251ecb8f1d | ||
|
|
c626e12fa3 | ||
|
|
13a72d9a41 | ||
|
|
3c5fac3874 | ||
|
|
15c8557459 | ||
|
|
d559c604fa | ||
|
|
cd84623305 | ||
|
|
9fb30080ab | ||
|
|
30ab519c3a | ||
|
|
914b9f3ffe | ||
|
|
0764f4fdf1 | ||
|
|
0367ee85a7 | ||
|
|
0aef9d4d9a | ||
|
|
8967011cbe | ||
|
|
682226c4b6 | ||
|
|
c7e501ac08 | ||
|
|
8d7302e4cd | ||
|
|
09b86605dd | ||
|
|
9e8376b3fb | ||
|
|
1a492cbfb8 | ||
|
|
155c32504a | ||
|
|
8c923ad653 | ||
|
|
25b9014c3d | ||
|
|
9807e7d646 | ||
|
|
728ab517f5 | ||
|
|
f1fb569e2b | ||
|
|
6d58027147 | ||
|
|
4a35f84e95 | ||
|
|
f7b37fb843 | ||
|
|
699bdd9a06 | ||
|
|
b5aad94ecd | ||
|
|
0f79213b0f | ||
|
|
c1654a21d4 | ||
|
|
da6a2e772b | ||
|
|
1626cd2b4b | ||
|
|
d1d62583c4 | ||
|
|
4c18eb0b34 | ||
|
|
35bb26b6fd | ||
|
|
2c853e7a52 | ||
|
|
6bc1474c19 | ||
|
|
4b11342f0c | ||
|
|
a7c65d5d60 | ||
|
|
50fcb15725 | ||
|
|
18ae455824 | ||
|
|
8db19f33e3 | ||
|
|
8a43611232 | ||
|
|
75e78d2ab6 | ||
|
|
568a1be91f | ||
|
|
b10a23dcc4 | ||
|
|
dab943baad | ||
|
|
db734d4acd | ||
|
|
0415f3d7cc | ||
|
|
3927545e32 | ||
|
|
3f0a855526 | ||
|
|
615abffeb4 | ||
|
|
31e300e05b | ||
|
|
d2879aa80c | ||
|
|
d1b390b929 | ||
|
|
d1a78ab81b | ||
|
|
34b3188f96 | ||
|
|
bdf5dd55f6 | ||
|
|
3ae6ef25a1 | ||
|
|
382622d5ac | ||
|
|
0680669e59 | ||
|
|
dbab181759 | ||
|
|
f5069f9699 | ||
|
|
e97e20889e | ||
|
|
68ba219096 | ||
|
|
649776951c | ||
|
|
7c3b3ac200 | ||
|
|
a147a07bab | ||
|
|
228717195b | ||
|
|
f0d9870143 | ||
|
|
d6f145eee9 | ||
|
|
47300e477e | ||
|
|
c96e7068e5 | ||
|
|
b4d00a490b | ||
|
|
9505958306 | ||
|
|
b00a39a7ce | ||
|
|
b4dc07170f | ||
|
|
57eb27fcee | ||
|
|
9e13b8eb70 | ||
|
|
09bfa35cd5 | ||
|
|
918e9be7ed | ||
|
|
fc021b3736 | ||
|
|
6718ed678d | ||
|
|
ab62014bf0 | ||
|
|
1940b8762b | ||
|
|
46838ca66f | ||
|
|
5fe9df48dd | ||
|
|
5bacb5f38a | ||
|
|
0f70b346ca | ||
|
|
f162d14e41 | ||
|
|
c87aa3dd2b | ||
|
|
7812c4aa48 | ||
|
|
fefa8ba56e | ||
|
|
1dd8b01f5a | ||
|
|
81dc9303f6 | ||
|
|
10ccee3480 | ||
|
|
8ca8d173e8 | ||
|
|
46e682bca3 | ||
|
|
11bf2029be | ||
|
|
a44aba6fcf | ||
|
|
d1a7d684b5 | ||
|
|
4e0e5b9956 | ||
|
|
25b502e5d5 | ||
|
|
6af8899cd1 | ||
|
|
7407be944c | ||
|
|
a00e46f629 | ||
|
|
aba206c87e | ||
|
|
db666fd103 | ||
|
|
57a2dffe52 | ||
|
|
d5617b9462 | ||
|
|
79ab444f59 | ||
|
|
480546ded7 | ||
|
|
6645b156e7 | ||
|
|
17b13bb652 | ||
|
|
6ba88d262b | ||
|
|
f76ef6b975 | ||
|
|
12bd930334 | ||
|
|
b9104bf63b | ||
|
|
f798ab9f57 | ||
|
|
e3c6d7aebe | ||
|
|
87105b22bb | ||
|
|
1fd2ccc54e | ||
|
|
aa8557ada1 | ||
|
|
cdeccec01b | ||
|
|
2991cbce1f | ||
|
|
10255ed1ce | ||
|
|
df2345bda9 | ||
|
|
adcfc43f56 | ||
|
|
aa58775c90 | ||
|
|
0ef0188462 | ||
|
|
e42fdde2ac | ||
|
|
32ea1106f9 | ||
|
|
93e58f4c80 | ||
|
|
6b5aaa442d | ||
|
|
40aadbfec7 | ||
|
|
a1ae7aca9e |
+17
-6
@@ -5,10 +5,15 @@ QUART_DEBUG=true
|
||||
HOST=127.0.0.1
|
||||
PORT=5000
|
||||
|
||||
|
||||
LNBITS_ALLOWED_USERS=""
|
||||
LNBITS_ADMIN_USERS=""
|
||||
# Extensions only admin can access
|
||||
LNBITS_ADMIN_EXTENSIONS="ngrok"
|
||||
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
||||
|
||||
# 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://...
|
||||
@@ -17,8 +22,6 @@ LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
||||
LNBITS_DATA_FOLDER="./data"
|
||||
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
|
||||
|
||||
# disable selected extensions, or use "all" to disable all extensions
|
||||
LNBITS_DISABLED_EXTENSIONS="amilk,ngrok"
|
||||
LNBITS_FORCE_HTTPS=true
|
||||
LNBITS_SERVICE_FEE="0.0"
|
||||
|
||||
@@ -27,10 +30,10 @@ 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, salvador, autumn, monochrome, classic
|
||||
LNBITS_THEME_OPTIONS="mint, flamingo, classic, autumn, monochrome, salvador"
|
||||
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, mint, autumn, monochrome, salvador"
|
||||
|
||||
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, LndWallet (gRPC),
|
||||
# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet
|
||||
# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, FakeWallet
|
||||
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.
|
||||
@@ -44,7 +47,7 @@ SPARK_TOKEN=myaccesstoken
|
||||
CLIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
|
||||
|
||||
# LnbitsWallet
|
||||
LNBITS_ENDPOINT=http://127.0.0.1:5000
|
||||
LNBITS_ENDPOINT=https://legend.lnbits.com
|
||||
LNBITS_KEY=LNBITS_ADMIN_KEY
|
||||
|
||||
# LndWallet
|
||||
@@ -52,11 +55,15 @@ LND_GRPC_ENDPOINT=127.0.0.1
|
||||
LND_GRPC_PORT=11009
|
||||
LND_GRPC_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
|
||||
LND_GRPC_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_GRPC_MACAROON_ENCRYPTED="eNcRyPtEdMaCaRoOn"
|
||||
|
||||
# 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/
|
||||
@@ -72,3 +79,7 @@ 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
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 Arc
|
||||
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
|
||||
|
||||
@@ -29,6 +29,8 @@ uvicorn = {extras = ["standard"], version = "*"}
|
||||
sse-starlette = "*"
|
||||
jinja2 = "3.0.1"
|
||||
pyngrok = "*"
|
||||
secp256k1 = "*"
|
||||
pycryptodomex = "*"
|
||||
|
||||
[dev-packages]
|
||||
black = "==20.8b1"
|
||||
|
||||
Generated
+553
-323
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,6 @@
|
||||
LNbits
|
||||
======
|
||||
|
||||
[![github-tests-badge]][github-tests]
|
||||
[![github-mypy-badge]][github-mypy]
|
||||
[![codecov-badge]][codecov]
|
||||
[![license-badge]](LICENSE)
|
||||
[![docs-badge]][docs]
|
||||
|
||||
@@ -34,11 +31,7 @@ LNbits is inspired by all the great work of [opennode.com](https://www.opennode.
|
||||
|
||||
## Running LNbits
|
||||
|
||||
See the [install guide](docs/guide/installation.md) for details on installation and setup.
|
||||
|
||||
### Contributing to LNbits
|
||||
|
||||
There's a [slightly different setup](docs/devs/installation.md) if you want to contribute to LNbits, but if your changes don't require adding or removing any package dependencies you don't have to bother with that, just follow the [normal installation](docs/guide/installation.md) steps.
|
||||
See the [install guide](docs/devs/installation.md) for details on installation and setup.
|
||||
|
||||
## LNbits as an account system
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
## Python script to migrate an LNbits SQLite DB to Postgres
|
||||
## All credits to @Fritz446 for the awesome work
|
||||
|
||||
## pip install psycopg2 OR psycopg2-binary
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
import psycopg2
|
||||
import sqlite3
|
||||
import os
|
||||
# Python script to migrate an LNbits SQLite DB to Postgres
|
||||
# All credits to @Fritz446 for the awesome work
|
||||
|
||||
|
||||
# pip install psycopg2 OR psycopg2-binary
|
||||
|
||||
|
||||
# Change these values as needed
|
||||
|
||||
@@ -34,7 +34,8 @@ def get_postgres_cursor():
|
||||
def check_db_versions(sqdb):
|
||||
sqlite = get_sqlite_cursor(sqdb)
|
||||
dblite = dict(sqlite.execute("SELECT * FROM dbversions;").fetchall())
|
||||
del dblite["lnurlpos"] # wrongly added?
|
||||
if "lnurlpos" in dblite:
|
||||
del dblite["lnurlpos"]
|
||||
sqlite.close()
|
||||
|
||||
postgres = get_postgres_cursor()
|
||||
@@ -42,7 +43,7 @@ def check_db_versions(sqdb):
|
||||
dbpost = dict(postgres.fetchall())
|
||||
|
||||
for key in dblite.keys():
|
||||
if dblite[key] != dbpost[key]:
|
||||
if key in dblite and key in dbpost and dblite[key] != dbpost[key]:
|
||||
raise Exception(
|
||||
f"sqlite database version ({dblite[key]}) of {key} doesn't match postgres database version {dbpost[key]}"
|
||||
)
|
||||
@@ -76,7 +77,10 @@ def insert_to_pg(query, data):
|
||||
connection = cursor.connection
|
||||
|
||||
for d in data:
|
||||
cursor.execute(query, d)
|
||||
try:
|
||||
cursor.execute(query, d)
|
||||
except:
|
||||
raise ValueError(f"Failed to insert {d}")
|
||||
connection.commit()
|
||||
|
||||
cursor.close()
|
||||
|
||||
+24
-42
@@ -7,59 +7,41 @@ nav_order: 1
|
||||
|
||||
# Installation
|
||||
|
||||
Download the latest stable release https://github.com/lnbits/lnbits/releases
|
||||
LNbits uses [Pipenv][pipenv] to manage Python packages.
|
||||
|
||||
## Application dependencies
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits-legend/
|
||||
pipenv shell
|
||||
# pipenv --python 3.9 shell (if you wish to use a version of Python higher than 3.7)
|
||||
pipenv install --dev
|
||||
# pipenv --python 3.9 install --dev (if you wish to use a version of Python higher than 3.7)
|
||||
|
||||
The application uses [Pipenv][pipenv] to manage Python packages.
|
||||
While in development, you will need to install all dependencies:
|
||||
# If any of the modules fails to install, try checking and upgrading your setupTool module
|
||||
# pip install -U setuptools
|
||||
```
|
||||
## Running the server
|
||||
|
||||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits-legend/
|
||||
# for now you'll need to `git checkout FastAPI`
|
||||
pipenv shell
|
||||
pipenv install --dev
|
||||
Create the data folder and edit the .env file:
|
||||
|
||||
If any of the modules fails to install, try checking and upgrading your setupTool module.
|
||||
`pip install -U setuptools`
|
||||
mkdir data
|
||||
cp .env.example .env
|
||||
sudo nano .env
|
||||
|
||||
If you wish to use a version of Python higher than 3.7:
|
||||
To then run the server for development purposes (includes hot-reload), use:
|
||||
|
||||
pipenv --python 3.8 install --dev
|
||||
pipenv run python -m uvicorn lnbits.__main__:app --host 0.0.0.0 --reload
|
||||
|
||||
For production, use:
|
||||
|
||||
You will need to copy `.env.example` to `.env`, then set variables there.
|
||||
|
||||

|
||||
pipenv run python -m uvicorn lnbits.__main__:app --host 0.0.0.0
|
||||
|
||||
You might also need to install additional packages, depending on the [backend wallet](../guide/wallets.md) you use.
|
||||
E.g. when you want to use LND you have to `pipenv run pip install lndgrpc` and `pipenv run pip install purerpc`.
|
||||
|
||||
Take a look at [Polar][polar] for an excellent way of spinning up a Lightning Network dev environment.
|
||||
|
||||
## Running the server
|
||||
**Notes**:
|
||||
|
||||
LNbits uses [FastAPI][fastapi] as an application server.
|
||||
Before running the server for the first time, make sure to create the data folder and edit the .env file:
|
||||
|
||||
mkdir data
|
||||
cp .env.example .env
|
||||
sudo nano .env
|
||||
|
||||
To then run the server, use:
|
||||
|
||||
pipenv run python -m uvicorn lnbits.__main__:app --reload
|
||||
|
||||
**Note**: You'll need to use _https_ for some endpoints and/or extensions. You can use [ngrok](https://ngrok.com/) for that. Follow the installation instructions on the website and when it's all set you can run:
|
||||
|
||||
./nrok http 5000
|
||||
|
||||
this will give you an _https_ tunnel for the _localhost_, use that URL for navigating to LNBits.
|
||||
|
||||
## Frontend
|
||||
|
||||
The frontend uses [Vue.js and Quasar][quasar].
|
||||
|
||||
[quart]: https://pgjones.gitlab.io/
|
||||
[pipenv]: https://pipenv.pypa.io/
|
||||
[polar]: https://lightningpolar.com/
|
||||
[quasar]: https://quasar.dev/start/how-to-use-vue
|
||||
* We reccomend using <a href="https://caddyserver.com/docs/install#debian-ubuntu-raspbian">Caddy</a> for a reverse-proxy if you want to serve your install through a domain, alternatively you can use [ngrok](https://ngrok.com/).
|
||||
* <a href="https://linuxize.com/post/how-to-use-linux-screen/#starting-linux-screen">Screen</a> works well if you want LNbits to continue running when you close your terminal session.
|
||||
|
||||
@@ -6,6 +6,7 @@ nav_order: 2
|
||||
|
||||
# Basic installation
|
||||
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/
|
||||
@@ -25,14 +26,13 @@ Download this repo and install the dependencies:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits/
|
||||
cd lnbits-legend/
|
||||
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3-venv' should work
|
||||
# for now you'll need to `git checkout FastAPI`
|
||||
python3 -m venv venv
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
# 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
|
||||
# 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
|
||||
./venv/bin/uvicorn lnbits.__main__:app --port 5000
|
||||
@@ -50,6 +50,7 @@ You might also need to install additional packages or perform additional setup s
|
||||
If you already have LNbits installed and running, on an SQLite database, we **HIGHLY** recommend you migrate to postgres!
|
||||
|
||||
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, check the guide above.
|
||||
|
||||
```sh
|
||||
# STOP LNbits
|
||||
# on the LNBits folder, locate and edit 'conv.py' with the relevant credentials
|
||||
@@ -67,6 +68,39 @@ Hopefully, everything works and get migrated... Launch LNbits again and check if
|
||||
|
||||
# Additional guides
|
||||
|
||||
### 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
|
||||
#Wants=lnd.service # you can uncomment these lines if you know what you're doing
|
||||
#After=lnd.service # it will make sure that lnbits starts after lnd (replace with your own backend service)
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=/home/bitcoin/lnbits # replace with the absolute path of your lnbits installation
|
||||
ExecStart=/home/bitcoin/lnbits/venv/bin/uvicorn lnbits.__main__:app --port 5000 # same here
|
||||
User=bitcoin # replace with the user that you're running lnbits on
|
||||
Restart=always
|
||||
TimeoutSec=120
|
||||
RestartSec=30
|
||||
Environment=PYTHONUNBUFFERED=1 # this makes sure that you receive logs in real time
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Save the file and run the following commands:
|
||||
|
||||
```sh
|
||||
sudo systemctl enable lnbits.service
|
||||
sudo systemctl start lnbits.service
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
+11
-2
@@ -17,6 +17,7 @@ A backend wallet can be configured using the following LNbits environment variab
|
||||
### CLightning
|
||||
|
||||
Using this wallet requires the installation of the `pylightning` Python package.
|
||||
If you want to use LNURLp you should use SparkWallet because of an issue with description_hash and CLightning.
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CLightningWallet**
|
||||
- `CLIGHTNING_RPC`: /file/path/lightning-rpc
|
||||
@@ -35,16 +36,24 @@ Using this wallet requires the installation of the `grpcio` and `protobuf` Pytho
|
||||
- `LND_GRPC_ENDPOINT`: ip_address
|
||||
- `LND_GRPC_PORT`: port
|
||||
- `LND_GRPC_CERT`: /file/path/tls.cert
|
||||
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon
|
||||
- `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`.
|
||||
|
||||
### LND (REST)
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
|
||||
- `LND_REST_ENDPOINT`: ip_address
|
||||
- `LND_REST_CERT`: /file/path/tls.cert
|
||||
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon
|
||||
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
|
||||
|
||||
or
|
||||
|
||||
- `LND_REST_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
|
||||
|
||||
### LNbits
|
||||
|
||||
|
||||
+3
-7
@@ -1,6 +1,5 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
from lnbits.core.tasks import register_task_listeners
|
||||
import sys
|
||||
import traceback
|
||||
import warnings
|
||||
@@ -12,6 +11,7 @@ from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
import lnbits.settings
|
||||
from lnbits.core.tasks import register_task_listeners
|
||||
|
||||
from .commands import db_migrate, handle_assets
|
||||
from .core import core_app
|
||||
@@ -45,14 +45,10 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||
"/core/static", StaticFiles(directory="lnbits/core/static"), name="core_static"
|
||||
)
|
||||
|
||||
origins = ["http://localhost", "http://localhost:5000"]
|
||||
origins = ["*"]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
CORSMiddleware, allow_origins=origins, allow_methods=["*"], allow_headers=["*"]
|
||||
)
|
||||
|
||||
g().config = lnbits.settings
|
||||
|
||||
+205
-4
@@ -2,10 +2,14 @@ import bitstring # type: ignore
|
||||
import re
|
||||
import hashlib
|
||||
from typing import List, NamedTuple, Optional
|
||||
from bech32 import bech32_decode, CHARSET # type: ignore
|
||||
from bech32 import bech32_encode, bech32_decode, CHARSET
|
||||
from ecdsa import SECP256k1, VerifyingKey # type: ignore
|
||||
from ecdsa.util import sigdecode_string # type: ignore
|
||||
from binascii import unhexlify
|
||||
import time
|
||||
from decimal import Decimal
|
||||
import embit
|
||||
import secp256k1
|
||||
|
||||
|
||||
class Route(NamedTuple):
|
||||
@@ -116,6 +120,166 @@ def decode(pr: str) -> Invoice:
|
||||
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", hashlib.sha256(v.encode("utf-8")).digest())
|
||||
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:
|
||||
@@ -146,6 +310,34 @@ def _pull_tagged(stream):
|
||||
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()
|
||||
@@ -156,9 +348,9 @@ def _trim_to_bytes(barr):
|
||||
|
||||
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),
|
||||
blockheight=((short_channel_id >> 40) & 0xffffff),
|
||||
transactionindex=((short_channel_id >> 16) & 0xffffff),
|
||||
outputindex=(short_channel_id & 0xffff),
|
||||
)
|
||||
|
||||
|
||||
@@ -167,3 +359,12 @@ def _u5_to_bitarray(arr: List[int]) -> 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
|
||||
|
||||
+19
-4
@@ -6,12 +6,11 @@ from urllib.parse import urlparse
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.db import Connection, POSTGRES, COCKROACH
|
||||
from lnbits.settings import DEFAULT_WALLET_NAME
|
||||
from lnbits.settings import DEFAULT_WALLET_NAME, LNBITS_ADMIN_USERS
|
||||
|
||||
from . import db
|
||||
from .models import User, Wallet, Payment, BalanceCheck
|
||||
|
||||
|
||||
# accounts
|
||||
# --------
|
||||
|
||||
@@ -62,6 +61,9 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -217,6 +219,8 @@ async def get_payments(
|
||||
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]:
|
||||
"""
|
||||
@@ -261,6 +265,15 @@ async def get_payments(
|
||||
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)}"
|
||||
@@ -271,14 +284,16 @@ async def get_payments(
|
||||
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:
|
||||
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"""
|
||||
|
||||
@@ -57,6 +57,7 @@ class User(BaseModel):
|
||||
extensions: List[str] = []
|
||||
wallets: List[Wallet] = []
|
||||
password: Optional[str] = None
|
||||
admin: bool = False
|
||||
|
||||
@property
|
||||
def wallet_ids(self) -> List[str]:
|
||||
|
||||
+16
-9
@@ -96,8 +96,6 @@ async def pay_invoice(
|
||||
raise ValueError("Amount in invoice is too high.")
|
||||
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
if invoice.amount_msat > wallet.balance_msat - (wallet.balance_msat / 100 * 2):
|
||||
raise PermissionError("LNbits requires you keep at least 2% reserve to cover potential routing fees.")
|
||||
|
||||
# put all parameters that don't change here
|
||||
PaymentKwargs = TypedDict(
|
||||
@@ -141,11 +139,21 @@ async def pay_invoice(
|
||||
**payment_kwargs,
|
||||
)
|
||||
|
||||
# do the balance check
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
assert wallet
|
||||
if wallet.balance_msat < 0:
|
||||
raise PermissionError("Insufficient balance.")
|
||||
# do the balance check if internal payment
|
||||
if internal_checking_id:
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
assert wallet
|
||||
if wallet.balance_msat < 0:
|
||||
raise PermissionError("Insufficient balance.")
|
||||
|
||||
# do the balance check if external payment
|
||||
else:
|
||||
if invoice.amount_msat > wallet.balance_msat - (
|
||||
wallet.balance_msat / 100 * 2
|
||||
):
|
||||
raise PermissionError(
|
||||
"LNbits requires you keep at least 2% reserve to cover potential routing fees."
|
||||
)
|
||||
|
||||
if internal_checking_id:
|
||||
# mark the invoice from the other side as not pending anymore
|
||||
@@ -322,8 +330,7 @@ async def check_invoice_status(
|
||||
if not payment.pending:
|
||||
return status
|
||||
if payment.is_out and status.failed:
|
||||
print(
|
||||
f" - deleting outgoing failed payment {payment.checking_id}: {status}")
|
||||
print(f" - deleting outgoing failed payment {payment.checking_id}: {status}")
|
||||
await payment.delete()
|
||||
elif not status.pending:
|
||||
print(
|
||||
|
||||
@@ -161,14 +161,14 @@ new Vue({
|
||||
{
|
||||
name: 'sat',
|
||||
align: 'right',
|
||||
label: 'Amount (sat)',
|
||||
label: 'Amount (' + LNBITS_DENOMINATION + ')',
|
||||
field: 'sat',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'fee',
|
||||
align: 'right',
|
||||
label: 'Fee (msat)',
|
||||
label: 'Fee (m' + LNBITS_DENOMINATION + ')',
|
||||
field: 'fee'
|
||||
}
|
||||
],
|
||||
@@ -185,12 +185,17 @@ new Vue({
|
||||
location: window.location
|
||||
},
|
||||
balance: 0,
|
||||
credit: 0,
|
||||
newName: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
formattedBalance: function () {
|
||||
return LNbits.utils.formatSat(this.balance || this.g.wallet.sat)
|
||||
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
|
||||
@@ -249,6 +254,28 @@ new Vue({
|
||||
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)
|
||||
@@ -271,7 +298,9 @@ new Vue({
|
||||
},
|
||||
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,
|
||||
|
||||
@@ -50,7 +50,7 @@ async def dispatch_invoice_listener(payment: Payment):
|
||||
|
||||
async def dispatch_webhook(payment: Payment):
|
||||
async with httpx.AsyncClient() as client:
|
||||
data = payment._asdict()
|
||||
data = payment.dict()
|
||||
try:
|
||||
r = await client.post(payment.webhook, json=data, timeout=40)
|
||||
await mark_webhook_sent(payment, r.status_code)
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": false,
|
||||
"amount": <int>, "memo": <string>, "webhook":
|
||||
<url:string>}' -H "X-Api-Key: <i>{{ wallet.inkey }}</i>" -H
|
||||
<url:string>, "unit": <string>}' -H "X-Api-Key: <i>{{ wallet.inkey }}</i>" -H
|
||||
"Content-type: application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
color="primary"
|
||||
@click="processing"
|
||||
type="a"
|
||||
href="{{ url_for('core.lnurlwallet', lightning=lnurl) }}"
|
||||
href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}"
|
||||
>
|
||||
Press to claim bitcoin
|
||||
</q-btn>
|
||||
|
||||
@@ -15,10 +15,54 @@
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h3 class="q-my-none">
|
||||
<strong>{% raw %}{{ formattedBalance }}{% endraw %}</strong> sat
|
||||
<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">
|
||||
<div class="row q-pb-md q-px-md q-col-gutter-md gt-sm">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
unelevated
|
||||
@@ -141,7 +185,17 @@
|
||||
<q-tooltip>{{ props.row.date }}</q-tooltip>
|
||||
{{ props.row.dateFrom }}
|
||||
</q-td>
|
||||
<q-td auto-width key="sat" :props="props">
|
||||
{% 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">
|
||||
@@ -223,7 +277,7 @@
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
|
||||
{{ SITE_TITLE }} wallet: <strong><em>{{ wallet.name }}</em></strong>
|
||||
{{ SITE_TITLE }} Wallet: <strong><em>{{ wallet.name }}</em></strong>
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
@@ -337,7 +391,20 @@
|
||||
<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
|
||||
@@ -350,25 +417,29 @@
|
||||
filled
|
||||
dense
|
||||
v-model.number="receive.data.amount"
|
||||
type="number"
|
||||
:label="`Amount (${receive.unit}) *`"
|
||||
:step="receive.unit != 'sat' ? '0.001' : '1'"
|
||||
: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 *"
|
||||
placeholder="LNbits invoice"
|
||||
label="Memo"
|
||||
></q-input>
|
||||
{% raw %}
|
||||
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="receive.data.memo == null || receive.data.amount == null || receive.data.amount <= 0"
|
||||
:disable="receive.data.amount == null || receive.data.amount <= 0"
|
||||
type="submit"
|
||||
>
|
||||
<span v-if="receive.lnurl">
|
||||
@@ -410,8 +481,13 @@
|
||||
<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">
|
||||
{% raw %}
|
||||
<h6 class="q-my-none">{{ parse.invoice.fsat }} sat</h6>
|
||||
<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 />
|
||||
@@ -461,7 +537,7 @@
|
||||
<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 }} sat
|
||||
parse.lnurlpay.maxSendable | msatoshiFormat }} {{LNBITS_DENOMINATION}}
|
||||
<span v-if="parse.lnurlpay.commentAllowed > 0">
|
||||
<br />
|
||||
and a {{parse.lnurlpay.commentAllowed}}-char comment
|
||||
@@ -471,7 +547,8 @@
|
||||
<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> sat
|
||||
<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
|
||||
@@ -488,16 +565,18 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{% endraw %}
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="parse.data.amount"
|
||||
type="number"
|
||||
label="Amount (sat) *"
|
||||
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
|
||||
@@ -511,7 +590,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn unelevated color="primary" type="submit">Send satoshis</q-btn>
|
||||
<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
|
||||
>
|
||||
@@ -583,7 +664,23 @@
|
||||
</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-max"
|
||||
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>
|
||||
{% if service_fee > 0 %}
|
||||
<div ref="disclaimer"></div>
|
||||
<q-dialog v-model="disclaimerDialog.show">
|
||||
|
||||
+85
-23
@@ -3,7 +3,7 @@ import hashlib
|
||||
import json
|
||||
from binascii import unhexlify
|
||||
from http import HTTPStatus
|
||||
from typing import Dict, Optional, Union
|
||||
from typing import Dict, List, Optional, Union
|
||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
import httpx
|
||||
@@ -12,6 +12,7 @@ from fastapi.exceptions import HTTPException
|
||||
from fastapi.param_functions import Depends
|
||||
from fastapi.params import Body
|
||||
from pydantic import BaseModel
|
||||
from pydantic.fields import Field
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from lnbits import bolt11, lnurl
|
||||
@@ -25,7 +26,12 @@ from lnbits.decorators import (
|
||||
)
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.requestvars import g
|
||||
from lnbits.utils.exchange_rates import currencies, fiat_amount_as_satoshis
|
||||
from lnbits.utils.exchange_rates import (
|
||||
currencies,
|
||||
fiat_amount_as_satoshis,
|
||||
satoshis_amount_as_fiat,
|
||||
)
|
||||
from lnbits.settings import LNBITS_SITE_TITLE
|
||||
|
||||
from .. import core_app, db
|
||||
from ..crud import (
|
||||
@@ -33,6 +39,9 @@ from ..crud import (
|
||||
get_standalone_payment,
|
||||
save_balance_check,
|
||||
update_wallet,
|
||||
create_payment,
|
||||
get_wallet,
|
||||
update_payment_status,
|
||||
)
|
||||
from ..services import (
|
||||
InvoiceFailure,
|
||||
@@ -43,6 +52,8 @@ from ..services import (
|
||||
perform_lnurlauth,
|
||||
)
|
||||
from ..tasks import api_invoice_listeners
|
||||
from lnbits.settings import LNBITS_ADMIN_USERS
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
|
||||
@core_app.get("/api/v1/wallet")
|
||||
@@ -57,6 +68,35 @@ async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
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(WalletAdminKeyChecker())
|
||||
@@ -72,7 +112,9 @@ async def api_update_wallet(
|
||||
@core_app.get("/api/v1/payments")
|
||||
async def api_payments(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
await get_payments(wallet_id=wallet.wallet.id, pending=True, complete=True)
|
||||
pendingPayments = await get_payments(wallet_id=wallet.wallet.id, pending=True, exclude_uncheckable=True)
|
||||
pendingPayments = await get_payments(
|
||||
wallet_id=wallet.wallet.id, pending=True, exclude_uncheckable=True
|
||||
)
|
||||
for payment in pendingPayments:
|
||||
await check_invoice_status(
|
||||
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
|
||||
@@ -82,7 +124,7 @@ async def api_payments(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
|
||||
class CreateInvoiceData(BaseModel):
|
||||
out: Optional[bool] = True
|
||||
amount: int = Query(None, ge=1)
|
||||
amount: float = Query(None, ge=0)
|
||||
memo: str = None
|
||||
unit: Optional[str] = "sat"
|
||||
description_hash: Optional[str] = None
|
||||
@@ -99,9 +141,9 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
memo = ""
|
||||
else:
|
||||
description_hash = b""
|
||||
memo = data.memo
|
||||
memo = data.memo or LNBITS_SITE_TITLE
|
||||
if data.unit == "sat":
|
||||
amount = data.amount
|
||||
amount = int(data.amount)
|
||||
else:
|
||||
price_in_sats = await fiat_amount_as_satoshis(data.amount, data.unit)
|
||||
amount = price_in_sats
|
||||
@@ -126,8 +168,8 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
|
||||
lnurl_response: Union[None, bool, str] = None
|
||||
if data.lnurl_callback:
|
||||
if "lnurl_balance_check" in g().data:
|
||||
save_balance_check(g().wallet.id, data.lnurl_balance_check)
|
||||
if "lnurl_balance_check" in data:
|
||||
save_balance_check(wallet.id, data.lnurl_balance_check)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
@@ -138,7 +180,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
"balanceNotify": url_for(
|
||||
f"/withdraw/notify/{urlparse(data.lnurl_callback).netloc}",
|
||||
external=True,
|
||||
wal=g().wallet.id,
|
||||
wal=wallet.id,
|
||||
),
|
||||
},
|
||||
timeout=10,
|
||||
@@ -193,8 +235,7 @@ async def api_payments_create(
|
||||
invoiceData: CreateInvoiceData = Body(...),
|
||||
):
|
||||
if wallet.wallet_type < 0 or wallet.wallet_type > 2:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Key is invalid")
|
||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Key is invalid")
|
||||
|
||||
if invoiceData.out is True and wallet.wallet_type == 0:
|
||||
if not invoiceData.bolt11:
|
||||
@@ -249,14 +290,14 @@ async def api_payments_pay_lnurl(
|
||||
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}.",
|
||||
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}.",
|
||||
)
|
||||
# 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 = {}
|
||||
|
||||
@@ -374,16 +415,14 @@ async def api_lnurlscan(code: str):
|
||||
params.update(callback=url) # with k1 already in it
|
||||
|
||||
lnurlauth_key = g().wallet.lnurlauth_key(domain)
|
||||
params.update(
|
||||
pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
|
||||
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"},
|
||||
detail={"domain": domain, "message": "failed to get parameters"},
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -413,8 +452,7 @@ async def api_lnurlscan(code: str):
|
||||
|
||||
if tag == "withdrawRequest":
|
||||
params.update(kind="withdraw")
|
||||
params.update(fixed=data["minWithdrawable"]
|
||||
== data["maxWithdrawable"])
|
||||
params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"])
|
||||
|
||||
# callback with k1 already in it
|
||||
parsed_callback: ParseResult = urlparse(data["callback"])
|
||||
@@ -501,3 +539,27 @@ async def api_perform_lnurlauth(callback: str):
|
||||
@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["sats"] = int(data.amount)
|
||||
output["BTC"] = data.amount / 100000000
|
||||
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.to)
|
||||
output["BTC"] = output["sats"] / 100000000
|
||||
return output
|
||||
|
||||
@@ -14,7 +14,12 @@ 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_ALLOWED_USERS, LNBITS_SITE_TITLE, SERVICE_FEE
|
||||
from lnbits.settings import (
|
||||
LNBITS_ADMIN_USERS,
|
||||
LNBITS_ALLOWED_USERS,
|
||||
LNBITS_SITE_TITLE,
|
||||
SERVICE_FEE,
|
||||
)
|
||||
|
||||
from ..crud import (
|
||||
create_account,
|
||||
@@ -43,9 +48,7 @@ async def home(request: Request, lightning: str = None):
|
||||
|
||||
|
||||
@core_html_routes.get(
|
||||
"/extensions",
|
||||
name="core.extensions",
|
||||
response_class=HTMLResponse,
|
||||
"/extensions", name="core.extensions", response_class=HTMLResponse
|
||||
)
|
||||
async def extensions(
|
||||
request: Request,
|
||||
@@ -115,6 +118,8 @@ async def wallet(
|
||||
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:
|
||||
wallet = user.wallets[0]
|
||||
@@ -221,7 +226,7 @@ async def lnurl_balance_notify(request: Request, service: str):
|
||||
redeem_lnurl_withdraw(bc.wallet, bc.url)
|
||||
|
||||
|
||||
@core_html_routes.get("/lnurlwallet", response_class=RedirectResponse)
|
||||
@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)
|
||||
|
||||
+33
-3
@@ -13,7 +13,7 @@ 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_ALLOWED_USERS
|
||||
from lnbits.settings import LNBITS_ALLOWED_USERS, LNBITS_ADMIN_USERS, LNBITS_ADMIN_EXTENSIONS
|
||||
|
||||
|
||||
class KeyChecker(SecurityBase):
|
||||
@@ -122,6 +122,7 @@ async def get_key_type(
|
||||
# 0: admin
|
||||
# 1: invoice
|
||||
# 2: invalid
|
||||
pathname = r['path'].split('/')[1]
|
||||
|
||||
if not api_key_header and not api_key_query:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -131,7 +132,10 @@ async def get_key_type(
|
||||
try:
|
||||
checker = WalletAdminKeyChecker(api_key=token)
|
||||
await checker.__call__(r)
|
||||
return WalletTypeInfo(0, checker.wallet)
|
||||
wallet = WalletTypeInfo(0, checker.wallet)
|
||||
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
|
||||
@@ -143,7 +147,10 @@ async def get_key_type(
|
||||
try:
|
||||
checker = WalletInvoiceKeyChecker(api_key=token)
|
||||
await checker.__call__(r)
|
||||
return WalletTypeInfo(1, checker.wallet)
|
||||
wallet = WalletTypeInfo(0, checker.wallet)
|
||||
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
|
||||
@@ -172,6 +179,26 @@ async def require_admin_key(
|
||||
return wallet
|
||||
|
||||
|
||||
async def require_invoice_key(
|
||||
r: Request,
|
||||
api_key_header: str = Security(api_key_header),
|
||||
api_key_query: str = Security(api_key_query),
|
||||
):
|
||||
token = api_key_header if api_key_header else api_key_query
|
||||
|
||||
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:
|
||||
@@ -184,4 +211,7 @@ async def check_user_exists(usr: UUID4) -> User:
|
||||
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
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import Copilots, CreateCopilotData
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
###############COPILOTS##########################
|
||||
|
||||
@@ -15,7 +16,7 @@ async def create_copilot(
|
||||
"""
|
||||
INSERT INTO copilot.newer_copilots (
|
||||
id,
|
||||
user,
|
||||
"user",
|
||||
lnurl_toggle,
|
||||
wallet,
|
||||
title,
|
||||
|
||||
@@ -30,6 +30,6 @@ from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
|
||||
|
||||
def lnticket_start():
|
||||
def livestream_start():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from http import HTTPStatus
|
||||
from mmap import MAP_DENYWRITE
|
||||
# from mmap import MAP_DENYWRITE
|
||||
|
||||
from fastapi.param_functions import Depends
|
||||
from fastapi.params import Query
|
||||
|
||||
@@ -160,7 +160,7 @@ async def set_address_renewed(address_id: str, duration: int):
|
||||
|
||||
|
||||
async def check_address_available(username: str, domain: str):
|
||||
row, = await db.fetchone(
|
||||
(row,) = await db.fetchone(
|
||||
"SELECT COUNT(username) FROM lnaddress.address WHERE username = ? AND domain = ?",
|
||||
(username, domain),
|
||||
)
|
||||
|
||||
@@ -52,7 +52,7 @@ async def lnurl_callback(address_id, amount: int = Query(...)):
|
||||
amount_received = amount
|
||||
|
||||
domain = await get_domain(address.domain)
|
||||
|
||||
|
||||
base_url = (
|
||||
address.wallet_endpoint[:-1]
|
||||
if address.wallet_endpoint.endswith("/")
|
||||
@@ -71,7 +71,9 @@ async def lnurl_callback(address_id, amount: int = Query(...)):
|
||||
"out": False,
|
||||
"amount": int(amount_received / 1000),
|
||||
"description_hash": hashlib.sha256(
|
||||
(await address.lnurlpay_metadata(domain=domain.domain)).encode("utf-8")
|
||||
(await address.lnurlpay_metadata(domain=domain.domain)).encode(
|
||||
"utf-8"
|
||||
)
|
||||
).hexdigest(),
|
||||
"extra": {"tag": f"Payment to {address.username}@{domain.domain}"},
|
||||
},
|
||||
|
||||
@@ -53,5 +53,5 @@ class Addresses(BaseModel):
|
||||
text = f"Payment to {self.username}"
|
||||
identifier = f"{self.username}@{domain}"
|
||||
metadata = [["text/plain", text], ["text/identifier", identifier]]
|
||||
|
||||
|
||||
return LnurlPayMetadata(json.dumps(metadata))
|
||||
|
||||
@@ -36,7 +36,7 @@ async def display(domain_id, request: Request):
|
||||
|
||||
wallet = await get_wallet(domain.wallet)
|
||||
url = urlparse(str(request.url))
|
||||
|
||||
|
||||
return lnaddress_renderer().TemplateResponse(
|
||||
"lnaddress/display.html",
|
||||
{
|
||||
@@ -45,6 +45,6 @@ async def display(domain_id, request: Request):
|
||||
"domain_domain": domain.domain,
|
||||
"domain_cost": domain.cost,
|
||||
"domain_wallet_inkey": wallet.inkey,
|
||||
"root_url": f"{url.scheme}://{url.netloc}"
|
||||
"root_url": f"{url.scheme}://{url.netloc}",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import time
|
||||
import asyncio
|
||||
|
||||
from base64 import urlsafe_b64encode
|
||||
from http import HTTPStatus
|
||||
|
||||
@@ -11,7 +13,7 @@ from lnbits import bolt11
|
||||
from lnbits.core.crud import delete_expired_invoices, get_payments
|
||||
from lnbits.core.services import create_invoice, pay_invoice
|
||||
from lnbits.decorators import WalletTypeInfo
|
||||
from lnbits.settings import WALLET
|
||||
from lnbits.settings import WALLET, LNBITS_SITE_TITLE
|
||||
|
||||
from . import lndhub_ext
|
||||
from .decorators import check_wallet, require_admin_key
|
||||
@@ -55,14 +57,13 @@ async def lndhub_addinvoice(
|
||||
_, pr = await create_invoice(
|
||||
wallet_id=wallet.wallet.id,
|
||||
amount=int(data.amt),
|
||||
memo=data.memo or "received sats",
|
||||
memo=data.memo or LNBITS_SITE_TITLE,
|
||||
extra={"tag": "lndhub"},
|
||||
)
|
||||
except:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Failed to create invoice"
|
||||
)
|
||||
|
||||
invoice = bolt11.decode(pr)
|
||||
return {
|
||||
"pay_req": pr,
|
||||
@@ -108,13 +109,17 @@ async def lndhub_payinvoice(
|
||||
|
||||
|
||||
@lndhub_ext.get("/ext/balance")
|
||||
async def lndhub_balance(wallet: WalletTypeInfo = Depends(check_wallet),):
|
||||
async def lndhub_balance(
|
||||
wallet: WalletTypeInfo = Depends(check_wallet),
|
||||
):
|
||||
return {"BTC": {"AvailableBalance": wallet.wallet.balance}}
|
||||
|
||||
|
||||
@lndhub_ext.get("/ext/gettxs")
|
||||
async def lndhub_gettxs(
|
||||
wallet: WalletTypeInfo = Depends(check_wallet), limit: int = Query(0, ge=0, lt=200)
|
||||
wallet: WalletTypeInfo = Depends(check_wallet),
|
||||
limit: int = Query(20, ge=1, le=20),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
for payment in await get_payments(
|
||||
wallet_id=wallet.wallet.id,
|
||||
@@ -122,11 +127,15 @@ async def lndhub_gettxs(
|
||||
pending=True,
|
||||
outgoing=True,
|
||||
incoming=False,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
exclude_uncheckable=True,
|
||||
):
|
||||
await payment.set_pending(
|
||||
(await WALLET.get_payment_status(payment.checking_id)).pending
|
||||
)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
return [
|
||||
{
|
||||
"payment_preimage": payment.preimage,
|
||||
@@ -146,28 +155,34 @@ async def lndhub_gettxs(
|
||||
complete=True,
|
||||
outgoing=True,
|
||||
incoming=False,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
)[:limit]
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@lndhub_ext.get("/ext/getuserinvoices")
|
||||
async def lndhub_getuserinvoices(
|
||||
wallet: WalletTypeInfo = Depends(check_wallet), limit: int = Query(0, ge=0, lt=200)
|
||||
wallet: WalletTypeInfo = Depends(check_wallet),
|
||||
limit: int = Query(20, ge=1, le=20),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
await delete_expired_invoices()
|
||||
for invoice in await get_payments(
|
||||
wallet_id=wallet.wallet.id,
|
||||
complete=False,
|
||||
pending=True,
|
||||
outgoing=False,
|
||||
incoming=True,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
exclude_uncheckable=True,
|
||||
):
|
||||
await invoice.set_pending(
|
||||
(await WALLET.get_invoice_status(invoice.checking_id)).pending
|
||||
)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -190,8 +205,10 @@ async def lndhub_getuserinvoices(
|
||||
complete=True,
|
||||
incoming=True,
|
||||
outgoing=False,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
)[:limit]
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# LNURLDevice
|
||||
|
||||
For offline LNURL devices
|
||||
@@ -0,0 +1,17 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
|
||||
db = Database("ext_lnurldevice")
|
||||
|
||||
lnurldevice_ext: APIRouter = APIRouter(prefix="/lnurldevice", tags=["lnurldevice"])
|
||||
|
||||
|
||||
def lnurldevice_renderer():
|
||||
return template_renderer(["lnbits/extensions/lnurldevice/templates"])
|
||||
|
||||
|
||||
from .lnurl import * # noqa
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "LNURLDevice",
|
||||
"short_description": "For offline LNURL devices",
|
||||
"icon": "point_of_sale",
|
||||
"contributors": ["arcbtc"]
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import createLnurldevice, lnurldevicepayment, lnurldevices
|
||||
|
||||
###############lnurldeviceS##########################
|
||||
|
||||
|
||||
async def create_lnurldevice(
|
||||
data: createLnurldevice,
|
||||
) -> lnurldevices:
|
||||
lnurldevice_id = urlsafe_short_hash()
|
||||
lnurldevice_key = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO lnurldevice.lnurldevices (
|
||||
id,
|
||||
key,
|
||||
title,
|
||||
wallet,
|
||||
currency,
|
||||
device,
|
||||
profit
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
lnurldevice_id,
|
||||
lnurldevice_key,
|
||||
data.title,
|
||||
data.wallet,
|
||||
data.currency,
|
||||
data.device,
|
||||
data.profit,
|
||||
),
|
||||
)
|
||||
return await get_lnurldevice(lnurldevice_id)
|
||||
|
||||
|
||||
async def update_lnurldevice(lnurldevice_id: str, **kwargs) -> Optional[lnurldevices]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE lnurldevice.lnurldevices SET {q} WHERE id = ?",
|
||||
(*kwargs.values(), lnurldevice_id),
|
||||
)
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM lnurldevice.lnurldevices WHERE id = ?", (lnurldevice_id,)
|
||||
)
|
||||
return lnurldevices(**row) if row else None
|
||||
|
||||
|
||||
async def get_lnurldevice(lnurldevice_id: str) -> lnurldevices:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM lnurldevice.lnurldevices WHERE id = ?", (lnurldevice_id,)
|
||||
)
|
||||
return lnurldevices(**row) if row else None
|
||||
|
||||
|
||||
async def get_lnurldevices(wallet_ids: Union[str, List[str]]) -> List[lnurldevices]:
|
||||
wallet_ids = [wallet_ids]
|
||||
q = ",".join(["?"] * len(wallet_ids[0]))
|
||||
rows = await db.fetchall(
|
||||
f"""
|
||||
SELECT * FROM lnurldevice.lnurldevices WHERE wallet IN ({q})
|
||||
ORDER BY id
|
||||
""",
|
||||
(*wallet_ids,),
|
||||
)
|
||||
|
||||
return [lnurldevices(**row) if row else None for row in rows]
|
||||
|
||||
|
||||
async def delete_lnurldevice(lnurldevice_id: str) -> None:
|
||||
await db.execute(
|
||||
"DELETE FROM lnurldevice.lnurldevices WHERE id = ?", (lnurldevice_id,)
|
||||
)
|
||||
|
||||
########################lnuldevice payments###########################
|
||||
|
||||
|
||||
async def create_lnurldevicepayment(
|
||||
deviceid: str,
|
||||
payload: Optional[str] = None,
|
||||
pin: Optional[str] = None,
|
||||
payhash: Optional[str] = None,
|
||||
sats: Optional[int] = 0,
|
||||
) -> lnurldevicepayment:
|
||||
lnurldevicepayment_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO lnurldevice.lnurldevicepayment (
|
||||
id,
|
||||
deviceid,
|
||||
payload,
|
||||
pin,
|
||||
payhash,
|
||||
sats
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(lnurldevicepayment_id, deviceid, payload, pin, payhash, sats),
|
||||
)
|
||||
return await get_lnurldevicepayment(lnurldevicepayment_id)
|
||||
|
||||
|
||||
async def update_lnurldevicepayment(
|
||||
lnurldevicepayment_id: str, **kwargs
|
||||
) -> Optional[lnurldevicepayment]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE lnurldevice.lnurldevicepayment SET {q} WHERE id = ?",
|
||||
(*kwargs.values(), lnurldevicepayment_id),
|
||||
)
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM lnurldevice.lnurldevicepayment WHERE id = ?",
|
||||
(lnurldevicepayment_id,),
|
||||
)
|
||||
return lnurldevicepayment(**row) if row else None
|
||||
|
||||
|
||||
async def get_lnurldevicepayment(lnurldevicepayment_id: str) -> lnurldevicepayment:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM lnurldevice.lnurldevicepayment WHERE id = ?",
|
||||
(lnurldevicepayment_id,),
|
||||
)
|
||||
return lnurldevicepayment(**row) if row else None
|
||||
|
||||
|
||||
async def get_lnurlpayload(lnurldevicepayment_payload: str) -> lnurldevicepayment:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM lnurldevice.lnurldevicepayment WHERE payload = ?",
|
||||
(lnurldevicepayment_payload,),
|
||||
)
|
||||
return lnurldevicepayment(**row) if row else None
|
||||
@@ -0,0 +1,231 @@
|
||||
import base64
|
||||
import hashlib
|
||||
from http import HTTPStatus
|
||||
from typing import Optional
|
||||
|
||||
from embit import bech32
|
||||
from embit import compact
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import hmac
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.param_functions import Query
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||
from lnbits.core.views.api import pay_invoice
|
||||
|
||||
|
||||
from . import lnurldevice_ext
|
||||
from .crud import (
|
||||
create_lnurldevicepayment,
|
||||
get_lnurldevice,
|
||||
get_lnurldevicepayment,
|
||||
update_lnurldevicepayment,
|
||||
get_lnurlpayload,
|
||||
)
|
||||
|
||||
|
||||
def bech32_decode(bech):
|
||||
"""tweaked version of bech32_decode that ignores length limitations"""
|
||||
if (any(ord(x) < 33 or ord(x) > 126 for x in bech)) or (
|
||||
bech.lower() != bech and bech.upper() != bech
|
||||
):
|
||||
return
|
||||
bech = bech.lower()
|
||||
device = bech.rfind("1")
|
||||
if device < 1 or device + 7 > len(bech):
|
||||
return
|
||||
if not all(x in bech32.CHARSET for x in bech[device + 1 :]):
|
||||
return
|
||||
hrp = bech[:device]
|
||||
data = [bech32.CHARSET.find(x) for x in bech[device + 1 :]]
|
||||
encoding = bech32.bech32_verify_checksum(hrp, data)
|
||||
if encoding is None:
|
||||
return
|
||||
return bytes(bech32.convertbits(data[:-6], 5, 8, False))
|
||||
|
||||
|
||||
def xor_decrypt(key, blob):
|
||||
s = BytesIO(blob)
|
||||
variant = s.read(1)[0]
|
||||
if variant != 1:
|
||||
raise RuntimeError("Not implemented")
|
||||
# reading nonce
|
||||
l = s.read(1)[0]
|
||||
nonce = s.read(l)
|
||||
if len(nonce) != l:
|
||||
raise RuntimeError("Missing nonce bytes")
|
||||
if l < 8:
|
||||
raise RuntimeError("Nonce is too short")
|
||||
# reading payload
|
||||
l = s.read(1)[0]
|
||||
payload = s.read(l)
|
||||
if len(payload) > 32:
|
||||
raise RuntimeError("Payload is too long for this encryption method")
|
||||
if len(payload) != l:
|
||||
raise RuntimeError("Missing payload bytes")
|
||||
hmacval = s.read()
|
||||
expected = hmac.new(
|
||||
key, b"Data:" + blob[: -len(hmacval)], digestmod="sha256"
|
||||
).digest()
|
||||
if len(hmacval) < 8:
|
||||
raise RuntimeError("HMAC is too short")
|
||||
if hmacval != expected[: len(hmacval)]:
|
||||
raise RuntimeError("HMAC is invalid")
|
||||
secret = hmac.new(key, b"Round secret:" + nonce, digestmod="sha256").digest()
|
||||
payload = bytearray(payload)
|
||||
for i in range(len(payload)):
|
||||
payload[i] = payload[i] ^ secret[i]
|
||||
s = BytesIO(payload)
|
||||
pin = compact.read_from(s)
|
||||
amount_in_cent = compact.read_from(s)
|
||||
return pin, amount_in_cent
|
||||
|
||||
|
||||
@lnurldevice_ext.get(
|
||||
"/api/v1/lnurl/{device_id}",
|
||||
status_code=HTTPStatus.OK,
|
||||
name="lnurldevice.lnurl_v1_params",
|
||||
)
|
||||
async def lnurl_v1_params(
|
||||
request: Request,
|
||||
device_id: str = Query(None),
|
||||
p: str = Query(None),
|
||||
atm: str = Query(None),
|
||||
):
|
||||
device = await get_lnurldevice(device_id)
|
||||
if not device:
|
||||
return {
|
||||
"status": "ERROR",
|
||||
"reason": f"lnurldevice {device_id} not found on this server",
|
||||
}
|
||||
paymentcheck = await get_lnurlpayload(p)
|
||||
if device.device == "atm":
|
||||
if paymentcheck:
|
||||
return {"status": "ERROR", "reason": f"Payment already claimed"}
|
||||
|
||||
if len(p) % 4 > 0:
|
||||
p += "=" * (4 - (len(p) % 4))
|
||||
|
||||
data = base64.urlsafe_b64decode(p)
|
||||
pin = 0
|
||||
amount_in_cent = 0
|
||||
try:
|
||||
result = xor_decrypt(device.key.encode(), data)
|
||||
pin = result[0]
|
||||
amount_in_cent = result[1]
|
||||
except Exception as exc:
|
||||
return {"status": "ERROR", "reason": str(exc)}
|
||||
|
||||
price_msat = (
|
||||
await fiat_amount_as_satoshis(float(amount_in_cent) / 100, device.currency)
|
||||
if device.currency != "sat"
|
||||
else amount_in_cent
|
||||
) * 1000
|
||||
|
||||
if atm:
|
||||
if device.device != "atm":
|
||||
return {"status": "ERROR", "reason": "Not ATM device."}
|
||||
price_msat = int(price_msat * (1 - (device.profit / 100)) / 1000)
|
||||
lnurldevicepayment = await create_lnurldevicepayment(
|
||||
deviceid=device.id,
|
||||
payload=p,
|
||||
sats=price_msat * 1000,
|
||||
pin=pin,
|
||||
payhash="payment_hash",
|
||||
)
|
||||
if not lnurldevicepayment:
|
||||
return {"status": "ERROR", "reason": "Could not create payment."}
|
||||
return {
|
||||
"tag": "withdrawRequest",
|
||||
"callback": request.url_for(
|
||||
"lnurldevice.lnurl_callback", paymentid=lnurldevicepayment.id
|
||||
),
|
||||
"k1": lnurldevicepayment.id,
|
||||
"minWithdrawable": price_msat * 1000,
|
||||
"maxWithdrawable": price_msat * 1000,
|
||||
"defaultDescription": device.title,
|
||||
}
|
||||
price_msat = int(price_msat * ((device.profit / 100) + 1) / 1000)
|
||||
print(price_msat)
|
||||
lnurldevicepayment = await create_lnurldevicepayment(
|
||||
deviceid=device.id,
|
||||
payload=p,
|
||||
sats=price_msat * 1000,
|
||||
pin=pin,
|
||||
payhash="payment_hash",
|
||||
)
|
||||
if not lnurldevicepayment:
|
||||
return {"status": "ERROR", "reason": "Could not create payment."}
|
||||
return {
|
||||
"tag": "payRequest",
|
||||
"callback": request.url_for(
|
||||
"lnurldevice.lnurl_callback", paymentid=lnurldevicepayment.id
|
||||
),
|
||||
"minSendable": price_msat * 1000,
|
||||
"maxSendable": price_msat * 1000,
|
||||
"metadata": await device.lnurlpay_metadata(),
|
||||
}
|
||||
|
||||
|
||||
@lnurldevice_ext.get(
|
||||
"/api/v1/lnurl/cb/{paymentid}",
|
||||
status_code=HTTPStatus.OK,
|
||||
name="lnurldevice.lnurl_callback",
|
||||
)
|
||||
async def lnurl_callback(
|
||||
request: Request,
|
||||
paymentid: str = Query(None),
|
||||
pr: str = Query(None),
|
||||
k1: str = Query(None),
|
||||
):
|
||||
lnurldevicepayment = await get_lnurldevicepayment(paymentid)
|
||||
device = await get_lnurldevice(lnurldevicepayment.deviceid)
|
||||
if not device:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="lnurldevice not found."
|
||||
)
|
||||
if pr:
|
||||
if lnurldevicepayment.id != k1:
|
||||
return {"status": "ERROR", "reason": "Bad K1"}
|
||||
if lnurldevicepayment.payhash != "payment_hash":
|
||||
return {"status": "ERROR", "reason": f"Payment already claimed"}
|
||||
lnurldevicepayment = await update_lnurldevicepayment(
|
||||
lnurldevicepayment_id=paymentid, payhash=lnurldevicepayment.payload
|
||||
)
|
||||
|
||||
await pay_invoice(
|
||||
wallet_id=device.wallet,
|
||||
payment_request=pr,
|
||||
max_sat=lnurldevicepayment.sats / 1000,
|
||||
extra={"tag": "withdraw"},
|
||||
)
|
||||
return {"status": "OK"}
|
||||
print(lnurldevicepayment.sats)
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=device.wallet,
|
||||
amount=lnurldevicepayment.sats / 1000,
|
||||
memo=device.title,
|
||||
description_hash=hashlib.sha256(
|
||||
(await device.lnurlpay_metadata()).encode("utf-8")
|
||||
).digest(),
|
||||
extra={"tag": "PoS"},
|
||||
)
|
||||
lnurldevicepayment = await update_lnurldevicepayment(
|
||||
lnurldevicepayment_id=paymentid, payhash=payment_hash
|
||||
)
|
||||
|
||||
return {
|
||||
"pr": payment_request,
|
||||
"successAction": {
|
||||
"tag": "url",
|
||||
"description": "Check the attached link",
|
||||
"url": request.url_for("lnurldevice.displaypin", paymentid=paymentid),
|
||||
},
|
||||
"routes": [],
|
||||
}
|
||||
|
||||
return resp.dict()
|
||||
@@ -0,0 +1,81 @@
|
||||
from lnbits.db import Database
|
||||
|
||||
db2 = Database("ext_lnurlpos")
|
||||
|
||||
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial lnurldevice table.
|
||||
"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE lnurldevice.lnurldevices (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
key TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
device TEXT NOT NULL,
|
||||
profit FLOAT NOT NULL,
|
||||
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE lnurldevice.lnurldevicepayment (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
deviceid TEXT NOT NULL,
|
||||
payhash TEXT,
|
||||
payload TEXT NOT NULL,
|
||||
pin INT,
|
||||
sats INT,
|
||||
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m002_redux(db):
|
||||
"""
|
||||
Moves everything from lnurlpos to lnurldevices
|
||||
"""
|
||||
try:
|
||||
for row in [
|
||||
list(row) for row in await db2.fetchall("SELECT * FROM lnurlpos.lnurlposs")
|
||||
]:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO lnurldevice.lnurldevices (
|
||||
id,
|
||||
key,
|
||||
title,
|
||||
wallet,
|
||||
currency,
|
||||
device,
|
||||
profit
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(row[0], row[1], row[2], row[3], row[4], "pos", 0),
|
||||
)
|
||||
for row in [
|
||||
list(row)
|
||||
for row in await db2.fetchall("SELECT * FROM lnurlpos.lnurlpospayment")
|
||||
]:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO lnurldevice.lnurldevicepayment (
|
||||
id,
|
||||
deviceid,
|
||||
payhash,
|
||||
payload,
|
||||
pin,
|
||||
sats
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(row[0], row[1], row[3], row[4], row[5], row[6]),
|
||||
)
|
||||
except:
|
||||
return
|
||||
@@ -11,33 +11,40 @@ from pydantic import BaseModel
|
||||
from pydantic.main import BaseModel
|
||||
|
||||
|
||||
class createLnurlpos(BaseModel):
|
||||
class createLnurldevice(BaseModel):
|
||||
title: str
|
||||
wallet: str
|
||||
currency: str
|
||||
device: str
|
||||
profit: float
|
||||
|
||||
|
||||
class lnurlposs(BaseModel):
|
||||
class lnurldevices(BaseModel):
|
||||
id: str
|
||||
key: str
|
||||
title: str
|
||||
wallet: str
|
||||
currency: str
|
||||
device: str
|
||||
profit: float
|
||||
timestamp: str
|
||||
|
||||
def from_row(cls, row: Row) -> "lnurlposs":
|
||||
def from_row(cls, row: Row) -> "lnurldevices":
|
||||
return cls(**dict(row))
|
||||
|
||||
def lnurl(self, req: Request) -> Lnurl:
|
||||
url = req.url_for("lnurlpos.lnurl_response", pos_id=self.id, _external=True)
|
||||
url = req.url_for(
|
||||
"lnurldevice.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 lnurlpospayment(BaseModel):
|
||||
|
||||
class lnurldevicepayment(BaseModel):
|
||||
id: str
|
||||
posid: str
|
||||
deviceid: str
|
||||
payhash: str
|
||||
payload: str
|
||||
pin: int
|
||||
@@ -45,5 +52,5 @@ class lnurlpospayment(BaseModel):
|
||||
timestamp: str
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "lnurlpospayment":
|
||||
def from_row(cls, row: Row) -> "lnurldevicepayment":
|
||||
return cls(**dict(row))
|
||||
+35
-24
@@ -1,10 +1,10 @@
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
Register LNURLPoS devices to receive payments in your LNbits wallet.<br />
|
||||
Register LNURLDevice devices to receive payments in your LNbits wallet.<br />
|
||||
Build your own here
|
||||
<a href="https://github.com/arcbtc/LNURLPoS"
|
||||
>https://github.com/arcbtc/LNURLPoS</a
|
||||
<a href="https://github.com/arcbtc/bitcoinpos"
|
||||
>https://github.com/arcbtc/bitcoinpos</a
|
||||
><br />
|
||||
<small>
|
||||
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
|
||||
@@ -21,12 +21,12 @@
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Create lnurlpos"
|
||||
label="Create lnurldevice"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">POST</span> /lnurlpos/api/v1/lnurlpos</code
|
||||
><span class="text-blue"></span> /lnurldevice/api/v1/lnurlpos</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
@@ -36,10 +36,10 @@
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<lnurlpos_object>, ...]</code>
|
||||
<code>[<lnurldevice_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}api/v1/lnurlpos -d '{"title":
|
||||
>curl -X POST {{ request.base_url }}api/v1/lnurldevice -d '{"title":
|
||||
<string>, "message":<string>, "currency":
|
||||
<integer>}' -H "Content-type: application/json" -H "X-Api-Key:
|
||||
{{user.wallets[0].adminkey }}"
|
||||
@@ -51,13 +51,13 @@
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Update lnurlpos"
|
||||
label="Update lnurldevice"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">PUT</span>
|
||||
/lnurlpos/api/v1/lnurlpos/<lnurlpos_id></code
|
||||
/lnurldevice/api/v1/lnurlpos/<lnurldevice_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
@@ -67,25 +67,30 @@
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<lnurlpos_object>, ...]</code>
|
||||
<code>[<lnurldevice_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url
|
||||
}}api/v1/lnurlpos/<lnurlpos_id> -d ''{"title": <string>,
|
||||
"message":<string>, "currency": <integer>} -H
|
||||
"Content-type: application/json" -H "X-Api-Key:
|
||||
}}api/v1/lnurlpos/<lnurldevice_id> -d ''{"title":
|
||||
<string>, "message":<string>, "currency":
|
||||
<integer>} -H "Content-type: application/json" -H "X-Api-Key:
|
||||
{{user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item group="api" dense expand-separator label="Get lnurlpos">
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Get lnurldevice"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/lnurlpos/api/v1/lnurlpos/<lnurlpos_id></code
|
||||
/lnurldevice/api/v1/lnurlpos/<lnurldevice_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
@@ -95,21 +100,27 @@
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<lnurlpos_object>, ...]</code>
|
||||
<code>[<lnurldevice_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}api/v1/lnurlpos/<lnurlpos_id> -H "X-Api-Key: {{
|
||||
}}api/v1/lnurlpos/<lnurldevice_id> -H "X-Api-Key: {{
|
||||
user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Get lnurlposs">
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Get lnurldevices"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span> /lnurlpos/api/v1/lnurlposs</code
|
||||
><span class="text-blue">GET</span>
|
||||
/lnurldevice/api/v1/lnurlposs</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
@@ -119,11 +130,11 @@
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<lnurlpos_object>, ...]</code>
|
||||
<code>[<lnurldevice_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}api/v1/lnurlposs -H "X-Api-Key:
|
||||
{{ user.wallets[0].inkey }}"
|
||||
>curl -X GET {{ request.base_url }}api/v1/lnurldevices -H
|
||||
"X-Api-Key: {{ user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -139,7 +150,7 @@
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">DELETE</span>
|
||||
/lnurlpos/api/v1/lnurlpos/<lnurlpos_id></code
|
||||
/lnurldevice/api/v1/lnurlpos/<lnurldevice_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
@@ -148,7 +159,7 @@
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X DELETE {{ request.base_url
|
||||
}}api/v1/lnurlpos/<lnurlpos_id> -H "X-Api-Key: {{
|
||||
}}api/v1/lnurlpos/<lnurldevice_id> -H "X-Api-Key: {{
|
||||
user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
+148
-88
@@ -8,8 +8,8 @@
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="formDialoglnurlpos.show = true"
|
||||
>New LNURLPoS instance
|
||||
@click="formDialoglnurldevice.show = true"
|
||||
>New LNURLDevice instance
|
||||
</q-btn>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -18,7 +18,7 @@
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">lNURLPoS</h5>
|
||||
<h5 class="text-subtitle1 q-my-none">lNURLdevice</h5>
|
||||
</div>
|
||||
|
||||
<div class="col-auto">
|
||||
@@ -33,7 +33,7 @@
|
||||
<q-icon name="search"></q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-btn flat color="grey" @click="exportlnurlposCSV"
|
||||
<q-btn flat color="grey" @click="exportlnurldeviceCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
@@ -41,10 +41,10 @@
|
||||
<q-table
|
||||
flat
|
||||
dense
|
||||
:data="lnurlposLinks"
|
||||
:data="lnurldeviceLinks"
|
||||
row-key="id"
|
||||
:columns="lnurlpossTable.columns"
|
||||
:pagination.sync="lnurlpossTable.pagination"
|
||||
:columns="lnurldevicesTable.columns"
|
||||
:pagination.sync="lnurldevicesTable.pagination"
|
||||
:filter="filter"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
@@ -72,11 +72,11 @@
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deletelnurlposLink(props.row.id)"
|
||||
@click="deletelnurldeviceLink(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
>
|
||||
<q-tooltip> Delete LNURLPoS </q-tooltip>
|
||||
<q-tooltip> Delete LNURLDevice </q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td>
|
||||
@@ -84,11 +84,11 @@
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openlnurlposSettings(props.row.id)"
|
||||
@click="openlnurldeviceSettings(props.row.id)"
|
||||
icon="perm_data_setting"
|
||||
color="primary"
|
||||
>
|
||||
<q-tooltip> LNURLPoS Settings </q-tooltip>
|
||||
<q-tooltip> LNURLDevice Settings </q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td
|
||||
@@ -112,46 +112,57 @@
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">
|
||||
{{SITE_TITLE}} LNURLPoS Extension
|
||||
{{SITE_TITLE}} LNURLDevice Extension
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "lnurlpos/_api_docs.html" %} </q-list>
|
||||
<q-list> {% include "lnurldevice/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog
|
||||
v-model="settingsDialog.show"
|
||||
position="top"
|
||||
deviceition="top"
|
||||
@hide="closeFormDialog"
|
||||
>
|
||||
<q-card
|
||||
style="width: 700px; max-width: 80vw"
|
||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||
>
|
||||
<div class="text-h6">Copy to LNURLPoS device</div>
|
||||
<div class="text-h6">LNURLDevice device string</div>
|
||||
<q-btn
|
||||
dense
|
||||
outline
|
||||
unelevated
|
||||
color="primary"
|
||||
size="md"
|
||||
@click="copyText(location + '/lnurldevice/api/v1/lnurl/' + settingsDialog.data.id + ',' +
|
||||
settingsDialog.data.key + ',' + settingsDialog.data.currency, 'Link copied to clipboard!')"
|
||||
>{% raw
|
||||
%}{{location}}/lnurldevice/api/v1/lnurl/{{settingsDialog.data.id}},
|
||||
{{settingsDialog.data.key}}, {{settingsDialog.data.currency}}{% endraw
|
||||
%}<q-tooltip> Click to copy URL </q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<div class="text-subtitle2">
|
||||
{% raw %} String baseURL =
|
||||
"{{location}}/lnurlpos/api/v1/lnurl/{{settingsDialog.data.id}}";<br />
|
||||
String key = "{{settingsDialog.data.key}}";<br />
|
||||
String currency = "{{settingsDialog.data.currency}}";{% endraw %}
|
||||
<small> </small>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog
|
||||
v-model="formDialoglnurlpos.show"
|
||||
position="top"
|
||||
v-model="formDialoglnurldevice.show"
|
||||
deviceition="top"
|
||||
@hide="closeFormDialog"
|
||||
>
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="sendFormDatalnurlpos" class="q-gutter-md">
|
||||
<q-form @submit="sendFormDatalnurldevice" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialoglnurlpos.data.title"
|
||||
v-model.trim="formDialoglnurldevice.data.title"
|
||||
type="text"
|
||||
label="Title"
|
||||
></q-input>
|
||||
@@ -160,7 +171,7 @@
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialoglnurlpos.data.wallet"
|
||||
v-model="formDialoglnurldevice.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
></q-select>
|
||||
@@ -168,32 +179,46 @@
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialoglnurlpos.data.currency"
|
||||
v-model.trim="formDialoglnurldevice.data.currency"
|
||||
type="text"
|
||||
label="Fiat currency for PoS"
|
||||
label="Fiat currency for device"
|
||||
:options="currency"
|
||||
></q-select>
|
||||
<q-option-group
|
||||
v-model.trim="formDialoglnurldevice.data.device"
|
||||
:options="devices"
|
||||
color="primary"
|
||||
label="Type of device"
|
||||
></q-option-group>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialoglnurldevice.data.profit"
|
||||
type="number"
|
||||
max="90"
|
||||
label="Profit margin (% added to invoices/deducted from faucets)"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="formDialoglnurlpos.data.id"
|
||||
v-if="formDialoglnurldevice.data.id"
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="
|
||||
formDialoglnurlpos.data.title == ''"
|
||||
formDialoglnurldevice.data.title == ''"
|
||||
type="submit"
|
||||
>Update lnurlpos</q-btn
|
||||
>Update lnurldevice</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="
|
||||
formDialoglnurlpos.data.title == ''"
|
||||
formDialoglnurldevice.data.title == ''"
|
||||
type="submit"
|
||||
>Create lnurlpos</q-btn
|
||||
>Create lnurldevice</q-btn
|
||||
>
|
||||
<q-btn @click="cancellnurlpos" flat color="grey" class="q-ml-auto"
|
||||
<q-btn @click="cancellnurldevice" flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
@@ -206,7 +231,7 @@
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
var maplnurlpos = obj => {
|
||||
var maplnurldevice = obj => {
|
||||
obj._data = _.clone(obj)
|
||||
obj.theTime = obj.time * 60 - (Date.now() / 1000 - obj.timestamp)
|
||||
obj.time = obj.time + 'mins'
|
||||
@@ -230,9 +255,19 @@
|
||||
location: window.location.hostname,
|
||||
filter: '',
|
||||
currency: 'USD',
|
||||
lnurlposLinks: [],
|
||||
lnurlposLinksObj: [],
|
||||
lnurlpossTable: {
|
||||
lnurldeviceLinks: [],
|
||||
lnurldeviceLinksObj: [],
|
||||
devices: [
|
||||
{
|
||||
label: 'PoS',
|
||||
value: 'pos'
|
||||
},
|
||||
{
|
||||
label: 'ATM',
|
||||
value: 'atm'
|
||||
}
|
||||
],
|
||||
lnurldevicesTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'title',
|
||||
@@ -258,6 +293,18 @@
|
||||
label: 'wallet',
|
||||
field: 'wallet'
|
||||
},
|
||||
{
|
||||
name: 'device',
|
||||
align: 'left',
|
||||
label: 'device',
|
||||
field: 'device'
|
||||
},
|
||||
{
|
||||
name: 'profit',
|
||||
align: 'left',
|
||||
label: 'profit',
|
||||
field: 'profit'
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
align: 'left',
|
||||
@@ -269,7 +316,7 @@
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
passedlnurlpos: {},
|
||||
passedlnurldevice: {},
|
||||
settingsDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
@@ -278,13 +325,15 @@
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
formDialoglnurlpos: {
|
||||
formDialoglnurldevice: {
|
||||
show: false,
|
||||
data: {
|
||||
lnurl_toggle: false,
|
||||
show_message: false,
|
||||
show_ack: false,
|
||||
show_price: 'None',
|
||||
device: 'pos',
|
||||
profit: 2,
|
||||
title: ''
|
||||
}
|
||||
},
|
||||
@@ -295,33 +344,33 @@
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
cancellnurlpos: function (data) {
|
||||
cancellnurldevice: function (data) {
|
||||
var self = this
|
||||
self.formDialoglnurlpos.show = false
|
||||
self.clearFormDialoglnurlpos()
|
||||
self.formDialoglnurldevice.show = false
|
||||
self.clearFormDialoglnurldevice()
|
||||
},
|
||||
closeFormDialog: function () {
|
||||
this.clearFormDialoglnurlpos()
|
||||
this.clearFormDialoglnurldevice()
|
||||
this.formDialog.data = {
|
||||
is_unique: false
|
||||
}
|
||||
},
|
||||
sendFormDatalnurlpos: function () {
|
||||
sendFormDatalnurldevice: function () {
|
||||
var self = this
|
||||
if (self.formDialoglnurlpos.data.id) {
|
||||
this.updatelnurlpos(
|
||||
if (self.formDialoglnurldevice.data.id) {
|
||||
this.updatelnurldevice(
|
||||
self.g.user.wallets[0].adminkey,
|
||||
self.formDialoglnurlpos.data
|
||||
self.formDialoglnurldevice.data
|
||||
)
|
||||
} else {
|
||||
this.createlnurlpos(
|
||||
this.createlnurldevice(
|
||||
self.g.user.wallets[0].adminkey,
|
||||
self.formDialoglnurlpos.data
|
||||
self.formDialoglnurldevice.data
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
createlnurlpos: function (wallet, data) {
|
||||
createlnurldevice: function (wallet, data) {
|
||||
var self = this
|
||||
var updatedData = {}
|
||||
for (const property in data) {
|
||||
@@ -330,86 +379,91 @@
|
||||
}
|
||||
}
|
||||
LNbits.api
|
||||
.request('POST', '/lnurlpos/api/v1/lnurlpos', wallet, updatedData)
|
||||
.request('POST', '/lnurldevice/api/v1/lnurlpos', wallet, updatedData)
|
||||
.then(function (response) {
|
||||
self.lnurlposLinks.push(maplnurlpos(response.data))
|
||||
self.formDialoglnurlpos.show = false
|
||||
self.clearFormDialoglnurlpos()
|
||||
self.lnurldeviceLinks.push(maplnurldevice(response.data))
|
||||
self.formDialoglnurldevice.show = false
|
||||
self.clearFormDialoglnurldevice()
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
getlnurlposs: function () {
|
||||
getlnurldevices: function () {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/lnurlpos/api/v1/lnurlpos',
|
||||
'/lnurldevice/api/v1/lnurlpos',
|
||||
self.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
if (response.data) {
|
||||
self.lnurlposLinks = response.data.map(maplnurlpos)
|
||||
self.lnurldeviceLinks = response.data.map(maplnurldevice)
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
getlnurlpos: function (lnurlpos_id) {
|
||||
getlnurldevice: function (lnurldevice_id) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/lnurlpos/api/v1/lnurlpos/' + lnurlpos_id,
|
||||
'/lnurldevice/api/v1/lnurlpos/' + lnurldevice_id,
|
||||
self.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
localStorage.setItem('lnurlpos', JSON.stringify(response.data))
|
||||
localStorage.setItem('lnurldevice', JSON.stringify(response.data))
|
||||
localStorage.setItem('inkey', self.g.user.wallets[0].inkey)
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deletelnurlposLink: function (lnurlposId) {
|
||||
deletelnurldeviceLink: function (lnurldeviceId) {
|
||||
var self = this
|
||||
var link = _.findWhere(this.lnurlposLinks, {id: lnurlposId})
|
||||
var link = _.findWhere(this.lnurldeviceLinks, {id: lnurldeviceId})
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this pay link?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/lnurlpos/api/v1/lnurlpos/' + lnurlposId,
|
||||
'/lnurldevice/api/v1/lnurlpos/' + lnurldeviceId,
|
||||
self.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.lnurlposLinks = _.reject(self.lnurlposLinks, function (
|
||||
obj
|
||||
) {
|
||||
return obj.id === lnurlposId
|
||||
})
|
||||
self.lnurldeviceLinks = _.reject(
|
||||
self.lnurldeviceLinks,
|
||||
function (obj) {
|
||||
return obj.id === lnurldeviceId
|
||||
}
|
||||
)
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
openUpdatelnurlposLink: function (lnurlposId) {
|
||||
openUpdatelnurldeviceLink: function (lnurldeviceId) {
|
||||
var self = this
|
||||
var lnurlpos = _.findWhere(this.lnurlposLinks, {id: lnurlposId})
|
||||
self.formDialoglnurlpos.data = _.clone(lnurlpos._data)
|
||||
self.formDialoglnurlpos.show = true
|
||||
var lnurldevice = _.findWhere(this.lnurldeviceLinks, {
|
||||
id: lnurldeviceId
|
||||
})
|
||||
self.formDialoglnurldevice.data = _.clone(lnurldevice._data)
|
||||
self.formDialoglnurldevice.show = true
|
||||
},
|
||||
openlnurlposSettings: function (lnurlposId) {
|
||||
openlnurldeviceSettings: function (lnurldeviceId) {
|
||||
var self = this
|
||||
var lnurlpos = _.findWhere(this.lnurlposLinks, {id: lnurlposId})
|
||||
self.settingsDialog.data = _.clone(lnurlpos._data)
|
||||
var lnurldevice = _.findWhere(this.lnurldeviceLinks, {
|
||||
id: lnurldeviceId
|
||||
})
|
||||
self.settingsDialog.data = _.clone(lnurldevice._data)
|
||||
self.settingsDialog.show = true
|
||||
},
|
||||
updatelnurlpos: function (wallet, data) {
|
||||
updatelnurldevice: function (wallet, data) {
|
||||
var self = this
|
||||
var updatedData = {}
|
||||
for (const property in data) {
|
||||
@@ -421,24 +475,27 @@
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/lnurlpos/api/v1/lnurlpos/' + updatedData.id,
|
||||
'/lnurldevice/api/v1/lnurlpos/' + updatedData.id,
|
||||
wallet,
|
||||
updatedData
|
||||
)
|
||||
.then(function (response) {
|
||||
self.lnurlposLinks = _.reject(self.lnurlposLinks, function (obj) {
|
||||
return obj.id === updatedData.id
|
||||
})
|
||||
self.lnurlposLinks.push(maplnurlpos(response.data))
|
||||
self.formDialoglnurlpos.show = false
|
||||
self.clearFormDialoglnurlpos()
|
||||
self.lnurldeviceLinks = _.reject(
|
||||
self.lnurldeviceLinks,
|
||||
function (obj) {
|
||||
return obj.id === updatedData.id
|
||||
}
|
||||
)
|
||||
self.lnurldeviceLinks.push(maplnurldevice(response.data))
|
||||
self.formDialoglnurldevice.show = false
|
||||
self.clearFormDialoglnurldevice()
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
clearFormDialoglnurlpos() {
|
||||
this.formDialoglnurlpos.data = {
|
||||
clearFormDialoglnurldevice() {
|
||||
this.formDialoglnurldevice.data = {
|
||||
lnurl_toggle: false,
|
||||
show_message: false,
|
||||
show_ack: false,
|
||||
@@ -446,15 +503,18 @@
|
||||
title: ''
|
||||
}
|
||||
},
|
||||
exportlnurlposCSV: function () {
|
||||
exportlnurldeviceCSV: function () {
|
||||
var self = this
|
||||
LNbits.utils.exportCSV(self.lnurlpossTable.columns, this.lnurlposLinks)
|
||||
LNbits.utils.exportCSV(
|
||||
self.lnurldevicesTable.columns,
|
||||
this.lnurldeviceLinks
|
||||
)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
var self = this
|
||||
var getlnurlposs = this.getlnurlposs
|
||||
getlnurlposs()
|
||||
var getlnurldevices = this.getlnurldevices
|
||||
getlnurldevices()
|
||||
self.location = [
|
||||
window.location.protocol,
|
||||
'//',
|
||||
@@ -0,0 +1,53 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.param_functions import Query
|
||||
from fastapi.params import Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.crud import update_payment_status
|
||||
from lnbits.core.models import User
|
||||
from lnbits.core.views.api import api_payment
|
||||
from lnbits.decorators import check_user_exists
|
||||
|
||||
from . import lnurldevice_ext, lnurldevice_renderer
|
||||
from .crud import get_lnurldevice, get_lnurldevicepayment
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@lnurldevice_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
return lnurldevice_renderer().TemplateResponse(
|
||||
"lnurldevice/index.html", {"request": request, "user": user.dict()}
|
||||
)
|
||||
|
||||
|
||||
@lnurldevice_ext.get(
|
||||
"/{paymentid}", name="lnurldevice.displaypin", response_class=HTMLResponse
|
||||
)
|
||||
async def displaypin(request: Request, paymentid: str = Query(None)):
|
||||
lnurldevicepayment = await get_lnurldevicepayment(paymentid)
|
||||
if not lnurldevicepayment:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="No lmurldevice payment"
|
||||
)
|
||||
device = await get_lnurldevice(lnurldevicepayment.deviceid)
|
||||
if not device:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="lnurldevice not found."
|
||||
)
|
||||
status = await api_payment(lnurldevicepayment.payhash)
|
||||
if status["paid"]:
|
||||
await update_payment_status(
|
||||
checking_id=lnurldevicepayment.payhash, pending=True
|
||||
)
|
||||
return lnurldevice_renderer().TemplateResponse(
|
||||
"lnurldevice/paid.html", {"request": request, "pin": lnurldevicepayment.pin}
|
||||
)
|
||||
return lnurldevice_renderer().TemplateResponse(
|
||||
"lnurldevice/error.html",
|
||||
{"request": request, "pin": "filler", "not_paid": True},
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.param_functions import Query
|
||||
from fastapi.params import Depends
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
from lnbits.extensions.lnurldevice import lnurldevice_ext
|
||||
from lnbits.utils.exchange_rates import currencies
|
||||
|
||||
from . import lnurldevice_ext
|
||||
from .crud import (
|
||||
create_lnurldevice,
|
||||
delete_lnurldevice,
|
||||
get_lnurldevice,
|
||||
get_lnurldevices,
|
||||
update_lnurldevice,
|
||||
)
|
||||
from .models import createLnurldevice
|
||||
|
||||
|
||||
@lnurldevice_ext.get("/api/v1/currencies")
|
||||
async def api_list_currencies_available():
|
||||
return list(currencies.keys())
|
||||
|
||||
|
||||
#######################lnurldevice##########################
|
||||
|
||||
|
||||
@lnurldevice_ext.post("/api/v1/lnurlpos")
|
||||
@lnurldevice_ext.put("/api/v1/lnurlpos/{lnurldevice_id}")
|
||||
async def api_lnurldevice_create_or_update(
|
||||
data: createLnurldevice,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
lnurldevice_id: str = Query(None),
|
||||
):
|
||||
if not lnurldevice_id:
|
||||
lnurldevice = await create_lnurldevice(data)
|
||||
return lnurldevice.dict()
|
||||
else:
|
||||
lnurldevice = await update_lnurldevice(data, lnurldevice_id=lnurldevice_id)
|
||||
return lnurldevice.dict()
|
||||
|
||||
|
||||
@lnurldevice_ext.get("/api/v1/lnurlpos")
|
||||
async def api_lnurldevices_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
|
||||
try:
|
||||
return [
|
||||
{**lnurldevice.dict()} for lnurldevice in await get_lnurldevices(wallet_ids)
|
||||
]
|
||||
except:
|
||||
return ""
|
||||
|
||||
|
||||
@lnurldevice_ext.get("/api/v1/lnurlpos/{lnurldevice_id}")
|
||||
async def api_lnurldevice_retrieve(
|
||||
request: Request,
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
lnurldevice_id: str = Query(None),
|
||||
):
|
||||
lnurldevice = await get_lnurldevice(lnurldevice_id)
|
||||
if not lnurldevice:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="lnurldevice does not exist"
|
||||
)
|
||||
if not lnurldevice.lnurl_toggle:
|
||||
return {**lnurldevice.dict()}
|
||||
return {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(request=request)}}
|
||||
|
||||
|
||||
@lnurldevice_ext.delete("/api/v1/lnurlpos/{lnurldevice_id}")
|
||||
async def api_lnurldevice_delete(
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
lnurldevice_id: str = Query(None),
|
||||
):
|
||||
lnurldevice = await get_lnurldevice(lnurldevice_id)
|
||||
|
||||
if not lnurldevice:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Wallet link does not exist."
|
||||
)
|
||||
|
||||
await delete_lnurldevice(lnurldevice_id)
|
||||
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
@@ -66,7 +66,7 @@ async def api_link_retrieve(
|
||||
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
||||
)
|
||||
|
||||
return {**link._asdict(), **{"lnurl": link.lnurl(r)}}
|
||||
return {**link.dict(), **{"lnurl": link.lnurl(r)}}
|
||||
|
||||
|
||||
@lnurlp_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED)
|
||||
|
||||
@@ -13,10 +13,12 @@ lnurlpayout_ext: APIRouter = APIRouter(prefix="/lnurlpayout", tags=["lnurlpayout
|
||||
def lnurlpayout_renderer():
|
||||
return template_renderer(["lnbits/extensions/lnurlpayout/templates"])
|
||||
|
||||
|
||||
from .tasks import wait_for_paid_invoices
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
|
||||
|
||||
def lnurlpayout_start():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
||||
|
||||
@@ -6,14 +6,23 @@ from . import db
|
||||
from .models import lnurlpayout, CreateLnurlPayoutData
|
||||
|
||||
|
||||
async def create_lnurlpayout(wallet_id: str, admin_key: str, data: CreateLnurlPayoutData) -> lnurlpayout:
|
||||
async def create_lnurlpayout(
|
||||
wallet_id: str, admin_key: str, data: CreateLnurlPayoutData
|
||||
) -> lnurlpayout:
|
||||
lnurlpayout_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO lnurlpayout.lnurlpayouts (id, title, wallet, admin_key, lnurlpay, threshold)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(lnurlpayout_id, data.title, wallet_id, admin_key, data.lnurlpay, data.threshold),
|
||||
(
|
||||
lnurlpayout_id,
|
||||
data.title,
|
||||
wallet_id,
|
||||
admin_key,
|
||||
data.lnurlpay,
|
||||
data.threshold,
|
||||
),
|
||||
)
|
||||
|
||||
lnurlpayout = await get_lnurlpayout(lnurlpayout_id)
|
||||
@@ -22,13 +31,19 @@ async def create_lnurlpayout(wallet_id: str, admin_key: str, data: CreateLnurlPa
|
||||
|
||||
|
||||
async def get_lnurlpayout(lnurlpayout_id: str) -> Optional[lnurlpayout]:
|
||||
row = await db.fetchone("SELECT * FROM lnurlpayout.lnurlpayouts WHERE id = ?", (lnurlpayout_id,))
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM lnurlpayout.lnurlpayouts WHERE id = ?", (lnurlpayout_id,)
|
||||
)
|
||||
return lnurlpayout(**row) if row else None
|
||||
|
||||
|
||||
async def get_lnurlpayout_from_wallet(wallet_id: str) -> Optional[lnurlpayout]:
|
||||
row = await db.fetchone("SELECT * FROM lnurlpayout.lnurlpayouts WHERE wallet = ?", (wallet_id,))
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM lnurlpayout.lnurlpayouts WHERE wallet = ?", (wallet_id,)
|
||||
)
|
||||
return lnurlpayout(**row) if row else None
|
||||
|
||||
|
||||
async def get_lnurlpayouts(wallet_ids: Union[str, List[str]]) -> List[lnurlpayout]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
@@ -42,4 +57,6 @@ async def get_lnurlpayouts(wallet_ids: Union[str, List[str]]) -> List[lnurlpayou
|
||||
|
||||
|
||||
async def delete_lnurlpayout(lnurlpayout_id: str) -> None:
|
||||
await db.execute("DELETE FROM lnurlpayout.lnurlpayouts WHERE id = ?", (lnurlpayout_id,))
|
||||
await db.execute(
|
||||
"DELETE FROM lnurlpayout.lnurlpayouts WHERE id = ?", (lnurlpayout_id,)
|
||||
)
|
||||
|
||||
@@ -2,11 +2,13 @@ from sqlite3 import Row
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CreateLnurlPayoutData(BaseModel):
|
||||
title: str
|
||||
lnurlpay: str
|
||||
threshold: int
|
||||
|
||||
|
||||
class lnurlpayout(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import asyncio
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
|
||||
import httpx
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core import db as core_db
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
from lnbits.core.views.api import api_wallet
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.core.views.api import api_payment, api_payments_decode, pay_invoice
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services import pay_invoice
|
||||
from lnbits.core.views.api import api_payments_decode
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from .crud import get_lnurlpayout, get_lnurlpayout_from_wallet
|
||||
from .crud import get_lnurlpayout_from_wallet
|
||||
|
||||
|
||||
async def wait_for_paid_invoices():
|
||||
@@ -25,32 +27,44 @@ async def on_invoice_paid(payment: Payment) -> None:
|
||||
try:
|
||||
# Check its got a payout associated with it
|
||||
lnurlpayout_link = await get_lnurlpayout_from_wallet(payment.wallet_id)
|
||||
print("LNURLpayout", lnurlpayout_link)
|
||||
if lnurlpayout_link:
|
||||
|
||||
|
||||
# Check the wallet balance is more than the threshold
|
||||
|
||||
|
||||
wallet = await get_wallet(lnurlpayout_link.wallet)
|
||||
if wallet.balance < lnurlpayout_link.threshold + (lnurlpayout_link.threshold*0.02):
|
||||
threshold = lnurlpayout_link.threshold + (lnurlpayout_link.threshold * 0.02)
|
||||
|
||||
if wallet.balance < threshold:
|
||||
return
|
||||
|
||||
# Get the invoice from the LNURL to pay
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
url = await api_payments_decode({"data": lnurlpayout_link.lnurlpay})
|
||||
if str(url["domain"])[0:4] != "http":
|
||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="LNURL broken")
|
||||
try:
|
||||
r = await client.get(
|
||||
str(url["domain"]),
|
||||
timeout=40,
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="LNURL broken"
|
||||
)
|
||||
|
||||
try:
|
||||
r = await client.get(str(url["domain"]), timeout=40)
|
||||
res = r.json()
|
||||
try:
|
||||
r = await client.get(
|
||||
res["callback"] + "?amount=" + str(int((wallet.balance - wallet.balance*0.02) * 1000)),
|
||||
res["callback"]
|
||||
+ "?amount="
|
||||
+ str(
|
||||
int((wallet.balance - wallet.balance * 0.02) * 1000)
|
||||
),
|
||||
timeout=40,
|
||||
)
|
||||
res = r.json()
|
||||
|
||||
if hasattr(res, "status") and res["status"] == "ERROR":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail=res["reason"],
|
||||
)
|
||||
try:
|
||||
await pay_invoice(
|
||||
wallet_id=payment.wallet_id,
|
||||
@@ -60,11 +74,16 @@ async def on_invoice_paid(payment: Payment) -> None:
|
||||
return
|
||||
except:
|
||||
pass
|
||||
except:
|
||||
|
||||
except Exception as e:
|
||||
print("ERROR", str(e))
|
||||
return
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
return
|
||||
except Exception:
|
||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Failed to save LNURLPayout")
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Failed to save LNURLPayout",
|
||||
)
|
||||
except:
|
||||
return
|
||||
return
|
||||
|
||||
@@ -4,16 +4,24 @@ from fastapi import Query
|
||||
from fastapi.params import Depends
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_user, get_payments
|
||||
from lnbits.core.crud import get_payments, get_user
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.core.views.api import api_payment, api_payments_decode
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
|
||||
from . import lnurlpayout_ext
|
||||
from .crud import create_lnurlpayout, delete_lnurlpayout, get_lnurlpayout, get_lnurlpayouts, get_lnurlpayout_from_wallet
|
||||
from .models import lnurlpayout, CreateLnurlPayoutData
|
||||
from .crud import (
|
||||
create_lnurlpayout,
|
||||
delete_lnurlpayout,
|
||||
get_lnurlpayout,
|
||||
get_lnurlpayout_from_wallet,
|
||||
get_lnurlpayouts,
|
||||
)
|
||||
from .models import CreateLnurlPayoutData, lnurlpayout
|
||||
from .tasks import on_invoice_paid
|
||||
|
||||
|
||||
@lnurlpayout_ext.get("/api/v1/lnurlpayouts", status_code=HTTPStatus.OK)
|
||||
async def api_lnurlpayouts(
|
||||
all_wallets: bool = Query(None), wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
@@ -28,23 +36,33 @@ async def api_lnurlpayouts(
|
||||
@lnurlpayout_ext.post("/api/v1/lnurlpayouts", status_code=HTTPStatus.CREATED)
|
||||
async def api_lnurlpayout_create(
|
||||
data: CreateLnurlPayoutData, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
):
|
||||
if await get_lnurlpayout_from_wallet(wallet.wallet.id):
|
||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Wallet already has lnurlpayout set")
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Wallet already has lnurlpayout set",
|
||||
)
|
||||
return
|
||||
url = await api_payments_decode({"data": data.lnurlpay})
|
||||
if "domain" not in url:
|
||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="LNURL could not be decoded")
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="LNURL could not be decoded"
|
||||
)
|
||||
return
|
||||
if str(url["domain"])[0:4] != "http":
|
||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not valid LNURL")
|
||||
return
|
||||
lnurlpayout = await create_lnurlpayout(wallet_id=wallet.wallet.id, admin_key=wallet.wallet.adminkey, data=data)
|
||||
lnurlpayout = await create_lnurlpayout(
|
||||
wallet_id=wallet.wallet.id, admin_key=wallet.wallet.adminkey, data=data
|
||||
)
|
||||
if not lnurlpayout:
|
||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Failed to save LNURLPayout")
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="Failed to save LNURLPayout"
|
||||
)
|
||||
return
|
||||
return lnurlpayout.dict()
|
||||
|
||||
|
||||
@lnurlpayout_ext.delete("/api/v1/lnurlpayouts/{lnurlpayout_id}")
|
||||
async def api_lnurlpayout_delete(
|
||||
lnurlpayout_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
@@ -57,26 +75,44 @@ async def api_lnurlpayout_delete(
|
||||
)
|
||||
|
||||
if lnurlpayout.wallet != wallet.wallet.id:
|
||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your lnurlpayout.")
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="Not your lnurlpayout."
|
||||
)
|
||||
|
||||
await delete_lnurlpayout(lnurlpayout_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
@lnurlpayout_ext.get("/api/v1/lnurlpayouts/{lnurlpayout_id}", status_code=HTTPStatus.OK)
|
||||
async def api_lnurlpayout_check(
|
||||
lnurlpayout_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
):
|
||||
lnurlpayout = await get_lnurlpayout(lnurlpayout_id)
|
||||
payments = await get_payments(
|
||||
wallet_id=lnurlpayout.wallet, complete=True, pending=False, outgoing=True, incoming=True
|
||||
## THIS
|
||||
mock_payment = Payment(
|
||||
checking_id="mock",
|
||||
pending=False,
|
||||
amount=1,
|
||||
fee=1,
|
||||
time=0000,
|
||||
bolt11="mock",
|
||||
preimage="mock",
|
||||
payment_hash="mock",
|
||||
wallet_id=lnurlpayout.wallet,
|
||||
)
|
||||
result = await on_invoice_paid(payments[0])
|
||||
## INSTEAD OF THIS
|
||||
# payments = await get_payments(
|
||||
# wallet_id=lnurlpayout.wallet, complete=True, pending=False, outgoing=True, incoming=True
|
||||
# )
|
||||
|
||||
result = await on_invoice_paid(mock_payment)
|
||||
return
|
||||
|
||||
# get payouts func
|
||||
# lnurlpayouts = await get_lnurlpayouts(wallet_ids)
|
||||
# for lnurlpayout in lnurlpayouts:
|
||||
# payments = await get_payments(
|
||||
# wallet_id=lnurlpayout.wallet, complete=True, pending=False, outgoing=True, incoming=True
|
||||
# )
|
||||
# await on_invoice_paid(payments[0])
|
||||
|
||||
# get payouts func
|
||||
# lnurlpayouts = await get_lnurlpayouts(wallet_ids)
|
||||
# for lnurlpayout in lnurlpayouts:
|
||||
# payments = await get_payments(
|
||||
# wallet_id=lnurlpayout.wallet, complete=True, pending=False, outgoing=True, incoming=True
|
||||
# )
|
||||
# await on_invoice_paid(payments[0])
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# LNURLPoS
|
||||
|
||||
For offline LNURL PoS devices
|
||||
@@ -1,17 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
|
||||
db = Database("ext_lnurlpos")
|
||||
|
||||
lnurlpos_ext: APIRouter = APIRouter(prefix="/lnurlpos", tags=["lnurlpos"])
|
||||
|
||||
|
||||
def lnurlpos_renderer():
|
||||
return template_renderer(["lnbits/extensions/lnurlpos/templates"])
|
||||
|
||||
|
||||
from .lnurl import * # noqa
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "LNURLPoS",
|
||||
"short_description": "For offline LNURL PoS systems",
|
||||
"icon": "point_of_sale",
|
||||
"contributors": ["arcbtc"]
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import createLnurlpos, lnurlpospayment, lnurlposs
|
||||
|
||||
###############lnurlposS##########################
|
||||
|
||||
|
||||
async def create_lnurlpos(data: createLnurlpos,) -> lnurlposs:
|
||||
lnurlpos_id = urlsafe_short_hash()
|
||||
lnurlpos_key = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO lnurlpos.lnurlposs (
|
||||
id,
|
||||
key,
|
||||
title,
|
||||
wallet,
|
||||
currency
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(lnurlpos_id, lnurlpos_key, data.title, data.wallet, data.currency),
|
||||
)
|
||||
return await get_lnurlpos(lnurlpos_id)
|
||||
|
||||
|
||||
async def update_lnurlpos(lnurlpos_id: str, **kwargs) -> Optional[lnurlposs]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE lnurlpos.lnurlposs SET {q} WHERE id = ?",
|
||||
(*kwargs.values(), lnurlpos_id),
|
||||
)
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM lnurlpos.lnurlposs WHERE id = ?", (lnurlpos_id,)
|
||||
)
|
||||
return lnurlposs(**row) if row else None
|
||||
|
||||
|
||||
async def get_lnurlpos(lnurlpos_id: str) -> lnurlposs:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM lnurlpos.lnurlposs WHERE id = ?", (lnurlpos_id,)
|
||||
)
|
||||
return lnurlposs(**row) if row else None
|
||||
|
||||
|
||||
async def get_lnurlposs(wallet_ids: Union[str, List[str]]) -> List[lnurlposs]:
|
||||
wallet_ids = [wallet_ids]
|
||||
q = ",".join(["?"] * len(wallet_ids[0]))
|
||||
rows = await db.fetchall(
|
||||
f"""
|
||||
SELECT * FROM lnurlpos.lnurlposs WHERE wallet IN ({q})
|
||||
ORDER BY id
|
||||
""",
|
||||
(*wallet_ids,),
|
||||
)
|
||||
|
||||
return [lnurlposs(**row) if row else None for row in rows]
|
||||
|
||||
|
||||
async def delete_lnurlpos(lnurlpos_id: str) -> None:
|
||||
await db.execute("DELETE FROM lnurlpos.lnurlposs WHERE id = ?", (lnurlpos_id,))
|
||||
|
||||
########################lnulpos payments###########################
|
||||
|
||||
|
||||
async def create_lnurlpospayment(
|
||||
posid: str,
|
||||
payload: Optional[str] = None,
|
||||
pin: Optional[str] = None,
|
||||
payhash: Optional[str] = None,
|
||||
sats: Optional[int] = 0,
|
||||
) -> lnurlpospayment:
|
||||
lnurlpospayment_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO lnurlpos.lnurlpospayment (
|
||||
id,
|
||||
posid,
|
||||
payload,
|
||||
pin,
|
||||
payhash,
|
||||
sats
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(lnurlpospayment_id, posid, payload, pin, payhash, sats),
|
||||
)
|
||||
return await get_lnurlpospayment(lnurlpospayment_id)
|
||||
|
||||
|
||||
async def update_lnurlpospayment(
|
||||
lnurlpospayment_id: str, **kwargs
|
||||
) -> Optional[lnurlpospayment]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE lnurlpos.lnurlpospayment SET {q} WHERE id = ?",
|
||||
(*kwargs.values(), lnurlpospayment_id),
|
||||
)
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM lnurlpos.lnurlpospayment WHERE id = ?", (lnurlpospayment_id,)
|
||||
)
|
||||
return lnurlpospayment(**row) if row else None
|
||||
|
||||
|
||||
async def get_lnurlpospayment(lnurlpospayment_id: str) -> lnurlpospayment:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM lnurlpos.lnurlpospayment WHERE id = ?", (lnurlpospayment_id,)
|
||||
)
|
||||
return lnurlpospayment(**row) if row else None
|
||||
@@ -1,169 +0,0 @@
|
||||
import base64
|
||||
import hashlib
|
||||
from http import HTTPStatus
|
||||
from typing import Optional
|
||||
|
||||
from embit import bech32
|
||||
from embit import compact
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import hmac
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.param_functions import Query
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||
|
||||
from . import lnurlpos_ext
|
||||
from .crud import (
|
||||
create_lnurlpospayment,
|
||||
get_lnurlpos,
|
||||
get_lnurlpospayment,
|
||||
update_lnurlpospayment,
|
||||
)
|
||||
|
||||
def bech32_decode(bech):
|
||||
"""tweaked version of bech32_decode that ignores length limitations"""
|
||||
if ((any(ord(x) < 33 or ord(x) > 126 for x in bech)) or
|
||||
(bech.lower() != bech and bech.upper() != bech)):
|
||||
return
|
||||
bech = bech.lower()
|
||||
pos = bech.rfind('1')
|
||||
if pos < 1 or pos + 7 > len(bech):
|
||||
return
|
||||
if not all(x in bech32.CHARSET for x in bech[pos+1:]):
|
||||
return
|
||||
hrp = bech[:pos]
|
||||
data = [bech32.CHARSET.find(x) for x in bech[pos+1:]]
|
||||
encoding = bech32.bech32_verify_checksum(hrp, data)
|
||||
if encoding is None:
|
||||
return
|
||||
return bytes(bech32.convertbits(data[:-6], 5, 8, False))
|
||||
|
||||
def xor_decrypt(key, blob):
|
||||
s = BytesIO(blob)
|
||||
variant = s.read(1)[0]
|
||||
if variant != 1:
|
||||
raise RuntimeError("Not implemented")
|
||||
# reading nonce
|
||||
l = s.read(1)[0]
|
||||
nonce = s.read(l)
|
||||
if len(nonce) != l:
|
||||
raise RuntimeError("Missing nonce bytes")
|
||||
if l < 8:
|
||||
raise RuntimeError("Nonce is too short")
|
||||
# reading payload
|
||||
l = s.read(1)[0]
|
||||
payload = s.read(l)
|
||||
if len(payload) > 32:
|
||||
raise RuntimeError("Payload is too long for this encryption method")
|
||||
if len(payload) != l:
|
||||
raise RuntimeError("Missing payload bytes")
|
||||
hmacval = s.read()
|
||||
expected = hmac.new(key, b"Data:" + blob[:-len(hmacval)], digestmod="sha256").digest()
|
||||
if len(hmacval) < 8:
|
||||
raise RuntimeError("HMAC is too short")
|
||||
if hmacval != expected[:len(hmacval)]:
|
||||
raise RuntimeError("HMAC is invalid")
|
||||
secret = hmac.new(key, b"Round secret:" + nonce, digestmod="sha256").digest()
|
||||
payload = bytearray(payload)
|
||||
for i in range(len(payload)):
|
||||
payload[i] = payload[i] ^ secret[i]
|
||||
s = BytesIO(payload)
|
||||
pin = compact.read_from(s)
|
||||
amount_in_cent = compact.read_from(s)
|
||||
return pin, amount_in_cent
|
||||
|
||||
@lnurlpos_ext.get(
|
||||
"/api/v1/lnurl/{pos_id}",
|
||||
status_code=HTTPStatus.OK,
|
||||
name="lnurlpos.lnurl_v1_params",
|
||||
)
|
||||
async def lnurl_v1_params(
|
||||
request: Request,
|
||||
pos_id: str = Query(None),
|
||||
p: str = Query(None),
|
||||
):
|
||||
return await handle_lnurl_firstrequest(request, pos_id, p)
|
||||
|
||||
|
||||
async def handle_lnurl_firstrequest(
|
||||
request: Request, pos_id: str, payload: str
|
||||
):
|
||||
pos = await get_lnurlpos(pos_id)
|
||||
if not pos:
|
||||
return {
|
||||
"status": "ERROR",
|
||||
"reason": f"lnurlpos {pos_id} not found on this server",
|
||||
}
|
||||
|
||||
if len(payload) % 4 > 0:
|
||||
payload += "="*(4-(len(payload)%4))
|
||||
|
||||
data = base64.urlsafe_b64decode(payload)
|
||||
pin, amount_in_cent = xor_decrypt(pos.key.encode(), data)
|
||||
price_msat = (
|
||||
await fiat_amount_as_satoshis(float(amount_in_cent) / 100, pos.currency)
|
||||
if pos.currency != "sat"
|
||||
else amount
|
||||
) * 1000
|
||||
|
||||
lnurlpospayment = await create_lnurlpospayment(
|
||||
posid=pos.id,
|
||||
payload=payload,
|
||||
sats=price_msat,
|
||||
pin=pin,
|
||||
payhash="payment_hash",
|
||||
)
|
||||
if not lnurlpospayment:
|
||||
return {"status": "ERROR", "reason": "Could not create payment."}
|
||||
|
||||
return {
|
||||
"tag": "payRequest",
|
||||
"callback": request.url_for(
|
||||
"lnurlpos.lnurl_callback", paymentid=lnurlpospayment.id
|
||||
),
|
||||
"minSendable": price_msat,
|
||||
"maxSendable": price_msat,
|
||||
"metadata": await pos.lnurlpay_metadata(),
|
||||
}
|
||||
|
||||
|
||||
@lnurlpos_ext.get(
|
||||
"/api/v1/lnurl/cb/{paymentid}",
|
||||
status_code=HTTPStatus.OK,
|
||||
name="lnurlpos.lnurl_callback",
|
||||
)
|
||||
async def lnurl_callback(request: Request, paymentid: str = Query(None)):
|
||||
lnurlpospayment = await get_lnurlpospayment(paymentid)
|
||||
pos = await get_lnurlpos(lnurlpospayment.posid)
|
||||
if not pos:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="lnurlpos not found."
|
||||
)
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=pos.wallet,
|
||||
amount=int(lnurlpospayment.sats / 1000),
|
||||
memo=pos.title,
|
||||
description_hash=hashlib.sha256(
|
||||
(await pos.lnurlpay_metadata()).encode("utf-8")
|
||||
).digest(),
|
||||
extra={"tag": "lnurlpos"},
|
||||
)
|
||||
lnurlpospayment = await update_lnurlpospayment(
|
||||
lnurlpospayment_id=paymentid, payhash=payment_hash
|
||||
)
|
||||
|
||||
return {
|
||||
"pr": payment_request,
|
||||
"successAction": {
|
||||
"tag": "url",
|
||||
"description": "Check the attached link",
|
||||
"url": request.url_for("lnurlpos.displaypin", paymentid=paymentid),
|
||||
},
|
||||
"routes": [],
|
||||
}
|
||||
|
||||
return resp.dict()
|
||||
@@ -1,30 +0,0 @@
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial lnurlpos table.
|
||||
"""
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE lnurlpos.lnurlposs (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
key TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE lnurlpos.lnurlpospayment (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
posid TEXT NOT NULL,
|
||||
payhash TEXT,
|
||||
payload TEXT NOT NULL,
|
||||
pin INT,
|
||||
sats INT,
|
||||
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -1,50 +0,0 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.param_functions import Query
|
||||
from fastapi.params import Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.crud import update_payment_status
|
||||
from lnbits.core.models import User
|
||||
from lnbits.core.views.api import api_payment
|
||||
from lnbits.decorators import check_user_exists
|
||||
|
||||
from . import lnurlpos_ext, lnurlpos_renderer
|
||||
from .crud import get_lnurlpos, get_lnurlpospayment
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@lnurlpos_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
return lnurlpos_renderer().TemplateResponse(
|
||||
"lnurlpos/index.html", {"request": request, "user": user.dict()}
|
||||
)
|
||||
|
||||
|
||||
@lnurlpos_ext.get(
|
||||
"/{paymentid}", name="lnurlpos.displaypin", response_class=HTMLResponse
|
||||
)
|
||||
async def displaypin(request: Request, paymentid: str = Query(None)):
|
||||
lnurlpospayment = await get_lnurlpospayment(paymentid)
|
||||
if not lnurlpospayment:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="No lmurlpos payment"
|
||||
)
|
||||
pos = await get_lnurlpos(lnurlpospayment.posid)
|
||||
if not pos:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="lnurlpos not found."
|
||||
)
|
||||
status = await api_payment(lnurlpospayment.payhash)
|
||||
if status["paid"]:
|
||||
await update_payment_status(checking_id=lnurlpospayment.payhash, pending=True)
|
||||
return lnurlpos_renderer().TemplateResponse(
|
||||
"lnurlpos/paid.html", {"request": request, "pin": lnurlpospayment.pin}
|
||||
)
|
||||
return lnurlpos_renderer().TemplateResponse(
|
||||
"lnurlpos/error.html", {"request": request, "pin": "filler", "not_paid": True}
|
||||
)
|
||||
@@ -1,85 +0,0 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.param_functions import Query
|
||||
from fastapi.params import Depends
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
from lnbits.extensions.lnurlpos import lnurlpos_ext
|
||||
from lnbits.utils.exchange_rates import currencies
|
||||
|
||||
from . import lnurlpos_ext
|
||||
from .crud import (
|
||||
create_lnurlpos,
|
||||
delete_lnurlpos,
|
||||
get_lnurlpos,
|
||||
get_lnurlposs,
|
||||
update_lnurlpos,
|
||||
)
|
||||
from .models import createLnurlpos
|
||||
|
||||
|
||||
@lnurlpos_ext.get("/api/v1/currencies")
|
||||
async def api_list_currencies_available():
|
||||
return list(currencies.keys())
|
||||
|
||||
|
||||
#######################lnurlpos##########################
|
||||
|
||||
|
||||
@lnurlpos_ext.post("/api/v1/lnurlpos")
|
||||
@lnurlpos_ext.put("/api/v1/lnurlpos/{lnurlpos_id}")
|
||||
async def api_lnurlpos_create_or_update(
|
||||
data: createLnurlpos,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
lnurlpos_id: str = Query(None),
|
||||
):
|
||||
if not lnurlpos_id:
|
||||
lnurlpos = await create_lnurlpos(data)
|
||||
return lnurlpos.dict()
|
||||
else:
|
||||
lnurlpos = await update_lnurlpos(data, lnurlpos_id=lnurlpos_id)
|
||||
return lnurlpos.dict()
|
||||
|
||||
|
||||
@lnurlpos_ext.get("/api/v1/lnurlpos")
|
||||
async def api_lnurlposs_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
|
||||
try:
|
||||
return [{**lnurlpos.dict()} for lnurlpos in await get_lnurlposs(wallet_ids)]
|
||||
except:
|
||||
return ""
|
||||
|
||||
|
||||
@lnurlpos_ext.get("/api/v1/lnurlpos/{lnurlpos_id}")
|
||||
async def api_lnurlpos_retrieve(
|
||||
request: Request,
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
lnurlpos_id: str = Query(None),
|
||||
):
|
||||
lnurlpos = await get_lnurlpos(lnurlpos_id)
|
||||
if not lnurlpos:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="lnurlpos does not exist"
|
||||
)
|
||||
if not lnurlpos.lnurl_toggle:
|
||||
return {**lnurlpos.dict()}
|
||||
return {**lnurlpos.dict(), **{"lnurl": lnurlpos.lnurl(request=request)}}
|
||||
|
||||
|
||||
@lnurlpos_ext.delete("/api/v1/lnurlpos/{lnurlpos_id}")
|
||||
async def api_lnurlpos_delete(
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key), lnurlpos_id: str = Query(None)
|
||||
):
|
||||
lnurlpos = await get_lnurlpos(lnurlpos_id)
|
||||
|
||||
if not lnurlpos:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Wallet link does not exist."
|
||||
)
|
||||
|
||||
await delete_lnurlpos(lnurlpos_id)
|
||||
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
@@ -8,8 +8,8 @@ def hotp(key, counter, digits=6, digest="sha1"):
|
||||
key = base64.b32decode(key.upper() + "=" * ((8 - len(key)) % 8))
|
||||
counter = struct.pack(">Q", counter)
|
||||
mac = hmac.new(key, counter, digest).digest()
|
||||
offset = mac[-1] & 0x0F
|
||||
binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7FFFFFFF
|
||||
offset = mac[-1] & 0x0f
|
||||
binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7fffffff
|
||||
return str(binary)[-digits:].zfill(digits)
|
||||
|
||||
|
||||
|
||||
@@ -77,8 +77,8 @@
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
userAmount: {{ paywall.amount }},
|
||||
paywallAmount: {{ paywall.amount }},
|
||||
userAmount: '{{ paywall.amount }}',
|
||||
paywallAmount: '{{ paywall.amount }}',
|
||||
paymentReq: null,
|
||||
redirectUrl: null,
|
||||
paymentDialog: {
|
||||
@@ -89,7 +89,9 @@
|
||||
},
|
||||
computed: {
|
||||
amount: function () {
|
||||
return (this.paywallAmount > this.userAmount) ? this.paywallAmount : this.userAmount
|
||||
return this.paywallAmount > this.userAmount
|
||||
? this.paywallAmount
|
||||
: this.userAmount
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -102,48 +104,55 @@
|
||||
},
|
||||
createInvoice: function () {
|
||||
var self = this
|
||||
console.log(this.amount)
|
||||
axios
|
||||
.post(
|
||||
'/paywall/api/v1/paywalls/{{ paywall.id }}/invoice',
|
||||
{amount: self.amount}
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
'/paywall/api/v1/paywalls/invoice/{{ paywall.id }}',
|
||||
'filler',
|
||||
{
|
||||
amount: self.amount
|
||||
}
|
||||
)
|
||||
.then(function (response) {
|
||||
self.paymentReq = response.data.payment_request.toUpperCase()
|
||||
if (response.data) {
|
||||
self.paymentReq = response.data.payment_request.toUpperCase()
|
||||
self.paymentDialog.dismissMsg = self.$q.notify({
|
||||
timeout: 0,
|
||||
message: 'Waiting for payment...'
|
||||
})
|
||||
|
||||
self.paymentDialog.dismissMsg = self.$q.notify({
|
||||
timeout: 0,
|
||||
message: 'Waiting for payment...'
|
||||
})
|
||||
|
||||
self.paymentDialog.checker = setInterval(function () {
|
||||
axios
|
||||
.post(
|
||||
'/paywall/api/v1/paywalls/{{ paywall.id }}/check_invoice',
|
||||
{payment_hash: response.data.payment_hash}
|
||||
)
|
||||
.then(function (res) {
|
||||
if (res.data.paid) {
|
||||
self.cancelPayment()
|
||||
self.redirectUrl = res.data.url
|
||||
if (res.data.remembers) {
|
||||
self.$q.localStorage.set(
|
||||
'lnbits.paywall.{{ paywall.id }}',
|
||||
res.data.url
|
||||
)
|
||||
self.paymentDialog.checker = setInterval(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
'/paywall/api/v1/paywalls/check_invoice/{{ paywall.id }}',
|
||||
'filler',
|
||||
{payment_hash: response.data.payment_hash}
|
||||
)
|
||||
.then(function (response) {
|
||||
if (response.data) {
|
||||
if (response.data.paid) {
|
||||
self.cancelPayment()
|
||||
self.redirectUrl = response.data.url
|
||||
if (response.data.remembers) {
|
||||
self.$q.localStorage.set(
|
||||
'lnbits.paywall.{{ paywall.id }}',
|
||||
response.data.url
|
||||
)
|
||||
}
|
||||
self.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Payment received!',
|
||||
icon: null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
self.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Payment received!',
|
||||
icon: null
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}, 2000)
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}, 2000)
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
|
||||
@@ -52,20 +52,17 @@ async def api_paywall_delete(
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
@paywall_ext.post("/api/v1/paywalls/{paywall_id}/invoice")
|
||||
@paywall_ext.post("/api/v1/paywalls/invoice/{paywall_id}")
|
||||
async def api_paywall_create_invoice(
|
||||
paywall_id,
|
||||
data: CreatePaywallInvoice,
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
paywall_id: str = Query(None)
|
||||
):
|
||||
paywall = await get_paywall(paywall_id)
|
||||
|
||||
if data.amount < paywall.amount:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Minimum amount is {paywall.amount} sat.",
|
||||
)
|
||||
|
||||
try:
|
||||
amount = data.amount if data.amount > paywall.amount else paywall.amount
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
@@ -80,15 +77,14 @@ async def api_paywall_create_invoice(
|
||||
return {"payment_hash": payment_hash, "payment_request": payment_request}
|
||||
|
||||
|
||||
@paywall_ext.post("/api/v1/paywalls/{paywall_id}/check_invoice")
|
||||
async def api_paywal_check_invoice(data: CheckPaywallInvoice, paywall_id):
|
||||
@paywall_ext.post("/api/v1/paywalls/check_invoice/{paywall_id}")
|
||||
async def api_paywal_check_invoice(data: CheckPaywallInvoice, paywall_id: str = Query(None)):
|
||||
paywall = await get_paywall(paywall_id)
|
||||
payment_hash = data.payment_hash
|
||||
if not paywall:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Paywall does not exist."
|
||||
)
|
||||
|
||||
try:
|
||||
status = await check_invoice_status(paywall.wallet, payment_hash)
|
||||
is_paid = not status.pending
|
||||
@@ -101,5 +97,4 @@ async def api_paywal_check_invoice(data: CheckPaywallInvoice, paywall_id):
|
||||
await payment.set_pending(False)
|
||||
|
||||
return {"paid": True, "url": paywall.url, "remembers": paywall.remembers}
|
||||
|
||||
return {"paid": False}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.tasks import catch_everything_and_restart
|
||||
|
||||
db = Database("ext_satspay")
|
||||
|
||||
@@ -13,5 +16,11 @@ def satspay_renderer():
|
||||
return template_renderer(["lnbits/extensions/satspay/templates"])
|
||||
|
||||
|
||||
from .tasks import wait_for_paid_invoices
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
|
||||
|
||||
def satspay_start():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
||||
|
||||
@@ -25,7 +25,10 @@ async def create_charge(user: str, data: CreateCharge) -> Charges:
|
||||
onchainaddress = None
|
||||
if data.lnbitswallet:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=data.lnbitswallet, amount=data.amount, memo=charge_id
|
||||
wallet_id=data.lnbitswallet,
|
||||
amount=data.amount,
|
||||
memo=charge_id,
|
||||
extra={"tag": "charge"},
|
||||
)
|
||||
else:
|
||||
payment_hash = None
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import time
|
||||
from sqlite3 import Row
|
||||
from typing import Optional
|
||||
|
||||
from fastapi.param_functions import Query
|
||||
from pydantic import BaseModel
|
||||
import time
|
||||
|
||||
|
||||
class CreateCharge(BaseModel):
|
||||
@@ -23,8 +24,8 @@ class Charges(BaseModel):
|
||||
onchainwallet: Optional[str]
|
||||
onchainaddress: Optional[str]
|
||||
lnbitswallet: Optional[str]
|
||||
payment_request: str
|
||||
payment_hash: str
|
||||
payment_request: Optional[str]
|
||||
payment_hash: Optional[str]
|
||||
webhook: Optional[str]
|
||||
completelink: Optional[str]
|
||||
completelinktext: Optional[str] = "Back to Merchant"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import asyncio
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.extensions.satspay.crud import check_address_balance, get_charge
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
# from .crud import get_ticket, set_ticket_paid
|
||||
|
||||
|
||||
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 "charge" != payment.extra.get("tag"):
|
||||
# not a charge invoice
|
||||
return
|
||||
|
||||
charge = await get_charge(payment.memo)
|
||||
if not charge:
|
||||
print("this should never happen", payment)
|
||||
return
|
||||
|
||||
await payment.set_pending(False)
|
||||
await check_address_balance(charge_id=charge.id)
|
||||
@@ -132,7 +132,7 @@
|
||||
lightning-network invoice</span
|
||||
>
|
||||
</center>
|
||||
<a href="lightning://{{ charge.payment_request }}">
|
||||
<a href="lightning:{{ charge.payment_request }}">
|
||||
<q-responsive :ratio="1" class="q-mx-md">
|
||||
<qrcode
|
||||
:value="'{{ charge.payment_request }}'"
|
||||
@@ -182,7 +182,7 @@
|
||||
to this onchain address</span
|
||||
>
|
||||
</center>
|
||||
<a href="bitcoin://{{ charge.onchainaddress }}">
|
||||
<a href="bitcoin:{{ charge.onchainaddress }}">
|
||||
<q-responsive :ratio="1" class="q-mx-md">
|
||||
<qrcode
|
||||
:value="'{{ charge.onchainaddress }}'"
|
||||
@@ -235,10 +235,24 @@
|
||||
charge_time_elapsed: '{{charge.time_elapsed}}',
|
||||
charge_amount: '{{charge.amount}}',
|
||||
charge_balance: '{{charge.balance}}',
|
||||
charge_paid: '{{charge.paid}}'
|
||||
charge_paid: '{{charge.paid}}',
|
||||
wallet: {
|
||||
inkey: ''
|
||||
},
|
||||
cancelListener: () => {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
startPaymentNotifier(){
|
||||
this.cancelListener()
|
||||
|
||||
this.cancelListener = LNbits.event.onInvoicePaid(
|
||||
this.wallet,
|
||||
payment => {
|
||||
this.checkBalance()
|
||||
}
|
||||
)
|
||||
},
|
||||
checkBalance: function () {
|
||||
var self = this
|
||||
LNbits.api
|
||||
@@ -307,12 +321,14 @@
|
||||
this.lnbtc = false
|
||||
this.onbtc = true
|
||||
}
|
||||
this.wallet.inkey = '{{ wallet_inkey }}'
|
||||
this.getTheTime()
|
||||
this.getThePercentage()
|
||||
var timerCount = this.timerCount
|
||||
if ('{{ charge.paid }}' == 'False') {
|
||||
timerCount()
|
||||
}
|
||||
this.startPaymentNotifier()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -475,7 +475,7 @@
|
||||
},
|
||||
sendFormDataCharge: function () {
|
||||
var self = this
|
||||
var wallet = this.g.user.wallets[0].adminkey
|
||||
var wallet = this.g.user.wallets[0].inkey
|
||||
var data = this.formDialogCharge.data
|
||||
data.amount = parseInt(data.amount)
|
||||
data.time = parseInt(data.time)
|
||||
|
||||
@@ -6,6 +6,7 @@ from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists
|
||||
|
||||
@@ -29,6 +30,8 @@ async def display(request: Request, charge_id):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Charge link does not exist."
|
||||
)
|
||||
wallet = await get_wallet(charge.lnbitswallet)
|
||||
return satspay_renderer().TemplateResponse(
|
||||
"satspay/display.html", {"request": request, "charge": charge}
|
||||
"satspay/display.html",
|
||||
{"request": request, "charge": charge, "wallet_key": wallet.inkey},
|
||||
)
|
||||
|
||||
@@ -5,7 +5,12 @@ from fastapi import Query
|
||||
from fastapi.params import Depends
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
get_key_type,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.extensions.satspay import satspay_ext
|
||||
|
||||
from .crud import (
|
||||
@@ -22,18 +27,21 @@ from .models import CreateCharge
|
||||
|
||||
|
||||
@satspay_ext.post("/api/v1/charge")
|
||||
async def api_charge_create(
|
||||
data: CreateCharge, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
||||
):
|
||||
charge = await create_charge(user=wallet.wallet.user, data=data)
|
||||
return charge.dict()
|
||||
|
||||
|
||||
@satspay_ext.put("/api/v1/charge/{charge_id}")
|
||||
async def api_charge_create_or_update(
|
||||
async def api_charge_update(
|
||||
data: CreateCharge,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
charge_id=None,
|
||||
):
|
||||
if not charge_id:
|
||||
charge = await create_charge(user=wallet.wallet.user, data=data)
|
||||
return charge.dict()
|
||||
else:
|
||||
charge = await update_charge(charge_id=charge_id, data=data)
|
||||
return charge.dict()
|
||||
charge = await update_charge(charge_id=charge_id, data=data)
|
||||
return charge.dict()
|
||||
|
||||
|
||||
@satspay_ext.get("/api/v1/charges")
|
||||
|
||||
@@ -173,7 +173,7 @@ async def api_get_donations(g: WalletTypeInfo = Depends(get_key_type)):
|
||||
for wallet_id in wallet_ids:
|
||||
new_donations = await get_donations(wallet_id)
|
||||
donations += new_donations if new_donations else []
|
||||
return [donation._asdict() for donation in donations] if donations else []
|
||||
return [donation.dict() for donation in donations] if donations else []
|
||||
|
||||
|
||||
@streamalerts_ext.put("/api/v1/donations/{donation_id}")
|
||||
|
||||
@@ -33,7 +33,7 @@ async def create_subdomain(payment_hash, wallet, data: CreateDomain) -> Subdomai
|
||||
|
||||
async def set_subdomain_paid(payment_hash: str) -> Subdomains:
|
||||
row = await db.fetchone(
|
||||
"SELECT s.*, d.domain as domain_name FROM subdomains.subdomain s INNER JOIN domain d ON (s.domain = d.id) WHERE s.id = ?",
|
||||
"SELECT s.*, d.domain as domain_name FROM subdomains.subdomain s INNER JOIN subdomains.domain d ON (s.domain = d.id) WHERE s.id = ?",
|
||||
(payment_hash,),
|
||||
)
|
||||
if row[8] == False:
|
||||
@@ -66,7 +66,7 @@ async def set_subdomain_paid(payment_hash: str) -> Subdomains:
|
||||
|
||||
async def get_subdomain(subdomain_id: str) -> Optional[Subdomains]:
|
||||
row = await db.fetchone(
|
||||
"SELECT s.*, d.domain as domain_name FROM subdomains.subdomain s INNER JOIN domain d ON (s.domain = d.id) WHERE s.id = ?",
|
||||
"SELECT s.*, d.domain as domain_name FROM subdomains.subdomain s INNER JOIN subdomains.domain d ON (s.domain = d.id) WHERE s.id = ?",
|
||||
(subdomain_id,),
|
||||
)
|
||||
return Subdomains(**row) if row else None
|
||||
@@ -74,7 +74,7 @@ async def get_subdomain(subdomain_id: str) -> Optional[Subdomains]:
|
||||
|
||||
async def get_subdomainBySubdomain(subdomain: str) -> Optional[Subdomains]:
|
||||
row = await db.fetchone(
|
||||
"SELECT s.*, d.domain as domain_name FROM subdomains.subdomain s INNER JOIN domain d ON (s.domain = d.id) WHERE s.subdomain = ?",
|
||||
"SELECT s.*, d.domain as domain_name FROM subdomains.subdomain s INNER JOIN subdomains.domain d ON (s.domain = d.id) WHERE s.subdomain = ?",
|
||||
(subdomain,),
|
||||
)
|
||||
return Subdomains(**row) if row else None
|
||||
@@ -86,7 +86,7 @@ async def get_subdomains(wallet_ids: Union[str, List[str]]) -> List[Subdomains]:
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT s.*, d.domain as domain_name FROM subdomains.subdomain s INNER JOIN domain d ON (s.domain = d.id) WHERE s.wallet IN ({q})",
|
||||
f"SELECT s.*, d.domain as domain_name FROM subdomains.subdomain s INNER JOIN subdomains.domain d ON (s.domain = d.id) WHERE s.wallet IN ({q})",
|
||||
(*wallet_ids,),
|
||||
)
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ async def api_get_tipjars(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
for wallet_id in wallet_ids:
|
||||
new_tipjars = await get_tipjars(wallet_id)
|
||||
tipjars += new_tipjars if new_tipjars else []
|
||||
return [tipjar._asdict() for tipjar in tipjars] if tipjars else []
|
||||
return [tipjar.dict() for tipjar in tipjars] if tipjars else []
|
||||
|
||||
|
||||
@tipjar_ext.get("/api/v1/tips")
|
||||
@@ -103,7 +103,7 @@ async def api_get_tips(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
for wallet_id in wallet_ids:
|
||||
new_tips = await get_tips(wallet_id)
|
||||
tips += new_tips if new_tips else []
|
||||
return [tip._asdict() for tip in tips] if tips else []
|
||||
return [tip.dict() for tip in tips] if tips else []
|
||||
|
||||
|
||||
@tipjar_ext.put("/api/v1/tips/{tip_id}")
|
||||
|
||||
@@ -82,11 +82,9 @@ async def api_wallet_delete(wallet_id, w: WalletTypeInfo = Depends(require_admin
|
||||
|
||||
@watchonly_ext.get("/api/v1/address/{wallet_id}")
|
||||
async def api_fresh_address(wallet_id, w: WalletTypeInfo = Depends(get_key_type)):
|
||||
await get_fresh_address(wallet_id)
|
||||
address = await get_fresh_address(wallet_id)
|
||||
|
||||
addresses = await get_addresses(wallet_id)
|
||||
|
||||
return [address.dict() for address in addresses]
|
||||
return [address.dict()]
|
||||
|
||||
|
||||
@watchonly_ext.get("/api/v1/addresses/{wallet_id}")
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<code>[<withdraw_link_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}api/v1/links -H "X-Api-Key: {{
|
||||
>curl -X GET {{ request.base_url }}withdraw/api/v1/links -H "X-Api-Key: {{
|
||||
user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
@@ -49,7 +49,7 @@
|
||||
<code>{"lnurl": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}api/v1/links/<withdraw_id> -H
|
||||
>curl -X GET {{ request.base_url }}withdraw/api/v1/links/<withdraw_id> -H
|
||||
"X-Api-Key: {{ user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
@@ -78,7 +78,7 @@
|
||||
<code>{"lnurl": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}api/v1/links -d '{"title":
|
||||
>curl -X POST {{ request.base_url }}withdraw/api/v1/links -d '{"title":
|
||||
<string>, "min_withdrawable": <integer>,
|
||||
"max_withdrawable": <integer>, "uses": <integer>,
|
||||
"wait_time": <integer>, "is_unique": <boolean>}' -H
|
||||
@@ -114,7 +114,7 @@
|
||||
<code>{"lnurl": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X PUT {{ request.base_url }}api/v1/links/<withdraw_id> -d
|
||||
>curl -X PUT {{ request.base_url }}withdraw/api/v1/links/<withdraw_id> -d
|
||||
'{"title": <string>, "min_withdrawable": <integer>,
|
||||
"max_withdrawable": <integer>, "uses": <integer>,
|
||||
"wait_time": <integer>, "is_unique": <boolean>}' -H
|
||||
@@ -142,7 +142,7 @@
|
||||
<code></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X DELETE {{ request.base_url }}api/v1/links/<withdraw_id>
|
||||
>curl -X DELETE {{ request.base_url }}withdraw/api/v1/links/<withdraw_id>
|
||||
-H "X-Api-Key: {{ user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
@@ -170,7 +170,7 @@
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}api/v1/links/<the_hash>/<lnurl_id> -H "X-Api-Key: {{
|
||||
}}withdraw/api/v1/links/<the_hash>/<lnurl_id> -H "X-Api-Key: {{
|
||||
user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
@@ -191,7 +191,7 @@
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}/withdraw/img/<lnurl_id>"
|
||||
>curl -X GET {{ request.base_url }}withdraw/img/<lnurl_id>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
{% if link.is_spent %}
|
||||
<q-badge color="red" class="q-mb-md">Withdraw is spent.</q-badge>
|
||||
{% endif %}
|
||||
<a href="lightning://{{ lnurl }}">
|
||||
<a href="lightning:{{ lnurl }}">
|
||||
<q-responsive :ratio="1" class="q-mx-md">
|
||||
<qrcode
|
||||
:value="this.here + '/?lightning={{lnurl }}'"
|
||||
|
||||
@@ -15,6 +15,7 @@ import lnbits.settings as settings
|
||||
class Extension(NamedTuple):
|
||||
code: str
|
||||
is_valid: bool
|
||||
is_admin_only: bool
|
||||
name: Optional[str] = None
|
||||
short_description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
@@ -25,6 +26,7 @@ class Extension(NamedTuple):
|
||||
class ExtensionManager:
|
||||
def __init__(self):
|
||||
self._disabled: List[str] = settings.LNBITS_DISABLED_EXTENSIONS
|
||||
self._admin_only: List[str] = [x.strip(' ') for x in settings.LNBITS_ADMIN_EXTENSIONS]
|
||||
self._extension_folders: List[str] = [
|
||||
x[1] for x in os.walk(os.path.join(settings.LNBITS_PATH, "extensions"))
|
||||
][0]
|
||||
@@ -47,14 +49,17 @@ class ExtensionManager:
|
||||
) as json_file:
|
||||
config = json.load(json_file)
|
||||
is_valid = True
|
||||
is_admin_only = True if extension in self._admin_only else False
|
||||
except Exception:
|
||||
config = {}
|
||||
is_valid = False
|
||||
is_admin_only = False
|
||||
|
||||
output.append(
|
||||
Extension(
|
||||
extension,
|
||||
is_valid,
|
||||
is_admin_only,
|
||||
config.get("name"),
|
||||
config.get("short_description"),
|
||||
config.get("icon"),
|
||||
@@ -156,6 +161,7 @@ def template_renderer(additional_folders: List = []) -> Jinja2Templates:
|
||||
)
|
||||
)
|
||||
t.env.globals["SITE_TITLE"] = settings.LNBITS_SITE_TITLE
|
||||
t.env.globals["LNBITS_DENOMINATION"] = settings.LNBITS_DENOMINATION
|
||||
t.env.globals["SITE_TAGLINE"] = settings.LNBITS_SITE_TAGLINE
|
||||
t.env.globals["SITE_DESCRIPTION"] = settings.LNBITS_SITE_DESCRIPTION
|
||||
t.env.globals["LNBITS_THEME_OPTIONS"] = settings.LNBITS_THEME_OPTIONS
|
||||
|
||||
@@ -28,11 +28,14 @@ LNBITS_DATABASE_URL = env.str("LNBITS_DATABASE_URL", default=None)
|
||||
LNBITS_ALLOWED_USERS: List[str] = env.list(
|
||||
"LNBITS_ALLOWED_USERS", default=[], subcast=str
|
||||
)
|
||||
LNBITS_ADMIN_USERS: List[str] = env.list("LNBITS_ADMIN_USERS", default=[], subcast=str)
|
||||
LNBITS_ADMIN_EXTENSIONS: List[str] = env.list("LNBITS_ADMIN_EXTENSIONS", default=[], subcast=str)
|
||||
LNBITS_DISABLED_EXTENSIONS: List[str] = env.list(
|
||||
"LNBITS_DISABLED_EXTENSIONS", default=[], subcast=str
|
||||
)
|
||||
|
||||
LNBITS_SITE_TITLE = env.str("LNBITS_SITE_TITLE", default="LNbits")
|
||||
LNBITS_DENOMINATION = env.str("LNBITS_DENOMINATION", default="sats")
|
||||
LNBITS_SITE_TAGLINE = env.str(
|
||||
"LNBITS_SITE_TAGLINE", default="free and open-source lightning wallet"
|
||||
)
|
||||
|
||||
+37
-11
@@ -111,7 +111,7 @@ window.LNbits = {
|
||||
'/wallet?' + (userId ? 'usr=' + userId + '&' : '') + 'nme=' + walletName
|
||||
},
|
||||
updateWallet: function (walletName, userId, walletId) {
|
||||
window.location.href = `/wallet?usr=${userId}&wal=${walletId}&nme=${walletName}`
|
||||
window.location.href = `/wallet?usr=${userId}&wal=${walletId}&nme=${walletName}`
|
||||
},
|
||||
deleteWallet: function (walletId, userId) {
|
||||
window.location.href = '/deletewallet?usr=' + userId + '&wal=' + walletId
|
||||
@@ -123,6 +123,7 @@ window.LNbits = {
|
||||
[
|
||||
'code',
|
||||
'isValid',
|
||||
'isAdminOnly',
|
||||
'name',
|
||||
'shortDescription',
|
||||
'icon',
|
||||
@@ -135,7 +136,12 @@ window.LNbits = {
|
||||
return obj
|
||||
},
|
||||
user: function (data) {
|
||||
var obj = {id: data.id, email: data.email, extensions: data.extensions, wallets: data.wallets}
|
||||
var obj = {
|
||||
id: data.id,
|
||||
email: data.email,
|
||||
extensions: data.extensions,
|
||||
wallets: data.wallets
|
||||
}
|
||||
var mapWallet = this.wallet
|
||||
obj.wallets = obj.wallets
|
||||
.map(function (obj) {
|
||||
@@ -153,16 +159,23 @@ window.LNbits = {
|
||||
return obj
|
||||
},
|
||||
wallet: function (data) {
|
||||
newWallet = {id: data.id, name: data.name, adminkey: data.adminkey, inkey: data.inkey}
|
||||
newWallet = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
adminkey: data.adminkey,
|
||||
inkey: data.inkey
|
||||
}
|
||||
newWallet.msat = data.balance_msat
|
||||
newWallet.sat = Math.round(data.balance_msat / 1000)
|
||||
newWallet.fsat = new Intl.NumberFormat(window.LOCALE).format(newWallet.sat)
|
||||
newWallet.fsat = new Intl.NumberFormat(window.LOCALE).format(
|
||||
newWallet.sat
|
||||
)
|
||||
newWallet.url = ['/wallet?usr=', data.user, '&wal=', data.id].join('')
|
||||
return newWallet
|
||||
},
|
||||
payment: function (data) {
|
||||
obj = {
|
||||
checking_id:data.id,
|
||||
checking_id: data.id,
|
||||
pending: data.pending,
|
||||
amount: data.amount,
|
||||
fee: data.fee,
|
||||
@@ -174,8 +187,8 @@ window.LNbits = {
|
||||
extra: data.extra,
|
||||
wallet_id: data.wallet_id,
|
||||
webhook: data.webhook,
|
||||
webhook_status: data.webhook_status,
|
||||
}
|
||||
webhook_status: data.webhook_status
|
||||
}
|
||||
|
||||
obj.date = Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
@@ -225,7 +238,8 @@ window.LNbits = {
|
||||
Quasar.plugins.Notify.create({
|
||||
timeout: 5000,
|
||||
type: types[error.response.status] || 'warning',
|
||||
message: error.response.data.message || error.response.data.detail || null,
|
||||
message:
|
||||
error.response.data.message || error.response.data.detail || null,
|
||||
caption:
|
||||
[error.response.status, ' ', error.response.statusText]
|
||||
.join('')
|
||||
@@ -331,17 +345,25 @@ window.windowMixin = {
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
this.$q.dark.set(this.$q.localStorage.getItem('lnbits.darkMode'))
|
||||
this.g.allowedThemes = window.allowedThemes ?? ['classic']
|
||||
|
||||
if(this.$q.localStorage.getItem('lnbits.darkMode') == true || this.$q.localStorage.getItem('lnbits.darkMode') == false){
|
||||
this.$q.dark.set(this.$q.localStorage.getItem('lnbits.darkMode'))
|
||||
}
|
||||
else{
|
||||
this.$q.dark.set(true)
|
||||
}
|
||||
this.g.allowedThemes = window.allowedThemes ?? ['bitcoin']
|
||||
|
||||
// failsafe if admin changes themes halfway
|
||||
if (!this.$q.localStorage.getItem('lnbits.theme')){
|
||||
this.changeColor(this.g.allowedThemes[0])
|
||||
}
|
||||
if (
|
||||
this.$q.localStorage.getItem('lnbits.theme') &&
|
||||
!this.g.allowedThemes.includes(
|
||||
this.$q.localStorage.getItem('lnbits.theme')
|
||||
)
|
||||
) {
|
||||
console.log('allowedThemes changed by Admin', this.g.allowedThemes[0])
|
||||
this.changeColor(this.g.allowedThemes[0])
|
||||
}
|
||||
|
||||
@@ -368,6 +390,10 @@ window.windowMixin = {
|
||||
.filter(function (obj) {
|
||||
return !obj.hidden
|
||||
})
|
||||
.filter(function (obj) {
|
||||
if (window.user.admin) return obj
|
||||
return !obj.isAdminOnly
|
||||
})
|
||||
.map(function (obj) {
|
||||
if (user) {
|
||||
obj.isEnabled = user.extensions.indexOf(obj.code) !== -1
|
||||
|
||||
@@ -22,7 +22,8 @@ Vue.component('lnbits-wallet-list', {
|
||||
activeWallet: null,
|
||||
activeBalance: [],
|
||||
showForm: false,
|
||||
walletName: ''
|
||||
walletName: '',
|
||||
LNBITS_DENOMINATION: LNBITS_DENOMINATION
|
||||
}
|
||||
},
|
||||
template: `
|
||||
@@ -43,7 +44,8 @@ Vue.component('lnbits-wallet-list', {
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label lines="1">{{ wallet.name }}</q-item-label>
|
||||
<q-item-label caption>{{ wallet.live_fsat }} sat</q-item-label>
|
||||
<q-item-label v-if="LNBITS_DENOMINATION != 'sats'" caption>{{ parseFloat(String(wallet.live_fsat).replaceAll(",", "")) / 100 }} {{ LNBITS_DENOMINATION }}</q-item-label>
|
||||
<q-item-label v-else caption>{{ wallet.live_fsat }} {{ LNBITS_DENOMINATION }}</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side v-show="activeWallet && activeWallet.id === wallet.id">
|
||||
<q-icon name="chevron_right" color="grey-5" size="md"></q-icon>
|
||||
@@ -194,11 +196,11 @@ Vue.component('lnbits-payment-details', {
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-3"><b>Amount</b>:</div>
|
||||
<div class="col-9">{{ (payment.amount / 1000).toFixed(3) }} sat</div>
|
||||
<div class="col-9">{{ (payment.amount / 1000).toFixed(3) }} {{LNBITS_DENOMINATION}}</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-3"><b>Fee</b>:</div>
|
||||
<div class="col-9">{{ (payment.fee / 1000).toFixed(3) }} sat</div>
|
||||
<div class="col-9">{{ (payment.fee / 1000).toFixed(3) }} {{LNBITS_DENOMINATION}}</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-3"><b>Payment hash</b>:</div>
|
||||
|
||||
@@ -7,6 +7,14 @@ $themes: (
|
||||
marginal-bg: #1f2234,
|
||||
marginal-text: #fff
|
||||
),
|
||||
'bitcoin': (
|
||||
primary: #ff9853,
|
||||
secondary: #ff7353,
|
||||
dark: #2d293b,
|
||||
info: #333646,
|
||||
marginal-bg: #2d293b,
|
||||
marginal-text: #fff
|
||||
),
|
||||
'mint': (
|
||||
primary: #3ab77d,
|
||||
secondary: #27b065,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
{% block head_scripts %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body data-theme="classic">
|
||||
<body data-theme="bitcoin">
|
||||
<q-layout id="vue" view="hHh lpR lfr" v-cloak>
|
||||
<q-header bordered class="bg-marginal-bg">
|
||||
<q-toolbar>
|
||||
@@ -67,8 +67,19 @@
|
||||
icon="format_color_fill"
|
||||
color="deep-purple"
|
||||
size="md"
|
||||
><q-tooltip>classic</q-tooltip> </q-btn
|
||||
><q-btn
|
||||
><q-tooltip>classic</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('bitcoin')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('bitcoin')"
|
||||
icon="format_color_fill"
|
||||
color="orange"
|
||||
size="md"
|
||||
><q-tooltip>bitcoin</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('mint')"
|
||||
dense
|
||||
flat
|
||||
@@ -151,11 +162,12 @@
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
{% endblock %} {% block footer %}
|
||||
|
||||
<q-footer
|
||||
class="bg-transparent q-px-lg q-py-md"
|
||||
:class="{'text-dark': !$q.dark.isActive}"
|
||||
>
|
||||
<q-toolbar>
|
||||
<q-toolbar class="gt-sm">
|
||||
<q-toolbar-title class="text-caption">
|
||||
{{ SITE_TITLE }}, {{SITE_TAGLINE}}
|
||||
<br />
|
||||
@@ -179,6 +191,7 @@
|
||||
</q-btn>
|
||||
</q-toolbar>
|
||||
</q-footer>
|
||||
|
||||
{% endblock %}
|
||||
</q-layout>
|
||||
|
||||
@@ -192,6 +205,8 @@
|
||||
<script src="/static/js/components.js"></script>
|
||||
<script type="text/javascript">
|
||||
const themes = {{ LNBITS_THEME_OPTIONS | tojson }}
|
||||
const LNBITS_DENOMINATION = {{ LNBITS_DENOMINATION | tojson}}
|
||||
console.log(LNBITS_DENOMINATION)
|
||||
if(themes && themes.length) {
|
||||
window.allowedThemes = themes.map(str => str.trim())
|
||||
}
|
||||
|
||||
@@ -279,8 +279,12 @@ async def btc_price(currency: str) -> float:
|
||||
|
||||
|
||||
async def get_fiat_rate_satoshis(currency: str) -> float:
|
||||
return int(100_000_000 / (await btc_price(currency)))
|
||||
return float(100_000_000 / (await btc_price(currency)))
|
||||
|
||||
|
||||
async def fiat_amount_as_satoshis(amount: float, currency: str) -> int:
|
||||
return int(amount * (await get_fiat_rate_satoshis(currency)))
|
||||
|
||||
|
||||
async def satoshis_amount_as_fiat(amount: float, currency: str) -> float:
|
||||
return float(amount / (await get_fiat_rate_satoshis(currency)))
|
||||
|
||||
@@ -9,3 +9,4 @@ from .lnpay import LNPayWallet
|
||||
from .lnbits import LNbitsWallet
|
||||
from .lndrest import LndRestWallet
|
||||
from .spark import SparkWallet
|
||||
from .fake import FakeWallet
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import asyncio
|
||||
import json
|
||||
import httpx
|
||||
from os import getenv
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, AsyncGenerator
|
||||
import random
|
||||
import string
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
import hashlib
|
||||
from ..bolt11 import encode, decode
|
||||
from .base import (
|
||||
StatusResponse,
|
||||
InvoiceResponse,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
Wallet,
|
||||
)
|
||||
|
||||
|
||||
class FakeWallet(Wallet):
|
||||
async def status(self) -> StatusResponse:
|
||||
print(
|
||||
"FakeWallet funding source is for using LNbits as a centralised, stand-alone payment system with brrrrrr."
|
||||
)
|
||||
return StatusResponse(None, float("inf"))
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
) -> InvoiceResponse:
|
||||
secret = getenv("FAKE_WALLET_SECRET")
|
||||
data: Dict = {
|
||||
"out": False,
|
||||
"amount": amount,
|
||||
"currency": "bc",
|
||||
"privkey": hashlib.pbkdf2_hmac('sha256', secret.encode("utf-8"), ("FakeWallet").encode("utf-8"), 2048, 32).hex(),
|
||||
"memo": None,
|
||||
"description_hash": None,
|
||||
"description": "",
|
||||
"fallback": None,
|
||||
"expires": None,
|
||||
"route": None,
|
||||
}
|
||||
data["amount"] = amount * 1000
|
||||
data["timestamp"] = datetime.now().timestamp()
|
||||
if description_hash:
|
||||
data["tags_set"] = ["h"]
|
||||
data["description_hash"] = description_hash.hex()
|
||||
else:
|
||||
data["tags_set"] = ["d"]
|
||||
data["memo"] = memo
|
||||
data["description"] = memo
|
||||
randomHash = data["privkey"][:6] + hashlib.sha256(
|
||||
str(random.getrandbits(256)).encode("utf-8")
|
||||
).hexdigest()[6:]
|
||||
data["paymenthash"] = randomHash
|
||||
payment_request = encode(data)
|
||||
checking_id = randomHash
|
||||
|
||||
return InvoiceResponse(True, checking_id, payment_request)
|
||||
|
||||
async def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
||||
invoice = decode(bolt11)
|
||||
if hasattr(invoice, 'checking_id') and invoice.checking_id[6:] == data["privkey"][:6]:
|
||||
return PaymentResponse(True, invoice.payment_hash, 0)
|
||||
else:
|
||||
return PaymentResponse(ok = False, error_message="Only internal invoices can be used!")
|
||||
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
return PaymentStatus(False)
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
return PaymentStatus(False)
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
self.queue = asyncio.Queue(0)
|
||||
while True:
|
||||
value = await self.queue.get()
|
||||
yield value
|
||||
@@ -11,6 +11,7 @@ import base64
|
||||
import hashlib
|
||||
from os import environ, error, getenv
|
||||
from typing import Optional, Dict, AsyncGenerator
|
||||
from .macaroon import load_macaroon, AESCipher
|
||||
|
||||
if imports_ok:
|
||||
import lnbits.wallets.lnd_grpc_files.lightning_pb2 as ln
|
||||
@@ -58,12 +59,6 @@ def get_ssl_context(cert_path: str):
|
||||
return context
|
||||
|
||||
|
||||
def load_macaroon(macaroon_path: str):
|
||||
with open(macaroon_path, "rb") as f:
|
||||
macaroon_bytes = f.read()
|
||||
return macaroon_bytes.hex()
|
||||
|
||||
|
||||
def parse_checking_id(checking_id: str) -> bytes:
|
||||
return base64.b64decode(checking_id.replace("_", "/"))
|
||||
|
||||
@@ -90,18 +85,19 @@ class LndWallet(Wallet):
|
||||
self.port = int(getenv("LND_GRPC_PORT"))
|
||||
self.cert_path = getenv("LND_GRPC_CERT") or getenv("LND_CERT")
|
||||
|
||||
macaroon_path = (
|
||||
macaroon = (
|
||||
getenv("LND_GRPC_MACAROON")
|
||||
or getenv("LND_GRPC_ADMIN_MACAROON")
|
||||
or getenv("LND_ADMIN_MACAROON")
|
||||
or getenv("LND_GRPC_INVOICE_MACAROON")
|
||||
or getenv("LND_INVOICE_MACAROON")
|
||||
)
|
||||
|
||||
if macaroon_path.split(".")[-1] == "macaroon":
|
||||
self.macaroon = load_macaroon(macaroon_path)
|
||||
else:
|
||||
self.macaroon = macaroon_path
|
||||
|
||||
|
||||
encrypted_macaroon = getenv("LND_GRPC_MACAROON_ENCRYPTED")
|
||||
if encrypted_macaroon:
|
||||
macaroon = AESCipher(description="macaroon decryption").decrypt(encrypted_macaroon)
|
||||
self.macaroon = load_macaroon(macaroon)
|
||||
|
||||
cert = open(self.cert_path, "rb").read()
|
||||
creds = grpc.ssl_channel_credentials(cert)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
from pydoc import describe
|
||||
import httpx
|
||||
import json
|
||||
import base64
|
||||
@@ -6,6 +7,7 @@ from os import getenv
|
||||
from typing import Optional, Dict, AsyncGenerator
|
||||
|
||||
from lnbits import bolt11 as lnbits_bolt11
|
||||
from .macaroon import load_macaroon, AESCipher
|
||||
|
||||
from .base import (
|
||||
StatusResponse,
|
||||
@@ -23,8 +25,7 @@ class LndRestWallet(Wallet):
|
||||
endpoint = getenv("LND_REST_ENDPOINT")
|
||||
endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
|
||||
endpoint = (
|
||||
"https://" +
|
||||
endpoint if not endpoint.startswith("http") else endpoint
|
||||
"https://" + endpoint if not endpoint.startswith("http") else endpoint
|
||||
)
|
||||
self.endpoint = endpoint
|
||||
|
||||
@@ -35,7 +36,13 @@ class LndRestWallet(Wallet):
|
||||
or getenv("LND_INVOICE_MACAROON")
|
||||
or getenv("LND_REST_INVOICE_MACAROON")
|
||||
)
|
||||
self.auth = {"Grpc-Metadata-macaroon": macaroon}
|
||||
|
||||
encrypted_macaroon = getenv("LND_REST_MACAROON_ENCRYPTED")
|
||||
if encrypted_macaroon:
|
||||
macaroon = AESCipher(description="macaroon decryption").decrypt(encrypted_macaroon)
|
||||
self.macaroon = load_macaroon(macaroon)
|
||||
|
||||
self.auth = {"Grpc-Metadata-macaroon": self.macaroon}
|
||||
self.cert = getenv("LND_REST_CERT")
|
||||
|
||||
async def status(self) -> StatusResponse:
|
||||
@@ -103,10 +110,7 @@ class LndRestWallet(Wallet):
|
||||
r = await client.post(
|
||||
url=f"{self.endpoint}/v1/channels/transactions",
|
||||
headers=self.auth,
|
||||
json={
|
||||
"payment_request": bolt11,
|
||||
"fee_limit": lnrpcFeeLimit,
|
||||
},
|
||||
json={"payment_request": bolt11, "fee_limit": lnrpcFeeLimit},
|
||||
timeout=180,
|
||||
)
|
||||
|
||||
@@ -183,8 +187,7 @@ class LndRestWallet(Wallet):
|
||||
except:
|
||||
continue
|
||||
|
||||
payment_hash = base64.b64decode(
|
||||
inv["r_hash"]).hex()
|
||||
payment_hash = base64.b64decode(inv["r_hash"]).hex()
|
||||
yield payment_hash
|
||||
except (OSError, httpx.ConnectError, httpx.ReadError):
|
||||
pass
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .macaroon import load_macaroon, AESCipher
|
||||
@@ -0,0 +1,103 @@
|
||||
from Cryptodome import Random
|
||||
from Cryptodome.Cipher import AES
|
||||
import base64
|
||||
from hashlib import md5
|
||||
import getpass
|
||||
|
||||
BLOCK_SIZE = 16
|
||||
import getpass
|
||||
|
||||
def load_macaroon(macaroon: str) -> str:
|
||||
"""Returns hex version of a macaroon encoded in base64 or the file path.
|
||||
|
||||
:param macaroon: Macaroon encoded in base64 or file path.
|
||||
:type macaroon: str
|
||||
:return: Hex version of macaroon.
|
||||
:rtype: str
|
||||
"""
|
||||
|
||||
# if the macaroon is a file path, load it
|
||||
if macaroon.split(".")[-1] == "macaroon":
|
||||
with open(macaroon, "rb") as f:
|
||||
macaroon_bytes = f.read()
|
||||
return macaroon_bytes.hex()
|
||||
else:
|
||||
# convert the bas64 macaroon to hex
|
||||
try:
|
||||
macaroon = base64.b64decode(macaroon).hex()
|
||||
except:
|
||||
pass
|
||||
return macaroon
|
||||
|
||||
class AESCipher(object):
|
||||
"""This class is compatible with crypto-js/aes.js
|
||||
|
||||
Encrypt and decrypt in Javascript using:
|
||||
import AES from "crypto-js/aes.js";
|
||||
import Utf8 from "crypto-js/enc-utf8.js";
|
||||
AES.encrypt(decrypted, password).toString()
|
||||
AES.decrypt(encrypted, password).toString(Utf8);
|
||||
|
||||
"""
|
||||
def __init__(self, key=None, description=""):
|
||||
self.key = key
|
||||
self.description = description + " "
|
||||
|
||||
def pad(self, data):
|
||||
length = BLOCK_SIZE - (len(data) % BLOCK_SIZE)
|
||||
return data + (chr(length) * length).encode()
|
||||
|
||||
|
||||
def unpad(self, data):
|
||||
return data[: -(data[-1] if type(data[-1]) == int else ord(data[-1]))]
|
||||
|
||||
@property
|
||||
def passphrase(self):
|
||||
passphrase = self.key if self.key is not None else None
|
||||
if passphrase is None:
|
||||
passphrase = getpass.getpass(f"Enter {self.description}password:")
|
||||
return passphrase
|
||||
|
||||
def bytes_to_key(self, data, salt, output=48):
|
||||
# extended from https://gist.github.com/gsakkis/4546068
|
||||
assert len(salt) == 8, len(salt)
|
||||
data += salt
|
||||
key = md5(data).digest()
|
||||
final_key = key
|
||||
while len(final_key) < output:
|
||||
key = md5(key + data).digest()
|
||||
final_key += key
|
||||
return final_key[:output]
|
||||
|
||||
def decrypt(self, encrypted: str) -> str:
|
||||
"""Decrypts a string using AES-256-CBC.
|
||||
"""
|
||||
passphrase = self.passphrase
|
||||
encrypted = base64.b64decode(encrypted)
|
||||
assert encrypted[0:8] == b"Salted__"
|
||||
salt = encrypted[8:16]
|
||||
key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16)
|
||||
key = key_iv[:32]
|
||||
iv = key_iv[32:]
|
||||
aes = AES.new(key, AES.MODE_CBC, iv)
|
||||
try:
|
||||
return self.unpad(aes.decrypt(encrypted[16:])).decode()
|
||||
except UnicodeDecodeError:
|
||||
raise ValueError("Wrong passphrase")
|
||||
|
||||
def encrypt(self, message: bytes) -> str:
|
||||
passphrase = self.passphrase
|
||||
salt = Random.new().read(8)
|
||||
key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16)
|
||||
key = key_iv[:32]
|
||||
iv = key_iv[32:]
|
||||
aes = AES.new(key, AES.MODE_CBC, iv)
|
||||
return base64.b64encode(b"Salted__" + salt + aes.encrypt(self.pad(message))).decode()
|
||||
|
||||
# if this file is executed directly, ask for a macaroon and encrypt it
|
||||
if __name__ == "__main__":
|
||||
macaroon = input("Enter macaroon: ")
|
||||
macaroon = load_macaroon(macaroon)
|
||||
macaroon = AESCipher(description="encryption").encrypt(macaroon.encode())
|
||||
print("Encrypted macaroon:")
|
||||
print(macaroon)
|
||||
@@ -1,12 +1,12 @@
|
||||
from typing import Optional, AsyncGenerator
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
from .base import (
|
||||
StatusResponse,
|
||||
InvoiceResponse,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
Wallet,
|
||||
StatusResponse,
|
||||
Unsupported,
|
||||
Wallet,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,10 +20,10 @@ class VoidWallet(Wallet):
|
||||
raise Unsupported("")
|
||||
|
||||
async def status(self) -> StatusResponse:
|
||||
return StatusResponse(
|
||||
"This backend does nothing, it is here just as a placeholder, you must configure an actual backend before being able to do anything useful with LNbits.",
|
||||
0,
|
||||
print(
|
||||
"This backend does nothing, it is here just as a placeholder, you must configure an actual backend before being able to do anything useful with LNbits."
|
||||
)
|
||||
return StatusResponse(None, 0)
|
||||
|
||||
async def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
||||
raise Unsupported("")
|
||||
|
||||
Generated
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "lnbits",
|
||||
"name": "lnbits-legend",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
||||
+3
-1
@@ -25,6 +25,7 @@ markupsafe==2.0.1
|
||||
marshmallow==3.13.0
|
||||
outcome==1.1.0
|
||||
psycopg2-binary==2.9.1
|
||||
pycryptodomex==3.14.1
|
||||
pydantic==1.8.2
|
||||
pypng==0.0.21
|
||||
pyqrcode==1.2.1
|
||||
@@ -33,6 +34,7 @@ python-dotenv==0.19.0
|
||||
pyyaml==5.4.1
|
||||
represent==1.6.0.post0
|
||||
rfc3986==1.5.0
|
||||
secp256k1==0.14.0
|
||||
shortuuid==1.0.1
|
||||
six==1.16.0
|
||||
sniffio==1.2.0
|
||||
@@ -45,4 +47,4 @@ uvicorn==0.15.0
|
||||
uvloop==0.16.0
|
||||
watchgod==0.7
|
||||
websockets==10.0
|
||||
zipp==3.5.0
|
||||
zipp==3.5.0
|
||||
@@ -9,18 +9,21 @@ from tests.helpers import credit_wallet
|
||||
from tests.extensions.bleskomat.conftest import bleskomat, lnurl
|
||||
from tests.mocks import WALLET
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bleskomat_lnurl_api_missing_secret(client):
|
||||
response = await client.get("/bleskomat/u")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ERROR", "reason": "Missing secret"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bleskomat_lnurl_api_invalid_secret(client):
|
||||
response = await client.get("/bleskomat/u?k1=invalid-secret")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ERROR", "reason": "Invalid secret"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bleskomat_lnurl_api_unknown_api_key(client):
|
||||
query = {
|
||||
@@ -33,11 +36,12 @@ async def test_bleskomat_lnurl_api_unknown_api_key(client):
|
||||
"f": "EUR",
|
||||
}
|
||||
payload = query_to_signing_payload(query)
|
||||
signature = "xxx"# not checked, so doesn't matter
|
||||
response = await client.get(f'/bleskomat/u?{payload}&signature={signature}')
|
||||
signature = "xxx" # not checked, so doesn't matter
|
||||
response = await client.get(f"/bleskomat/u?{payload}&signature={signature}")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ERROR", "reason": "Unknown API key"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bleskomat_lnurl_api_invalid_signature(client, bleskomat):
|
||||
query = {
|
||||
@@ -51,10 +55,11 @@ async def test_bleskomat_lnurl_api_invalid_signature(client, bleskomat):
|
||||
}
|
||||
payload = query_to_signing_payload(query)
|
||||
signature = "invalid"
|
||||
response = await client.get(f'/bleskomat/u?{payload}&signature={signature}')
|
||||
response = await client.get(f"/bleskomat/u?{payload}&signature={signature}")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ERROR", "reason": "Invalid API key signature"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bleskomat_lnurl_api_valid_signature(client, bleskomat):
|
||||
query = {
|
||||
@@ -64,41 +69,42 @@ async def test_bleskomat_lnurl_api_valid_signature(client, bleskomat):
|
||||
"minWithdrawable": "1",
|
||||
"maxWithdrawable": "1",
|
||||
"defaultDescription": "test valid sig",
|
||||
"f": "EUR",# tests use the dummy exchange rate provider
|
||||
"f": "EUR", # tests use the dummy exchange rate provider
|
||||
}
|
||||
payload = query_to_signing_payload(query)
|
||||
signature = generate_bleskomat_lnurl_signature(
|
||||
payload=payload,
|
||||
api_key_secret=bleskomat.api_key_secret,
|
||||
api_key_encoding=bleskomat.api_key_encoding
|
||||
payload=payload, api_key_secret=bleskomat.api_key_secret, api_key_encoding=bleskomat.api_key_encoding
|
||||
)
|
||||
response = await client.get(f'/bleskomat/u?{payload}&signature={signature}')
|
||||
response = await client.get(f"/bleskomat/u?{payload}&signature={signature}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["tag"] == "withdrawRequest"
|
||||
assert data["minWithdrawable"] == 1000
|
||||
assert data["maxWithdrawable"] == 1000
|
||||
assert data["defaultDescription"] == "test valid sig"
|
||||
assert data["callback"] == f'http://{HOST}:{PORT}/bleskomat/u'
|
||||
assert data["callback"] == f"http://{HOST}:{PORT}/bleskomat/u"
|
||||
k1 = data["k1"]
|
||||
lnurl = await get_bleskomat_lnurl(secret=k1)
|
||||
assert lnurl
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bleskomat_lnurl_api_action_insufficient_balance(client, lnurl):
|
||||
bleskomat = lnurl["bleskomat"]
|
||||
secret = lnurl["secret"]
|
||||
pr = "lntb500n1pseq44upp5xqd38rgad72lnlh4gl339njlrsl3ykep82j6gj4g02dkule7k54qdqqcqzpgxqyz5vqsp5h0zgewuxdxcl2rnlumh6g520t4fr05rgudakpxm789xgjekha75s9qyyssq5vhwsy9knhfeqg0wn6hcnppwmum8fs3g3jxkgw45havgfl6evchjsz3s8e8kr6eyacz02szdhs7v5lg0m7wehd5rpf6yg8480cddjlqpae52xu"
|
||||
WALLET.pay_invoice.reset_mock()
|
||||
response = await client.get(f'/bleskomat/u?k1={secret}&pr={pr}')
|
||||
response = await client.get(f"/bleskomat/u?k1={secret}&pr={pr}")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ERROR", "reason": "Failed to pay invoice: Insufficient balance."}
|
||||
assert response.json()["status"] == "ERROR"
|
||||
assert ("Insufficient balance" in response.json()["reason"]) or ("fee" in response.json()["reason"])
|
||||
wallet = await get_wallet(bleskomat.wallet)
|
||||
assert wallet.balance_msat == 0
|
||||
bleskomat_lnurl = await get_bleskomat_lnurl(secret)
|
||||
assert bleskomat_lnurl.has_uses_remaining() == True
|
||||
WALLET.pay_invoice.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bleskomat_lnurl_api_action_success(client, lnurl):
|
||||
bleskomat = lnurl["bleskomat"]
|
||||
@@ -111,7 +117,7 @@ async def test_bleskomat_lnurl_api_action_success(client, lnurl):
|
||||
wallet = await get_wallet(bleskomat.wallet)
|
||||
assert wallet.balance_msat == 100000
|
||||
WALLET.pay_invoice.reset_mock()
|
||||
response = await client.get(f'/bleskomat/u?k1={secret}&pr={pr}')
|
||||
response = await client.get(f"/bleskomat/u?k1={secret}&pr={pr}")
|
||||
assert response.json() == {"status": "OK"}
|
||||
wallet = await get_wallet(bleskomat.wallet)
|
||||
assert wallet.balance_msat == 50000
|
||||
|
||||
Reference in New Issue
Block a user