Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b98a10c9e | ||
|
|
ffcff3f441 | ||
|
|
191124d8fc | ||
|
|
f22e1e16af | ||
|
|
66a3b24dd5 | ||
|
|
809d03d3db | ||
|
|
f9fd071315 | ||
|
|
739c108bdf | ||
|
|
c419afc1b1 | ||
|
|
46a7b54a00 | ||
|
|
0f204485c3 | ||
|
|
8c6b31ec12 | ||
|
|
d79d4e371e | ||
|
|
e4078504ba | ||
|
|
25f37b0e4f | ||
|
|
057de41678 | ||
|
|
31b6a9410c | ||
|
|
e960285c8f | ||
|
|
86f7ea64c4 | ||
|
|
ac3f95e6c2 | ||
|
|
4cb54ce549 | ||
|
|
5a3e1d7fbb | ||
|
|
4fa8b5a292 | ||
|
|
ccae7876d9 | ||
|
|
cce341fb39 | ||
|
|
5317d5afd5 | ||
|
|
6da62c648a | ||
|
|
40f5cf77f6 | ||
|
|
475fd566e6 | ||
|
|
a9506cee11 | ||
|
|
efbf34a51e | ||
|
|
0cd8ba956a | ||
|
|
05bc887ad5 | ||
|
|
80e1545920 | ||
|
|
728209da7d | ||
|
|
3bd4253292 | ||
|
|
912a7e1c4b | ||
|
|
d74b48b0b6 | ||
|
|
615520bcb3 | ||
|
|
1dd51a2752 | ||
|
|
e3d36e6a3a | ||
|
|
09a9a0f9ec | ||
|
|
74322232d0 | ||
|
|
91c6d32852 | ||
|
|
6c21bf359c | ||
|
|
b5e97f08a6 | ||
|
|
8e05a11978 | ||
|
|
50b2dcfcaa | ||
|
|
6cfa8e30bc | ||
|
|
5a005e830c | ||
|
|
d93b199735 | ||
|
|
dc501d3f40 | ||
|
|
6c63d3000f | ||
|
|
370a962528 | ||
|
|
885c690cc6 | ||
|
|
3d417bc40a | ||
|
|
87dc9136a4 | ||
|
|
c1f6845ad9 | ||
|
|
3f51117ea9 | ||
|
|
3d1b3b7173 | ||
|
|
cff9a59693 | ||
|
|
5bc2907f80 | ||
|
|
7bb936b09b | ||
|
|
f72421c62c | ||
|
|
cf75df366a | ||
|
|
23954b501f | ||
|
|
98366cfaf1 | ||
|
|
ee2342e1ba | ||
|
|
c1289c43a4 | ||
|
|
92e7dde500 | ||
|
|
085bcb8234 | ||
|
|
8e4e0bdf2d | ||
|
|
a2f48de583 | ||
|
|
dec8d4b6b3 | ||
|
|
2b5fc82110 | ||
|
|
2ef9548b05 | ||
|
|
184da9958c | ||
|
|
ad3e1728cb | ||
|
|
618717b529 | ||
|
|
261a156fa3 | ||
|
|
a57850f391 | ||
|
|
a7d27467f0 | ||
|
|
19b6acf932 | ||
|
|
eafefce709 | ||
|
|
a3a183b86c | ||
|
|
cf11b6c42d | ||
|
|
4ca701a442 | ||
|
|
56cac668ca | ||
|
|
a601e178ea | ||
|
|
04114c82f2 | ||
|
|
7f79b5ac7f | ||
|
|
4199f1bf98 | ||
|
|
51dac0275c | ||
|
|
730add5116 | ||
|
|
4c8641fe00 | ||
|
|
dab109614d | ||
|
|
9a9f3ae809 | ||
|
|
822373a763 | ||
|
|
4441837cb7 | ||
|
|
85db59c359 | ||
|
|
fa0649646c | ||
|
|
7f8ee461a2 | ||
|
|
d322e6d4dc | ||
|
|
c1278dbef9 | ||
|
|
3ec9cb6c89 | ||
|
|
556eef35ab | ||
|
|
783502ebf8 | ||
|
|
c340a55925 | ||
|
|
f5a3f5b503 | ||
|
|
254fa67859 | ||
|
|
bfb4006b02 | ||
|
|
b60ad6974c | ||
|
|
354fedc0b8 | ||
|
|
817472c036 | ||
|
|
521c821f6b | ||
|
|
c72834e47e | ||
|
|
7da496dbb4 | ||
|
|
347baa076a | ||
|
|
36cb1eb47b | ||
|
|
7667c2f531 | ||
|
|
80dd330ec0 | ||
|
|
00dba54ac8 | ||
|
|
e63c4c9fa2 | ||
|
|
27ce17cd74 | ||
|
|
1c543e3a84 | ||
|
|
345cf86544 | ||
|
|
e4078910c4 | ||
|
|
5810a4993b | ||
|
|
8d89518271 | ||
|
|
d9849435a8 | ||
|
|
e67b2c0529 | ||
|
|
5779243d3e | ||
|
|
3d1abfa35a | ||
|
|
0bf4457c2f | ||
|
|
a7c10dde4a | ||
|
|
9cecd42467 | ||
|
|
800eda2f6d | ||
|
|
df00a018f0 | ||
|
|
66477bc392 | ||
|
|
b865fad95e | ||
|
|
e939b9a5aa | ||
|
|
a3e4f175dd | ||
|
|
4dfa0873e5 | ||
|
|
0e393111de | ||
|
|
463fbb39bd | ||
|
|
88ee5fd6cc | ||
|
|
a0707f8a32 | ||
|
|
b9373f7283 | ||
|
|
a80fd5de7a | ||
|
|
9a17e4896b | ||
|
|
7f2c25336c | ||
|
|
f677777f07 | ||
|
|
25d19244be | ||
|
|
704b572349 | ||
|
|
56560fca02 | ||
|
|
7ad9ad799e | ||
|
|
8e9b3190e4 | ||
|
|
808bd36e23 | ||
|
|
895770e26a | ||
|
|
2891a421f1 | ||
|
|
4e57b75fa1 | ||
|
|
20817df4de | ||
|
|
b17aed2d37 | ||
|
|
d93bd4bec1 | ||
|
|
dd7f622a67 | ||
|
|
923fad7382 | ||
|
|
52c889f1d8 | ||
|
|
70d270bd69 | ||
|
|
3b172630e3 | ||
|
|
5aa30c843c | ||
|
|
76fdce4328 | ||
|
|
7f960d8802 | ||
|
|
04df2129b0 | ||
|
|
613c5b4667 | ||
|
|
0784ae02b1 | ||
|
|
f4e3a3980e | ||
|
|
06b1f71d39 | ||
|
|
2e0276bc60 | ||
|
|
c186e53cef | ||
|
|
5f6dc757dc | ||
|
|
627959e30b | ||
|
|
64502c355e | ||
|
|
56b4b0cf43 | ||
|
|
8dbf64a0d0 | ||
|
|
e96944a4fa | ||
|
|
68a195952a | ||
|
|
e1d1ce7edc | ||
|
|
045212a070 | ||
|
|
886c74f205 | ||
|
|
91c5a68ccc | ||
|
|
8901ff8084 | ||
|
|
45a13fd807 | ||
|
|
83a000d7c9 | ||
|
|
42d342cd0d | ||
|
|
c8a6caa4e3 | ||
|
|
6147bd23ec | ||
|
|
6867d65733 | ||
|
|
fea92354a8 | ||
|
|
a7efff858b | ||
|
|
267530f217 | ||
|
|
6b66ca1531 | ||
|
|
73b8bd682c | ||
|
|
c90cf6ee1b | ||
|
|
6a4170f4cc | ||
|
|
10e1bbcb66 | ||
|
|
f6d922e1b7 | ||
|
|
1a394d0a73 | ||
|
|
395b4379a4 | ||
|
|
5574fa7263 | ||
|
|
5e28183a24 | ||
|
|
b0db8d8f5a | ||
|
|
f43d8ff0c3 | ||
|
|
829e220096 | ||
|
|
b5703e0dfc | ||
|
|
c7322fd071 | ||
|
|
a6c84ceb61 |
@@ -5,10 +5,9 @@ on: [push, pull_request]
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ 'false' == 'true' }} # skip mypy for now
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: jpetrucciani/mypy-check@master
|
||||
with:
|
||||
mypy_flags: '--install-types --non-interactive'
|
||||
path: lnbits
|
||||
path: 'lnbits'
|
||||
|
||||
@@ -16,7 +16,6 @@ jobs:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Setup Regtest
|
||||
run: |
|
||||
docker build -t lnbits-legend .
|
||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||
cd docker
|
||||
chmod +x ./tests
|
||||
@@ -39,8 +38,8 @@ jobs:
|
||||
LNBITS_DATA_FOLDER: ./data
|
||||
LNBITS_BACKEND_WALLET_CLASS: LndRestWallet
|
||||
LND_REST_ENDPOINT: https://localhost:8081/
|
||||
LND_REST_CERT: docker/data/lnd-1/tls.cert
|
||||
LND_REST_MACAROON: docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
|
||||
LND_REST_CERT: ./docker/data/lnd-1/tls.cert
|
||||
LND_REST_MACAROON: ./docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
|
||||
run: |
|
||||
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
|
||||
make test-real-wallet
|
||||
@@ -57,7 +56,6 @@ jobs:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Setup Regtest
|
||||
run: |
|
||||
docker build -t lnbits-legend .
|
||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||
cd docker
|
||||
chmod +x ./tests
|
||||
@@ -79,7 +77,7 @@ jobs:
|
||||
PORT: 5123
|
||||
LNBITS_DATA_FOLDER: ./data
|
||||
LNBITS_BACKEND_WALLET_CLASS: CLightningWallet
|
||||
CLIGHTNING_RPC: docker/data/clightning-1/regtest/lightning-rpc
|
||||
CLIGHTNING_RPC: ./docker/data/clightning-1/regtest/lightning-rpc
|
||||
run: |
|
||||
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
|
||||
make test-real-wallet
|
||||
|
||||
@@ -68,11 +68,11 @@ jobs:
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
pipenv-sqlite:
|
||||
poetry-sqlite:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.7, 3.8, 3.9]
|
||||
python-version: [3.9]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
@@ -80,9 +80,56 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
env:
|
||||
VIRTUAL_ENV: ./venv
|
||||
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
||||
run: |
|
||||
pip install pipenv
|
||||
pipenv install --dev
|
||||
pipenv install importlib-metadata
|
||||
python -m venv ${{ env.VIRTUAL_ENV }}
|
||||
./venv/bin/python -m pip install --upgrade pip
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
|
||||
- name: Run tests
|
||||
run: make test-pipenv
|
||||
run: make test
|
||||
poetry-postgres:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
# maps tcp port 5432 on service container to the host
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.9]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
env:
|
||||
VIRTUAL_ENV: ./venv
|
||||
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
||||
run: |
|
||||
python -m venv ${{ env.VIRTUAL_ENV }}
|
||||
./venv/bin/python -m pip install --upgrade pip
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
|
||||
- name: Run tests
|
||||
env:
|
||||
LNBITS_DATABASE_URL: postgres://postgres:postgres@0.0.0.0:5432/postgres
|
||||
run: make test
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
@@ -1,44 +0,0 @@
|
||||
[[source]]
|
||||
name = "pypi"
|
||||
url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
|
||||
[requires]
|
||||
python_version = "3.8"
|
||||
|
||||
[packages]
|
||||
bitstring = "*"
|
||||
cerberus = "*"
|
||||
ecdsa = "*"
|
||||
environs = "*"
|
||||
lnurl = "==0.3.6"
|
||||
loguru = "*"
|
||||
pyscss = "*"
|
||||
shortuuid = "*"
|
||||
typing-extensions = "*"
|
||||
httpx = "*"
|
||||
sqlalchemy-aio = "*"
|
||||
embit = "*"
|
||||
pyqrcode = "*"
|
||||
pypng = "*"
|
||||
sqlalchemy = "==1.3.23"
|
||||
psycopg2-binary = "*"
|
||||
aiofiles = "*"
|
||||
asyncio = "*"
|
||||
fastapi = "*"
|
||||
uvicorn = {extras = ["standard"], version = "*"}
|
||||
sse-starlette = "*"
|
||||
jinja2 = "==3.0.1"
|
||||
pyngrok = "*"
|
||||
secp256k1 = "==0.14.0"
|
||||
cffi = "==1.15.0"
|
||||
pycryptodomex = "*"
|
||||
|
||||
[dev-packages]
|
||||
black = "==20.8b1"
|
||||
pytest = "*"
|
||||
pytest-cov = "*"
|
||||
mypy = "*"
|
||||
pytest-asyncio = "*"
|
||||
requests = "*"
|
||||
mock = "*"
|
||||
Generated
-1157
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ LNbits
|
||||
|
||||

|
||||
|
||||
# LNbits v0.3 BETA, free and open-source lightning-network wallet/accounts system
|
||||
# LNbits v0.9 BETA, free and open-source lightning-network wallet/accounts system
|
||||
|
||||
(Join us on [https://t.me/lnbits](https://t.me/lnbits))
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ nav_order: 1
|
||||
# Installation
|
||||
|
||||
This guide has been moved to the [installation guide](../guide/installation.md).
|
||||
To install the developer packages, use `pipenv install --dev`.
|
||||
To install the developer packages for running tests etc before pr'ing, use `./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock black mypy isort`.
|
||||
|
||||
## Notes:
|
||||
|
||||
|
||||
+30
-58
@@ -8,7 +8,7 @@ nav_order: 2
|
||||
|
||||
# Basic installation
|
||||
|
||||
You can choose between four package managers, `poetry`, `pipenv`, `venv` and `nix`.
|
||||
You can choose between four package managers, `poetry`, `nix` and `venv`.
|
||||
|
||||
By default, LNbits will use SQLite as its database. You can also use PostgreSQL which is recommended for applications with a high load (see guide below).
|
||||
|
||||
@@ -33,64 +33,13 @@ poetry run lnbits
|
||||
# To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0'
|
||||
```
|
||||
|
||||
## Option 2: pipenv
|
||||
## Option 2: Nix
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits-legend/
|
||||
|
||||
sudo apt update && sudo apt install -y pipenv
|
||||
pipenv install --dev
|
||||
# pipenv --python 3.9 install --dev (if you wish to use a version of Python higher than 3.7)
|
||||
pipenv shell
|
||||
# pipenv --python 3.9 shell (if you wish to use a version of Python higher than 3.7)
|
||||
|
||||
# If any of the modules fails to install, try checking and upgrading your setupTool module
|
||||
# pip install -U setuptools wheel
|
||||
|
||||
# install libffi/libpq in case "pipenv install" fails
|
||||
# sudo apt-get install -y libffi-dev libpq-dev
|
||||
|
||||
mkdir data && cp .env.example .env
|
||||
```
|
||||
|
||||
#### Running the server
|
||||
|
||||
```sh
|
||||
pipenv run python -m uvicorn lnbits.__main__:app --port 5000 --host 0.0.0.0
|
||||
```
|
||||
|
||||
Add the flag `--reload` for development (includes hot-reload).
|
||||
|
||||
|
||||
## Option 3: venv
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits-legend/
|
||||
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3-venv'
|
||||
python3 -m venv venv
|
||||
# If you have problems here, try `sudo apt install -y pkg-config libpq-dev`
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
# create the data folder and the .env file
|
||||
mkdir data && cp .env.example .env
|
||||
```
|
||||
|
||||
#### Running the server
|
||||
|
||||
```sh
|
||||
./venv/bin/uvicorn lnbits.__main__:app --port 5000
|
||||
```
|
||||
|
||||
If you want to host LNbits on the internet, run with the option `--host 0.0.0.0`.
|
||||
|
||||
## Option 4: Nix
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits-legend/
|
||||
# Install nix, modern debian distros usually already include
|
||||
sh <(curl -L https://nixos.org/nix/install) --daemon
|
||||
# Modern debian distros usually include Nix, however you can install with:
|
||||
# 'sh <(curl -L https://nixos.org/nix/install) --daemon', or use setup here https://nixos.org/download.html#nix-verify-installation
|
||||
|
||||
nix build .#lnbits
|
||||
mkdir data
|
||||
@@ -104,6 +53,29 @@ mkdir data
|
||||
LNBITS_DATA_FOLDER=data LNBITS_BACKEND_WALLET_CLASS=LNbitsWallet LNBITS_ENDPOINT=https://legend.lnbits.com LNBITS_KEY=7b1a78d6c78f48b09a202f2dcb2d22eb ./result/bin/lnbits --port 9000
|
||||
```
|
||||
|
||||
## Option 3: venv
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits-legend/
|
||||
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3-venv'
|
||||
python3 -m venv venv
|
||||
# If you have problems here, try `sudo apt install -y pkg-config libpq-dev`
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
# create the data folder and the .env file
|
||||
mkdir data && cp .env.example .env
|
||||
# build the static files
|
||||
./venv/bin/python build.py
|
||||
```
|
||||
|
||||
#### Running the server
|
||||
|
||||
```sh
|
||||
./venv/bin/uvicorn lnbits.__main__:app --port 5000
|
||||
```
|
||||
|
||||
If you want to host LNbits on the internet, run with the option `--host 0.0.0.0`.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
Problems installing? These commands have helped us install LNbits.
|
||||
@@ -112,10 +84,10 @@ Problems installing? These commands have helped us install LNbits.
|
||||
sudo apt install pkg-config libffi-dev libpq-dev
|
||||
|
||||
# if the secp256k1 build fails:
|
||||
# if you used pipenv (option 1)
|
||||
pipenv install setuptools wheel
|
||||
# if you used venv (option 2)
|
||||
# if you used venv
|
||||
./venv/bin/pip install setuptools wheel
|
||||
# if you used poetry
|
||||
poetry add setuptools wheel
|
||||
# build essentials for debian/ubuntu
|
||||
sudo apt install python3-dev gcc build-essential
|
||||
```
|
||||
|
||||
@@ -17,7 +17,6 @@ from loguru import logger
|
||||
import lnbits.settings
|
||||
from lnbits.core.tasks import register_task_listeners
|
||||
|
||||
from .commands import db_migrate, handle_assets
|
||||
from .core import core_app
|
||||
from .core.views.generic import core_html_routes
|
||||
from .helpers import (
|
||||
@@ -93,7 +92,6 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||
check_funding_source(app)
|
||||
register_assets(app)
|
||||
register_routes(app)
|
||||
# register_commands(app)
|
||||
register_async_tasks(app)
|
||||
register_exception_handlers(app)
|
||||
|
||||
@@ -146,12 +144,6 @@ def register_routes(app: FastAPI) -> None:
|
||||
)
|
||||
|
||||
|
||||
def register_commands(app: FastAPI):
|
||||
"""Register Click commands."""
|
||||
app.cli.add_command(db_migrate)
|
||||
app.cli.add_command(handle_assets)
|
||||
|
||||
|
||||
def register_assets(app: FastAPI):
|
||||
"""Serve each vendored asset separately or a bundle."""
|
||||
|
||||
|
||||
+2
-2
@@ -61,7 +61,7 @@ def decode(pr: str) -> Invoice:
|
||||
invoice = Invoice()
|
||||
|
||||
# decode the amount from the hrp
|
||||
m = re.search("[^\d]+", hrp[2:])
|
||||
m = re.search(r"[^\d]+", hrp[2:])
|
||||
if m:
|
||||
amountstr = hrp[2 + m.end() :]
|
||||
if amountstr != "":
|
||||
@@ -296,7 +296,7 @@ def _unshorten_amount(amount: str) -> int:
|
||||
# BOLT #11:
|
||||
# A reader SHOULD fail if `amount` contains a non-digit, or is followed by
|
||||
# anything except a `multiplier` in the table above.
|
||||
if not re.fullmatch("\d+[pnum]?", str(amount)):
|
||||
if not re.fullmatch(r"\d+[pnum]?", str(amount)):
|
||||
raise ValueError("Invalid amount '{}'".format(amount))
|
||||
|
||||
if unit in units:
|
||||
|
||||
+1
-1
@@ -113,7 +113,7 @@ async def create_wallet(
|
||||
async def update_wallet(
|
||||
wallet_id: str, new_name: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Wallet]:
|
||||
await (conn or db).execute(
|
||||
return await (conn or db).execute(
|
||||
"""
|
||||
UPDATE wallets SET
|
||||
name = ?
|
||||
|
||||
@@ -106,6 +106,8 @@ class Payment(BaseModel):
|
||||
|
||||
@property
|
||||
def tag(self) -> Optional[str]:
|
||||
if self.extra is None:
|
||||
return ""
|
||||
return self.extra.get("tag")
|
||||
|
||||
@property
|
||||
|
||||
+10
-12
@@ -109,18 +109,15 @@ async def pay_invoice(
|
||||
raise ValueError("Amount in invoice is too high.")
|
||||
|
||||
# put all parameters that don't change here
|
||||
PaymentKwargs = TypedDict(
|
||||
"PaymentKwargs",
|
||||
{
|
||||
"wallet_id": str,
|
||||
"payment_request": str,
|
||||
"payment_hash": str,
|
||||
"amount": int,
|
||||
"memo": str,
|
||||
"extra": Optional[Dict],
|
||||
},
|
||||
)
|
||||
payment_kwargs: PaymentKwargs = dict(
|
||||
class PaymentKwargs(TypedDict):
|
||||
wallet_id: str
|
||||
payment_request: str
|
||||
payment_hash: str
|
||||
amount: int
|
||||
memo: str
|
||||
extra: Optional[Dict]
|
||||
|
||||
payment_kwargs: PaymentKwargs = PaymentKwargs(
|
||||
wallet_id=wallet_id,
|
||||
payment_request=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
@@ -272,6 +269,7 @@ async def perform_lnurlauth(
|
||||
cb = urlparse(callback)
|
||||
|
||||
k1 = unhexlify(parse_qs(cb.query)["k1"][0])
|
||||
|
||||
key = wallet.wallet.lnurlauth_key(cb.netloc)
|
||||
|
||||
def int_to_bytes_suitable_der(x: int) -> bytes:
|
||||
|
||||
@@ -55,7 +55,7 @@ async def dispatch_webhook(payment: Payment):
|
||||
data = payment.dict()
|
||||
try:
|
||||
logger.debug("sending webhook", payment.webhook)
|
||||
r = await client.post(payment.webhook, json=data, timeout=40)
|
||||
r = await client.post(payment.webhook, json=data, timeout=40) # type: ignore
|
||||
await mark_webhook_sent(payment, r.status_code)
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
await mark_webhook_sent(payment, -1)
|
||||
|
||||
+40
-22
@@ -3,10 +3,12 @@ import hashlib
|
||||
import json
|
||||
from binascii import unhexlify
|
||||
from http import HTTPStatus
|
||||
from typing import Dict, List, Optional, Union
|
||||
from io import BytesIO
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
import httpx
|
||||
import pyqrcode
|
||||
from fastapi import Depends, Header, Query, Request
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.params import Body
|
||||
@@ -14,6 +16,7 @@ from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from pydantic.fields import Field
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
from starlette.responses import HTMLResponse, StreamingResponse
|
||||
|
||||
from lnbits import bolt11, lnurl
|
||||
from lnbits.core.models import Payment, Wallet
|
||||
@@ -185,7 +188,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
assert (
|
||||
data.lnurl_balance_check is not None
|
||||
), "lnurl_balance_check is required"
|
||||
save_balance_check(wallet.id, data.lnurl_balance_check)
|
||||
await save_balance_check(wallet.id, data.lnurl_balance_check)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
@@ -248,7 +251,7 @@ async def api_payments_pay_invoice(bolt11: str, wallet: Wallet):
|
||||
)
|
||||
async def api_payments_create(
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
invoiceData: CreateInvoiceData = Body(...),
|
||||
invoiceData: CreateInvoiceData = Body(...), # type: ignore
|
||||
):
|
||||
if invoiceData.out is True and wallet.wallet_type == 0:
|
||||
if not invoiceData.bolt11:
|
||||
@@ -291,7 +294,7 @@ async def api_payments_pay_lnurl(
|
||||
timeout=40,
|
||||
)
|
||||
if r.is_error:
|
||||
raise httpx.ConnectError
|
||||
raise httpx.ConnectError("LNURL callback connection error")
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
@@ -354,7 +357,7 @@ async def subscribe(request: Request, wallet: Wallet):
|
||||
logger.debug("adding sse listener", payment_queue)
|
||||
api_invoice_listeners.append(payment_queue)
|
||||
|
||||
send_queue: asyncio.Queue[tuple[str, Payment]] = asyncio.Queue(0)
|
||||
send_queue: asyncio.Queue[Tuple[str, Payment]] = asyncio.Queue(0)
|
||||
|
||||
async def payment_received() -> None:
|
||||
while True:
|
||||
@@ -393,16 +396,13 @@ async def api_payments_sse(
|
||||
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
||||
# We use X_Api_Key here because we want this call to work with and without keys
|
||||
# If a valid key is given, we also return the field "details", otherwise not
|
||||
wallet = None
|
||||
try:
|
||||
if X_Api_Key.extra:
|
||||
logger.warning("No key")
|
||||
except:
|
||||
wallet = await get_wallet_for_key(X_Api_Key)
|
||||
wallet = await get_wallet_for_key(X_Api_Key) if type(X_Api_Key) == str else None
|
||||
|
||||
# we have to specify the wallet id here, because postgres and sqlite return internal payments in different order
|
||||
# and get_standalone_payment otherwise just fetches the first one, causing unpredictable results
|
||||
payment = await get_standalone_payment(
|
||||
payment_hash, wallet_id=wallet.id if wallet else None
|
||||
) # we have to specify the wallet id here, because postgres and sqlite return internal payments in different order
|
||||
# and get_standalone_payment otherwise just fetches the first one, causing unpredictable results
|
||||
)
|
||||
if payment is None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||
@@ -488,7 +488,8 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
||||
)
|
||||
|
||||
try:
|
||||
tag = data["tag"]
|
||||
tag: str = data.get("tag")
|
||||
params.update(**data)
|
||||
if tag == "channelRequest":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
@@ -498,10 +499,7 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
||||
"message": "unsupported",
|
||||
},
|
||||
)
|
||||
|
||||
params.update(**data)
|
||||
|
||||
if tag == "withdrawRequest":
|
||||
elif tag == "withdrawRequest":
|
||||
params.update(kind="withdraw")
|
||||
params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"])
|
||||
|
||||
@@ -519,8 +517,7 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
||||
query=urlencode(qs, doseq=True)
|
||||
)
|
||||
params.update(callback=urlunparse(parsed_callback))
|
||||
|
||||
if tag == "payRequest":
|
||||
elif tag == "payRequest":
|
||||
params.update(kind="pay")
|
||||
params.update(fixed=data["minSendable"] == data["maxSendable"])
|
||||
|
||||
@@ -538,8 +535,8 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
||||
params.update(image=data_uri)
|
||||
if k == "text/email" or k == "text/identifier":
|
||||
params.update(targetUser=v)
|
||||
|
||||
params.update(commentAllowed=data.get("commentAllowed", 0))
|
||||
|
||||
except KeyError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
@@ -612,8 +609,8 @@ class ConversionData(BaseModel):
|
||||
async def api_fiat_as_sats(data: ConversionData):
|
||||
output = {}
|
||||
if data.from_ == "sat":
|
||||
output["sats"] = int(data.amount)
|
||||
output["BTC"] = data.amount / 100000000
|
||||
output["sats"] = int(data.amount)
|
||||
for currency in data.to.split(","):
|
||||
output[currency.strip().upper()] = await satoshis_amount_as_fiat(
|
||||
data.amount, currency.strip()
|
||||
@@ -624,3 +621,24 @@ async def api_fiat_as_sats(data: ConversionData):
|
||||
output["sats"] = await fiat_amount_as_satoshis(data.amount, data.from_)
|
||||
output["BTC"] = output["sats"] / 100000000
|
||||
return output
|
||||
|
||||
|
||||
@core_app.get("/api/v1/qrcode/{data}", response_class=StreamingResponse)
|
||||
async def img(request: Request, data):
|
||||
qr = pyqrcode.create(data)
|
||||
stream = BytesIO()
|
||||
qr.svg(stream, scale=3)
|
||||
stream.seek(0)
|
||||
|
||||
async def _generator(stream: BytesIO):
|
||||
yield stream.getvalue()
|
||||
|
||||
return StreamingResponse(
|
||||
_generator(stream),
|
||||
headers={
|
||||
"Content-Type": "image/svg+xml",
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -55,9 +55,9 @@ async def home(request: Request, lightning: str = None):
|
||||
)
|
||||
async def extensions(
|
||||
request: Request,
|
||||
user: User = Depends(check_user_exists),
|
||||
enable: str = Query(None),
|
||||
disable: str = Query(None),
|
||||
user: User = Depends(check_user_exists), # type: ignore
|
||||
enable: str = Query(None), # type: ignore
|
||||
disable: str = Query(None), # type: ignore
|
||||
):
|
||||
extension_to_enable = enable
|
||||
extension_to_disable = disable
|
||||
@@ -88,7 +88,7 @@ async def extensions(
|
||||
|
||||
# Update user as his extensions have been updated
|
||||
if extension_to_enable or extension_to_disable:
|
||||
user = await get_user(user.id)
|
||||
user = await get_user(user.id) # type: ignore
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"core/extensions.html", {"request": request, "user": user.dict()}
|
||||
@@ -109,10 +109,10 @@ nothing: create everything<br>
|
||||
""",
|
||||
)
|
||||
async def wallet(
|
||||
request: Request = Query(None),
|
||||
nme: Optional[str] = Query(None),
|
||||
usr: Optional[UUID4] = Query(None),
|
||||
wal: Optional[UUID4] = Query(None),
|
||||
request: Request = Query(None), # type: ignore
|
||||
nme: Optional[str] = Query(None), # type: ignore
|
||||
usr: Optional[UUID4] = Query(None), # type: ignore
|
||||
wal: Optional[UUID4] = Query(None), # type: ignore
|
||||
):
|
||||
user_id = usr.hex if usr else None
|
||||
wallet_id = wal.hex if wal else None
|
||||
@@ -121,7 +121,7 @@ async def wallet(
|
||||
|
||||
if not user_id:
|
||||
user = await get_user((await create_account()).id)
|
||||
logger.info(f"Create user {user.id}")
|
||||
logger.info(f"Create user {user.id}") # type: ignore
|
||||
else:
|
||||
user = await get_user(user_id)
|
||||
if not user:
|
||||
@@ -135,22 +135,22 @@ async def wallet(
|
||||
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]
|
||||
if user.wallets and not wallet_name: # type: ignore
|
||||
wallet = user.wallets[0] # type: ignore
|
||||
else:
|
||||
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name)
|
||||
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name) # type: ignore
|
||||
logger.info(
|
||||
f"Created new wallet {wallet_name if wallet_name else '(no name)'} for user {user.id}"
|
||||
f"Created new wallet {wallet_name if wallet_name else '(no name)'} for user {user.id}" # type: ignore
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
f"/wallet?usr={user.id}&wal={wallet.id}",
|
||||
f"/wallet?usr={user.id}&wal={wallet.id}", # type: ignore
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
)
|
||||
|
||||
logger.debug(f"Access wallet {wallet_name}{'of user '+ user.id if user else ''}")
|
||||
wallet = user.get_wallet(wallet_id)
|
||||
if not wallet:
|
||||
userwallet = user.get_wallet(wallet_id) # type: ignore
|
||||
if not userwallet:
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "Wallet not found"}
|
||||
)
|
||||
@@ -159,10 +159,10 @@ async def wallet(
|
||||
"core/wallet.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(),
|
||||
"wallet": wallet.dict(),
|
||||
"user": user.dict(), # type: ignore
|
||||
"wallet": userwallet.dict(),
|
||||
"service_fee": service_fee,
|
||||
"web_manifest": f"/manifest/{user.id}.webmanifest",
|
||||
"web_manifest": f"/manifest/{user.id}.webmanifest", # type: ignore
|
||||
},
|
||||
)
|
||||
|
||||
@@ -216,20 +216,20 @@ async def lnurl_full_withdraw_callback(request: Request):
|
||||
|
||||
|
||||
@core_html_routes.get("/deletewallet", response_class=RedirectResponse)
|
||||
async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query(...)):
|
||||
async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query(...)): # type: ignore
|
||||
user = await get_user(usr)
|
||||
user_wallet_ids = [u.id for u in user.wallets]
|
||||
user_wallet_ids = [u.id for u in user.wallets] # type: ignore
|
||||
|
||||
if wal not in user_wallet_ids:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
||||
else:
|
||||
await delete_wallet(user_id=user.id, wallet_id=wal)
|
||||
await delete_wallet(user_id=user.id, wallet_id=wal) # type: ignore
|
||||
user_wallet_ids.remove(wal)
|
||||
logger.debug("Deleted wallet {wal} of user {user.id}")
|
||||
|
||||
if user_wallet_ids:
|
||||
return RedirectResponse(
|
||||
url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]),
|
||||
url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]), # type: ignore
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
)
|
||||
|
||||
@@ -242,7 +242,7 @@ async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query
|
||||
async def lnurl_balance_notify(request: Request, service: str):
|
||||
bc = await get_balance_check(request.query_params.get("wal"), service)
|
||||
if bc:
|
||||
redeem_lnurl_withdraw(bc.wallet, bc.url)
|
||||
await redeem_lnurl_withdraw(bc.wallet, bc.url)
|
||||
|
||||
|
||||
@core_html_routes.get(
|
||||
@@ -252,7 +252,7 @@ async def lnurlwallet(request: Request):
|
||||
async with db.connect() as conn:
|
||||
account = await create_account(conn=conn)
|
||||
user = await get_user(account.id, conn=conn)
|
||||
wallet = await create_wallet(user_id=user.id, conn=conn)
|
||||
wallet = await create_wallet(user_id=user.id, conn=conn) # type: ignore
|
||||
|
||||
asyncio.create_task(
|
||||
redeem_lnurl_withdraw(
|
||||
@@ -265,7 +265,7 @@ async def lnurlwallet(request: Request):
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
f"/wallet?usr={user.id}&wal={wallet.id}",
|
||||
f"/wallet?usr={user.id}&wal={wallet.id}", # type: ignore
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
)
|
||||
|
||||
|
||||
+21
-18
@@ -1,4 +1,5 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Union
|
||||
|
||||
from cerberus import Validator # type: ignore
|
||||
from fastapi import status
|
||||
@@ -29,20 +30,21 @@ class KeyChecker(SecurityBase):
|
||||
self._key_type = "invoice"
|
||||
self._api_key = api_key
|
||||
if api_key:
|
||||
self.model: APIKey = APIKey(
|
||||
key = APIKey(
|
||||
**{"in": APIKeyIn.query},
|
||||
name="X-API-KEY",
|
||||
description="Wallet API Key - QUERY",
|
||||
)
|
||||
else:
|
||||
self.model: APIKey = APIKey(
|
||||
key = APIKey(
|
||||
**{"in": APIKeyIn.header},
|
||||
name="X-API-KEY",
|
||||
description="Wallet API Key - HEADER",
|
||||
)
|
||||
self.wallet = None
|
||||
self.wallet = None # type: ignore
|
||||
self.model: APIKey = key
|
||||
|
||||
async def __call__(self, request: Request) -> Wallet:
|
||||
async def __call__(self, request: Request):
|
||||
try:
|
||||
key_value = (
|
||||
self._api_key
|
||||
@@ -52,7 +54,7 @@ class KeyChecker(SecurityBase):
|
||||
# FIXME: Find another way to validate the key. A fetch from DB should be avoided here.
|
||||
# Also, we should not return the wallet here - thats silly.
|
||||
# Possibly store it in a Redis DB
|
||||
self.wallet = await get_wallet_for_key(key_value, self._key_type)
|
||||
self.wallet = await get_wallet_for_key(key_value, self._key_type) # type: ignore
|
||||
if not self.wallet:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
@@ -120,8 +122,8 @@ api_key_query = APIKeyQuery(
|
||||
|
||||
async def get_key_type(
|
||||
r: Request,
|
||||
api_key_header: str = Security(api_key_header),
|
||||
api_key_query: str = Security(api_key_query),
|
||||
api_key_header: str = Security(api_key_header), # type: ignore
|
||||
api_key_query: str = Security(api_key_query), # type: ignore
|
||||
) -> WalletTypeInfo:
|
||||
# 0: admin
|
||||
# 1: invoice
|
||||
@@ -134,9 +136,9 @@ async def get_key_type(
|
||||
token = api_key_header if api_key_header else api_key_query
|
||||
|
||||
try:
|
||||
checker = WalletAdminKeyChecker(api_key=token)
|
||||
await checker.__call__(r)
|
||||
wallet = WalletTypeInfo(0, checker.wallet)
|
||||
admin_checker = WalletAdminKeyChecker(api_key=token)
|
||||
await admin_checker.__call__(r)
|
||||
wallet = WalletTypeInfo(0, admin_checker.wallet) # type: ignore
|
||||
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (
|
||||
LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS
|
||||
):
|
||||
@@ -153,9 +155,9 @@ async def get_key_type(
|
||||
raise
|
||||
|
||||
try:
|
||||
checker = WalletInvoiceKeyChecker(api_key=token)
|
||||
await checker.__call__(r)
|
||||
wallet = WalletTypeInfo(1, checker.wallet)
|
||||
invoice_checker = WalletInvoiceKeyChecker(api_key=token)
|
||||
await invoice_checker.__call__(r)
|
||||
wallet = WalletTypeInfo(1, invoice_checker.wallet) # type: ignore
|
||||
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (
|
||||
LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS
|
||||
):
|
||||
@@ -167,15 +169,16 @@ async def get_key_type(
|
||||
if e.status_code == HTTPStatus.BAD_REQUEST:
|
||||
raise
|
||||
if e.status_code == HTTPStatus.UNAUTHORIZED:
|
||||
return WalletTypeInfo(2, None)
|
||||
return WalletTypeInfo(2, None) # type: ignore
|
||||
except:
|
||||
raise
|
||||
return wallet
|
||||
|
||||
|
||||
async def require_admin_key(
|
||||
r: Request,
|
||||
api_key_header: str = Security(api_key_header),
|
||||
api_key_query: str = Security(api_key_query),
|
||||
api_key_header: str = Security(api_key_header), # type: ignore
|
||||
api_key_query: str = Security(api_key_query), # type: ignore
|
||||
):
|
||||
token = api_key_header if api_key_header else api_key_query
|
||||
|
||||
@@ -193,8 +196,8 @@ async def require_admin_key(
|
||||
|
||||
async def require_invoice_key(
|
||||
r: Request,
|
||||
api_key_header: str = Security(api_key_header),
|
||||
api_key_query: str = Security(api_key_query),
|
||||
api_key_header: str = Security(api_key_header), # type: ignore
|
||||
api_key_query: str = Security(api_key_query), # type: ignore
|
||||
):
|
||||
token = api_key_header if api_key_header else api_key_query
|
||||
|
||||
|
||||
@@ -133,7 +133,8 @@ async def api_ticket_send_ticket(event_id, payment_hash, data: CreateTicket):
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail=f"Event could not be fetched."
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail=f"Event could not be fetched.",
|
||||
)
|
||||
|
||||
return {"paid": True, "ticket_id": ticket.id}
|
||||
|
||||
@@ -4,12 +4,7 @@
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-btn
|
||||
flat
|
||||
label="Swagger API"
|
||||
type="a"
|
||||
href="../docs#/lnurlpayout"
|
||||
></q-btn>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/lnurlpayout"></q-btn>
|
||||
<q-expansion-item group="api" dense expand-separator label="List lnurlpayout">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
@@ -38,7 +33,6 @@
|
||||
expand-separator
|
||||
label="Create a lnurlpayout"
|
||||
>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
|
||||
@@ -52,14 +52,20 @@ async def set_method(shop: int, method: str, wordlist: str = "") -> Optional[Sho
|
||||
|
||||
|
||||
async def add_item(
|
||||
shop: int, name: str, description: str, image: Optional[str], price: int, unit: str
|
||||
shop: int,
|
||||
name: str,
|
||||
description: str,
|
||||
image: Optional[str],
|
||||
price: int,
|
||||
unit: str,
|
||||
fiat_base_multiplier: int,
|
||||
) -> int:
|
||||
result = await db.execute(
|
||||
"""
|
||||
INSERT INTO offlineshop.items (shop, name, description, image, price, unit)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO offlineshop.items (shop, name, description, image, price, unit, fiat_base_multiplier)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(shop, name, description, image, price, unit),
|
||||
(shop, name, description, image, price, unit, fiat_base_multiplier),
|
||||
)
|
||||
return result._result_proxy.lastrowid
|
||||
|
||||
@@ -72,6 +78,7 @@ async def update_item(
|
||||
image: Optional[str],
|
||||
price: int,
|
||||
unit: str,
|
||||
fiat_base_multiplier: int,
|
||||
) -> int:
|
||||
await db.execute(
|
||||
"""
|
||||
@@ -80,10 +87,11 @@ async def update_item(
|
||||
description = ?,
|
||||
image = ?,
|
||||
price = ?,
|
||||
unit = ?
|
||||
unit = ?,
|
||||
fiat_base_multiplier = ?
|
||||
WHERE shop = ? AND id = ?
|
||||
""",
|
||||
(name, description, image, price, unit, shop, item_id),
|
||||
(name, description, image, price, unit, fiat_base_multiplier, shop, item_id),
|
||||
)
|
||||
return item_id
|
||||
|
||||
@@ -92,12 +100,12 @@ async def get_item(id: int) -> Optional[Item]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM offlineshop.items WHERE id = ? LIMIT 1", (id,)
|
||||
)
|
||||
return Item(**dict(row)) if row else None
|
||||
return Item.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_items(shop: int) -> List[Item]:
|
||||
rows = await db.fetchall("SELECT * FROM offlineshop.items WHERE shop = ?", (shop,))
|
||||
return [Item(**dict(row)) for row in rows]
|
||||
return [Item.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def delete_item_from_shop(shop: int, item_id: int):
|
||||
|
||||
@@ -27,3 +27,13 @@ async def m001_initial(db):
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m002_fiat_base_multiplier(db):
|
||||
"""
|
||||
Store the multiplier for fiat prices. We store the price in cents and
|
||||
remember to multiply by 100 when we use it to convert to Dollars.
|
||||
"""
|
||||
await db.execute(
|
||||
"ALTER TABLE offlineshop.items ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;"
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import base64
|
||||
import hashlib
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from sqlite3 import Row
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from lnurl import encode as lnurl_encode # type: ignore
|
||||
@@ -87,8 +88,16 @@ class Item(BaseModel):
|
||||
description: str
|
||||
image: Optional[str]
|
||||
enabled: bool
|
||||
price: int
|
||||
price: float
|
||||
unit: str
|
||||
fiat_base_multiplier: int
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Item":
|
||||
data = dict(row)
|
||||
if data["unit"] != "sat" and data["fiat_base_multiplier"]:
|
||||
data["price"] /= data["fiat_base_multiplier"]
|
||||
return cls(**data)
|
||||
|
||||
def lnurl(self, req: Request) -> str:
|
||||
return lnurl_encode(req.url_for("offlineshop.lnurl_response", item_id=self.id))
|
||||
|
||||
@@ -124,7 +124,8 @@ new Vue({
|
||||
description,
|
||||
image,
|
||||
price,
|
||||
unit
|
||||
unit,
|
||||
fiat_base_multiplier: unit == 'sat' ? 1 : 100
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Query
|
||||
from fastapi.params import Depends
|
||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
|
||||
from pydantic.main import BaseModel
|
||||
@@ -34,7 +35,6 @@ async def api_shop_from_wallet(
|
||||
):
|
||||
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
|
||||
items = await get_items(shop.id)
|
||||
|
||||
try:
|
||||
return {
|
||||
**shop.dict(),
|
||||
@@ -51,8 +51,9 @@ class CreateItemsData(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
image: Optional[str]
|
||||
price: int
|
||||
price: float
|
||||
unit: str
|
||||
fiat_base_multiplier: int = Query(100, ge=1)
|
||||
|
||||
|
||||
@offlineshop_ext.post("/api/v1/offlineshop/items")
|
||||
@@ -61,9 +62,18 @@ async def api_add_or_update_item(
|
||||
data: CreateItemsData, item_id=None, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
|
||||
if data.unit != "sat":
|
||||
data.price = data.price * 100
|
||||
if item_id == None:
|
||||
|
||||
await add_item(
|
||||
shop.id, data.name, data.description, data.image, data.price, data.unit
|
||||
shop.id,
|
||||
data.name,
|
||||
data.description,
|
||||
data.image,
|
||||
data.price,
|
||||
data.unit,
|
||||
data.fiat_base_multiplier,
|
||||
)
|
||||
return HTMLResponse(status_code=HTTPStatus.CREATED)
|
||||
else:
|
||||
@@ -75,6 +85,7 @@ async def api_add_or_update_item(
|
||||
data.image,
|
||||
data.price,
|
||||
data.unit,
|
||||
data.fiat_base_multiplier,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
@@ -11,6 +12,14 @@ db = Database("ext_satspay")
|
||||
|
||||
satspay_ext: APIRouter = APIRouter(prefix="/satspay", tags=["satspay"])
|
||||
|
||||
satspay_static_files = [
|
||||
{
|
||||
"path": "/satspay/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/satspay/static"),
|
||||
"name": "satspay_static",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def satspay_renderer():
|
||||
return template_renderer(["lnbits/extensions/satspay/templates"])
|
||||
|
||||
@@ -6,7 +6,7 @@ from lnbits.core.services import create_invoice
|
||||
from lnbits.core.views.api import api_payment
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from ..watchonly.crud import get_fresh_address, get_mempool, get_watch_wallet
|
||||
from ..watchonly.crud import get_config, get_fresh_address
|
||||
|
||||
# from lnbits.db import open_ext_db
|
||||
from . import db
|
||||
@@ -18,7 +18,6 @@ from .models import Charges, CreateCharge
|
||||
async def create_charge(user: str, data: CreateCharge) -> Charges:
|
||||
charge_id = urlsafe_short_hash()
|
||||
if data.onchainwallet:
|
||||
wallet = await get_watch_wallet(data.onchainwallet)
|
||||
onchain = await get_fresh_address(data.onchainwallet)
|
||||
onchainaddress = onchain.address
|
||||
else:
|
||||
@@ -89,7 +88,8 @@ async def get_charge(charge_id: str) -> Charges:
|
||||
|
||||
async def get_charges(user: str) -> List[Charges]:
|
||||
rows = await db.fetchall(
|
||||
"""SELECT * FROM satspay.charges WHERE "user" = ?""", (user,)
|
||||
"""SELECT * FROM satspay.charges WHERE "user" = ? ORDER BY "timestamp" DESC """,
|
||||
(user,),
|
||||
)
|
||||
return [Charges.from_row(row) for row in rows]
|
||||
|
||||
@@ -102,14 +102,16 @@ async def check_address_balance(charge_id: str) -> List[Charges]:
|
||||
charge = await get_charge(charge_id)
|
||||
if not charge.paid:
|
||||
if charge.onchainaddress:
|
||||
mempool = await get_mempool(charge.user)
|
||||
config = await get_config(charge.user)
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(
|
||||
mempool.endpoint + "/api/address/" + charge.onchainaddress
|
||||
config.mempool_endpoint
|
||||
+ "/api/address/"
|
||||
+ charge.onchainaddress
|
||||
)
|
||||
respAmount = r.json()["chain_stats"]["funded_txo_sum"]
|
||||
if respAmount >= charge.balance:
|
||||
if respAmount > charge.balance:
|
||||
await update_charge(charge_id=charge_id, balance=respAmount)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from sqlite3 import Row
|
||||
from typing import Optional
|
||||
|
||||
@@ -38,12 +38,16 @@ class Charges(BaseModel):
|
||||
def from_row(cls, row: Row) -> "Charges":
|
||||
return cls(**dict(row))
|
||||
|
||||
@property
|
||||
def time_left(self):
|
||||
now = datetime.utcnow().timestamp()
|
||||
start = datetime.fromtimestamp(self.timestamp)
|
||||
expiration = (start + timedelta(minutes=self.time)).timestamp()
|
||||
return (expiration - now) / 60
|
||||
|
||||
@property
|
||||
def time_elapsed(self):
|
||||
if (self.timestamp + (self.time * 60)) >= time.time():
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
return self.time_left < 0
|
||||
|
||||
@property
|
||||
def paid(self):
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
const retryWithDelay = async function (fn, retryCount = 0) {
|
||||
try {
|
||||
await sleep(25)
|
||||
// Do not return the call directly, use result.
|
||||
// Otherwise the error will not be cought in this try-catch block.
|
||||
const result = await fn()
|
||||
return result
|
||||
} catch (err) {
|
||||
if (retryCount > 100) throw err
|
||||
await sleep((retryCount + 1) * 1000)
|
||||
return retryWithDelay(fn, retryCount + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const mapCharge = (obj, oldObj = {}) => {
|
||||
const charge = _.clone(obj)
|
||||
|
||||
charge.progress = obj.time_left < 0 ? 1 : 1 - obj.time_left / obj.time
|
||||
charge.time = minutesToTime(obj.time)
|
||||
charge.timeLeft = minutesToTime(obj.time_left)
|
||||
|
||||
charge.expanded = false
|
||||
charge.displayUrl = ['/satspay/', obj.id].join('')
|
||||
charge.expanded = oldObj.expanded
|
||||
charge.pendingBalance = oldObj.pendingBalance || 0
|
||||
return charge
|
||||
}
|
||||
|
||||
const minutesToTime = min =>
|
||||
min > 0 ? new Date(min * 1000).toISOString().substring(14, 19) : ''
|
||||
@@ -8,172 +8,10 @@
|
||||
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
|
||||
>
|
||||
</p>
|
||||
<br />
|
||||
<br />
|
||||
<a target="_blank" href="/docs#/satspay" class="text-white"
|
||||
>Swagger REST API Documentation</a
|
||||
>
|
||||
</q-card-section>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/satspay"></q-btn>
|
||||
<q-expansion-item group="api" dense expand-separator label="Create charge">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">POST</span> /satspay/api/v1/charge</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<charge_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}satspay/api/v1/charge -d
|
||||
'{"onchainwallet": <string, watchonly_wallet_id>,
|
||||
"description": <string>, "webhook":<string>, "time":
|
||||
<integer>, "amount": <integer>, "lnbitswallet":
|
||||
<string, lnbits_wallet_id>}' -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="Update charge">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">PUT</span>
|
||||
/satspay/api/v1/charge/<charge_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<charge_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url
|
||||
}}satspay/api/v1/charge/<charge_id> -d '{"onchainwallet":
|
||||
<string, watchonly_wallet_id>, "description": <string>,
|
||||
"webhook":<string>, "time": <integer>, "amount":
|
||||
<integer>, "lnbitswallet": <string, lnbits_wallet_id>}'
|
||||
-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 charge">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/satspay/api/v1/charge/<charge_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<charge_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}satspay/api/v1/charge/<charge_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 charges">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span> /satspay/api/v1/charges</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<charge_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}satspay/api/v1/charges -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="Delete a pay link"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">DELETE</span>
|
||||
/satspay/api/v1/charge/<charge_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
|
||||
<code></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X DELETE {{ request.base_url
|
||||
}}satspay/api/v1/charge/<charge_id> -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 balances"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/satspay/api/v1/charges/balance/<charge_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<charge_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}satspay/api/v1/charges/balance/<charge_id> -H "X-Api-Key: {{
|
||||
user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
||||
</q-card>
|
||||
|
||||
@@ -1,223 +1,299 @@
|
||||
{% extends "public.html" %} {% block page %}
|
||||
<div class="q-pa-sm theCard">
|
||||
<q-card class="my-card">
|
||||
<div class="column">
|
||||
<center>
|
||||
<div class="col theHeading">{{ charge.description }}</div>
|
||||
</center>
|
||||
<div class="col">
|
||||
<div
|
||||
class="col"
|
||||
color="white"
|
||||
style="background-color: grey; height: 30px; padding: 5px"
|
||||
v-if="timetoComplete < 1"
|
||||
>
|
||||
<center>Time elapsed</center>
|
||||
</div>
|
||||
<div
|
||||
class="col"
|
||||
color="white"
|
||||
style="background-color: grey; height: 30px; padding: 5px"
|
||||
v-else-if="charge_paid == 'True'"
|
||||
>
|
||||
<center>Charge paid</center>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-linear-progress size="30px" :value="newProgress" color="grey">
|
||||
<q-item-section>
|
||||
<q-item style="padding: 3px">
|
||||
<q-spinner color="white" size="0.8em"></q-spinner
|
||||
><span style="font-size: 15px; color: white"
|
||||
><span class="q-pr-xl q-pl-md"> Awaiting payment...</span>
|
||||
<span class="q-pl-xl" style="color: white">
|
||||
{% raw %} {{ newTimeLeft }} {% endraw %}</span
|
||||
></span
|
||||
>
|
||||
</q-item>
|
||||
</q-item-section>
|
||||
</q-linear-progress>
|
||||
<div class="row items-center q-mt-md">
|
||||
<div class="col-lg-4 col-md-3 col-sm-1"></div>
|
||||
<div class="col-lg-4 col-md-6 col-sm-10">
|
||||
<q-card>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col text-center q-mt-md">
|
||||
<span class="text-h4" v-text="charge.description"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col" style="margin: 2px 15px; max-height: 100px">
|
||||
<center>
|
||||
<q-btn flat dense outline @click="copyText('{{ charge.id }}')"
|
||||
>Charge ID: {{ charge.id }}</q-btn
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<div
|
||||
color="white"
|
||||
style="background-color: grey; height: 30px; padding: 5px"
|
||||
v-if="!charge.timeLeft"
|
||||
>
|
||||
</center>
|
||||
<span
|
||||
><small
|
||||
>{% raw %} Total to pay: {{ charge_amount }}sats<br />
|
||||
Amount paid: {{ charge_balance }}</small
|
||||
><br />
|
||||
Amount due: {{ charge_amount - charge_balance }}sats {% endraw %}
|
||||
</span>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<div class="col">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
flat
|
||||
disable
|
||||
v-if="'{{ charge.lnbitswallet }}' == 'None' || charge_time_elapsed == 'True'"
|
||||
style="color: primary; width: 100%"
|
||||
label="lightning⚡"
|
||||
>
|
||||
<q-tooltip>
|
||||
bitcoin lightning payment method not available
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
v-else
|
||||
@click="payLN"
|
||||
style="color: primary; width: 100%"
|
||||
label="lightning⚡"
|
||||
>
|
||||
<q-tooltip> pay with lightning </q-tooltip>
|
||||
</q-btn>
|
||||
Time elapsed
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
flat
|
||||
disable
|
||||
v-if="'{{ charge.onchainwallet }}' == 'None' || charge_time_elapsed == 'True'"
|
||||
style="color: primary; width: 100%"
|
||||
label="onchain⛓️"
|
||||
>
|
||||
<q-tooltip>
|
||||
bitcoin onchain payment method not available
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
v-else
|
||||
@click="payON"
|
||||
style="color: primary; width: 100%"
|
||||
label="onchain⛓️"
|
||||
>
|
||||
<q-tooltip> pay onchain </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
</div>
|
||||
</div>
|
||||
<q-card class="q-pa-lg" v-if="lnbtc">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="text-center q-pt-md">
|
||||
<div v-if="timetoComplete < 1 && charge_paid == 'False'">
|
||||
<q-icon
|
||||
name="block"
|
||||
style="color: #ccc; font-size: 21.4em"
|
||||
></q-icon>
|
||||
</div>
|
||||
<div v-else-if="charge_paid == 'True'">
|
||||
<q-icon
|
||||
name="check"
|
||||
style="color: green; font-size: 21.4em"
|
||||
></q-icon>
|
||||
<q-btn
|
||||
outline
|
||||
v-if="'{{ charge.webhook }}' != 'None'"
|
||||
type="a"
|
||||
href="{{ charge.completelink }}"
|
||||
label="{{ charge.completelinktext }}"
|
||||
></q-btn>
|
||||
<div
|
||||
color="white"
|
||||
style="background-color: grey; height: 30px; padding: 5px"
|
||||
v-else-if="charge.paid"
|
||||
>
|
||||
Charge paid
|
||||
</div>
|
||||
<div v-else>
|
||||
<center>
|
||||
<span class="text-subtitle2"
|
||||
>Pay this <br />
|
||||
lightning-network invoice</span
|
||||
<q-linear-progress
|
||||
size="30px"
|
||||
:value="charge.progress"
|
||||
color="secondary"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item style="padding: 3px">
|
||||
<q-spinner color="white" size="0.8em"></q-spinner
|
||||
><span style="font-size: 15px; color: white"
|
||||
><span class="q-pr-xl q-pl-md"> Awaiting payment...</span>
|
||||
<span class="q-pl-xl" style="color: white">
|
||||
{% raw %} {{ charge.timeLeft }} {% endraw %}</span
|
||||
></span
|
||||
>
|
||||
</q-item>
|
||||
</q-item-section>
|
||||
</q-linear-progress>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-ml-md q-mt-md q-mb-lg">
|
||||
<div class="col">
|
||||
<div class="row">
|
||||
<div class="col-4 q-pr-lg">Charge Id:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-btn flat dense outline @click="copyText(charge.id)"
|
||||
><span v-text="charge.id"></span
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center">
|
||||
<div class="col-4 q-pr-lg">Total to pay:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-badge color="blue">
|
||||
<span v-text="charge.amount" class="text-subtitle2"></span> sat
|
||||
</q-badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-sm">
|
||||
<div class="col-4 q-pr-lg">Amount paid:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-badge color="orange">
|
||||
<span v-text="charge.balance" class="text-subtitle2"></span>
|
||||
sat</q-badge
|
||||
>
|
||||
</center>
|
||||
<a href="lightning:{{ charge.payment_request }}">
|
||||
<q-responsive :ratio="1" class="q-mx-md">
|
||||
<qrcode
|
||||
:value="'{{ charge.payment_request }}'"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText('{{ charge.payment_request }}')"
|
||||
>Copy invoice</q-btn
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pendingFunds" class="row items-center q-mt-sm">
|
||||
<div class="col-4 q-pr-lg">Amount pending:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-badge color="gray">
|
||||
<span v-text="pendingFunds" class="text-subtitle2"></span> sat
|
||||
</q-badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-sm">
|
||||
<div class="col-4 q-pr-lg">Amount due:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-badge v-if="charge.amount - charge.balance > 0" color="green">
|
||||
<span
|
||||
v-text="charge.amount - charge.balance"
|
||||
class="text-subtitle2"
|
||||
></span>
|
||||
sat
|
||||
</q-badge>
|
||||
<q-badge
|
||||
v-else="charge.amount - charge.balance <= 0"
|
||||
color="green"
|
||||
class="text-subtitle2"
|
||||
>
|
||||
none</q-badge
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
flat
|
||||
disable
|
||||
v-if="!charge.lnbitswallet || charge.time_elapsed"
|
||||
style="color: primary; width: 100%"
|
||||
label="lightning⚡"
|
||||
>
|
||||
<q-tooltip>
|
||||
bitcoin lightning payment method not available
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
v-else
|
||||
@click="payInvoice"
|
||||
style="color: primary; width: 100%"
|
||||
label="lightning⚡"
|
||||
>
|
||||
<q-tooltip> pay with lightning </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
flat
|
||||
disable
|
||||
v-if="!charge.onchainwallet || charge.time_elapsed"
|
||||
style="color: primary; width: 100%"
|
||||
label="onchain⛓️"
|
||||
>
|
||||
<q-tooltip>
|
||||
bitcoin onchain payment method not available
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
v-else
|
||||
@click="payOnchain"
|
||||
style="color: primary; width: 100%"
|
||||
label="onchain⛓️"
|
||||
>
|
||||
<q-tooltip> pay onchain </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
<q-card class="q-pa-lg" v-if="lnbtc">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="row items-center q-mt-sm">
|
||||
<div class="col-md-2 col-sm-0"></div>
|
||||
<div class="col-md-8 col-sm-12">
|
||||
<div v-if="!charge.timeLeft && !charge.paid">
|
||||
<q-icon
|
||||
name="block"
|
||||
style="color: #ccc; font-size: 21.4em"
|
||||
></q-icon>
|
||||
</div>
|
||||
<div v-else-if="charge.paid">
|
||||
<q-icon
|
||||
name="check"
|
||||
style="color: green; font-size: 21.4em"
|
||||
></q-icon>
|
||||
<q-btn
|
||||
outline
|
||||
v-if="charge.webhook"
|
||||
type="a"
|
||||
:href="charge.completelink"
|
||||
:label="charge.completelinktext"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="row text-center q-mb-sm">
|
||||
<div class="col text-center">
|
||||
<span class="text-subtitle2"
|
||||
>Pay this lightning-network invoice:</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a :href="'lightning:'+charge.payment_request">
|
||||
<q-responsive :ratio="1" class="q-mx-md">
|
||||
<qrcode
|
||||
:value="charge.payment_request"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
<div class="row text-center q-mt-lg">
|
||||
<div class="col text-center">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(charge.payment_request)"
|
||||
>Copy invoice</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-0"></div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card class="q-pa-lg" v-if="onbtc">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="text-center q-pt-md">
|
||||
<div v-if="timetoComplete < 1 && charge_paid == 'False'">
|
||||
<q-icon
|
||||
name="block"
|
||||
style="color: #ccc; font-size: 21.4em"
|
||||
></q-icon>
|
||||
</div>
|
||||
<div v-else-if="charge_paid == 'True'">
|
||||
<q-icon
|
||||
name="check"
|
||||
style="color: green; font-size: 21.4em"
|
||||
></q-icon>
|
||||
<q-btn
|
||||
outline
|
||||
v-if="'{{ charge.webhook }}' != None"
|
||||
type="a"
|
||||
href="{{ charge.completelink }}"
|
||||
label="{{ charge.completelinktext }}"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<center>
|
||||
<span class="text-subtitle2"
|
||||
>Send {{ charge.amount }}sats<br />
|
||||
to this onchain address</span
|
||||
>
|
||||
</center>
|
||||
<a href="bitcoin:{{ charge.onchainaddress }}">
|
||||
<q-responsive :ratio="1" class="q-mx-md">
|
||||
<qrcode
|
||||
:value="'{{ charge.onchainaddress }}'"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
<div v-if="charge.timeLeft && !charge.paid" class="row items-center">
|
||||
<div class="col text-center">
|
||||
<a
|
||||
style="color: unset"
|
||||
:href="mempool_endpoint + '/address/' + charge.onchainaddress"
|
||||
target="_blank"
|
||||
><span
|
||||
class="text-subtitle1"
|
||||
v-text="charge.onchainaddress"
|
||||
></span>
|
||||
</a>
|
||||
<div class="row q-mt-lg">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-md">
|
||||
<div class="col-md-2 col-sm-0"></div>
|
||||
<div class="col-md-8 col-sm-12 text-center">
|
||||
<div v-if="!charge.timeLeft && !charge.paid">
|
||||
<q-icon
|
||||
name="block"
|
||||
style="color: #ccc; font-size: 21.4em"
|
||||
></q-icon>
|
||||
</div>
|
||||
<div v-else-if="charge.paid">
|
||||
<q-icon
|
||||
name="check"
|
||||
style="color: green; font-size: 21.4em"
|
||||
></q-icon>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText('{{ charge.onchainaddress }}')"
|
||||
>Copy address</q-btn
|
||||
>
|
||||
v-if="charge.webhook"
|
||||
type="a"
|
||||
:href="charge.completelink"
|
||||
:label="charge.completelinktext"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="row items-center q-mb-sm">
|
||||
<div class="col text-center">
|
||||
<span class="text-subtitle2"
|
||||
>Send
|
||||
|
||||
<span v-text="charge.amount"></span>
|
||||
sats to this onchain address</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a :href="'bitcoin:'+charge.onchainaddress">
|
||||
<q-responsive :ratio="1" class="q-mx-md">
|
||||
<qrcode
|
||||
:value="charge.onchainaddress"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
<div class="row items-center q-mt-lg">
|
||||
<div class="col text-center">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(charge.onchainaddress)"
|
||||
>Copy address</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-0"></div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-lg- 4 col-md-3 col-sm-1"></div>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %}
|
||||
|
||||
<style>
|
||||
.theCard {
|
||||
width: 360px;
|
||||
margin: 10px auto;
|
||||
}
|
||||
.theHeading {
|
||||
margin: 15px;
|
||||
font-size: 25px;
|
||||
}
|
||||
</style>
|
||||
<script src="https://mempool.space/mempool.js"></script>
|
||||
<script src="{{ url_for('satspay_static', path='js/utils.js') }}"></script>
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
@@ -226,16 +302,14 @@
|
||||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
charge: JSON.parse('{{charge_data | tojson}}'),
|
||||
mempool_endpoint: '{{mempool_endpoint}}',
|
||||
pendingFunds: 0,
|
||||
ws: null,
|
||||
newProgress: 0.4,
|
||||
counter: 1,
|
||||
newTimeLeft: '',
|
||||
timetoComplete: 100,
|
||||
lnbtc: true,
|
||||
onbtc: false,
|
||||
charge_time_elapsed: '{{charge.time_elapsed}}',
|
||||
charge_amount: '{{charge.amount}}',
|
||||
charge_balance: '{{charge.balance}}',
|
||||
charge_paid: '{{charge.paid}}',
|
||||
wallet: {
|
||||
inkey: ''
|
||||
},
|
||||
@@ -245,90 +319,141 @@
|
||||
methods: {
|
||||
startPaymentNotifier() {
|
||||
this.cancelListener()
|
||||
|
||||
this.cancelListener = LNbits.event.onInvoicePaid(
|
||||
if (!this.lnbitswallet) return
|
||||
this.cancelListener = LNbits.events.onInvoicePaid(
|
||||
this.wallet,
|
||||
payment => {
|
||||
this.checkBalance()
|
||||
this.checkInvoiceBalance()
|
||||
}
|
||||
)
|
||||
},
|
||||
checkBalance: function () {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
checkBalances: async function () {
|
||||
if (!this.charge.hasStaleBalance) await this.refreshCharge()
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charges/balance/{{ charge.id }}',
|
||||
'filla'
|
||||
`/satspay/api/v1/charge/balance/${this.charge.id}`
|
||||
)
|
||||
.then(function (response) {
|
||||
self.charge_time_elapsed = response.data.time_elapsed
|
||||
self.charge_amount = response.data.amount
|
||||
self.charge_balance = response.data.balance
|
||||
if (self.charge_balance >= self.charge_amount) {
|
||||
self.charge_paid = 'True'
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
this.charge = mapCharge(data, this.charge)
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
payLN: function () {
|
||||
refreshCharge: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/satspay/api/v1/charge/${this.charge.id}`
|
||||
)
|
||||
this.charge = mapCharge(data, this.charge)
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
checkPendingOnchain: async function () {
|
||||
const {
|
||||
bitcoin: {addresses: addressesAPI}
|
||||
} = mempoolJS({
|
||||
hostname: new URL(this.mempool_endpoint).hostname
|
||||
})
|
||||
|
||||
try {
|
||||
const utxos = await addressesAPI.getAddressTxsUtxo({
|
||||
address: this.charge.onchainaddress
|
||||
})
|
||||
const newBalance = utxos.reduce((t, u) => t + u.value, 0)
|
||||
this.charge.hasStaleBalance = this.charge.balance === newBalance
|
||||
|
||||
this.pendingFunds = utxos
|
||||
.filter(u => !u.status.confirmed)
|
||||
.reduce((t, u) => t + u.value, 0)
|
||||
} catch (error) {
|
||||
console.error('cannot check pending funds')
|
||||
}
|
||||
},
|
||||
payInvoice: function () {
|
||||
this.lnbtc = true
|
||||
this.onbtc = false
|
||||
},
|
||||
payON: function () {
|
||||
payOnchain: function () {
|
||||
this.lnbtc = false
|
||||
this.onbtc = true
|
||||
},
|
||||
getTheTime: function () {
|
||||
var timeToComplete =
|
||||
parseInt('{{ charge.time }}') * 60 -
|
||||
(Date.now() / 1000 - parseInt('{{ charge.timestamp }}'))
|
||||
this.timetoComplete = timeToComplete
|
||||
var timeLeft = Quasar.utils.date.formatDate(
|
||||
new Date((timeToComplete - 3600) * 1000),
|
||||
'HH:mm:ss'
|
||||
)
|
||||
this.newTimeLeft = timeLeft
|
||||
},
|
||||
getThePercentage: function () {
|
||||
var timeToComplete =
|
||||
parseInt('{{ charge.time }}') * 60 -
|
||||
(Date.now() / 1000 - parseInt('{{ charge.timestamp }}'))
|
||||
this.newProgress =
|
||||
1 - timeToComplete / (parseInt('{{ charge.time }}') * 60)
|
||||
},
|
||||
|
||||
timerCount: function () {
|
||||
self = this
|
||||
var refreshIntervalId = setInterval(function () {
|
||||
if (self.charge_paid == 'True' || self.timetoComplete < 1) {
|
||||
loopRefresh: function () {
|
||||
// invoice only
|
||||
const refreshIntervalId = setInterval(async () => {
|
||||
if (this.charge.paid || !this.charge.timeLeft) {
|
||||
clearInterval(refreshIntervalId)
|
||||
}
|
||||
self.getTheTime()
|
||||
self.getThePercentage()
|
||||
self.counter++
|
||||
if (self.counter % 10 === 0) {
|
||||
self.checkBalance()
|
||||
if (this.counter % 10 === 0) {
|
||||
await this.checkBalances()
|
||||
await this.checkPendingOnchain()
|
||||
}
|
||||
this.counter++
|
||||
}, 1000)
|
||||
},
|
||||
initWs: async function () {
|
||||
const {
|
||||
bitcoin: {websocket}
|
||||
} = mempoolJS({
|
||||
hostname: new URL(this.mempool_endpoint).hostname
|
||||
})
|
||||
|
||||
this.ws = new WebSocket('wss://mempool.space/api/v1/ws')
|
||||
this.ws.addEventListener('open', x => {
|
||||
if (this.charge.onchainaddress) {
|
||||
this.trackAddress(this.charge.onchainaddress)
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.addEventListener('message', async ({data}) => {
|
||||
const res = JSON.parse(data.toString())
|
||||
if (res['address-transactions']) {
|
||||
await this.checkBalances()
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'New payment received!',
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
loopPingWs: function () {
|
||||
setInterval(() => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) this.initWs()
|
||||
this.ws.send(JSON.stringify({action: 'ping'}))
|
||||
}, 30 * 1000)
|
||||
},
|
||||
trackAddress: async function (address, retry = 0) {
|
||||
try {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) this.initWs()
|
||||
this.ws.send(JSON.stringify({'track-address': address}))
|
||||
} catch (error) {
|
||||
await sleep(1000)
|
||||
if (retry > 10) throw error
|
||||
this.trackAddress(address, retry + 1)
|
||||
}
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
console.log('{{ charge.onchainaddress }}' == 'None')
|
||||
if ('{{ charge.lnbitswallet }}' == 'None') {
|
||||
this.lnbtc = false
|
||||
this.onbtc = true
|
||||
}
|
||||
created: async function () {
|
||||
if (this.charge.lnbitswallet) this.payInvoice()
|
||||
else this.payOnchain()
|
||||
await this.checkBalances()
|
||||
|
||||
// empty for onchain
|
||||
this.wallet.inkey = '{{ wallet_inkey }}'
|
||||
this.getTheTime()
|
||||
this.getThePercentage()
|
||||
var timerCount = this.timerCount
|
||||
if ('{{ charge.paid }}' == 'False') {
|
||||
timerCount()
|
||||
}
|
||||
this.startPaymentNotifier()
|
||||
|
||||
if (!this.charge.paid) {
|
||||
this.loopRefresh()
|
||||
}
|
||||
|
||||
if (this.charge.onchainaddress) {
|
||||
this.loopPingWs()
|
||||
this.checkPendingOnchain()
|
||||
this.trackAddress(this.charge.onchainaddress)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -18,46 +18,54 @@
|
||||
<h5 class="text-subtitle1 q-my-none">Charges</h5>
|
||||
</div>
|
||||
|
||||
<div class="col-auto">
|
||||
<div class="col q-pr-lg">
|
||||
<q-input
|
||||
borderless
|
||||
dense
|
||||
debounce="300"
|
||||
v-model="filter"
|
||||
placeholder="Search"
|
||||
class="float-right"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon name="search"></q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-btn flat color="grey" @click="exportchargeCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn outline color="grey" label="...">
|
||||
<q-menu auto-close>
|
||||
<q-list style="min-width: 100px">
|
||||
<q-item clickable>
|
||||
<q-item-section @click="exportchargeCSV"
|
||||
>Export to CSV</q-item-section
|
||||
>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
flat
|
||||
dense
|
||||
:data="ChargeLinks"
|
||||
:data="chargeLinks"
|
||||
row-key="id"
|
||||
:columns="ChargesTable.columns"
|
||||
:pagination.sync="ChargesTable.pagination"
|
||||
:columns="chargesTable.columns"
|
||||
:pagination.sync="chargesTable.pagination"
|
||||
:filter="filter"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width></q-th>
|
||||
|
||||
<q-th
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
auto-width
|
||||
>
|
||||
<div v-if="col.name == 'id'"></div>
|
||||
<div v-else>{{ col.label }}</div>
|
||||
</q-th>
|
||||
<q-th auto-width>Status </q-th>
|
||||
<q-th auto-width>Title</q-th>
|
||||
<q-th auto-width>Time Left (hh:mm)</q-th>
|
||||
<q-th auto-width>Time To Pay (hh:mm)</q-th>
|
||||
<q-th auto-width>Amount To Pay</q-th>
|
||||
<q-th auto-width>Balance</q-th>
|
||||
<q-th auto-width>Pending Balance</q-th>
|
||||
<q-th auto-width>Onchain Address</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
@@ -66,73 +74,179 @@
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
unelevated
|
||||
size="sm"
|
||||
color="accent"
|
||||
round
|
||||
dense
|
||||
size="xs"
|
||||
icon="link"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
@click="props.row.expanded= !props.row.expanded"
|
||||
:icon="props.row.expanded? 'remove' : 'add'"
|
||||
/>
|
||||
</q-td>
|
||||
|
||||
<q-td auto-width>
|
||||
<q-badge
|
||||
v-if="props.row.time_elapsed && props.row.balance < props.row.amount"
|
||||
color="red"
|
||||
>
|
||||
<a
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>expired</a
|
||||
>
|
||||
</q-badge>
|
||||
|
||||
<q-badge
|
||||
v-else-if="props.row.balance >= props.row.amount"
|
||||
color="green"
|
||||
>
|
||||
<a
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>paid</a
|
||||
>
|
||||
</q-badge>
|
||||
|
||||
<q-badge v-else color="blue"
|
||||
><a
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>waiting</a
|
||||
>
|
||||
</q-badge>
|
||||
</q-td>
|
||||
<q-td key="description" :props="props" :class="">
|
||||
<a
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>{{props.row.description}}</a
|
||||
>
|
||||
<q-tooltip> Payment link </q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
v-if="props.row.time_elapsed && props.row.balance < props.row.amount"
|
||||
unelevated
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
icon="error"
|
||||
:color="($q.dark.isActive) ? 'red' : 'red'"
|
||||
<q-td key="timeLeft" :props="props" :class="">
|
||||
<div>{{props.row.timeLeft}}</div>
|
||||
<q-linear-progress
|
||||
v-if="props.row.timeLeft"
|
||||
:value="props.row.progress"
|
||||
color="secondary"
|
||||
>
|
||||
<q-tooltip> Time elapsed </q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
v-else-if="props.row.balance >= props.row.amount"
|
||||
unelevated
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
icon="check"
|
||||
:color="($q.dark.isActive) ? 'green' : 'green'"
|
||||
>
|
||||
<q-tooltip> PAID! </q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="cached"
|
||||
flat
|
||||
:color="($q.dark.isActive) ? 'blue' : 'blue'"
|
||||
>
|
||||
<q-tooltip> Processing </q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteChargeLink(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
>
|
||||
<q-tooltip> Delete charge </q-tooltip>
|
||||
</q-btn>
|
||||
</q-linear-progress>
|
||||
</q-td>
|
||||
<q-td
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
auto-width
|
||||
>
|
||||
<div v-if="col.name == 'id'"></div>
|
||||
<div v-else>{{ col.value }}</div>
|
||||
<q-td key="time to pay" :props="props" :class="">
|
||||
<div>{{props.row.time}}</div>
|
||||
</q-td>
|
||||
<q-td key="amount" :props="props" :class="">
|
||||
<div>{{props.row.amount}}</div>
|
||||
</q-td>
|
||||
<q-td key="balance" :props="props" :class="">
|
||||
<div>{{props.row.balance}}</div>
|
||||
</q-td>
|
||||
<q-td key="pendingBalance" :props="props" :class="">
|
||||
<div>
|
||||
{{props.row.pendingBalance ? props.row.pendingBalance : ''}}
|
||||
</div>
|
||||
</q-td>
|
||||
<q-td key="onchain address" :props="props" :class="">
|
||||
<a
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>{{props.row.onchainaddress}}</a
|
||||
>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
<q-tr v-show="props.row.expanded" :props="props">
|
||||
<q-td colspan="100%">
|
||||
<div
|
||||
v-if="props.row.onchainwallet"
|
||||
class="row items-center q-mt-md q-mb-lg"
|
||||
>
|
||||
<div class="col-2 q-pr-lg">Onchain Wallet:</div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
{{getOnchainWalletName(props.row.onchainwallet)}}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="props.row.lnbitswallet"
|
||||
class="row items-center q-mt-md q-mb-lg"
|
||||
>
|
||||
<div class="col-2 q-pr-lg">LNbits Wallet:</div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
{{getLNbitsWalletName(props.row.lnbitswallet)}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="props.row.completelink || props.row.completelinktext"
|
||||
class="row items-center q-mt-md q-mb-lg"
|
||||
>
|
||||
<div class="col-2 q-pr-lg">Completed Link:</div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
<a
|
||||
:href="props.row.completelink"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>{{props.row.completelinktext ||
|
||||
props.row.completelink}}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="props.row.webhook"
|
||||
class="row items-center q-mt-md q-mb-lg"
|
||||
>
|
||||
<div class="col-2 q-pr-lg">Webhook:</div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
<a
|
||||
:href="props.row.webhook"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>{{props.row.webhook || props.row.webhook}}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-md q-mb-lg">
|
||||
<div class="col-2 q-pr-lg">ID:</div>
|
||||
<div class="col-4 q-pr-lg">{{props.row.id}}</div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-md q-mb-lg">
|
||||
<div class="col-2 q-pr-lg"></div>
|
||||
<div class="col-6 q-pr-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="gray"
|
||||
outline
|
||||
type="a"
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
class="float-left q-mr-lg"
|
||||
>Details</q-btn
|
||||
>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="gray"
|
||||
outline
|
||||
type="a"
|
||||
@click="refreshBalance(props.row)"
|
||||
target="_blank"
|
||||
class="float-left"
|
||||
>Refresh Balance</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="pink"
|
||||
icon="cancel"
|
||||
@click="deleteChargeLink(props.row.id)"
|
||||
>Delete</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-4"></div>
|
||||
<div class="col-2 q-pr-lg"></div>
|
||||
</div>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
@@ -155,11 +269,7 @@
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<q-dialog
|
||||
v-model="formDialogCharge.show"
|
||||
position="top"
|
||||
@hide="closeFormDialog"
|
||||
>
|
||||
<q-dialog v-model="formDialogCharge.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="sendFormDataCharge" class="q-gutter-md">
|
||||
<q-input
|
||||
@@ -225,7 +335,8 @@
|
||||
<div v-else>
|
||||
<q-checkbox :value="false" label="Onchain" disabled>
|
||||
<q-tooltip>
|
||||
Watch-Only extension MUST be activated and have a wallet
|
||||
Onchain Wallet (watch-only) extension MUST be activated and
|
||||
have a wallet
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
@@ -245,7 +356,7 @@
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialogCharge.data.onchainwallet"
|
||||
v-model="onchainwallet"
|
||||
:options="walletLinks"
|
||||
label="Onchain Wallet"
|
||||
/>
|
||||
@@ -283,49 +394,28 @@
|
||||
<!-- lnbits/static/vendor
|
||||
<script src="/vendor/vue-qrcode@1.0.2/vue-qrcode.min.js"></script> -->
|
||||
<style></style>
|
||||
<!-- todo: use config mempool -->
|
||||
<script src="https://mempool.space/mempool.js"></script>
|
||||
<script src="{{ url_for('satspay_static', path='js/utils.js') }}"></script>
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
var mapCharge = obj => {
|
||||
obj._data = _.clone(obj)
|
||||
obj.theTime = obj.time * 60 - (Date.now() / 1000 - obj.timestamp)
|
||||
obj.time = obj.time + 'mins'
|
||||
|
||||
if (obj.time_elapsed) {
|
||||
obj.date = 'Time elapsed'
|
||||
} else {
|
||||
obj.date = Quasar.utils.date.formatDate(
|
||||
new Date((obj.theTime - 3600) * 1000),
|
||||
'HH:mm:ss'
|
||||
)
|
||||
}
|
||||
obj.displayUrl = ['/satspay/', obj.id].join('')
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
filter: '',
|
||||
watchonlyactive: false,
|
||||
balance: null,
|
||||
checker: null,
|
||||
walletLinks: [],
|
||||
ChargeLinks: [],
|
||||
ChargeLinksObj: [],
|
||||
chargeLinks: [],
|
||||
onchainwallet: '',
|
||||
currentaddress: '',
|
||||
Addresses: {
|
||||
show: false,
|
||||
data: null
|
||||
},
|
||||
rescanning: false,
|
||||
mempool: {
|
||||
endpoint: ''
|
||||
},
|
||||
|
||||
ChargesTable: {
|
||||
chargesTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'theId',
|
||||
@@ -340,10 +430,10 @@
|
||||
field: 'description'
|
||||
},
|
||||
{
|
||||
name: 'timeleft',
|
||||
name: 'timeLeft',
|
||||
align: 'left',
|
||||
label: 'Time left',
|
||||
field: 'date'
|
||||
field: 'timeLeft'
|
||||
},
|
||||
{
|
||||
name: 'time to pay',
|
||||
@@ -363,6 +453,12 @@
|
||||
label: 'Balance',
|
||||
field: 'balance'
|
||||
},
|
||||
{
|
||||
name: 'pendingBalance',
|
||||
align: 'left',
|
||||
label: 'Pending Balance',
|
||||
field: 'pendingBalance'
|
||||
},
|
||||
{
|
||||
name: 'onchain address',
|
||||
align: 'left',
|
||||
@@ -392,172 +488,218 @@
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
formDialogCharge: {
|
||||
show: false,
|
||||
data: {
|
||||
onchain: false,
|
||||
onchainwallet: '',
|
||||
lnbits: false,
|
||||
description: '',
|
||||
time: null,
|
||||
amount: null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
cancelCharge: function (data) {
|
||||
this.formDialogCharge.data.description = ''
|
||||
this.formDialogCharge.data.onchainwallet = ''
|
||||
this.formDialogCharge.data.lnbitswallet = ''
|
||||
this.formDialogCharge.data.time = null
|
||||
this.formDialogCharge.data.amount = null
|
||||
this.formDialogCharge.data.webhook = ''
|
||||
this.formDialogCharge.data.completelink = ''
|
||||
this.formDialogCharge.show = false
|
||||
},
|
||||
|
||||
getWalletLinks: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/wallet',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
this.walletLinks = data.map(w => ({
|
||||
id: w.id,
|
||||
label: w.title + ' - ' + w.id
|
||||
}))
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
|
||||
getWalletConfig: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/config',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
this.mempool.endpoint = data.mempool_endpoint
|
||||
const url = new URL(this.mempool.endpoint)
|
||||
this.mempool.hostname = url.hostname
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
getOnchainWalletName: function (walletId) {
|
||||
const wallet = this.walletLinks.find(w => w.id === walletId)
|
||||
if (!wallet) return 'unknown'
|
||||
return wallet.label
|
||||
},
|
||||
getLNbitsWalletName: function (walletId) {
|
||||
const wallet = this.g.user.walletOptions.find(w => w.value === walletId)
|
||||
if (!wallet) return 'unknown'
|
||||
return wallet.label
|
||||
},
|
||||
|
||||
getCharges: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charges',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
this.chargeLinks = data.map(c =>
|
||||
mapCharge(
|
||||
c,
|
||||
this.chargeLinks.find(old => old.id === c.id)
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
sendFormDataCharge: function () {
|
||||
const wallet = this.g.user.wallets[0].inkey
|
||||
const data = this.formDialogCharge.data
|
||||
data.amount = parseInt(data.amount)
|
||||
data.time = parseInt(data.time)
|
||||
data.onchainwallet = this.onchainwallet?.id
|
||||
this.createCharge(wallet, data)
|
||||
},
|
||||
refreshActiveChargesBalance: async function () {
|
||||
try {
|
||||
const activeLinkIds = this.chargeLinks
|
||||
.filter(c => !c.paid && !c.time_elapsed && !c.hasStaleBalance)
|
||||
.map(c => c.id)
|
||||
.join(',')
|
||||
if (activeLinkIds) {
|
||||
await LNbits.api.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charges/balance/' + activeLinkIds,
|
||||
'filla'
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
await this.getCharges()
|
||||
}
|
||||
},
|
||||
refreshBalance: async function (charge) {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charge/balance/' + charge.id,
|
||||
'filla'
|
||||
)
|
||||
charge.balance = data.balance
|
||||
} catch (error) {}
|
||||
},
|
||||
rescanOnchainAddresses: async function () {
|
||||
if (this.rescanning) return
|
||||
this.rescanning = true
|
||||
|
||||
const {
|
||||
bitcoin: {addresses: addressesAPI}
|
||||
} = mempoolJS({hostname: this.mempool.hostname})
|
||||
|
||||
try {
|
||||
const onchainActiveCharges = this.chargeLinks.filter(
|
||||
c => c.onchainaddress && !c.paid && !c.time_elapsed
|
||||
)
|
||||
for (const charge of onchainActiveCharges) {
|
||||
const fn = async () =>
|
||||
addressesAPI.getAddressTxsUtxo({
|
||||
address: charge.onchainaddress
|
||||
})
|
||||
|
||||
const utxos = await retryWithDelay(fn)
|
||||
const newBalance = utxos.reduce((t, u) => t + u.value, 0)
|
||||
|
||||
charge.pendingBalance = utxos
|
||||
.filter(u => !u.status.confirmed)
|
||||
.reduce((t, u) => t + u.value, 0)
|
||||
|
||||
charge.hasStaleBalance = charge.balance === newBalance
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
this.rescanning = false
|
||||
}
|
||||
},
|
||||
createCharge: async function (wallet, data) {
|
||||
try {
|
||||
const resp = await LNbits.api.request(
|
||||
'POST',
|
||||
'/satspay/api/v1/charge',
|
||||
wallet,
|
||||
data
|
||||
)
|
||||
|
||||
this.chargeLinks.unshift(mapCharge(resp.data))
|
||||
this.formDialogCharge.show = false
|
||||
this.formDialogCharge.data = {
|
||||
onchain: false,
|
||||
lnbits: false,
|
||||
description: '',
|
||||
time: null,
|
||||
amount: null
|
||||
}
|
||||
},
|
||||
qrCodeDialog: {
|
||||
show: false,
|
||||
data: null
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
cancelCharge: function (data) {
|
||||
var self = this
|
||||
self.formDialogCharge.data.description = ''
|
||||
self.formDialogCharge.data.onchainwallet = ''
|
||||
self.formDialogCharge.data.lnbitswallet = ''
|
||||
self.formDialogCharge.data.time = null
|
||||
self.formDialogCharge.data.amount = null
|
||||
self.formDialogCharge.data.webhook = ''
|
||||
self.formDialogCharge.data.completelink = ''
|
||||
self.formDialogCharge.show = false
|
||||
},
|
||||
|
||||
getWalletLinks: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/wallet',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
for (i = 0; i < response.data.length; i++) {
|
||||
self.walletLinks.push(response.data[i].id)
|
||||
}
|
||||
return
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
closeFormDialog: function () {
|
||||
this.formDialog.data = {
|
||||
is_unique: false
|
||||
}
|
||||
},
|
||||
openQrCodeDialog: function (linkId) {
|
||||
var self = this
|
||||
var getAddresses = this.getAddresses
|
||||
getAddresses(linkId)
|
||||
self.current = linkId
|
||||
self.Addresses.show = true
|
||||
},
|
||||
getCharges: function () {
|
||||
var self = this
|
||||
var getAddressBalance = this.getAddressBalance
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charges',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.ChargeLinks = response.data.map(mapCharge)
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
sendFormDataCharge: function () {
|
||||
var self = this
|
||||
var wallet = this.g.user.wallets[0].inkey
|
||||
var data = this.formDialogCharge.data
|
||||
data.amount = parseInt(data.amount)
|
||||
data.time = parseInt(data.time)
|
||||
this.createCharge(wallet, data)
|
||||
},
|
||||
timerCount: function () {
|
||||
self = this
|
||||
var refreshIntervalId = setInterval(function () {
|
||||
for (i = 0; i < self.ChargeLinks.length - 1; i++) {
|
||||
if (self.ChargeLinks[i]['paid'] == 'True') {
|
||||
setTimeout(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charges/balance/' +
|
||||
self.ChargeLinks[i]['id'],
|
||||
'filla'
|
||||
)
|
||||
.then(function (response) {})
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
self.getCharges()
|
||||
}, 20000)
|
||||
},
|
||||
createCharge: function (wallet, data) {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request('POST', '/satspay/api/v1/charge', wallet, data)
|
||||
.then(function (response) {
|
||||
self.ChargeLinks.push(mapCharge(response.data))
|
||||
self.formDialogCharge.show = false
|
||||
self.formDialogCharge.data = {
|
||||
onchain: false,
|
||||
lnbits: false,
|
||||
description: '',
|
||||
time: null,
|
||||
amount: null
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
|
||||
deleteChargeLink: function (chargeId) {
|
||||
var self = this
|
||||
var link = _.findWhere(this.ChargeLinks, {id: chargeId})
|
||||
const link = _.findWhere(this.chargeLinks, {id: chargeId})
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this pay link?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
.onOk(async () => {
|
||||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'DELETE',
|
||||
'/satspay/api/v1/charge/' + chargeId,
|
||||
self.g.user.wallets[0].adminkey
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.ChargeLinks = _.reject(self.ChargeLinks, function (obj) {
|
||||
return obj.id === chargeId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
|
||||
this.chargeLinks = _.reject(this.chargeLinks, function (obj) {
|
||||
return obj.id === chargeId
|
||||
})
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
})
|
||||
},
|
||||
exportchargeCSV: function () {
|
||||
var self = this
|
||||
LNbits.utils.exportCSV(self.ChargesTable.columns, this.ChargeLinks)
|
||||
LNbits.utils.exportCSV(
|
||||
this.chargesTable.columns,
|
||||
this.chargeLinks,
|
||||
'charges'
|
||||
)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
console.log(this.g.user)
|
||||
var self = this
|
||||
var getCharges = this.getCharges
|
||||
getCharges()
|
||||
var getWalletLinks = this.getWalletLinks
|
||||
getWalletLinks()
|
||||
var timerCount = this.timerCount
|
||||
timerCount()
|
||||
created: async function () {
|
||||
await this.getCharges()
|
||||
await this.getWalletLinks()
|
||||
await this.getWalletConfig()
|
||||
setInterval(() => this.refreshActiveChargesBalance(), 10 * 2000)
|
||||
await this.rescanOnchainAddresses()
|
||||
setInterval(() => this.rescanOnchainAddresses(), 10 * 1000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
from lnbits.extensions.watchonly.crud import get_config
|
||||
|
||||
from . import satspay_ext, satspay_renderer
|
||||
from .crud import get_charge
|
||||
@@ -24,14 +25,21 @@ async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
|
||||
|
||||
@satspay_ext.get("/{charge_id}", response_class=HTMLResponse)
|
||||
async def display(request: Request, charge_id):
|
||||
async def display(request: Request, charge_id: str):
|
||||
charge = await get_charge(charge_id)
|
||||
if not charge:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Charge link does not exist."
|
||||
)
|
||||
wallet = await get_wallet(charge.lnbitswallet)
|
||||
onchainwallet_config = await get_config(charge.user)
|
||||
inkey = wallet.inkey if wallet else None
|
||||
return satspay_renderer().TemplateResponse(
|
||||
"satspay/display.html",
|
||||
{"request": request, "charge": charge, "wallet_key": wallet.inkey},
|
||||
{
|
||||
"request": request,
|
||||
"charge_data": charge.dict(),
|
||||
"wallet_inkey": inkey,
|
||||
"mempool_endpoint": onchainwallet_config.mempool_endpoint,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
import httpx
|
||||
from fastapi import Query
|
||||
from fastapi.params import Depends
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
@@ -31,7 +30,12 @@ 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()
|
||||
return {
|
||||
**charge.dict(),
|
||||
**{"time_elapsed": charge.time_elapsed},
|
||||
**{"time_left": charge.time_left},
|
||||
**{"paid": charge.paid},
|
||||
}
|
||||
|
||||
|
||||
@satspay_ext.put("/api/v1/charge/{charge_id}")
|
||||
@@ -51,6 +55,7 @@ async def api_charges_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
{
|
||||
**charge.dict(),
|
||||
**{"time_elapsed": charge.time_elapsed},
|
||||
**{"time_left": charge.time_left},
|
||||
**{"paid": charge.paid},
|
||||
}
|
||||
for charge in await get_charges(wallet.wallet.user)
|
||||
@@ -73,6 +78,7 @@ async def api_charge_retrieve(
|
||||
return {
|
||||
**charge.dict(),
|
||||
**{"time_elapsed": charge.time_elapsed},
|
||||
**{"time_left": charge.time_left},
|
||||
**{"paid": charge.paid},
|
||||
}
|
||||
|
||||
@@ -93,9 +99,18 @@ async def api_charge_delete(charge_id, wallet: WalletTypeInfo = Depends(get_key_
|
||||
#############################BALANCE##########################
|
||||
|
||||
|
||||
@satspay_ext.get("/api/v1/charges/balance/{charge_id}")
|
||||
async def api_charges_balance(charge_id):
|
||||
@satspay_ext.get("/api/v1/charges/balance/{charge_ids}")
|
||||
async def api_charges_balance(charge_ids):
|
||||
charge_id_list = charge_ids.split(",")
|
||||
charges = []
|
||||
for charge_id in charge_id_list:
|
||||
charge = await api_charge_balance(charge_id)
|
||||
charges.append(charge)
|
||||
return charges
|
||||
|
||||
|
||||
@satspay_ext.get("/api/v1/charge/balance/{charge_id}")
|
||||
async def api_charge_balance(charge_id):
|
||||
charge = await check_address_balance(charge_id)
|
||||
|
||||
if not charge:
|
||||
@@ -125,23 +140,9 @@ async def api_charges_balance(charge_id):
|
||||
)
|
||||
except AssertionError:
|
||||
charge.webhook = None
|
||||
return charge.dict()
|
||||
|
||||
|
||||
#############################MEMPOOL##########################
|
||||
|
||||
|
||||
@satspay_ext.put("/api/v1/mempool")
|
||||
async def api_update_mempool(
|
||||
endpoint: str = Query(...), wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
mempool = await update_mempool(endpoint, user=wallet.wallet.user)
|
||||
return mempool.dict()
|
||||
|
||||
|
||||
@satspay_ext.route("/api/v1/mempool")
|
||||
async def api_get_mempool(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
mempool = await get_mempool(wallet.wallet.user)
|
||||
if not mempool:
|
||||
mempool = await create_mempool(user=wallet.wallet.user)
|
||||
return mempool.dict()
|
||||
return {
|
||||
**charge.dict(),
|
||||
**{"time_elapsed": charge.time_elapsed},
|
||||
**{"time_left": charge.time_left},
|
||||
**{"paid": charge.paid},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Scrub
|
||||
|
||||
## Automatically forward funds (Scrub) that get paid to the wallet to an LNURLpay or Lightning Address
|
||||
|
||||
SCRUB is a small but handy extension that allows a user to take advantage of all the functionalities inside **LNbits** and upon a payment received to your LNbits wallet, automatically forward it to your desired wallet via LNURL or LNAddress!
|
||||
|
||||
[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets)
|
||||
|
||||
## Usage
|
||||
|
||||
1. Create an scrub (New Scrub link)\
|
||||

|
||||
|
||||
- select the wallet to be _scrubbed_
|
||||
- make a small description
|
||||
- enter either an LNURL pay or a lightning address
|
||||
|
||||
Make sure your LNURL or LNaddress is correct!
|
||||
|
||||
2. A new scrub will show on the _Scrub links_ section\
|
||||

|
||||
|
||||
- only one scrub can be created for each wallet!
|
||||
- You can _edit_ or _delete_ the Scrub at any time\
|
||||

|
||||
|
||||
3. On your wallet, you'll see a transaction of a payment received and another right after it as apayment sent, marked with **#scrubed**\
|
||||

|
||||
@@ -0,0 +1,34 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.tasks import catch_everything_and_restart
|
||||
|
||||
db = Database("ext_scrub")
|
||||
|
||||
scrub_static_files = [
|
||||
{
|
||||
"path": "/scrub/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/scrub/static"),
|
||||
"name": "scrub_static",
|
||||
}
|
||||
]
|
||||
|
||||
scrub_ext: APIRouter = APIRouter(prefix="/scrub", tags=["scrub"])
|
||||
|
||||
|
||||
def scrub_renderer():
|
||||
return template_renderer(["lnbits/extensions/scrub/templates"])
|
||||
|
||||
|
||||
from .tasks import wait_for_paid_invoices
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
|
||||
|
||||
def scrub_start():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Scrub",
|
||||
"short_description": "Pass payments to LNURLp/LNaddress",
|
||||
"icon": "send",
|
||||
"contributors": ["arcbtc", "talvasconcelos"]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import CreateScrubLink, ScrubLink
|
||||
|
||||
|
||||
async def create_scrub_link(data: CreateScrubLink) -> ScrubLink:
|
||||
scrub_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO scrub.scrub_links (
|
||||
id,
|
||||
wallet,
|
||||
description,
|
||||
payoraddress
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
scrub_id,
|
||||
data.wallet,
|
||||
data.description,
|
||||
data.payoraddress,
|
||||
),
|
||||
)
|
||||
link = await get_scrub_link(scrub_id)
|
||||
assert link, "Newly created link couldn't be retrieved"
|
||||
return link
|
||||
|
||||
|
||||
async def get_scrub_link(link_id: str) -> Optional[ScrubLink]:
|
||||
row = await db.fetchone("SELECT * FROM scrub.scrub_links WHERE id = ?", (link_id,))
|
||||
return ScrubLink(**row) if row else None
|
||||
|
||||
|
||||
async def get_scrub_links(wallet_ids: Union[str, List[str]]) -> List[ScrubLink]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"""
|
||||
SELECT * FROM scrub.scrub_links WHERE wallet IN ({q})
|
||||
ORDER BY id
|
||||
""",
|
||||
(*wallet_ids,),
|
||||
)
|
||||
return [ScrubLink(**row) for row in rows]
|
||||
|
||||
|
||||
async def update_scrub_link(link_id: int, **kwargs) -> Optional[ScrubLink]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE scrub.scrub_links SET {q} WHERE id = ?",
|
||||
(*kwargs.values(), link_id),
|
||||
)
|
||||
row = await db.fetchone("SELECT * FROM scrub.scrub_links WHERE id = ?", (link_id,))
|
||||
return ScrubLink(**row) if row else None
|
||||
|
||||
|
||||
async def delete_scrub_link(link_id: int) -> None:
|
||||
await db.execute("DELETE FROM scrub.scrub_links WHERE id = ?", (link_id,))
|
||||
|
||||
|
||||
async def get_scrub_by_wallet(wallet_id) -> Optional[ScrubLink]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * from scrub.scrub_links WHERE wallet = ?",
|
||||
(wallet_id,),
|
||||
)
|
||||
return ScrubLink(**row) if row else None
|
||||
|
||||
|
||||
async def unique_scrubed_wallet(wallet_id):
|
||||
(row,) = await db.fetchone(
|
||||
"SELECT COUNT(wallet) FROM scrub.scrub_links WHERE wallet = ?",
|
||||
(wallet_id,),
|
||||
)
|
||||
return row
|
||||
@@ -0,0 +1,14 @@
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial scrub table.
|
||||
"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE scrub.scrub_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
payoraddress TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
from sqlite3 import Row
|
||||
|
||||
from pydantic import BaseModel
|
||||
from starlette.requests import Request
|
||||
|
||||
from lnbits.lnurl import encode as lnurl_encode # type: ignore
|
||||
|
||||
|
||||
class CreateScrubLink(BaseModel):
|
||||
wallet: str
|
||||
description: str
|
||||
payoraddress: str
|
||||
|
||||
|
||||
class ScrubLink(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
description: str
|
||||
payoraddress: str
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "ScrubLink":
|
||||
data = dict(row)
|
||||
return cls(**data)
|
||||
|
||||
def lnurl(self, req: Request) -> str:
|
||||
url = req.url_for("scrub.api_lnurl_response", link_id=self.id)
|
||||
return lnurl_encode(url)
|
||||
@@ -0,0 +1,143 @@
|
||||
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
var locationPath = [
|
||||
window.location.protocol,
|
||||
'//',
|
||||
window.location.host,
|
||||
window.location.pathname
|
||||
].join('')
|
||||
|
||||
var mapScrubLink = obj => {
|
||||
obj._data = _.clone(obj)
|
||||
obj.date = Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
obj.amount = new Intl.NumberFormat(LOCALE).format(obj.amount)
|
||||
obj.print_url = [locationPath, 'print/', obj.id].join('')
|
||||
obj.pay_url = [locationPath, obj.id].join('')
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
checker: null,
|
||||
payLinks: [],
|
||||
payLinksTable: {
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
qrCodeDialog: {
|
||||
show: false,
|
||||
data: null
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getScrubLinks() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/scrub/api/v1/links?all_wallets=true',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(response => {
|
||||
this.payLinks = response.data.map(mapScrubLink)
|
||||
})
|
||||
.catch(err => {
|
||||
clearInterval(this.checker)
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
closeFormDialog() {
|
||||
this.resetFormData()
|
||||
},
|
||||
openUpdateDialog(linkId) {
|
||||
const link = _.findWhere(this.payLinks, {id: linkId})
|
||||
|
||||
this.formDialog.data = _.clone(link._data)
|
||||
this.formDialog.show = true
|
||||
},
|
||||
sendFormData() {
|
||||
const wallet = _.findWhere(this.g.user.wallets, {
|
||||
id: this.formDialog.data.wallet
|
||||
})
|
||||
let data = Object.freeze(this.formDialog.data)
|
||||
console.log(wallet, data)
|
||||
|
||||
if (data.id) {
|
||||
this.updateScrubLink(wallet, data)
|
||||
} else {
|
||||
this.createScrubLink(wallet, data)
|
||||
}
|
||||
},
|
||||
resetFormData() {
|
||||
this.formDialog = {
|
||||
show: false,
|
||||
data: {}
|
||||
}
|
||||
},
|
||||
updateScrubLink(wallet, data) {
|
||||
LNbits.api
|
||||
.request('PUT', '/scrub/api/v1/links/' + data.id, wallet.adminkey, data)
|
||||
.then(response => {
|
||||
this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id)
|
||||
this.payLinks.push(mapScrubLink(response.data))
|
||||
this.formDialog.show = false
|
||||
this.resetFormData()
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
createScrubLink(wallet, data) {
|
||||
LNbits.api
|
||||
.request('POST', '/scrub/api/v1/links', wallet.adminkey, data)
|
||||
.then(response => {
|
||||
console.log('RES', response)
|
||||
this.getScrubLinks()
|
||||
this.formDialog.show = false
|
||||
this.resetFormData()
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
deleteScrubLink(linkId) {
|
||||
var link = _.findWhere(this.payLinks, {id: linkId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this pay link?')
|
||||
.onOk(() => {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/scrub/api/v1/links/' + linkId,
|
||||
_.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey
|
||||
)
|
||||
.then(response => {
|
||||
this.payLinks = _.reject(this.payLinks, obj => obj.id === linkId)
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
created() {
|
||||
if (this.g.user.wallets.length) {
|
||||
var getScrubLinks = this.getScrubLinks
|
||||
getScrubLinks()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import asyncio
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services import pay_invoice
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from .crud import get_scrub_by_wallet
|
||||
|
||||
|
||||
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:
|
||||
# (avoid loops)
|
||||
if "scrubed" == payment.extra.get("tag"):
|
||||
# already scrubbed
|
||||
return
|
||||
|
||||
scrub_link = await get_scrub_by_wallet(payment.wallet_id)
|
||||
|
||||
if not scrub_link:
|
||||
return
|
||||
|
||||
from lnbits.core.views.api import api_lnurlscan
|
||||
|
||||
# DECODE LNURLP OR LNADDRESS
|
||||
data = await api_lnurlscan(scrub_link.payoraddress)
|
||||
|
||||
# I REALLY HATE THIS DUPLICATION OF CODE!! CORE/VIEWS/API.PY, LINE 267
|
||||
domain = urlparse(data["callback"]).netloc
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.get(
|
||||
data["callback"],
|
||||
params={"amount": payment.amount},
|
||||
timeout=40,
|
||||
)
|
||||
if r.is_error:
|
||||
raise httpx.ConnectError
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Failed to connect to {domain}.",
|
||||
)
|
||||
|
||||
params = json.loads(r.text)
|
||||
if params.get("status") == "ERROR":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} said: '{params.get('reason', '')}'",
|
||||
)
|
||||
|
||||
invoice = bolt11.decode(params["pr"])
|
||||
if invoice.amount_msat != payment.amount:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} returned an invalid invoice. Expected {payment.amount} msat, got {invoice.amount_msat}.",
|
||||
)
|
||||
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=payment.wallet_id,
|
||||
payment_request=params["pr"],
|
||||
description=data["description"],
|
||||
extra={"tag": "scrubed"},
|
||||
)
|
||||
|
||||
return {
|
||||
"payment_hash": payment_hash,
|
||||
# maintain backwards compatibility with API clients:
|
||||
"checking_id": payment_hash,
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-expansion-item group="api" dense expand-separator label="List scrubs">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-blue">GET</span> /scrub/api/v1/links</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<pay_link_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}scrub/api/v1/links?all_wallets=true
|
||||
-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 a scrub">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/scrub/api/v1/links/<scrub_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"id": <string>, "wallet": <string>, "description":
|
||||
<string>, "payoraddress": <string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}scrub/api/v1/links/<pay_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="Create a scrub">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-green">POST</span> /scrub/api/v1/links</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"wallet": <string>, "description": <string>,
|
||||
"payoraddress": <string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"id": <string>, "wallet": <string>, "description":
|
||||
<string>, "payoraddress": <string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}scrub/api/v1/links -d '{"wallet":
|
||||
<string>, "description": <string>, "payoraddress":
|
||||
<string>}' -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="Update a scrub">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-green">PUT</span>
|
||||
/scrub/api/v1/links/<pay_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"wallet": <string>, "description": <string>,
|
||||
"payoraddress": <string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"id": <string>, "wallet": <string>, "description":
|
||||
<string>, "payoraddress": <string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X PUT {{ request.base_url }}scrub/api/v1/links/<pay_id>
|
||||
-d '{"wallet": <string>, "description": <string>,
|
||||
"payoraddress": <string>}' -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="Delete a scrub"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">DELETE</span>
|
||||
/scrub/api/v1/links/<pay_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
|
||||
<code></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X DELETE {{ request.base_url
|
||||
}}scrub/api/v1/links/<pay_id> -H "X-Api-Key: {{
|
||||
user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
||||
@@ -0,0 +1,28 @@
|
||||
<q-expansion-item group="extras" icon="info" label="Powered by LNURL">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
<b>WARNING: LNURL must be used over https or TOR</b><br />
|
||||
LNURL is a range of lightning-network standards that allow us to use
|
||||
lightning-network differently. An LNURL-pay is a link that wallets use
|
||||
to fetch an invoice from a server on-demand. The link or QR code is
|
||||
fixed, but each time it is read by a compatible wallet a new QR code is
|
||||
issued by the service. It can be used to activate machines without them
|
||||
having to maintain an electronic screen to generate and show invoices
|
||||
locally, or to sell any predefined good or service automatically.
|
||||
</p>
|
||||
<p>
|
||||
Exploring LNURL and finding use cases, is really helping inform
|
||||
lightning protocol development, rather than the protocol dictating how
|
||||
lightning-network should be engaged with.
|
||||
</p>
|
||||
<small
|
||||
>Check
|
||||
<a href="https://github.com/fiatjaf/awesome-lnurl" target="_blank"
|
||||
>Awesome LNURL</a
|
||||
>
|
||||
for further information.</small
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
@@ -0,0 +1,140 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-btn unelevated color="primary" @click="formDialog.show = true"
|
||||
>New scrub link</q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Scrub links</h5>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="payLinks"
|
||||
row-key="id"
|
||||
:pagination.sync="payLinksTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props" style="text-align: left">
|
||||
<q-th>Wallet</q-th>
|
||||
<q-th>Description</q-th>
|
||||
<q-th>LNURLPay/Address</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td>{{ props.row.wallet }}</q-td>
|
||||
<q-td>{{ props.row.description }}</q-td>
|
||||
<q-td>{{ props.row.payoraddress }}</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteScrubLink(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">{{SITE_TITLE}} Scrub extension</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list>
|
||||
{% include "scrub/_api_docs.html" %}
|
||||
<q-separator></q-separator>
|
||||
{% include "scrub/_lnurl.html" %}
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="formDialog.show" @hide="closeFormDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="sendFormData" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.description"
|
||||
type="text"
|
||||
label="Description *"
|
||||
></q-input>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.payoraddress"
|
||||
type="text"
|
||||
label="LNURLPay or LNAdress *"
|
||||
></q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="formDialog.data.id"
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
>Update pay link</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="
|
||||
formDialog.data.wallet == null ||
|
||||
formDialog.data.description == null ||
|
||||
formDialog.data.payoraddress == null
|
||||
"
|
||||
type="submit"
|
||||
>Create pay link</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="/scrub/static/js/index.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
from fastapi import Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists
|
||||
|
||||
from . import scrub_ext, scrub_renderer
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@scrub_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
return scrub_renderer().TemplateResponse(
|
||||
"scrub/index.html", {"request": request, "user": user.dict()}
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.param_functions import Query
|
||||
from fastapi.params import Depends
|
||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
|
||||
from . import scrub_ext
|
||||
from .crud import (
|
||||
create_scrub_link,
|
||||
delete_scrub_link,
|
||||
get_scrub_link,
|
||||
get_scrub_links,
|
||||
unique_scrubed_wallet,
|
||||
update_scrub_link,
|
||||
)
|
||||
from .models import CreateScrubLink
|
||||
|
||||
|
||||
@scrub_ext.get("/api/v1/links", status_code=HTTPStatus.OK)
|
||||
async def api_links(
|
||||
req: Request,
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
all_wallets: bool = Query(False),
|
||||
):
|
||||
wallet_ids = [wallet.wallet.id]
|
||||
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
|
||||
|
||||
try:
|
||||
return [link.dict() for link in await get_scrub_links(wallet_ids)]
|
||||
|
||||
except:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="No SCRUB links made yet",
|
||||
)
|
||||
|
||||
|
||||
@scrub_ext.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||
async def api_link_retrieve(
|
||||
r: Request, link_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
link = await get_scrub_link(link_id)
|
||||
|
||||
if not link:
|
||||
raise HTTPException(
|
||||
detail="Scrub link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||
)
|
||||
|
||||
if link.wallet != wallet.wallet.id:
|
||||
raise HTTPException(
|
||||
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
||||
)
|
||||
|
||||
return link
|
||||
|
||||
|
||||
@scrub_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED)
|
||||
@scrub_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||
async def api_scrub_create_or_update(
|
||||
data: CreateScrubLink,
|
||||
link_id=None,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
if link_id:
|
||||
link = await get_scrub_link(link_id)
|
||||
|
||||
if not link:
|
||||
raise HTTPException(
|
||||
detail="Scrub link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||
)
|
||||
|
||||
if link.wallet != wallet.wallet.id:
|
||||
raise HTTPException(
|
||||
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
||||
)
|
||||
|
||||
link = await update_scrub_link(**data.dict(), link_id=link_id)
|
||||
else:
|
||||
wallet_has_scrub = await unique_scrubed_wallet(wallet_id=data.wallet)
|
||||
if wallet_has_scrub > 0:
|
||||
raise HTTPException(
|
||||
detail="Wallet is already being Scrubbed",
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
link = await create_scrub_link(data=data)
|
||||
|
||||
return link
|
||||
|
||||
|
||||
@scrub_ext.delete("/api/v1/links/{link_id}")
|
||||
async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admin_key)):
|
||||
link = await get_scrub_link(link_id)
|
||||
|
||||
if not link:
|
||||
raise HTTPException(
|
||||
detail="Scrub link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||
)
|
||||
|
||||
if link.wallet != wallet.wallet.id:
|
||||
raise HTTPException(
|
||||
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
||||
)
|
||||
|
||||
await delete_scrub_link(link_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
@@ -18,7 +18,7 @@ In the "Whitelist Users" field, input the username of a Twitch account you contr
|
||||
For now, simply set the "Redirect URI" to `http://localhost`, you will change this soon.
|
||||
Then, hit create:
|
||||

|
||||
1. In LNbits, enable the Stream Alerts extension and optionally the SatsPayServer (to observe donations directly) and Watch Only (to accept on-chain donations) extenions:
|
||||
1. In LNbits, enable the Stream Alerts extension and optionally the SatsPayServer (to observe donations directly) and Onchain Wallet (watch-only) (to accept on-chain donations) extenions:
|
||||

|
||||
1. Create a "NEW SERVICE" using the button. Fill in all the information (you get your Client ID and Secret from the Streamlabs App page):
|
||||

|
||||
|
||||
@@ -168,7 +168,8 @@
|
||||
<div v-else>
|
||||
<q-checkbox :value="false" label="Chain" disabled>
|
||||
<q-tooltip>
|
||||
Watch-Only extension MUST be activated and have a wallet
|
||||
Onchain Wallet (watch-only) extension MUST be activated and
|
||||
have a wallet
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,85 @@
|
||||
# Watch Only wallet
|
||||
# Onchain Wallet (watch-only)
|
||||
|
||||
## Monitor an onchain wallet and generate addresses for onchain payments
|
||||
|
||||
Monitor an extended public key and generate deterministic fresh public keys with this simple watch only wallet. Invoice payments can also be generated, both through a publically shareable page and API.
|
||||
|
||||
1. Start by clicking "NEW WALLET"\
|
||||

|
||||
2. Fill the requested fields:
|
||||
- give the wallet a name
|
||||
- paste an Extended Public Key (xpub, ypub, zpub)
|
||||
- click "CREATE WATCH-ONLY WALLET"\
|
||||

|
||||
3. You can then access your onchain addresses\
|
||||

|
||||
4. You can then generate bitcoin onchain adresses from LNbits\
|
||||

|
||||
|
||||
You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/satspay/README.md) extension
|
||||
|
||||
### Wallet Account
|
||||
- a user can add one or more `xPubs` or `descriptors`
|
||||
- the `xPub` fingerprint must be unique per user
|
||||
- such and entry is called an `Wallet Account`
|
||||
- the addresses in a `Wallet Account` are split into `Receive Addresses` and `Change Address`
|
||||
- the user interacts directly only with the `Receive Addresses` (by sharing them)
|
||||
- see [BIP44](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki#account-discovery) for more details
|
||||
- same `xPub` will always generate the same addresses (deterministic)
|
||||
- when a `Wallet Account` is created, there are generated `20 Receive Addresses` and `5 Change Address`
|
||||
- the limits can be change from the `Config` page (see `screenshot 1`)
|
||||
- regular wallets only scan up to `20` empty receive addresses. If the user generates addresses beyond this limit a warning is shown (see `screenshot 4`)
|
||||
|
||||
### Scan Blockchain
|
||||
- when the user clicks `Scan Blockchain`, the wallet will loop over the all addresses (for each account)
|
||||
- if funds are found, then the list is extended
|
||||
- will scan addresses for all wallet accounts
|
||||
- the search is done on the client-side (using the `mempool.space` API). `mempool.space` has a limit on the number of req/sec, therefore it is expected for the scanning to start fast, but slow down as more HTTP requests have to be retried
|
||||
- addresses can also be rescanned individually form the `Address Details` section (`Addresses` tab) of each address
|
||||
|
||||
### New Receive Address
|
||||
- the `New Receive Address` button show the user the NEXT un-used address
|
||||
- un-used means funds have not already been sent to that address AND the address has not already been shared
|
||||
- internally there is a counter that keeps track of the last shared address
|
||||
- it is possible to add a `Note` to each address in order to remember when/with whom it was shared
|
||||
- mind the gap (`screenshot 4`)
|
||||
|
||||
### Addresses Tab
|
||||
- the `Addresses` tab contains a list with the addresses for all the `Wallet Accounts`
|
||||
- only one entry per address will be shown (even if there are multiple UTXOs at that address)
|
||||
- several filter criteria can be applied
|
||||
- unconfirmed funds are also taken into account
|
||||
- `Address Details` can be viewed by clicking the `Expand` button
|
||||
|
||||
### History Tap
|
||||
- shows the chronological order of transactions
|
||||
- it shows unconfirmed transactions at the top
|
||||
- it can be exported as CSV file
|
||||
|
||||
### Coins Tab
|
||||
- shows the UTXOs for all wallets
|
||||
- there can be multiple UTXOs for the same address
|
||||
|
||||
### Make Payment
|
||||
- create a new `Partially Signed Bitcoin Transaction`
|
||||
- multiple `Send Addresses` can be added
|
||||
- the `Max` button next to an address is for sending the remaining funds to this address (no change)
|
||||
- the user can select the inputs (UTXOs) manually, or it can use of the basic selection algorithms
|
||||
- amounts have to be provided for the `Send Addresses` beforehand (so the algorithm knows the amount to be selected)
|
||||
- `Show Advanced` allows to (see `screenshot 2`):
|
||||
- select from which account the change address will be selected (defaults to the first one)
|
||||
- select the `Fee Rate`
|
||||
- it defaults to the `Medium` value at the moment the `Make Payment` button was clicked
|
||||
- it can be refreshed
|
||||
- warnings are shown if the fee is too Low or to High
|
||||
|
||||
### Create PSBT
|
||||
- based on the Inputs & Outputs selected by the user a PSBT will be generated
|
||||
- this wallet is watch-only, therefore does not support signing
|
||||
- it is not mandatory for the `Selected Amount` to be grater than `Payed Amount`
|
||||
- the generated PSBT can be combined with other PSBTs that add more inputs.
|
||||
- the generated PSBT can be imported for signing into different wallets like Electrum
|
||||
- import the PSBT into Electrum and check the In/Outs/Fee (see `screenshot 3`)
|
||||
|
||||
## Screensots
|
||||
- screenshot 1:
|
||||

|
||||
|
||||
- screenshot 2:
|
||||

|
||||
|
||||
- screenshot 3:
|
||||

|
||||
|
||||
- screenshot 4:
|
||||

|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
|
||||
db = Database("ext_watchonly")
|
||||
|
||||
watchonly_static_files = [
|
||||
{
|
||||
"path": "/watchonly/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/watchonly/static"),
|
||||
"name": "watchonly_static",
|
||||
}
|
||||
]
|
||||
|
||||
watchonly_ext: APIRouter = APIRouter(prefix="/watchonly", tags=["watchonly"])
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "Watch Only",
|
||||
"name": "Onchain Wallet",
|
||||
"short_description": "Onchain watch only wallets",
|
||||
"icon": "visibility",
|
||||
"contributors": [
|
||||
"arcbtc"
|
||||
"arcbtc",
|
||||
"motorina0"
|
||||
]
|
||||
}
|
||||
|
||||
+157
-129
@@ -1,81 +1,16 @@
|
||||
import json
|
||||
from typing import List, Optional
|
||||
|
||||
from embit.descriptor import Descriptor, Key # type: ignore
|
||||
from embit.descriptor.arguments import AllowedDerivation # type: ignore
|
||||
from embit.networks import NETWORKS # type: ignore
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import Addresses, Mempool, Wallets
|
||||
from .helpers import derive_address, parse_key
|
||||
from .models import Address, Config, Mempool, WalletAccount
|
||||
|
||||
##########################WALLETS####################
|
||||
|
||||
|
||||
def detect_network(k):
|
||||
version = k.key.version
|
||||
for network_name in NETWORKS:
|
||||
net = NETWORKS[network_name]
|
||||
# not found in this network
|
||||
if version in [net["xpub"], net["ypub"], net["zpub"], net["Zpub"], net["Ypub"]]:
|
||||
return net
|
||||
|
||||
|
||||
def parse_key(masterpub: str):
|
||||
"""Parses masterpub or descriptor and returns a tuple: (Descriptor, network)
|
||||
To create addresses use descriptor.derive(num).address(network=network)
|
||||
"""
|
||||
network = None
|
||||
# probably a single key
|
||||
if "(" not in masterpub:
|
||||
k = Key.from_string(masterpub)
|
||||
if not k.is_extended:
|
||||
raise ValueError("The key is not a master public key")
|
||||
if k.is_private:
|
||||
raise ValueError("Private keys are not allowed")
|
||||
# check depth
|
||||
if k.key.depth != 3:
|
||||
raise ValueError(
|
||||
"Non-standard depth. Only bip44, bip49 and bip84 are supported with bare xpubs. For custom derivation paths use descriptors."
|
||||
)
|
||||
# if allowed derivation is not provided use default /{0,1}/*
|
||||
if k.allowed_derivation is None:
|
||||
k.allowed_derivation = AllowedDerivation.default()
|
||||
# get version bytes
|
||||
version = k.key.version
|
||||
for network_name in NETWORKS:
|
||||
net = NETWORKS[network_name]
|
||||
# not found in this network
|
||||
if version in [net["xpub"], net["ypub"], net["zpub"]]:
|
||||
network = net
|
||||
if version == net["xpub"]:
|
||||
desc = Descriptor.from_string("pkh(%s)" % str(k))
|
||||
elif version == net["ypub"]:
|
||||
desc = Descriptor.from_string("sh(wpkh(%s))" % str(k))
|
||||
elif version == net["zpub"]:
|
||||
desc = Descriptor.from_string("wpkh(%s)" % str(k))
|
||||
break
|
||||
# we didn't find correct version
|
||||
if network is None:
|
||||
raise ValueError("Unknown master public key version")
|
||||
else:
|
||||
desc = Descriptor.from_string(masterpub)
|
||||
if not desc.is_wildcard:
|
||||
raise ValueError("Descriptor should have wildcards")
|
||||
for k in desc.keys:
|
||||
if k.is_extended:
|
||||
net = detect_network(k)
|
||||
if net is None:
|
||||
raise ValueError(f"Unknown version: {k}")
|
||||
if network is not None and network != net:
|
||||
raise ValueError("Keys from different networks")
|
||||
network = net
|
||||
return desc, network
|
||||
|
||||
|
||||
async def create_watch_wallet(user: str, masterpub: str, title: str) -> Wallets:
|
||||
# check the masterpub is fine, it will raise an exception if not
|
||||
parse_key(masterpub)
|
||||
async def create_watch_wallet(w: WalletAccount) -> WalletAccount:
|
||||
wallet_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
@@ -83,34 +18,44 @@ async def create_watch_wallet(user: str, masterpub: str, title: str) -> Wallets:
|
||||
id,
|
||||
"user",
|
||||
masterpub,
|
||||
fingerprint,
|
||||
title,
|
||||
type,
|
||||
address_no,
|
||||
balance
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
# address_no is -1 so fresh address on empty wallet can get address with index 0
|
||||
(wallet_id, user, masterpub, title, -1, 0),
|
||||
(
|
||||
wallet_id,
|
||||
w.user,
|
||||
w.masterpub,
|
||||
w.fingerprint,
|
||||
w.title,
|
||||
w.type,
|
||||
w.address_no,
|
||||
w.balance,
|
||||
),
|
||||
)
|
||||
|
||||
return await get_watch_wallet(wallet_id)
|
||||
|
||||
|
||||
async def get_watch_wallet(wallet_id: str) -> Optional[Wallets]:
|
||||
async def get_watch_wallet(wallet_id: str) -> Optional[WalletAccount]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM watchonly.wallets WHERE id = ?", (wallet_id,)
|
||||
)
|
||||
return Wallets.from_row(row) if row else None
|
||||
return WalletAccount.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_watch_wallets(user: str) -> List[Wallets]:
|
||||
async def get_watch_wallets(user: str) -> List[WalletAccount]:
|
||||
rows = await db.fetchall(
|
||||
"""SELECT * FROM watchonly.wallets WHERE "user" = ?""", (user,)
|
||||
)
|
||||
return [Wallets(**row) for row in rows]
|
||||
return [WalletAccount(**row) for row in rows]
|
||||
|
||||
|
||||
async def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[Wallets]:
|
||||
async def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[WalletAccount]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
|
||||
await db.execute(
|
||||
@@ -119,94 +64,177 @@ async def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[Wallets]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM watchonly.wallets WHERE id = ?", (wallet_id,)
|
||||
)
|
||||
return Wallets.from_row(row) if row else None
|
||||
return WalletAccount.from_row(row) if row else None
|
||||
|
||||
|
||||
async def delete_watch_wallet(wallet_id: str) -> None:
|
||||
await db.execute("DELETE FROM watchonly.wallets WHERE id = ?", (wallet_id,))
|
||||
|
||||
########################ADDRESSES#######################
|
||||
|
||||
########################ADDRESSES#######################
|
||||
|
||||
|
||||
async def get_derive_address(wallet_id: str, num: int):
|
||||
wallet = await get_watch_wallet(wallet_id)
|
||||
key = wallet.masterpub
|
||||
desc, network = parse_key(key)
|
||||
return desc.derive(num).address(network=network)
|
||||
|
||||
|
||||
async def get_fresh_address(wallet_id: str) -> Optional[Addresses]:
|
||||
async def get_fresh_address(wallet_id: str) -> Optional[Address]:
|
||||
# todo: move logic to views_api after satspay refactoring
|
||||
wallet = await get_watch_wallet(wallet_id)
|
||||
|
||||
if not wallet:
|
||||
return None
|
||||
|
||||
address = await get_derive_address(wallet_id, wallet.address_no + 1)
|
||||
wallet_addresses = await get_addresses(wallet_id)
|
||||
receive_addresses = list(
|
||||
filter(
|
||||
lambda addr: addr.branch_index == 0 and addr.has_activity, wallet_addresses
|
||||
)
|
||||
)
|
||||
last_receive_index = (
|
||||
receive_addresses.pop().address_index if receive_addresses else -1
|
||||
)
|
||||
address_index = (
|
||||
last_receive_index
|
||||
if last_receive_index > wallet.address_no
|
||||
else wallet.address_no
|
||||
)
|
||||
|
||||
await update_watch_wallet(wallet_id=wallet_id, address_no=wallet.address_no + 1)
|
||||
masterpub_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
address = await get_address_at_index(wallet_id, 0, address_index + 1)
|
||||
|
||||
if not address:
|
||||
addresses = await create_fresh_addresses(
|
||||
wallet_id, address_index + 1, address_index + 2
|
||||
)
|
||||
address = addresses.pop()
|
||||
|
||||
await update_watch_wallet(wallet_id, **{"address_no": address_index + 1})
|
||||
|
||||
return address
|
||||
|
||||
|
||||
async def create_fresh_addresses(
|
||||
wallet_id: str,
|
||||
start_address_index: int,
|
||||
end_address_index: int,
|
||||
change_address=False,
|
||||
) -> List[Address]:
|
||||
if start_address_index > end_address_index:
|
||||
return None
|
||||
|
||||
wallet = await get_watch_wallet(wallet_id)
|
||||
if not wallet:
|
||||
return None
|
||||
|
||||
branch_index = 1 if change_address else 0
|
||||
|
||||
for address_index in range(start_address_index, end_address_index):
|
||||
address = await derive_address(wallet.masterpub, address_index, branch_index)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO watchonly.addresses (
|
||||
id,
|
||||
address,
|
||||
wallet,
|
||||
amount
|
||||
amount,
|
||||
branch_index,
|
||||
address_index
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(masterpub_id, address, wallet_id, 0),
|
||||
(urlsafe_short_hash(), address, wallet_id, 0, branch_index, address_index),
|
||||
)
|
||||
|
||||
# return fresh addresses
|
||||
rows = await db.fetchall(
|
||||
"""
|
||||
SELECT * FROM watchonly.addresses
|
||||
WHERE wallet = ? AND branch_index = ? AND address_index >= ? AND address_index < ?
|
||||
ORDER BY branch_index, address_index
|
||||
""",
|
||||
(wallet_id, branch_index, start_address_index, end_address_index),
|
||||
)
|
||||
|
||||
return await get_address(address)
|
||||
return [Address(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_address(address: str) -> Optional[Addresses]:
|
||||
async def get_address(address: str) -> Optional[Address]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM watchonly.addresses WHERE address = ?", (address,)
|
||||
)
|
||||
return Addresses.from_row(row) if row else None
|
||||
return Address.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_addresses(wallet_id: str) -> List[Addresses]:
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM watchonly.addresses WHERE wallet = ?", (wallet_id,)
|
||||
)
|
||||
return [Addresses(**row) for row in rows]
|
||||
|
||||
|
||||
######################MEMPOOL#######################
|
||||
|
||||
|
||||
async def create_mempool(user: str) -> Optional[Mempool]:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO watchonly.mempool ("user",endpoint)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(user, "https://mempool.space"),
|
||||
)
|
||||
async def get_address_at_index(
|
||||
wallet_id: str, branch_index: int, address_index: int
|
||||
) -> Optional[Address]:
|
||||
row = await db.fetchone(
|
||||
"""SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,)
|
||||
"""
|
||||
SELECT * FROM watchonly.addresses
|
||||
WHERE wallet = ? AND branch_index = ? AND address_index = ?
|
||||
""",
|
||||
(
|
||||
wallet_id,
|
||||
branch_index,
|
||||
address_index,
|
||||
),
|
||||
)
|
||||
return Mempool.from_row(row) if row else None
|
||||
return Address.from_row(row) if row else None
|
||||
|
||||
|
||||
async def update_mempool(user: str, **kwargs) -> Optional[Mempool]:
|
||||
async def get_addresses(wallet_id: str) -> List[Address]:
|
||||
rows = await db.fetchall(
|
||||
"""
|
||||
SELECT * FROM watchonly.addresses WHERE wallet = ?
|
||||
ORDER BY branch_index, address_index
|
||||
""",
|
||||
(wallet_id,),
|
||||
)
|
||||
|
||||
return [Address(**row) for row in rows]
|
||||
|
||||
|
||||
async def update_address(id: str, **kwargs) -> Optional[Address]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
|
||||
await db.execute(
|
||||
f"""UPDATE watchonly.mempool SET {q} WHERE "user" = ?""",
|
||||
(*kwargs.values(), user),
|
||||
f"""UPDATE watchonly.addresses SET {q} WHERE id = ? """,
|
||||
(*kwargs.values(), id),
|
||||
)
|
||||
row = await db.fetchone(
|
||||
"""SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,)
|
||||
)
|
||||
return Mempool.from_row(row) if row else None
|
||||
row = await db.fetchone("SELECT * FROM watchonly.addresses WHERE id = ?", (id))
|
||||
return Address.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_mempool(user: str) -> Mempool:
|
||||
row = await db.fetchone(
|
||||
"""SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,)
|
||||
async def delete_addresses_for_wallet(wallet_id: str) -> None:
|
||||
await db.execute("DELETE FROM watchonly.addresses WHERE wallet = ?", (wallet_id,))
|
||||
|
||||
|
||||
######################CONFIG#######################
|
||||
async def create_config(user: str) -> Config:
|
||||
config = Config()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO watchonly.config ("user", json_data)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(user, json.dumps(config.dict())),
|
||||
)
|
||||
return Mempool.from_row(row) if row else None
|
||||
row = await db.fetchone(
|
||||
"""SELECT json_data FROM watchonly.config WHERE "user" = ?""", (user,)
|
||||
)
|
||||
return json.loads(row[0], object_hook=lambda d: Config(**d))
|
||||
|
||||
|
||||
async def update_config(config: Config, user: str) -> Optional[Config]:
|
||||
await db.execute(
|
||||
f"""UPDATE watchonly.config SET json_data = ? WHERE "user" = ?""",
|
||||
(json.dumps(config.dict()), user),
|
||||
)
|
||||
row = await db.fetchone(
|
||||
"""SELECT json_data FROM watchonly.config WHERE "user" = ?""", (user,)
|
||||
)
|
||||
return json.loads(row[0], object_hook=lambda d: Config(**d))
|
||||
|
||||
|
||||
async def get_config(user: str) -> Optional[Config]:
|
||||
row = await db.fetchone(
|
||||
"""SELECT json_data FROM watchonly.config WHERE "user" = ?""", (user,)
|
||||
)
|
||||
return json.loads(row[0], object_hook=lambda d: Config(**d)) if row else None
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
from embit.descriptor import Descriptor, Key # type: ignore
|
||||
from embit.descriptor.arguments import AllowedDerivation # type: ignore
|
||||
from embit.networks import NETWORKS # type: ignore
|
||||
|
||||
|
||||
def detect_network(k):
|
||||
version = k.key.version
|
||||
for network_name in NETWORKS:
|
||||
net = NETWORKS[network_name]
|
||||
# not found in this network
|
||||
if version in [net["xpub"], net["ypub"], net["zpub"], net["Zpub"], net["Ypub"]]:
|
||||
return net
|
||||
|
||||
|
||||
def parse_key(masterpub: str) -> Descriptor:
|
||||
"""Parses masterpub or descriptor and returns a tuple: (Descriptor, network)
|
||||
To create addresses use descriptor.derive(num).address(network=network)
|
||||
"""
|
||||
network = None
|
||||
# probably a single key
|
||||
if "(" not in masterpub:
|
||||
k = Key.from_string(masterpub)
|
||||
if not k.is_extended:
|
||||
raise ValueError("The key is not a master public key")
|
||||
if k.is_private:
|
||||
raise ValueError("Private keys are not allowed")
|
||||
# check depth
|
||||
if k.key.depth != 3:
|
||||
raise ValueError(
|
||||
"Non-standard depth. Only bip44, bip49 and bip84 are supported with bare xpubs. For custom derivation paths use descriptors."
|
||||
)
|
||||
# if allowed derivation is not provided use default /{0,1}/*
|
||||
if k.allowed_derivation is None:
|
||||
k.allowed_derivation = AllowedDerivation.default()
|
||||
# get version bytes
|
||||
version = k.key.version
|
||||
for network_name in NETWORKS:
|
||||
net = NETWORKS[network_name]
|
||||
# not found in this network
|
||||
if version in [net["xpub"], net["ypub"], net["zpub"]]:
|
||||
network = net
|
||||
if version == net["xpub"]:
|
||||
desc = Descriptor.from_string("pkh(%s)" % str(k))
|
||||
elif version == net["ypub"]:
|
||||
desc = Descriptor.from_string("sh(wpkh(%s))" % str(k))
|
||||
elif version == net["zpub"]:
|
||||
desc = Descriptor.from_string("wpkh(%s)" % str(k))
|
||||
break
|
||||
# we didn't find correct version
|
||||
if network is None:
|
||||
raise ValueError("Unknown master public key version")
|
||||
else:
|
||||
desc = Descriptor.from_string(masterpub)
|
||||
if not desc.is_wildcard:
|
||||
raise ValueError("Descriptor should have wildcards")
|
||||
for k in desc.keys:
|
||||
if k.is_extended:
|
||||
net = detect_network(k)
|
||||
if net is None:
|
||||
raise ValueError(f"Unknown version: {k}")
|
||||
if network is not None and network != net:
|
||||
raise ValueError("Keys from different networks")
|
||||
network = net
|
||||
return desc, network
|
||||
|
||||
|
||||
async def derive_address(masterpub: str, num: int, branch_index=0):
|
||||
desc, network = parse_key(masterpub)
|
||||
return desc.derive(num, branch_index).address(network=network)
|
||||
@@ -34,3 +34,50 @@ async def m001_initial(db):
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m002_add_columns_to_adresses(db):
|
||||
"""
|
||||
Add 'branch_index', 'address_index', 'has_activity' and 'note' columns to the 'addresses' table
|
||||
"""
|
||||
|
||||
await db.execute(
|
||||
"ALTER TABLE watchonly.addresses ADD COLUMN branch_index INTEGER NOT NULL DEFAULT 0;"
|
||||
)
|
||||
await db.execute(
|
||||
"ALTER TABLE watchonly.addresses ADD COLUMN address_index INTEGER NOT NULL DEFAULT 0;"
|
||||
)
|
||||
await db.execute(
|
||||
"ALTER TABLE watchonly.addresses ADD COLUMN has_activity BOOLEAN DEFAULT false;"
|
||||
)
|
||||
await db.execute("ALTER TABLE watchonly.addresses ADD COLUMN note TEXT;")
|
||||
|
||||
|
||||
async def m003_add_columns_to_wallets(db):
|
||||
"""
|
||||
Add 'type' and 'fingerprint' columns to the 'wallets' table
|
||||
"""
|
||||
|
||||
await db.execute("ALTER TABLE watchonly.wallets ADD COLUMN type TEXT;")
|
||||
await db.execute(
|
||||
"ALTER TABLE watchonly.wallets ADD COLUMN fingerprint TEXT NOT NULL DEFAULT '';"
|
||||
)
|
||||
|
||||
|
||||
async def m004_create_config_table(db):
|
||||
"""
|
||||
Allow the extension to persist and retrieve any number of config values.
|
||||
Each user has its configurations saved as a JSON string
|
||||
"""
|
||||
|
||||
await db.execute(
|
||||
"""CREATE TABLE watchonly.config (
|
||||
"user" TEXT NOT NULL,
|
||||
json_data TEXT NOT NULL
|
||||
);"""
|
||||
)
|
||||
|
||||
### TODO: fix statspay dependcy first
|
||||
# await db.execute(
|
||||
# "DROP TABLE watchonly.wallets;"
|
||||
# )
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from sqlite3 import Row
|
||||
from typing import List
|
||||
|
||||
from fastapi.param_functions import Query
|
||||
from pydantic import BaseModel
|
||||
@@ -9,19 +10,22 @@ class CreateWallet(BaseModel):
|
||||
title: str = Query("")
|
||||
|
||||
|
||||
class Wallets(BaseModel):
|
||||
class WalletAccount(BaseModel):
|
||||
id: str
|
||||
user: str
|
||||
masterpub: str
|
||||
fingerprint: str
|
||||
title: str
|
||||
address_no: int
|
||||
balance: int
|
||||
type: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Wallets":
|
||||
def from_row(cls, row: Row) -> "WalletAccount":
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
### TODO: fix statspay dependcy and remove
|
||||
class Mempool(BaseModel):
|
||||
user: str
|
||||
endpoint: str
|
||||
@@ -31,12 +35,55 @@ class Mempool(BaseModel):
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
class Addresses(BaseModel):
|
||||
class Address(BaseModel):
|
||||
id: str
|
||||
address: str
|
||||
wallet: str
|
||||
amount: int
|
||||
amount: int = 0
|
||||
branch_index: int = 0
|
||||
address_index: int
|
||||
note: str = None
|
||||
has_activity: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Addresses":
|
||||
def from_row(cls, row: Row) -> "Address":
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
class TransactionInput(BaseModel):
|
||||
tx_id: str
|
||||
vout: int
|
||||
amount: int
|
||||
address: str
|
||||
branch_index: int
|
||||
address_index: int
|
||||
masterpub_fingerprint: str
|
||||
tx_hex: str
|
||||
|
||||
|
||||
class TransactionOutput(BaseModel):
|
||||
amount: int
|
||||
address: str
|
||||
branch_index: int = None
|
||||
address_index: int = None
|
||||
masterpub_fingerprint: str = None
|
||||
|
||||
|
||||
class MasterPublicKey(BaseModel):
|
||||
public_key: str
|
||||
fingerprint: str
|
||||
|
||||
|
||||
class CreatePsbt(BaseModel):
|
||||
masterpubs: List[MasterPublicKey]
|
||||
inputs: List[TransactionInput]
|
||||
outputs: List[TransactionOutput]
|
||||
fee_rate: int
|
||||
tx_size: int
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
mempool_endpoint = "https://mempool.space"
|
||||
receive_gap_limit = 20
|
||||
change_gap_limit = 5
|
||||
sats_denominated = True
|
||||
|
||||
@@ -0,0 +1,743 @@
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
Vue.filter('reverse', function (value) {
|
||||
// slice to make a copy of array, then reverse the copy
|
||||
return value.slice().reverse()
|
||||
})
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
DUST_LIMIT: 546,
|
||||
filter: '',
|
||||
|
||||
scan: {
|
||||
scanning: false,
|
||||
scanCount: 0,
|
||||
scanIndex: 0
|
||||
},
|
||||
|
||||
currentAddress: null,
|
||||
|
||||
tab: 'addresses',
|
||||
|
||||
config: {
|
||||
data: {
|
||||
mempool_endpoint: 'https://mempool.space',
|
||||
receive_gap_limit: 20,
|
||||
change_gap_limit: 5
|
||||
},
|
||||
DEFAULT_RECEIVE_GAP_LIMIT: 20,
|
||||
show: false
|
||||
},
|
||||
|
||||
formDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
qrCodeDialog: {
|
||||
show: false,
|
||||
data: null
|
||||
},
|
||||
...tables,
|
||||
...tableData
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
//################### CONFIG ###################
|
||||
getConfig: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/config',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
this.config.data = data
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
updateConfig: async function () {
|
||||
const wallet = this.g.user.wallets[0]
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
'/watchonly/api/v1/config',
|
||||
wallet.adminkey,
|
||||
this.config.data
|
||||
)
|
||||
this.config.show = false
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
|
||||
//################### WALLETS ###################
|
||||
getWalletName: function (walletId) {
|
||||
const wallet = this.walletAccounts.find(wl => wl.id === walletId)
|
||||
return wallet ? wallet.title : 'unknown'
|
||||
},
|
||||
addWalletAccount: async function () {
|
||||
const wallet = this.g.user.wallets[0]
|
||||
const data = _.omit(this.formDialog.data, 'wallet')
|
||||
await this.createWalletAccount(wallet, data)
|
||||
},
|
||||
createWalletAccount: async function (wallet, data) {
|
||||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'POST',
|
||||
'/watchonly/api/v1/wallet',
|
||||
wallet.adminkey,
|
||||
data
|
||||
)
|
||||
this.walletAccounts.push(mapWalletAccount(response.data))
|
||||
this.formDialog.show = false
|
||||
|
||||
await this.refreshWalletAccounts()
|
||||
await this.refreshAddresses()
|
||||
|
||||
if (!this.payment.changeWallett) {
|
||||
this.payment.changeWallet = this.walletAccounts[0]
|
||||
this.selectChangeAddress(this.payment.changeWallet)
|
||||
}
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
deleteWalletAccount: function (walletAccountId) {
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'Are you sure you want to delete this watch only wallet?'
|
||||
)
|
||||
.onOk(async () => {
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'DELETE',
|
||||
'/watchonly/api/v1/wallet/' + walletAccountId,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
this.walletAccounts = _.reject(this.walletAccounts, function (obj) {
|
||||
return obj.id === walletAccountId
|
||||
})
|
||||
await this.refreshWalletAccounts()
|
||||
await this.refreshAddresses()
|
||||
if (
|
||||
this.payment.changeWallet &&
|
||||
this.payment.changeWallet.id === walletAccountId
|
||||
) {
|
||||
this.payment.changeWallet = this.walletAccounts[0]
|
||||
this.selectChangeAddress(this.payment.changeWallet)
|
||||
}
|
||||
await this.scanAddressWithAmount()
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Error while deleting wallet account. Please try again.',
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
getAddressesForWallet: async function (walletId) {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/addresses/' + walletId,
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
return data.map(mapAddressesData)
|
||||
} catch (err) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: `Failed to fetch addresses for wallet with id ${walletId}.`,
|
||||
timeout: 10000
|
||||
})
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
return []
|
||||
},
|
||||
getWatchOnlyWallets: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/wallet',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
return data
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to fetch wallets.',
|
||||
timeout: 10000
|
||||
})
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
return []
|
||||
},
|
||||
refreshWalletAccounts: async function () {
|
||||
const wallets = await this.getWatchOnlyWallets()
|
||||
this.walletAccounts = wallets.map(w => mapWalletAccount(w))
|
||||
},
|
||||
getAmmountForWallet: function (walletId) {
|
||||
const amount = this.addresses.data
|
||||
.filter(a => a.wallet === walletId)
|
||||
.reduce((t, a) => t + a.amount || 0, 0)
|
||||
return this.satBtc(amount)
|
||||
},
|
||||
|
||||
//################### ADDRESSES ###################
|
||||
|
||||
refreshAddresses: async function () {
|
||||
const wallets = await this.getWatchOnlyWallets()
|
||||
this.addresses.data = []
|
||||
for (const {id, type} of wallets) {
|
||||
const newAddresses = await this.getAddressesForWallet(id)
|
||||
const uniqueAddresses = newAddresses.filter(
|
||||
newAddr =>
|
||||
!this.addresses.data.find(a => a.address === newAddr.address)
|
||||
)
|
||||
|
||||
const lastAcctiveAddress =
|
||||
uniqueAddresses.filter(a => !a.isChange && a.hasActivity).pop() || {}
|
||||
|
||||
uniqueAddresses.forEach(a => {
|
||||
a.expanded = false
|
||||
a.accountType = type
|
||||
a.gapLimitExceeded =
|
||||
!a.isChange &&
|
||||
a.addressIndex >
|
||||
lastAcctiveAddress.addressIndex +
|
||||
this.config.DEFAULT_RECEIVE_GAP_LIMIT
|
||||
})
|
||||
this.addresses.data.push(...uniqueAddresses)
|
||||
}
|
||||
},
|
||||
updateAmountForAddress: async function (addressData, amount = 0) {
|
||||
try {
|
||||
const wallet = this.g.user.wallets[0]
|
||||
addressData.amount = amount
|
||||
if (!addressData.isChange) {
|
||||
const addressWallet = this.walletAccounts.find(
|
||||
w => w.id === addressData.wallet
|
||||
)
|
||||
if (
|
||||
addressWallet &&
|
||||
addressWallet.address_no < addressData.addressIndex
|
||||
) {
|
||||
addressWallet.address_no = addressData.addressIndex
|
||||
}
|
||||
}
|
||||
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
`/watchonly/api/v1/address/${addressData.id}`,
|
||||
wallet.adminkey,
|
||||
{amount}
|
||||
)
|
||||
} catch (err) {
|
||||
addressData.error = 'Failed to refresh amount for address'
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: `Failed to refresh amount for address ${addressData.address}`,
|
||||
timeout: 10000
|
||||
})
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
},
|
||||
updateNoteForAddress: async function (addressData, note) {
|
||||
try {
|
||||
const wallet = this.g.user.wallets[0]
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
`/watchonly/api/v1/address/${addressData.id}`,
|
||||
wallet.adminkey,
|
||||
{note: addressData.note}
|
||||
)
|
||||
const updatedAddress =
|
||||
this.addresses.data.find(a => a.id === addressData.id) || {}
|
||||
updatedAddress.note = note
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
},
|
||||
getFilteredAddresses: function () {
|
||||
const selectedWalletId = this.addresses.selectedWallet?.id
|
||||
const filter = this.addresses.filterValues || []
|
||||
const includeChangeAddrs = filter.includes('Show Change Addresses')
|
||||
const includeGapAddrs = filter.includes('Show Gap Addresses')
|
||||
const excludeNoAmount = filter.includes('Only With Amount')
|
||||
|
||||
const walletsLimit = this.walletAccounts.reduce((r, w) => {
|
||||
r[`_${w.id}`] = w.address_no
|
||||
return r
|
||||
}, {})
|
||||
|
||||
const addresses = this.addresses.data.filter(
|
||||
a =>
|
||||
(includeChangeAddrs || !a.isChange) &&
|
||||
(includeGapAddrs ||
|
||||
a.isChange ||
|
||||
a.addressIndex <= walletsLimit[`_${a.wallet}`]) &&
|
||||
!(excludeNoAmount && a.amount === 0) &&
|
||||
(!selectedWalletId || a.wallet === selectedWalletId)
|
||||
)
|
||||
return addresses
|
||||
},
|
||||
openGetFreshAddressDialog: async function (walletId) {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/watchonly/api/v1/address/${walletId}`,
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
const addressData = mapAddressesData(data)
|
||||
|
||||
addressData.note = `Shared on ${currentDateTime()}`
|
||||
const lastAcctiveAddress =
|
||||
this.addresses.data
|
||||
.filter(
|
||||
a => a.wallet === addressData.wallet && !a.isChange && a.hasActivity
|
||||
)
|
||||
.pop() || {}
|
||||
addressData.gapLimitExceeded =
|
||||
!addressData.isChange &&
|
||||
addressData.addressIndex >
|
||||
lastAcctiveAddress.addressIndex +
|
||||
this.config.DEFAULT_RECEIVE_GAP_LIMIT
|
||||
|
||||
this.openQrCodeDialog(addressData)
|
||||
const wallet = this.walletAccounts.find(w => w.id === walletId) || {}
|
||||
wallet.address_no = addressData.addressIndex
|
||||
await this.refreshAddresses()
|
||||
},
|
||||
|
||||
//################### ADDRESS HISTORY ###################
|
||||
addressHistoryFromTxs: function (addressData, txs) {
|
||||
const addressHistory = []
|
||||
txs.forEach(tx => {
|
||||
const sent = tx.vin
|
||||
.filter(
|
||||
vin => vin.prevout.scriptpubkey_address === addressData.address
|
||||
)
|
||||
.map(vin => mapInputToSentHistory(tx, addressData, vin))
|
||||
|
||||
const received = tx.vout
|
||||
.filter(vout => vout.scriptpubkey_address === addressData.address)
|
||||
.map(vout => mapOutputToReceiveHistory(tx, addressData, vout))
|
||||
addressHistory.push(...sent, ...received)
|
||||
})
|
||||
return addressHistory
|
||||
},
|
||||
getFilteredAddressesHistory: function () {
|
||||
return this.addresses.history.filter(
|
||||
a => (!a.isChange || a.sent) && !a.isSubItem
|
||||
)
|
||||
},
|
||||
exportHistoryToCSV: function () {
|
||||
const history = this.getFilteredAddressesHistory().map(a => ({
|
||||
...a,
|
||||
action: a.sent ? 'Sent' : 'Received'
|
||||
}))
|
||||
LNbits.utils.exportCSV(
|
||||
this.historyTable.exportColums,
|
||||
history,
|
||||
'address-history'
|
||||
)
|
||||
},
|
||||
markSameTxAddressHistory: function () {
|
||||
this.addresses.history
|
||||
.filter(s => s.sent)
|
||||
.forEach((el, i, arr) => {
|
||||
if (el.isSubItem) return
|
||||
|
||||
const sameTxItems = arr.slice(i + 1).filter(e => e.txId === el.txId)
|
||||
if (!sameTxItems.length) return
|
||||
sameTxItems.forEach(e => {
|
||||
e.isSubItem = true
|
||||
})
|
||||
|
||||
el.totalAmount =
|
||||
el.amount + sameTxItems.reduce((t, e) => (t += e.amount || 0), 0)
|
||||
el.sameTxItems = sameTxItems
|
||||
})
|
||||
},
|
||||
showAddressHistoryDetails: function (addressHistory) {
|
||||
addressHistory.expanded = true
|
||||
},
|
||||
|
||||
//################### PAYMENT ###################
|
||||
createTx: function (excludeChange = false) {
|
||||
const tx = {
|
||||
fee_rate: this.payment.feeRate,
|
||||
tx_size: this.payment.txSize,
|
||||
masterpubs: this.walletAccounts.map(w => ({
|
||||
public_key: w.masterpub,
|
||||
fingerprint: w.fingerprint
|
||||
}))
|
||||
}
|
||||
tx.inputs = this.utxos.data
|
||||
.filter(utxo => utxo.selected)
|
||||
.map(mapUtxoToPsbtInput)
|
||||
.sort((a, b) =>
|
||||
a.tx_id < b.tx_id ? -1 : a.tx_id > b.tx_id ? 1 : a.vout - b.vout
|
||||
)
|
||||
|
||||
tx.outputs = this.payment.data.map(out => ({
|
||||
address: out.address,
|
||||
amount: out.amount
|
||||
}))
|
||||
|
||||
if (excludeChange) {
|
||||
this.payment.changeAmount = 0
|
||||
} else {
|
||||
const change = this.createChangeOutput()
|
||||
this.payment.changeAmount = change.amount
|
||||
if (change.amount >= this.DUST_LIMIT) {
|
||||
tx.outputs.push(change)
|
||||
}
|
||||
}
|
||||
// Only sort by amount on UI level (no lib for address decode)
|
||||
// Should sort by scriptPubKey (as byte array) on the backend
|
||||
tx.outputs.sort((a, b) => a.amount - b.amount)
|
||||
|
||||
return tx
|
||||
},
|
||||
createChangeOutput: function () {
|
||||
const change = this.payment.changeAddress
|
||||
const fee = this.payment.feeRate * this.payment.txSize
|
||||
const inputAmount = this.getTotalSelectedUtxoAmount()
|
||||
const payedAmount = this.getTotalPaymentAmount()
|
||||
const walletAcount =
|
||||
this.walletAccounts.find(w => w.id === change.wallet) || {}
|
||||
|
||||
return {
|
||||
address: change.address,
|
||||
amount: inputAmount - payedAmount - fee,
|
||||
addressIndex: change.addressIndex,
|
||||
addressIndex: change.addressIndex,
|
||||
masterpub_fingerprint: walletAcount.fingerprint
|
||||
}
|
||||
},
|
||||
computeFee: function () {
|
||||
const tx = this.createTx()
|
||||
this.payment.txSize = Math.round(txSize(tx))
|
||||
return this.payment.feeRate * this.payment.txSize
|
||||
},
|
||||
createPsbt: async function () {
|
||||
const wallet = this.g.user.wallets[0]
|
||||
try {
|
||||
this.computeFee()
|
||||
const tx = this.createTx()
|
||||
txSize(tx)
|
||||
for (const input of tx.inputs) {
|
||||
input.tx_hex = await this.fetchTxHex(input.tx_id)
|
||||
}
|
||||
|
||||
const {data} = await LNbits.api.request(
|
||||
'POST',
|
||||
'/watchonly/api/v1/psbt',
|
||||
wallet.adminkey,
|
||||
tx
|
||||
)
|
||||
|
||||
this.payment.psbtBase64 = data
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
},
|
||||
deletePaymentAddress: function (v) {
|
||||
const index = this.payment.data.indexOf(v)
|
||||
if (index !== -1) {
|
||||
this.payment.data.splice(index, 1)
|
||||
}
|
||||
},
|
||||
initPaymentData: async function () {
|
||||
if (!this.payment.show) return
|
||||
await this.refreshAddresses()
|
||||
|
||||
this.payment.showAdvanced = false
|
||||
this.payment.changeWallet = this.walletAccounts[0]
|
||||
this.selectChangeAddress(this.payment.changeWallet)
|
||||
|
||||
await this.refreshRecommendedFees()
|
||||
this.payment.feeRate = this.payment.recommededFees.halfHourFee
|
||||
},
|
||||
getFeeRateLabel: function (feeRate) {
|
||||
const fees = this.payment.recommededFees
|
||||
if (feeRate >= fees.fastestFee) return `High Priority (${feeRate} sat/vB)`
|
||||
if (feeRate >= fees.halfHourFee)
|
||||
return `Medium Priority (${feeRate} sat/vB)`
|
||||
if (feeRate >= fees.hourFee) return `Low Priority (${feeRate} sat/vB)`
|
||||
return `No Priority (${feeRate} sat/vB)`
|
||||
},
|
||||
addPaymentAddress: function () {
|
||||
this.payment.data.push({address: '', amount: undefined})
|
||||
},
|
||||
getTotalPaymentAmount: function () {
|
||||
return this.payment.data.reduce((t, a) => t + (a.amount || 0), 0)
|
||||
},
|
||||
selectChangeAddress: function (wallet = {}) {
|
||||
this.payment.changeAddress =
|
||||
this.addresses.data.find(
|
||||
a => a.wallet === wallet.id && a.isChange && !a.hasActivity
|
||||
) || {}
|
||||
},
|
||||
goToPaymentView: async function () {
|
||||
this.payment.show = true
|
||||
this.tab = 'utxos'
|
||||
await this.initPaymentData()
|
||||
},
|
||||
sendMaxToAddress: function (paymentAddress = {}) {
|
||||
paymentAddress.amount = 0
|
||||
const tx = this.createTx(true)
|
||||
this.payment.txSize = Math.round(txSize(tx))
|
||||
const fee = this.payment.feeRate * this.payment.txSize
|
||||
const inputAmount = this.getTotalSelectedUtxoAmount()
|
||||
const payedAmount = this.getTotalPaymentAmount()
|
||||
paymentAddress.amount = Math.max(0, inputAmount - payedAmount - fee)
|
||||
},
|
||||
|
||||
//################### UTXOs ###################
|
||||
scanAllAddresses: async function () {
|
||||
await this.refreshAddresses()
|
||||
this.addresses.history = []
|
||||
let addresses = this.addresses.data
|
||||
this.utxos.data = []
|
||||
this.utxos.total = 0
|
||||
// Loop while new funds are found on the gap adresses.
|
||||
// Use 1000 limit as a safety check (scan 20 000 addresses max)
|
||||
for (let i = 0; i < 1000 && addresses.length; i++) {
|
||||
await this.updateUtxosForAddresses(addresses)
|
||||
const oldAddresses = this.addresses.data.slice()
|
||||
await this.refreshAddresses()
|
||||
const newAddresses = this.addresses.data.slice()
|
||||
// check if gap addresses have been extended
|
||||
addresses = newAddresses.filter(
|
||||
newAddr => !oldAddresses.find(oldAddr => oldAddr.id === newAddr.id)
|
||||
)
|
||||
if (addresses.length) {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Funds found! Scanning for more...',
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
scanAddressWithAmount: async function () {
|
||||
this.utxos.data = []
|
||||
this.utxos.total = 0
|
||||
this.addresses.history = []
|
||||
const addresses = this.addresses.data.filter(a => a.hasActivity)
|
||||
await this.updateUtxosForAddresses(addresses)
|
||||
},
|
||||
scanAddress: async function (addressData) {
|
||||
this.updateUtxosForAddresses([addressData])
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Address Rescanned',
|
||||
timeout: 10000
|
||||
})
|
||||
},
|
||||
updateUtxosForAddresses: async function (addresses = []) {
|
||||
this.scan = {scanning: true, scanCount: addresses.length, scanIndex: 0}
|
||||
|
||||
try {
|
||||
for (addrData of addresses) {
|
||||
const addressHistory = await this.getAddressTxsDelayed(addrData)
|
||||
// remove old entries
|
||||
this.addresses.history = this.addresses.history.filter(
|
||||
h => h.address !== addrData.address
|
||||
)
|
||||
|
||||
// add new entrie
|
||||
this.addresses.history.push(...addressHistory)
|
||||
this.addresses.history.sort((a, b) =>
|
||||
!a.height ? -1 : b.height - a.height
|
||||
)
|
||||
this.markSameTxAddressHistory()
|
||||
|
||||
if (addressHistory.length) {
|
||||
// search only if it ever had any activity
|
||||
const utxos = await this.getAddressTxsUtxoDelayed(addrData.address)
|
||||
this.updateUtxosForAddress(addrData, utxos)
|
||||
}
|
||||
|
||||
this.scan.scanIndex++
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to scan addresses',
|
||||
timeout: 10000
|
||||
})
|
||||
} finally {
|
||||
this.scan.scanning = false
|
||||
}
|
||||
},
|
||||
updateUtxosForAddress: function (addressData, utxos = []) {
|
||||
const wallet =
|
||||
this.walletAccounts.find(w => w.id === addressData.wallet) || {}
|
||||
|
||||
const newUtxos = utxos.map(utxo =>
|
||||
mapAddressDataToUtxo(wallet, addressData, utxo)
|
||||
)
|
||||
// remove old utxos
|
||||
this.utxos.data = this.utxos.data.filter(
|
||||
u => u.address !== addressData.address
|
||||
)
|
||||
// add new utxos
|
||||
this.utxos.data.push(...newUtxos)
|
||||
if (utxos.length) {
|
||||
this.utxos.data.sort((a, b) => b.sort - a.sort)
|
||||
this.utxos.total = this.utxos.data.reduce(
|
||||
(total, y) => (total += y?.amount || 0),
|
||||
0
|
||||
)
|
||||
}
|
||||
const addressTotal = utxos.reduce(
|
||||
(total, y) => (total += y?.value || 0),
|
||||
0
|
||||
)
|
||||
this.updateAmountForAddress(addressData, addressTotal)
|
||||
},
|
||||
getTotalSelectedUtxoAmount: function () {
|
||||
const total = this.utxos.data
|
||||
.filter(u => u.selected)
|
||||
.reduce((t, a) => t + (a.amount || 0), 0)
|
||||
return total
|
||||
},
|
||||
applyUtxoSelectionMode: function () {
|
||||
const payedAmount = this.getTotalPaymentAmount()
|
||||
const mode = this.payment.utxoSelectionMode
|
||||
this.utxos.data.forEach(u => (u.selected = false))
|
||||
const isManual = mode === 'Manual'
|
||||
if (isManual || !payedAmount) return
|
||||
|
||||
const isSelectAll = mode === 'Select All'
|
||||
if (isSelectAll || payedAmount >= this.utxos.total) {
|
||||
this.utxos.data.forEach(u => (u.selected = true))
|
||||
return
|
||||
}
|
||||
const isSmallerFirst = mode === 'Smaller Inputs First'
|
||||
const isLargerFirst = mode === 'Larger Inputs First'
|
||||
|
||||
let selectedUtxos = this.utxos.data.slice()
|
||||
if (isSmallerFirst || isLargerFirst) {
|
||||
const sortFn = isSmallerFirst
|
||||
? (a, b) => a.amount - b.amount
|
||||
: (a, b) => b.amount - a.amount
|
||||
selectedUtxos.sort(sortFn)
|
||||
} else {
|
||||
// default to random order
|
||||
selectedUtxos = _.shuffle(selectedUtxos)
|
||||
}
|
||||
selectedUtxos.reduce((total, utxo) => {
|
||||
utxo.selected = total < payedAmount
|
||||
total += utxo.amount
|
||||
return total
|
||||
}, 0)
|
||||
},
|
||||
|
||||
//################### MEMPOOL API ###################
|
||||
getAddressTxsDelayed: async function (addrData) {
|
||||
const {
|
||||
bitcoin: {addresses: addressesAPI}
|
||||
} = mempoolJS({
|
||||
hostname: new URL(this.config.data.mempool_endpoint).hostname
|
||||
})
|
||||
|
||||
const fn = async () =>
|
||||
addressesAPI.getAddressTxs({
|
||||
address: addrData.address
|
||||
})
|
||||
const addressTxs = await retryWithDelay(fn)
|
||||
return this.addressHistoryFromTxs(addrData, addressTxs)
|
||||
},
|
||||
|
||||
refreshRecommendedFees: async function () {
|
||||
const {
|
||||
bitcoin: {fees: feesAPI}
|
||||
} = mempoolJS({
|
||||
hostname: new URL(this.config.data.mempool_endpoint).hostname
|
||||
})
|
||||
|
||||
const fn = async () => feesAPI.getFeesRecommended()
|
||||
this.payment.recommededFees = await retryWithDelay(fn)
|
||||
},
|
||||
getAddressTxsUtxoDelayed: async function (address) {
|
||||
const {
|
||||
bitcoin: {addresses: addressesAPI}
|
||||
} = mempoolJS({
|
||||
hostname: new URL(this.config.data.mempool_endpoint).hostname
|
||||
})
|
||||
|
||||
const fn = async () =>
|
||||
addressesAPI.getAddressTxsUtxo({
|
||||
address
|
||||
})
|
||||
return retryWithDelay(fn)
|
||||
},
|
||||
fetchTxHex: async function (txId) {
|
||||
const {
|
||||
bitcoin: {transactions: transactionsAPI}
|
||||
} = mempoolJS({
|
||||
hostname: new URL(this.config.data.mempool_endpoint).hostname
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await transactionsAPI.getTxHex({txid: txId})
|
||||
return response
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: `Failed to fetch transaction details for tx id: '${txId}'`,
|
||||
timeout: 10000
|
||||
})
|
||||
LNbits.utils.notifyApiError(error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
//################### OTHER ###################
|
||||
closeFormDialog: function () {
|
||||
this.formDialog.data = {
|
||||
is_unique: false
|
||||
}
|
||||
},
|
||||
openQrCodeDialog: function (addressData) {
|
||||
this.currentAddress = addressData
|
||||
this.addresses.note = addressData.note || ''
|
||||
this.addresses.show = true
|
||||
},
|
||||
searchInTab: function (tab, value) {
|
||||
this.tab = tab
|
||||
this[`${tab}Table`].filter = value
|
||||
},
|
||||
|
||||
satBtc(val, showUnit = true) {
|
||||
const value = this.config.data.sats_denominated
|
||||
? LNbits.utils.formatSat(val)
|
||||
: val == 0
|
||||
? 0.0
|
||||
: (val / 100000000).toFixed(8)
|
||||
if (!showUnit) return value
|
||||
return this.config.data.sats_denominated ? value + ' sat' : value + ' BTC'
|
||||
},
|
||||
getAccountDescription: function (accountType) {
|
||||
return getAccountDescription(accountType)
|
||||
}
|
||||
},
|
||||
created: async function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
await this.getConfig()
|
||||
await this.refreshWalletAccounts()
|
||||
await this.refreshAddresses()
|
||||
await this.scanAddressWithAmount()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
const mapAddressesData = a => ({
|
||||
id: a.id,
|
||||
address: a.address,
|
||||
amount: a.amount,
|
||||
wallet: a.wallet,
|
||||
note: a.note,
|
||||
|
||||
isChange: a.branch_index === 1,
|
||||
addressIndex: a.address_index,
|
||||
hasActivity: a.has_activity
|
||||
})
|
||||
|
||||
const mapInputToSentHistory = (tx, addressData, vin) => ({
|
||||
sent: true,
|
||||
txId: tx.txid,
|
||||
address: addressData.address,
|
||||
isChange: addressData.isChange,
|
||||
amount: vin.prevout.value,
|
||||
date: blockTimeToDate(tx.status.block_time),
|
||||
height: tx.status.block_height,
|
||||
confirmed: tx.status.confirmed,
|
||||
fee: tx.fee,
|
||||
expanded: false
|
||||
})
|
||||
|
||||
const mapOutputToReceiveHistory = (tx, addressData, vout) => ({
|
||||
received: true,
|
||||
txId: tx.txid,
|
||||
address: addressData.address,
|
||||
isChange: addressData.isChange,
|
||||
amount: vout.value,
|
||||
date: blockTimeToDate(tx.status.block_time),
|
||||
height: tx.status.block_height,
|
||||
confirmed: tx.status.confirmed,
|
||||
fee: tx.fee,
|
||||
expanded: false
|
||||
})
|
||||
|
||||
const mapUtxoToPsbtInput = utxo => ({
|
||||
tx_id: utxo.txId,
|
||||
vout: utxo.vout,
|
||||
amount: utxo.amount,
|
||||
address: utxo.address,
|
||||
branch_index: utxo.isChange ? 1 : 0,
|
||||
address_index: utxo.addressIndex,
|
||||
masterpub_fingerprint: utxo.masterpubFingerprint,
|
||||
accountType: utxo.accountType,
|
||||
txHex: ''
|
||||
})
|
||||
|
||||
const mapAddressDataToUtxo = (wallet, addressData, utxo) => ({
|
||||
id: addressData.id,
|
||||
address: addressData.address,
|
||||
isChange: addressData.isChange,
|
||||
addressIndex: addressData.addressIndex,
|
||||
wallet: addressData.wallet,
|
||||
accountType: addressData.accountType,
|
||||
masterpubFingerprint: wallet.fingerprint,
|
||||
txId: utxo.txid,
|
||||
vout: utxo.vout,
|
||||
confirmed: utxo.status.confirmed,
|
||||
amount: utxo.value,
|
||||
date: blockTimeToDate(utxo.status?.block_time),
|
||||
sort: utxo.status?.block_time,
|
||||
expanded: false,
|
||||
selected: false
|
||||
})
|
||||
|
||||
const mapWalletAccount = function (obj) {
|
||||
obj._data = _.clone(obj)
|
||||
obj.date = obj.time
|
||||
? Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
: ''
|
||||
obj.label = obj.title // for drop-downs
|
||||
obj.expanded = false
|
||||
return obj
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
const tables = {
|
||||
walletsTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'new',
|
||||
align: 'left',
|
||||
label: ''
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
align: 'left',
|
||||
label: 'Title',
|
||||
field: 'title'
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
align: 'left',
|
||||
label: 'Amount'
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
align: 'left',
|
||||
label: 'Type',
|
||||
field: 'type'
|
||||
},
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
},
|
||||
filter: ''
|
||||
},
|
||||
utxosTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'expand',
|
||||
align: 'left',
|
||||
label: ''
|
||||
},
|
||||
{
|
||||
name: 'selected',
|
||||
align: 'left',
|
||||
label: ''
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
align: 'center',
|
||||
label: 'Status',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'address',
|
||||
align: 'left',
|
||||
label: 'Address',
|
||||
field: 'address',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
align: 'left',
|
||||
label: 'Amount',
|
||||
field: 'amount',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
align: 'left',
|
||||
label: 'Date',
|
||||
field: 'date',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'wallet',
|
||||
align: 'left',
|
||||
label: 'Account',
|
||||
field: 'wallet',
|
||||
sortable: true
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
},
|
||||
filter: ''
|
||||
},
|
||||
paymentTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'data',
|
||||
align: 'left'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
},
|
||||
filter: ''
|
||||
},
|
||||
summaryTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'totalInputs',
|
||||
align: 'center',
|
||||
label: 'Selected Amount'
|
||||
},
|
||||
{
|
||||
name: 'totalOutputs',
|
||||
align: 'center',
|
||||
label: 'Payed Amount'
|
||||
},
|
||||
{
|
||||
name: 'fees',
|
||||
align: 'center',
|
||||
label: 'Fees'
|
||||
},
|
||||
{
|
||||
name: 'change',
|
||||
align: 'center',
|
||||
label: 'Change'
|
||||
}
|
||||
]
|
||||
},
|
||||
addressesTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'expand',
|
||||
align: 'left',
|
||||
label: ''
|
||||
},
|
||||
{
|
||||
name: 'address',
|
||||
align: 'left',
|
||||
label: 'Address',
|
||||
field: 'address',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
align: 'left',
|
||||
label: 'Amount',
|
||||
field: 'amount',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'note',
|
||||
align: 'left',
|
||||
label: 'Note',
|
||||
field: 'note',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'wallet',
|
||||
align: 'left',
|
||||
label: 'Account',
|
||||
field: 'wallet',
|
||||
sortable: true
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 0,
|
||||
sortBy: 'amount',
|
||||
descending: true
|
||||
},
|
||||
filter: ''
|
||||
},
|
||||
historyTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'expand',
|
||||
align: 'left',
|
||||
label: ''
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
align: 'left',
|
||||
label: 'Status'
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
align: 'left',
|
||||
label: 'Amount',
|
||||
field: 'amount',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'address',
|
||||
align: 'left',
|
||||
label: 'Address',
|
||||
field: 'address',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
align: 'left',
|
||||
label: 'Date',
|
||||
field: 'date',
|
||||
sortable: true
|
||||
}
|
||||
],
|
||||
exportColums: [
|
||||
{
|
||||
label: 'Action',
|
||||
field: 'action'
|
||||
},
|
||||
{
|
||||
label: 'Date&Time',
|
||||
field: 'date'
|
||||
},
|
||||
{
|
||||
label: 'Amount',
|
||||
field: 'amount'
|
||||
},
|
||||
{
|
||||
label: 'Fee',
|
||||
field: 'fee'
|
||||
},
|
||||
{
|
||||
label: 'Transaction Id',
|
||||
field: 'txId'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 0
|
||||
},
|
||||
filter: ''
|
||||
}
|
||||
}
|
||||
|
||||
const tableData = {
|
||||
walletAccounts: [],
|
||||
addresses: {
|
||||
show: false,
|
||||
data: [],
|
||||
history: [],
|
||||
selectedWallet: null,
|
||||
note: '',
|
||||
filterOptions: [
|
||||
'Show Change Addresses',
|
||||
'Show Gap Addresses',
|
||||
'Only With Amount'
|
||||
],
|
||||
filterValues: []
|
||||
},
|
||||
utxos: {
|
||||
data: [],
|
||||
total: 0
|
||||
},
|
||||
payment: {
|
||||
data: [{address: '', amount: undefined}],
|
||||
changeWallet: null,
|
||||
changeAddress: {},
|
||||
changeAmount: 0,
|
||||
|
||||
feeRate: 1,
|
||||
recommededFees: {
|
||||
fastestFee: 1,
|
||||
halfHourFee: 1,
|
||||
hourFee: 1,
|
||||
economyFee: 1,
|
||||
minimumFee: 1
|
||||
},
|
||||
fee: 0,
|
||||
txSize: 0,
|
||||
psbtBase64: '',
|
||||
utxoSelectionModes: [
|
||||
'Manual',
|
||||
'Random',
|
||||
'Select All',
|
||||
'Smaller Inputs First',
|
||||
'Larger Inputs First'
|
||||
],
|
||||
utxoSelectionMode: 'Manual',
|
||||
show: false,
|
||||
showAdvanced: false
|
||||
},
|
||||
summary: {
|
||||
data: [{totalInputs: 0, totalOutputs: 0, fees: 0, change: 0}]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
const blockTimeToDate = blockTime =>
|
||||
blockTime ? moment(blockTime * 1000).format('LLL') : ''
|
||||
|
||||
const currentDateTime = () => moment().format('LLL')
|
||||
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
const retryWithDelay = async function (fn, retryCount = 0) {
|
||||
try {
|
||||
await sleep(25)
|
||||
// Do not return the call directly, use result.
|
||||
// Otherwise the error will not be cought in this try-catch block.
|
||||
const result = await fn()
|
||||
return result
|
||||
} catch (err) {
|
||||
if (retryCount > 100) throw err
|
||||
await sleep((retryCount + 1) * 1000)
|
||||
return retryWithDelay(fn, retryCount + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const txSize = tx => {
|
||||
// https://bitcoinops.org/en/tools/calc-size/
|
||||
// overhead size
|
||||
const nVersion = 4
|
||||
const inCount = 1
|
||||
const outCount = 1
|
||||
const nlockTime = 4
|
||||
const hasSegwit = !!tx.inputs.find(inp =>
|
||||
['p2wsh', 'p2wpkh', 'p2tr'].includes(inp.accountType)
|
||||
)
|
||||
const segwitFlag = hasSegwit ? 0.5 : 0
|
||||
const overheadSize = nVersion + inCount + outCount + nlockTime + segwitFlag
|
||||
|
||||
// inputs size
|
||||
const outpoint = 36 // txId plus vout index number
|
||||
const scriptSigLength = 1
|
||||
const nSequence = 4
|
||||
const inputsSize = tx.inputs.reduce((t, inp) => {
|
||||
const scriptSig =
|
||||
inp.accountType === 'p2pkh' ? 107 : inp.accountType === 'p2sh' ? 254 : 0
|
||||
const witnessItemCount = hasSegwit ? 0.25 : 0
|
||||
const witnessItems =
|
||||
inp.accountType === 'p2wpkh'
|
||||
? 27
|
||||
: inp.accountType === 'p2wsh'
|
||||
? 63.5
|
||||
: inp.accountType === 'p2tr'
|
||||
? 16.5
|
||||
: 0
|
||||
t +=
|
||||
outpoint +
|
||||
scriptSigLength +
|
||||
nSequence +
|
||||
scriptSig +
|
||||
witnessItemCount +
|
||||
witnessItems
|
||||
return t
|
||||
}, 0)
|
||||
|
||||
// outputs size
|
||||
const nValue = 8
|
||||
const scriptPubKeyLength = 1
|
||||
|
||||
const outputsSize = tx.outputs.reduce((t, out) => {
|
||||
const type = guessAddressType(out.address)
|
||||
|
||||
const scriptPubKey =
|
||||
type === 'p2pkh'
|
||||
? 25
|
||||
: type === 'p2wpkh'
|
||||
? 22
|
||||
: type === 'p2sh'
|
||||
? 23
|
||||
: type === 'p2wsh'
|
||||
? 34
|
||||
: 34 // default to the largest size (p2tr included)
|
||||
t += nValue + scriptPubKeyLength + scriptPubKey
|
||||
return t
|
||||
}, 0)
|
||||
|
||||
return overheadSize + inputsSize + outputsSize
|
||||
}
|
||||
const guessAddressType = (a = '') => {
|
||||
if (a.startsWith('1') || a.startsWith('n')) return 'p2pkh'
|
||||
if (a.startsWith('3') || a.startsWith('2')) return 'p2sh'
|
||||
if (a.startsWith('bc1q') || a.startsWith('tb1q'))
|
||||
return a.length === 42 ? 'p2wpkh' : 'p2wsh'
|
||||
if (a.startsWith('bc1p') || a.startsWith('tb1p')) return 'p2tr'
|
||||
}
|
||||
|
||||
const ACCOUNT_TYPES = {
|
||||
p2tr: 'Taproot, BIP86, P2TR, Bech32m',
|
||||
p2wpkh: 'SegWit, BIP84, P2WPKH, Bech32',
|
||||
p2sh: 'BIP49, P2SH-P2WPKH, Base58',
|
||||
p2pkh: 'Legacy, BIP44, P2PKH, Base58'
|
||||
}
|
||||
|
||||
const getAccountDescription = type => ACCOUNT_TYPES[type] || 'nonstandard'
|
||||
@@ -1,248 +1,27 @@
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
Watch Only extension uses mempool.space<br />
|
||||
Onchain Wallet (watch-only) extension uses mempool.space<br />
|
||||
For use with "account Extended Public Key"
|
||||
<a href="https://iancoleman.io/bip39/">https://iancoleman.io/bip39/</a>
|
||||
<small>
|
||||
<br />Created by,
|
||||
<a target="_blank" href="https://github.com/arcbtc">Ben Arc</a> (using,
|
||||
<a target="_blank" href="https://github.com/diybitcoinhardware/embit"
|
||||
<a target="_blank" class="text-white" href="https://github.com/arcbtc"
|
||||
>Ben Arc</a
|
||||
>
|
||||
(using,
|
||||
<a
|
||||
target="_blank"
|
||||
class="text-white"
|
||||
href="https://github.com/diybitcoinhardware/embit"
|
||||
>Embit</a
|
||||
></small
|
||||
>)
|
||||
<br />
|
||||
<br />
|
||||
<a target="_blank" href="/docs#/watchonly" class="text-white"
|
||||
>Swagger REST API Documentation</a
|
||||
>
|
||||
</p>
|
||||
</q-card-section>
|
||||
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/watchonly"></q-btn>
|
||||
<q-expansion-item group="api" dense expand-separator label="List wallets">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span> /watchonly/api/v1/wallet</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<wallets_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}watchonly/api/v1/wallet -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 wallet details"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/watchonly/api/v1/wallet/<wallet_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>[<wallet_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}watchonly/api/v1/wallet/<wallet_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="Create wallet">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-green">POST</span> /watchonly/api/v1/wallet</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>[<wallet_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}watchonly/api/v1/wallet -d
|
||||
'{"title": <string>, "masterpub": <string>}' -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="Delete wallet"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">DELETE</span>
|
||||
/watchonly/api/v1/wallet/<wallet_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
|
||||
<code></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X DELETE {{ request.base_url
|
||||
}}watchonly/api/v1/wallet/<wallet_id> -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="List addresses">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/watchonly/api/v1/addresses/<wallet_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<address_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}watchonly/api/v1/addresses/<wallet_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 fresh address"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/watchonly/api/v1/address/<wallet_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<address_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}watchonly/api/v1/address/<wallet_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 mempool.space details"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span> /watchonly/api/v1/mempool</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<mempool_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}watchonly/api/v1/mempool -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="Update mempool.space"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-green">POST</span>
|
||||
/watchonly/api/v1/mempool</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>[<mempool_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X PUT {{ request.base_url }}watchonly/api/v1/mempool -d
|
||||
'{"endpoint": <string>}' -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>
|
||||
</q-card>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,11 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Query
|
||||
from embit import script
|
||||
from embit.descriptor import Descriptor, Key
|
||||
from embit.ec import PublicKey
|
||||
from embit.psbt import PSBT, DerivationPath
|
||||
from embit.transaction import Transaction, TransactionInput, TransactionOutput
|
||||
from fastapi import Query, Request
|
||||
from fastapi.params import Depends
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
@@ -8,17 +13,22 @@ from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
from lnbits.extensions.watchonly import watchonly_ext
|
||||
|
||||
from .crud import (
|
||||
create_mempool,
|
||||
create_config,
|
||||
create_fresh_addresses,
|
||||
create_watch_wallet,
|
||||
delete_addresses_for_wallet,
|
||||
delete_watch_wallet,
|
||||
get_addresses,
|
||||
get_config,
|
||||
get_fresh_address,
|
||||
get_mempool,
|
||||
get_watch_wallet,
|
||||
get_watch_wallets,
|
||||
update_mempool,
|
||||
update_address,
|
||||
update_config,
|
||||
update_watch_wallet,
|
||||
)
|
||||
from .models import CreateWallet
|
||||
from .helpers import parse_key
|
||||
from .models import Config, CreatePsbt, CreateWallet, WalletAccount
|
||||
|
||||
###################WALLETS#############################
|
||||
|
||||
@@ -48,18 +58,42 @@ async def api_wallet_retrieve(
|
||||
|
||||
@watchonly_ext.post("/api/v1/wallet")
|
||||
async def api_wallet_create_or_update(
|
||||
data: CreateWallet, wallet_id=None, w: WalletTypeInfo = Depends(require_admin_key)
|
||||
data: CreateWallet, w: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
try:
|
||||
wallet = await create_watch_wallet(
|
||||
user=w.wallet.user, masterpub=data.masterpub, title=data.title
|
||||
(descriptor, _) = parse_key(data.masterpub)
|
||||
|
||||
new_wallet = WalletAccount(
|
||||
id="none",
|
||||
user=w.wallet.user,
|
||||
masterpub=data.masterpub,
|
||||
fingerprint=descriptor.keys[0].fingerprint.hex(),
|
||||
type=descriptor.scriptpubkey_type(),
|
||||
title=data.title,
|
||||
address_no=-1, # so fresh address on empty wallet can get address with index 0
|
||||
balance=0,
|
||||
)
|
||||
|
||||
wallets = await get_watch_wallets(w.wallet.user)
|
||||
existing_wallet = next(
|
||||
(ew for ew in wallets if ew.fingerprint == new_wallet.fingerprint), None
|
||||
)
|
||||
if existing_wallet:
|
||||
raise ValueError(
|
||||
"Account '{}' has the same master pulic key".format(
|
||||
existing_wallet.title
|
||||
)
|
||||
)
|
||||
|
||||
wallet = await create_watch_wallet(new_wallet)
|
||||
|
||||
await api_get_addresses(wallet.id, w)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
|
||||
|
||||
mempool = await get_mempool(w.wallet.user)
|
||||
if not mempool:
|
||||
create_mempool(user=w.wallet.user)
|
||||
config = await get_config(w.wallet.user)
|
||||
if not config:
|
||||
await create_config(user=w.wallet.user)
|
||||
return wallet.dict()
|
||||
|
||||
|
||||
@@ -73,6 +107,7 @@ async def api_wallet_delete(wallet_id, w: WalletTypeInfo = Depends(require_admin
|
||||
)
|
||||
|
||||
await delete_watch_wallet(wallet_id)
|
||||
await delete_addresses_for_wallet(wallet_id)
|
||||
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
|
||||
@@ -83,42 +118,163 @@ 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)):
|
||||
address = await get_fresh_address(wallet_id)
|
||||
return address.dict()
|
||||
|
||||
return [address.dict()]
|
||||
|
||||
@watchonly_ext.put("/api/v1/address/{id}")
|
||||
async def api_update_address(
|
||||
id: str, req: Request, w: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
body = await req.json()
|
||||
params = {}
|
||||
# amout is only updated if the address has history
|
||||
if "amount" in body:
|
||||
params["amount"] = int(body["amount"])
|
||||
params["has_activity"] = True
|
||||
|
||||
if "note" in body:
|
||||
params["note"] = str(body["note"])
|
||||
|
||||
address = await update_address(**params, id=id)
|
||||
|
||||
wallet = (
|
||||
await get_watch_wallet(address.wallet)
|
||||
if address.branch_index == 0 and address.amount != 0
|
||||
else None
|
||||
)
|
||||
|
||||
if wallet and wallet.address_no < address.address_index:
|
||||
await update_watch_wallet(
|
||||
address.wallet, **{"address_no": address.address_index}
|
||||
)
|
||||
return address
|
||||
|
||||
|
||||
@watchonly_ext.get("/api/v1/addresses/{wallet_id}")
|
||||
async def api_get_addresses(wallet_id, w: WalletTypeInfo = Depends(get_key_type)):
|
||||
wallet = await get_watch_wallet(wallet_id)
|
||||
|
||||
if not wallet:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
|
||||
)
|
||||
|
||||
addresses = await get_addresses(wallet_id)
|
||||
config = await get_config(w.wallet.user)
|
||||
|
||||
if not addresses:
|
||||
await get_fresh_address(wallet_id)
|
||||
await create_fresh_addresses(wallet_id, 0, config.receive_gap_limit)
|
||||
await create_fresh_addresses(wallet_id, 0, config.change_gap_limit, True)
|
||||
addresses = await get_addresses(wallet_id)
|
||||
|
||||
receive_addresses = list(filter(lambda addr: addr.branch_index == 0, addresses))
|
||||
change_addresses = list(filter(lambda addr: addr.branch_index == 1, addresses))
|
||||
|
||||
last_receive_address = list(
|
||||
filter(lambda addr: addr.has_activity, receive_addresses)
|
||||
)[-1:]
|
||||
last_change_address = list(
|
||||
filter(lambda addr: addr.has_activity, change_addresses)
|
||||
)[-1:]
|
||||
|
||||
if last_receive_address:
|
||||
current_index = receive_addresses[-1].address_index
|
||||
address_index = last_receive_address[0].address_index
|
||||
await create_fresh_addresses(
|
||||
wallet_id, current_index + 1, address_index + config.receive_gap_limit + 1
|
||||
)
|
||||
|
||||
if last_change_address:
|
||||
current_index = change_addresses[-1].address_index
|
||||
address_index = last_change_address[0].address_index
|
||||
await create_fresh_addresses(
|
||||
wallet_id,
|
||||
current_index + 1,
|
||||
address_index + config.change_gap_limit + 1,
|
||||
True,
|
||||
)
|
||||
|
||||
addresses = await get_addresses(wallet_id)
|
||||
return [address.dict() for address in addresses]
|
||||
|
||||
|
||||
#############################MEMPOOL##########################
|
||||
#############################PSBT##########################
|
||||
|
||||
|
||||
@watchonly_ext.put("/api/v1/mempool")
|
||||
async def api_update_mempool(
|
||||
endpoint: str = Query(...), w: WalletTypeInfo = Depends(require_admin_key)
|
||||
@watchonly_ext.post("/api/v1/psbt")
|
||||
async def api_psbt_create(
|
||||
data: CreatePsbt, w: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
mempool = await update_mempool(**{"endpoint": endpoint}, user=w.wallet.user)
|
||||
return mempool.dict()
|
||||
try:
|
||||
vin = [
|
||||
TransactionInput(bytes.fromhex(inp.tx_id), inp.vout) for inp in data.inputs
|
||||
]
|
||||
vout = [
|
||||
TransactionOutput(out.amount, script.address_to_scriptpubkey(out.address))
|
||||
for out in data.outputs
|
||||
]
|
||||
|
||||
descriptors = {}
|
||||
for _, masterpub in enumerate(data.masterpubs):
|
||||
descriptors[masterpub.fingerprint] = parse_key(masterpub.public_key)
|
||||
|
||||
inputs_extra = []
|
||||
bip32_derivations = {}
|
||||
for i, inp in enumerate(data.inputs):
|
||||
descriptor = descriptors[inp.masterpub_fingerprint][0]
|
||||
d = descriptor.derive(inp.address_index, inp.branch_index)
|
||||
for k in d.keys:
|
||||
bip32_derivations[PublicKey.parse(k.sec())] = DerivationPath(
|
||||
k.origin.fingerprint, k.origin.derivation
|
||||
)
|
||||
inputs_extra.append(
|
||||
{
|
||||
"bip32_derivations": bip32_derivations,
|
||||
"non_witness_utxo": Transaction.from_string(inp.tx_hex),
|
||||
}
|
||||
)
|
||||
|
||||
tx = Transaction(vin=vin, vout=vout)
|
||||
psbt = PSBT(tx)
|
||||
|
||||
for i, inp in enumerate(inputs_extra):
|
||||
psbt.inputs[i].bip32_derivations = inp["bip32_derivations"]
|
||||
psbt.inputs[i].non_witness_utxo = inp.get("non_witness_utxo", None)
|
||||
|
||||
outputs_extra = []
|
||||
bip32_derivations = {}
|
||||
for i, out in enumerate(data.outputs):
|
||||
if out.branch_index == 1:
|
||||
descriptor = descriptors[out.masterpub_fingerprint][0]
|
||||
d = descriptor.derive(out.address_index, out.branch_index)
|
||||
for k in d.keys:
|
||||
bip32_derivations[PublicKey.parse(k.sec())] = DerivationPath(
|
||||
k.origin.fingerprint, k.origin.derivation
|
||||
)
|
||||
outputs_extra.append({"bip32_derivations": bip32_derivations})
|
||||
|
||||
for i, out in enumerate(outputs_extra):
|
||||
psbt.outputs[i].bip32_derivations = out["bip32_derivations"]
|
||||
|
||||
return psbt.to_string()
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@watchonly_ext.get("/api/v1/mempool")
|
||||
async def api_get_mempool(w: WalletTypeInfo = Depends(require_admin_key)):
|
||||
mempool = await get_mempool(w.wallet.user)
|
||||
if not mempool:
|
||||
mempool = await create_mempool(user=w.wallet.user)
|
||||
return mempool.dict()
|
||||
#############################CONFIG##########################
|
||||
|
||||
|
||||
@watchonly_ext.put("/api/v1/config")
|
||||
async def api_update_config(
|
||||
data: Config, w: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
config = await update_config(data, user=w.wallet.user)
|
||||
return config.dict()
|
||||
|
||||
|
||||
@watchonly_ext.get("/api/v1/config")
|
||||
async def api_get_config(w: WalletTypeInfo = Depends(get_key_type)):
|
||||
config = await get_config(w.wallet.user)
|
||||
if not config:
|
||||
config = await create_config(user=w.wallet.user)
|
||||
return config.dict()
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ class ExtensionManager:
|
||||
|
||||
@property
|
||||
def extensions(self) -> List[Extension]:
|
||||
output = []
|
||||
output: List[Extension] = []
|
||||
|
||||
if "all" in self._disabled:
|
||||
return output
|
||||
|
||||
@@ -21,7 +21,7 @@ class Jinja2Templates(templating.Jinja2Templates):
|
||||
self.env = self.get_environment(loader)
|
||||
|
||||
def get_environment(self, loader: "jinja2.BaseLoader") -> "jinja2.Environment":
|
||||
@jinja2.contextfunction
|
||||
@jinja2.pass_context
|
||||
def url_for(context: dict, name: str, **path_params: typing.Any) -> str:
|
||||
request: Request = context["request"]
|
||||
return request.app.url_path_for(name, **path_params)
|
||||
|
||||
@@ -261,7 +261,7 @@ window.LNbits = {
|
||||
return data
|
||||
}
|
||||
},
|
||||
exportCSV: function (columns, data) {
|
||||
exportCSV: function (columns, data, fileName) {
|
||||
var wrapCsvValue = function (val, formatFn) {
|
||||
var formatted = formatFn !== void 0 ? formatFn(val) : val
|
||||
|
||||
@@ -295,7 +295,7 @@ window.LNbits = {
|
||||
.join('\r\n')
|
||||
|
||||
var status = Quasar.utils.exportFile(
|
||||
'table-export.csv',
|
||||
`${fileName || 'table-export'}.csv`,
|
||||
content,
|
||||
'text/csv'
|
||||
)
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ async def webhook_handler():
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
internal_invoice_queue = asyncio.Queue(0)
|
||||
internal_invoice_queue: asyncio.Queue = asyncio.Queue(0)
|
||||
|
||||
|
||||
async def internal_invoice_listener():
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import asyncio
|
||||
from typing import Callable, NamedTuple
|
||||
from typing import Callable, List, NamedTuple
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
@@ -227,10 +227,10 @@ async def btc_price(currency: str) -> float:
|
||||
"TO": currency.upper(),
|
||||
"to": currency.lower(),
|
||||
}
|
||||
rates = []
|
||||
tasks = []
|
||||
rates: List[float] = []
|
||||
tasks: List[asyncio.Task] = []
|
||||
|
||||
send_channel = asyncio.Queue()
|
||||
send_channel: asyncio.Queue = asyncio.Queue()
|
||||
|
||||
async def controller():
|
||||
failures = 0
|
||||
|
||||
@@ -7,7 +7,10 @@ from typing import AsyncGenerator, Dict, Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from websockets import connect
|
||||
|
||||
# TODO: https://github.com/lnbits/lnbits-legend/issues/764
|
||||
# mypy https://github.com/aaugustin/websockets/issues/940
|
||||
from websockets import connect # type: ignore
|
||||
from websockets.exceptions import (
|
||||
ConnectionClosed,
|
||||
ConnectionClosedError,
|
||||
|
||||
@@ -28,7 +28,7 @@ class FakeWallet(Wallet):
|
||||
logger.info(
|
||||
"FakeWallet funding source is for using LNbits as a centralised, stand-alone payment system with brrrrrr."
|
||||
)
|
||||
return StatusResponse(None, float("inf"))
|
||||
return StatusResponse(None, 1000000000)
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
@@ -82,7 +82,7 @@ class FakeWallet(Wallet):
|
||||
invoice = decode(bolt11)
|
||||
if (
|
||||
hasattr(invoice, "checking_id")
|
||||
and invoice.checking_id[6:] == data["privkey"][:6]
|
||||
and invoice.checking_id[6:] == data["privkey"][:6] # type: ignore
|
||||
):
|
||||
return PaymentResponse(True, invoice.payment_hash, 0)
|
||||
else:
|
||||
@@ -97,7 +97,7 @@ class FakeWallet(Wallet):
|
||||
return PaymentStatus(None)
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
self.queue = asyncio.Queue(0)
|
||||
self.queue: asyncio.Queue = asyncio.Queue(0)
|
||||
while True:
|
||||
value = await self.queue.get()
|
||||
yield value
|
||||
|
||||
@@ -119,7 +119,7 @@ class LNPayWallet(Wallet):
|
||||
return PaymentStatus(statuses[r.json()["settled"]])
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
self.queue = asyncio.Queue(0)
|
||||
self.queue: asyncio.Queue = asyncio.Queue(0)
|
||||
while True:
|
||||
value = await self.queue.get()
|
||||
yield value
|
||||
|
||||
@@ -73,10 +73,10 @@ class AESCipher(object):
|
||||
final_key += key
|
||||
return final_key[:output]
|
||||
|
||||
def decrypt(self, encrypted: str) -> str:
|
||||
def decrypt(self, encrypted: str) -> str: # type: ignore
|
||||
"""Decrypts a string using AES-256-CBC."""
|
||||
passphrase = self.passphrase
|
||||
encrypted = base64.b64decode(encrypted)
|
||||
encrypted = base64.b64decode(encrypted) # type: ignore
|
||||
assert encrypted[0:8] == b"Salted__"
|
||||
salt = encrypted[8:16]
|
||||
key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16)
|
||||
@@ -84,7 +84,7 @@ class AESCipher(object):
|
||||
iv = key_iv[32:]
|
||||
aes = AES.new(key, AES.MODE_CBC, iv)
|
||||
try:
|
||||
return self.unpad(aes.decrypt(encrypted[16:])).decode()
|
||||
return self.unpad(aes.decrypt(encrypted[16:])).decode() # type: ignore
|
||||
except UnicodeDecodeError:
|
||||
raise ValueError("Wrong passphrase")
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ class OpenNodeWallet(Wallet):
|
||||
return PaymentStatus(statuses[r.json()["data"]["status"]])
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
self.queue = asyncio.Queue(0)
|
||||
self.queue: asyncio.Queue = asyncio.Queue(0)
|
||||
while True:
|
||||
value = await self.queue.get()
|
||||
yield value
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
[mypy]
|
||||
ignore_missing_imports = True
|
||||
exclude = lnbits/wallets/lnd_grpc_files/
|
||||
exclude = lnbits/extensions/
|
||||
|
||||
exclude = (?x)(
|
||||
^lnbits/extensions.
|
||||
| ^lnbits/wallets/lnd_grpc_files.
|
||||
)
|
||||
[mypy-lnbits.wallets.lnd_grpc_files.*]
|
||||
follow_imports = skip
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/nix/store/ds9c48q7hnkdmpzy3aq14kc1x9wrrszd-python3.9-lnbits-0.1.0
|
||||
@@ -1,6 +1,7 @@
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.core.views.api import api_payment
|
||||
|
||||
from ...helpers import get_random_invoice_data
|
||||
|
||||
@@ -155,3 +156,26 @@ async def test_decode_invoice(client, invoice):
|
||||
)
|
||||
assert response.status_code < 300
|
||||
assert response.json()["payment_hash"] == invoice["payment_hash"]
|
||||
|
||||
|
||||
# check api_payment() internal function call (NOT API): payment status
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_payment_without_key(invoice):
|
||||
# check the payment status
|
||||
response = await api_payment(invoice["payment_hash"])
|
||||
assert type(response) == dict
|
||||
assert response["paid"] == True
|
||||
# no key, that's why no "details"
|
||||
assert "details" not in response
|
||||
|
||||
|
||||
# check api_payment() internal function call (NOT API): payment status
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_payment_with_key(invoice, inkey_headers_from):
|
||||
# check the payment status
|
||||
response = await api_payment(
|
||||
invoice["payment_hash"], inkey_headers_from["X-Api-Key"]
|
||||
)
|
||||
assert type(response) == dict
|
||||
assert response["paid"] == True
|
||||
assert "details" in response
|
||||
|
||||
Binary file not shown.
+27
-13
@@ -1,9 +1,8 @@
|
||||
import psycopg2
|
||||
import sqlite3
|
||||
import os
|
||||
import argparse
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
|
||||
import psycopg2
|
||||
from environs import Env # type: ignore
|
||||
|
||||
env = Env()
|
||||
@@ -284,22 +283,24 @@ def migrate_ext(sqlite_db_file, schema, ignore_missing=True):
|
||||
masterpub,
|
||||
title,
|
||||
address_no,
|
||||
balance
|
||||
balance,
|
||||
type,
|
||||
fingerprint
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s);
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
# ADDRESSES
|
||||
res = sq.execute("SELECT * FROM addresses;")
|
||||
q = f"""
|
||||
INSERT INTO watchonly.addresses (id, address, wallet, amount)
|
||||
VALUES (%s, %s, %s, %s);
|
||||
INSERT INTO watchonly.addresses (id, address, wallet, amount, branch_index, address_index, has_activity, note)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s::boolean, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
# MEMPOOL
|
||||
res = sq.execute("SELECT * FROM mempool;")
|
||||
# CONFIG
|
||||
res = sq.execute("SELECT * FROM config;")
|
||||
q = f"""
|
||||
INSERT INTO watchonly.mempool ("user", endpoint)
|
||||
INSERT INTO watchonly.config ("user", json_data)
|
||||
VALUES (%s, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
@@ -540,8 +541,8 @@ def migrate_ext(sqlite_db_file, schema, ignore_missing=True):
|
||||
# ITEMS
|
||||
res = sq.execute("SELECT * FROM items;")
|
||||
q = f"""
|
||||
INSERT INTO offlineshop.items (shop, id, name, description, image, enabled, price, unit)
|
||||
VALUES (%s, %s, %s, %s, %s, %s::boolean, %s, %s);
|
||||
INSERT INTO offlineshop.items (shop, id, name, description, image, enabled, price, unit, fiat_base_multiplier)
|
||||
VALUES (%s, %s, %s, %s, %s, %s::boolean, %s, %s, %s);
|
||||
"""
|
||||
items = res.fetchall()
|
||||
insert_to_pg(q, items)
|
||||
@@ -703,6 +704,19 @@ def migrate_ext(sqlite_db_file, schema, ignore_missing=True):
|
||||
VALUES (%s, %s, %s, %s, %s, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
elif schema == "scrub":
|
||||
# SCRUB LINKS
|
||||
res = sq.execute("SELECT * FROM scrub_links;")
|
||||
q = f"""
|
||||
INSERT INTO scrub.scrub_links (
|
||||
id,
|
||||
wallet,
|
||||
description,
|
||||
payoraddress
|
||||
)
|
||||
VALUES (%s, %s, %s, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
else:
|
||||
print(f"❌ Not implemented: {schema}")
|
||||
sq.close()
|
||||
|
||||
Reference in New Issue
Block a user