Merge pull request #2 from lnbits/main

Merge from upstream
This commit is contained in:
blackcoffeexbt 2022-08-10 20:26:15 +01:00 committed by GitHub
commit 75e2c9b2c0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
116 changed files with 9128 additions and 25987 deletions

View file

@ -6,6 +6,10 @@ tests
venv
tools
lnbits/static/css/*
lnbits/static/bundle.js
lnbits/static/bundle.css
*.md
*.log

View file

@ -34,19 +34,23 @@ LNBITS_SITE_DESCRIPTION="Some description about your service, will display if ti
LNBITS_THEME_OPTIONS="classic, bitcoin, freedom, mint, autumn, monochrome, salvador"
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet,
# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, ClicheWallet
# LndRestWallet, CoreLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
# just so you can see the UI before dealing with this file.
# Set one of these blocks depending on the wallet kind you chose above:
# ClicheWallet
CLICHE_ENDPOINT=ws://127.0.0.1:12000
# SparkWallet
SPARK_URL=http://localhost:9737/rpc
SPARK_TOKEN=myaccesstoken
# CLightningWallet
CLIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
# CoreLightningWallet
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
# LnbitsWallet
LNBITS_ENDPOINT=https://legend.lnbits.com

View file

@ -7,30 +7,19 @@ on:
branches: [ main ]
jobs:
black:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: sudo apt-get install python3-venv
- run: python3 -m venv venv
- run: ./venv/bin/pip install black
- run: make checkblack
isort:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: abatilo/actions-poetry@v2.1.3
- name: Install packages
run: poetry install
- name: Check black
run: make checkblack
- name: Check isort
run: make checkisort
- uses: actions/setup-node@v3
- run: sudo apt-get install python3-venv
- run: python3 -m venv venv
- run: ./venv/bin/pip install isort
- run: make checkisort
prettier:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: sudo apt-get install python3-venv
- run: python3 -m venv venv
- run: npm install prettier
- run: ./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js
- name: Check prettier
run: |
npm install prettier
make checkprettier

View file

@ -22,22 +22,17 @@ jobs:
--health-retries 5
strategy:
matrix:
python-version: [3.8]
python-version: [3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3
- name: Install dependencies
env:
VIRTUAL_ENV: ./venv
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
run: |
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
poetry install
sudo apt install unzip
- name: Run migrations
run: |
@ -45,7 +40,7 @@ jobs:
mkdir -p ./data
export LNBITS_DATA_FOLDER="./data"
unzip tests/data/mock_data.zip -d ./data
timeout 5s ./venv/bin/uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5001 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5001 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
export LNBITS_DATABASE_URL="postgres://postgres:postgres@0.0.0.0:5432/postgres"
timeout 5s ./venv/bin/uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5001 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
./venv/bin/python tools/conv.py
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5001 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
poetry run python tools/conv.py

View file

@ -5,9 +5,18 @@ on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9]
steps:
- uses: actions/checkout@v1
- uses: jpetrucciani/mypy-check@master
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
mypy_flags: '--install-types --non-interactive'
path: 'lnbits'
python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3
- name: Install dependencies
run: |
poetry install
- name: Run tests
run: poetry run mypy

View file

@ -7,13 +7,14 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.8]
python-version: [3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3
- name: Setup Regtest
run: |
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
@ -22,15 +23,8 @@ jobs:
./tests
sudo chmod -R a+rwx .
- 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 pylightning
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
poetry install
- name: Run tests
env:
PYTHONUNBUFFERED: 1
@ -43,7 +37,11 @@ jobs:
run: |
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
make test-real-wallet
CLightningWallet:
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
LndWallet:
runs-on: ubuntu-latest
strategy:
matrix:
@ -54,6 +52,7 @@ jobs:
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3
- name: Setup Regtest
run: |
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
@ -62,22 +61,60 @@ jobs:
./tests
sudo chmod -R a+rwx .
- 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 pylightning
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
poetry install
poetry add grpcio protobuf
- name: Run tests
env:
PYTHONUNBUFFERED: 1
PORT: 5123
LNBITS_DATA_FOLDER: ./data
LNBITS_BACKEND_WALLET_CLASS: CLightningWallet
CLIGHTNING_RPC: ./docker/data/clightning-1/regtest/lightning-rpc
LNBITS_BACKEND_WALLET_CLASS: LndWallet
LND_GRPC_ENDPOINT: localhost
LND_GRPC_PORT: 10009
LND_GRPC_CERT: docker/data/lnd-1/tls.cert
LND_GRPC_MACAROON: docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
run: |
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
make test-real-wallet
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
CoreLightningWallet:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3
- name: Setup Regtest
run: |
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker
chmod +x ./tests
./tests
sudo chmod -R a+rwx .
- name: Install dependencies
run: |
poetry install
poetry add pyln-client
- name: Run tests
env:
PYTHONUNBUFFERED: 1
PORT: 5123
LNBITS_DATA_FOLDER: ./data
LNBITS_BACKEND_WALLET_CLASS: CoreLightningWallet
CORELIGHTNING_RPC: ./docker/data/clightning-1/regtest/lightning-rpc
run: |
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
make test-real-wallet
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml

View file

@ -4,71 +4,6 @@ on: [push, pull_request]
jobs:
venv-sqlite:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.7, 3.8, 3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
env:
VIRTUAL_ENV: ./venv
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
run: |
python -m venv ${{ env.VIRTUAL_ENV }}
./venv/bin/python -m pip install --upgrade pip
./venv/bin/pip install -r requirements.txt
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
- name: Run tests
run: make test
venv-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.8]
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
poetry-sqlite:
runs-on: ubuntu-latest
strategy:
matrix:
@ -88,9 +23,29 @@ jobs:
./venv/bin/python -m pip install --upgrade pip
./venv/bin/pip install -r requirements.txt
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
- name: Run tests
run: make test-venv
sqlite:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3
- name: Install dependencies
env:
VIRTUAL_ENV: ./venv
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
run: |
poetry install
- name: Run tests
run: make test
poetry-postgres:
postgres:
runs-on: ubuntu-latest
services:
postgres:
@ -116,15 +71,10 @@ jobs:
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3
- name: Install dependencies
env:
VIRTUAL_ENV: ./venv
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
run: |
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
poetry install
- name: Run tests
env:
LNBITS_DATABASE_URL: postgres://postgres:postgres@0.0.0.0:5432/postgres

View file

@ -1,45 +1,12 @@
# Build image
FROM python:3.7-slim as builder
# Setup virtualenv
ENV VIRTUAL_ENV=/opt/venv
RUN python -m venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
# Install build deps
FROM python:3.9-slim
RUN apt-get update
RUN apt-get install -y --no-install-recommends build-essential pkg-config libpq-dev
RUN python -m pip install --upgrade pip
RUN pip install wheel
# Install runtime deps
COPY requirements.txt /tmp/requirements.txt
RUN pip install -r /tmp/requirements.txt
# Install c-lightning specific deps
RUN pip install pylightning
# Install LND specific deps
RUN pip install lndgrpc
# Production image
FROM python:3.7-slim as lnbits
# Run as non-root
USER 1000:1000
# Copy over virtualenv
ENV VIRTUAL_ENV="/opt/venv"
COPY --from=builder --chown=1000:1000 $VIRTUAL_ENV $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
# Copy in app source
RUN apt-get install -y curl
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH"
WORKDIR /app
COPY --chown=1000:1000 lnbits /app/lnbits
ENV LNBITS_PORT="5000"
ENV LNBITS_HOST="0.0.0.0"
COPY . .
RUN poetry config virtualenvs.create false
RUN poetry install --no-dev --no-root
RUN poetry run python build.py
EXPOSE 5000
CMD ["sh", "-c", "uvicorn lnbits.__main__:app --port $LNBITS_PORT --host $LNBITS_HOST"]
CMD ["poetry", "run", "lnbits", "--port", "5000", "--host", "0.0.0.0"]

View file

@ -4,58 +4,47 @@ all: format check requirements.txt
format: prettier isort black
check: mypy checkprettier checkblack
check: mypy checkprettier checkisort checkblack
prettier: $(shell find lnbits -name "*.js" -name ".html")
./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js
./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
black: $(shell find lnbits -name "*.py")
./venv/bin/black lnbits
black:
poetry run black .
mypy: $(shell find lnbits -name "*.py")
./venv/bin/mypy lnbits
./venv/bin/mypy lnbits/core
./venv/bin/mypy lnbits/extensions/*
mypy:
poetry run mypy
isort: $(shell find lnbits -name "*.py")
./venv/bin/isort --profile black lnbits
isort:
poetry run isort .
checkprettier: $(shell find lnbits -name "*.js" -name ".html")
./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js
./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
checkblack: $(shell find lnbits -name "*.py")
./venv/bin/black --check lnbits
checkblack:
poetry run black --check .
checkisort: $(shell find lnbits -name "*.py")
./venv/bin/isort --profile black --check-only lnbits
Pipfile.lock: Pipfile
./venv/bin/pipenv lock
requirements.txt: Pipfile.lock
cat Pipfile.lock | jq -r '.default | map_values(.version) | to_entries | map("\(.key)\(.value)") | join("\n")' > requirements.txt
checkisort:
poetry run isort --check-only .
test:
mkdir -p ./tests/data
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
FAKE_WALLET_SECRET="ToTheMoon1" \
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
./venv/bin/pytest --durations=1 -s --cov=lnbits --cov-report=xml tests
poetry run pytest
test-real-wallet:
mkdir -p ./tests/data
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
./venv/bin/pytest --durations=1 -s --cov=lnbits --cov-report=xml tests
poetry run pytest
test-pipenv:
mkdir -p ./tests/data
test-venv:
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
FAKE_WALLET_SECRET="ToTheMoon1" \
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
pipenv run pytest --durations=1 -s --cov=lnbits --cov-report=xml tests
./venv/bin/pytest --durations=1 -s --cov=lnbits --cov-report=xml tests
bak:
# LNBITS_DATABASE_URL=postgres://postgres:postgres@0.0.0.0:5432/postgres

View file

@ -25,7 +25,7 @@ LNbits is a very simple Python server that sits on top of any funding source, an
LNbits can run on top of any lightning-network funding source, currently there is support for LND, c-lightning, Spark, LNpay, OpenNode, lntxbot, with more being added regularly.
See [lnbits.org](https://lnbits.org) for more detailed documentation.
See [legend.lnbits.org](https://legend.lnbits.org) for more detailed documentation.
Checkout the LNbits [YouTube](https://www.youtube.com/playlist?list=PLPj3KCksGbSYG0ciIQUWJru1dWstPHshe) video series.
@ -54,7 +54,7 @@ LNURL has a fallback scheme, so if scanned by a regular QR code reader it can de
![lnurl fallback](https://i.imgur.com/CPBKHIv.png)
Using **lnbits.com/?lightning="LNURL-withdraw"** will trigger a withdraw that builds an LNbits wallet.
Example use would be an ATM, which utilises LNURL, if the user scans the QR with a regular QR code scanner app, they will stilll be able to access the funds.
Example use would be an ATM, which utilises LNURL, if the user scans the QR with a regular QR code scanner app, they will still be able to access the funds.
![lnurl ATM](https://i.imgur.com/Gi6bn3L.jpg)
@ -70,7 +70,7 @@ Wallets can be easily generated and given out to people at events (one click mul
If you like this project and might even use or extend it, why not [send some tip love](https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)!
[docs]: https://lnbits.org/
[docs]: https://legend.lnbits.org/
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
[github-mypy]: https://github.com/lnbits/lnbits/actions?query=workflow%3Amypy
[github-mypy-badge]: https://github.com/lnbits/lnbits/workflows/mypy/badge.svg

View file

@ -1,13 +1,14 @@
import warnings
import subprocess
import glob
import os
import subprocess
import warnings
from os import path
from typing import Any, List, NamedTuple, Optional
from pathlib import Path
from typing import Any, List, NamedTuple, Optional
LNBITS_PATH = path.dirname(path.realpath(__file__)) + "/lnbits"
def get_js_vendored(prefer_minified: bool = False) -> List[str]:
paths = get_vendored(".js", prefer_minified)
@ -71,6 +72,7 @@ def get_vendored(ext: str, prefer_minified: bool = False) -> List[str]:
def url_for_vendored(abspath: str) -> str:
return "/" + os.path.relpath(abspath, LNBITS_PATH)
def transpile_scss():
with warnings.catch_warnings():
warnings.simplefilter("ignore")
@ -80,6 +82,7 @@ def transpile_scss():
with open(os.path.join(LNBITS_PATH, "static/css/base.css"), "w") as css:
css.write(compile_string(scss.read()))
def bundle_vendored():
for getfiles, outputpath in [
(get_js_vendored, os.path.join(LNBITS_PATH, "static/bundle.js")),
@ -96,15 +99,7 @@ def bundle_vendored():
def build():
transpile_scss()
bundle_vendored()
# root = Path("lnbits/static/foo")
# root.mkdir(parents=True)
# root.joinpath("example.css").write_text("")
if __name__ == "__main__":
build()
#def build(setup_kwargs):
# """Build """
# transpile_scss()
# bundle_vendored()
# subprocess.run(["ls", "-la", "./lnbits/static"])

View file

@ -1 +1 @@
lnbits.org
legend.lnbits.org

View file

@ -3,7 +3,7 @@ title: "LNbits docs"
remote_theme: pmarsceill/just-the-docs
logo: "/logos/lnbits-full.png"
search_enabled: true
url: https://lnbits.org
url: https://legend.lnbits.org
aux_links:
"LNbits on GitHub":
- "//github.com/lnbits/lnbits"

View file

@ -9,4 +9,4 @@ nav_order: 3
API reference
=============
Coming soon...
[Swagger Docs](https://legend.lnbits.org/devs/swagger.html)

View file

@ -17,10 +17,26 @@ Tests
This project has unit tests that help prevent regressions. Before you can run the tests, you must install a few dependencies:
```bash
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
poetry install
npm i
```
Then to run the tests:
```bash
make test
```
Run formatting:
```bash
make format
```
Run mypy checks:
```bash
poetry run mypy
```
Run everything:
```bash
make all
```

View file

@ -28,17 +28,24 @@ Going over the example extension's structure:
Adding new dependencies
-----------------------
If for some reason your extensions needs a new python package to work, you can add a new package using Pipenv:
If for some reason your extensions needs a new python package to work, you can add a new package using `venv`, or `poerty`:
```sh
$ pipenv install new_package_name
$ poetry add <package>
# or
$ ./venv/bin/pip install <package>
```
This will create a new entry in the `Pipenv` file.
**But we need an extra step to make sure LNbits doesn't break in production.**
All tests and deployments should run against the `requirements.txt` file so every time a new package is added
it is necessary to run the Pipenv `lock` command and manually update the requirements file:
Dependencies need to be added to `pyproject.toml` and `requirements.txt`, then tested by running on `venv` and `poetry`.
`nix` compatability can be tested with `nix build .#checks.x86_64-linux.vmTest`.
```sh
$ pipenv lock -r
```
SQLite to PostgreSQL migration
-----------------------
LNbits currently supports SQLite and PostgreSQL databases. There is a migration script `tools/conv.py` that helps users migrate from SQLite to PostgreSQL. This script also copies all extension databases to the new backend.
### Adding mock data to `mock_data.zip`
`mock_data.zip` contains a few lines of sample SQLite data and is used in automated GitHub test to see whether your migration in `conv.py` works. Run your extension and save a few lines of data into a SQLite `your_extension.sqlite3` file. Unzip `tests/data/mock_data.zip`, add `your_extension.sqlite3` and zip it again. Add the updated `mock_data.zip` to your PR.

View file

@ -1,16 +0,0 @@
---
layout: default
parent: For developers
title: Installation
nav_order: 1
---
# Installation
This guide has been moved to the [installation guide](../guide/installation.md).
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:
* We recommend using <a href="https://caddyserver.com/docs/install#debian-ubuntu-raspbian">Caddy</a> for a reverse-proxy if you want to serve your install through a domain, alternatively you can use [ngrok](https://ngrok.com/).
* <a href="https://linuxize.com/post/how-to-use-linux-screen/#starting-linux-screen">Screen</a> works well if you want LNbits to continue running when you close your terminal session.

29
docs/devs/swagger.html Normal file
View file

@ -0,0 +1,29 @@
<html>
<head>
<!-- Load the latest Swagger UI code and style from npm using unpkg.com -->
<script src="https://unpkg.com/swagger-ui-dist@3/swagger-ui-bundle.js"></script>
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@3/swagger-ui.css"/>
<title>My New API</title>
</head>
<body>
<div id="swagger-ui"></div> <!-- Div to hold the UI component -->
<script>
window.onload = function () {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
url: "https://legend.lnbits.com/openapi.json", //Location of Open API spec in the repo
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
})
window.ui = ui
}
</script>
</body>
</html>

View file

@ -4,8 +4,6 @@ title: Basic installation
nav_order: 2
---
# Basic installation
You can choose between four package managers, `poetry`, `nix` and `venv`.
@ -18,12 +16,22 @@ By default, LNbits will use SQLite as its database. You can also use PostgreSQL
git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend/
# for making sure python 3.9 is installed, skip if installed
sudo apt update
sudo apt install software-properties-common
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt install python3.9
curl -sSL https://install.python-poetry.org | python3 -
poetry install
export PATH="/home/ubuntu/.local/bin:$PATH" # or whatever is suggested in the poetry install notes printed to terminal
poetry env use python3.9
poetry install --no-dev
mkdir data
cp .env.example .env
sudo nano .env # set funding source
# You may need to install python 3.9, update your python following this guide https://linuxize.com/post/how-to-install-python-3-9-on-ubuntu-20-04/
mkdir data && cp .env.example .env
```
#### Running the server
@ -76,6 +84,17 @@ mkdir data && cp .env.example .env
If you want to host LNbits on the internet, run with the option `--host 0.0.0.0`.
## Option 4: Docker
```sh
git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend
docker build -t lnbits-legend .
cp .env.example .env
mkdir data
docker run --detach --publish 5000:5000 --name lnbits-legend --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits-legend
```
### Troubleshooting
Problems installing? These commands have helped us install LNbits.
@ -213,16 +232,22 @@ chmod +x mkcert-v*-linux-amd64
sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert
```
#### Create certificate
To create a certificate, first `cd` into your lnbits folder and execute the following command ([more info](https://kifarunix.com/how-to-create-self-signed-ssl-certificate-with-mkcert-on-ubuntu-18-04/))
To create a certificate, first `cd` into your LNbits folder and execute the following command on Linux:
```sh
openssl req -new -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out cert.pem -keyout key.pem
```
This will create two new files (`key.pem` and `cert.pem `).
Alternatively, you can use mkcert ([more info](https://kifarunix.com/how-to-create-self-signed-ssl-certificate-with-mkcert-on-ubuntu-18-04/)):
```sh
# add your local IP (192.x.x.x) as well if you want to use it in your local network
mkcert localhost 127.0.0.1 ::1
```
This will create two new files (`localhost-key.pem` and `localhost.pem `) which you can then pass to uvicorn when you start LNbits:
You can then pass the certificate files to uvicorn when you start LNbits:
```sh
./venv/bin/uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./localhost-key.pem --ssl-certfile ./localhost.pem
./venv/bin/uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./key.pem --ssl-certfile ./cert.pem
```
@ -235,9 +260,9 @@ If you want to run LNbits on your Umbrel but want it to be reached through clear
To install using docker you first need to build the docker image as:
```
git clone https://github.com/lnbits/lnbits.git
cd lnbits/ # ${PWD} referred as <lnbits_repo>
docker build -t lnbits .
git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend
docker build -t lnbits-legend .
```
You can launch the docker in a different directory, but make sure to copy `.env.example` from lnbits there
@ -248,17 +273,15 @@ cp <lnbits_repo>/.env.example .env
and change the configuration in `.env` as required.
Then create the data directory for the user ID 1000, which is the user that runs the lnbits within the docker container.
Then create the data directory
```
mkdir data
sudo chown 1000:1000 ./data/
```
Then the image can be run as:
```
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits
docker run --detach --publish 5000:5000 --name lnbits-legend -e "LNBITS_BACKEND_WALLET_CLASS='FakeWallet'" --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits-legend
```
Finally you can access your lnbits on your machine at port 5000.

View file

@ -8,18 +8,17 @@ nav_order: 3
Backend wallets
===============
LNbits can run on top of many lightning-network funding sources. Currently there is support for
CLightning, LND, LNbits, LNPay, lntxbot and OpenNode, with more being added regularily.
LNbits can run on top of many lightning-network funding sources. Currently there is support for CoreLightning, LND, LNbits, LNPay, lntxbot and OpenNode, with more being added regularly.
A backend wallet can be configured using the following LNbits environment variables:
### CLightning
### CoreLightning
Using this wallet requires the installation of the `pylightning` Python package.
- `LNBITS_BACKEND_WALLET_CLASS`: **CLightningWallet**
- `CLIGHTNING_RPC`: /file/path/lightning-rpc
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
- `CORELIGHTNING_RPC`: /file/path/lightning-rpc
### Spark (c-lightning)
@ -27,6 +26,17 @@ Using this wallet requires the installation of the `pylightning` Python package.
- `SPARK_URL`: http://10.147.17.230:9737/rpc
- `SPARK_TOKEN`: secret_access_key
### LND (REST)
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
- `LND_REST_CERT`: /file/path/tls.cert
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
or
- `LND_REST_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
### LND (gRPC)
Using this wallet requires the installation of the `grpcio` and `protobuf` Python packages.
@ -43,17 +53,6 @@ You can also use an AES-encrypted macaroon (more info) instead by using
To encrypt your macaroon, run `./venv/bin/python lnbits/wallets/macaroon/macaroon.py`.
### LND (REST)
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
- `LND_REST_CERT`: /file/path/tls.cert
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
or
- `LND_REST_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
### LNbits
- `LNBITS_BACKEND_WALLET_CLASS`: **LNbitsWallet**

View file

@ -4,6 +4,8 @@ from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
from uuid import uuid4
from loguru import logger
from lnbits import bolt11
from lnbits.db import COCKROACH, POSTGRES, Connection
from lnbits.settings import DEFAULT_WALLET_NAME, LNBITS_ADMIN_USERS
@ -334,7 +336,7 @@ async def delete_expired_invoices(
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
if expiration_date > datetime.datetime.utcnow():
continue
logger.debug(f"Deleting expired invoice: {invoice.payment_hash}")
await (conn or db).execute(
"""
DELETE FROM apipayments

View file

@ -141,19 +141,25 @@ class Payment(BaseModel):
if self.is_uncheckable:
return
logger.debug(
f"Checking {'outgoing' if self.is_out else 'incoming'} pending payment {self.checking_id}"
)
if self.is_out:
status = await WALLET.get_payment_status(self.checking_id)
else:
status = await WALLET.get_invoice_status(self.checking_id)
logger.debug(f"Status: {status}")
if self.is_out and status.failed:
logger.info(
f" - deleting outgoing failed payment {self.checking_id}: {status}"
f"Deleting outgoing failed payment {self.checking_id}: {status}"
)
await self.delete()
elif not status.pending:
logger.info(
f" - marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}"
f"Marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}"
)
await self.set_pending(status.pending)

View file

@ -182,7 +182,7 @@ async def pay_invoice(
payment_request, fee_reserve_msat
)
logger.debug(f"backend: pay_invoice finished {temp_id}")
if payment.checking_id:
if payment.ok and payment.checking_id:
logger.debug(f"creating final payment {payment.checking_id}")
async with db.connect() as conn:
await create_payment(
@ -196,7 +196,7 @@ async def pay_invoice(
logger.debug(f"deleting temporary payment {temp_id}")
await delete_payment(temp_id, conn=conn)
else:
logger.debug(f"backend payment failed, no checking_id {temp_id}")
logger.debug(f"backend payment failed")
async with db.connect() as conn:
logger.debug(f"deleting temporary payment {temp_id}")
await delete_payment(temp_id, conn=conn)
@ -337,12 +337,15 @@ async def perform_lnurlauth(
)
async def check_invoice_status(
async def check_transaction_status(
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> PaymentStatus:
payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
if not payment:
return PaymentStatus(None)
if payment.is_out:
status = await WALLET.get_payment_status(payment.checking_id)
else:
status = await WALLET.get_invoice_status(payment.checking_id)
if not payment.pending:
return status

View file

@ -689,7 +689,7 @@
</q-card>
</q-dialog>
<q-tabs
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-max"
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-top"
active-class="px-0"
indicator-color="transparent"
>

View file

@ -48,7 +48,7 @@ from ..crud import (
from ..services import (
InvoiceFailure,
PaymentFailure,
check_invoice_status,
check_transaction_status,
create_invoice,
pay_invoice,
perform_lnurlauth,
@ -123,7 +123,7 @@ async def api_payments(
offset=offset,
)
for payment in pendingPayments:
await check_invoice_status(
await check_transaction_status(
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
)
return await get_payments(
@ -184,10 +184,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
lnurl_response: Union[None, bool, str] = None
if data.lnurl_callback:
if "lnurl_balance_check" in data:
assert (
data.lnurl_balance_check is not None
), "lnurl_balance_check is required"
if data.lnurl_balance_check is not None:
await save_balance_check(wallet.id, data.lnurl_balance_check)
async with httpx.AsyncClient() as client:
@ -245,8 +242,6 @@ async def api_payments_pay_invoice(bolt11: str, wallet: Wallet):
@core_app.post(
"/api/v1/payments",
# deprecated=True,
# description="DEPRECATED. Use /api/v2/TBD and /api/v2/TBD instead",
status_code=HTTPStatus.CREATED,
)
async def api_payments_create(
@ -407,7 +402,7 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
await check_invoice_status(payment.wallet_id, payment_hash)
await check_transaction_status(payment.wallet_id, payment_hash)
payment = await get_standalone_payment(
payment_hash, wallet_id=wallet.id if wallet else None
)

View file

@ -148,7 +148,9 @@ async def wallet(
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
logger.debug(f"Access wallet {wallet_name}{'of user '+ user.id if user else ''}")
logger.debug(
f"Access {'user '+ user.id + ' ' if user else ''} {'wallet ' + wallet_name if wallet_name else ''}"
)
userwallet = user.get_wallet(wallet_id) # type: ignore
if not userwallet:
return template_renderer().TemplateResponse(

View file

@ -73,11 +73,9 @@ async def lnurl_callback(
wallet_id=cp.wallet,
amount=int(amount_received / 1000),
memo=cp.lnurl_title,
description_hash=hashlib.sha256(
(
description_hash=(
LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
).encode("utf-8")
).digest(),
).encode("utf-8"),
extra={"tag": "copilot", "copilotid": cp.id, "comment": comment},
)
payResponse = {"pr": payment_request, "routes": []}

View file

@ -90,9 +90,7 @@ async def lnurl_callback(
wallet_id=ls.wallet,
amount=int(amount_received / 1000),
memo=await track.fullname(),
description_hash=hashlib.sha256(
(await track.lnurlpay_metadata()).encode("utf-8")
).digest(),
description_hash=(await track.lnurlpay_metadata()).encode("utf-8"),
extra={"tag": "livestream", "track": track.id, "comment": comment},
)

View file

@ -70,11 +70,9 @@ async def lnurl_callback(address_id, amount: int = Query(...)):
json={
"out": False,
"amount": int(amount_received / 1000),
"description_hash": hashlib.sha256(
(await address.lnurlpay_metadata(domain=domain.domain)).encode(
"utf-8"
)
).hexdigest(),
"description_hash": (
await address.lnurlpay_metadata(domain=domain.domain)
).encode("utf-8"),
"extra": {"tag": f"Payment to {address.username}@{domain.domain}"},
},
timeout=40,

View file

@ -6,7 +6,7 @@ from fastapi.params import Depends, Query
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user
from lnbits.core.services import check_invoice_status, create_invoice
from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.decorators import WalletTypeInfo, get_key_type
from lnbits.extensions.lnaddress.models import CreateAddress, CreateDomain
@ -229,7 +229,7 @@ async def api_address_send_address(payment_hash):
address = await get_address(payment_hash)
domain = await get_domain(address.domain)
try:
status = await check_invoice_status(domain.wallet, payment_hash)
status = await check_transaction_status(domain.wallet, payment_hash)
is_paid = not status.pending
except Exception as e:
return {"paid": False, "error": str(e)}

View file

@ -281,7 +281,13 @@
</q-card-section>
{% endraw %}
<q-card-actions align="right">
<q-btn flat label="CLOSE" color="primary" v-close-popup />
<q-btn
flat
label="CLOSE"
color="primary"
v-close-popup
@click="resetForm"
/>
</q-card-actions>
</q-card>
</q-dialog>
@ -371,6 +377,9 @@
}
},
methods: {
resetForm() {
this.formDialog.data = {flatrate: false}
},
getTickets: function () {
var self = this
@ -463,7 +472,7 @@
.then(function (response) {
self.forms.push(mapLNTicket(response.data))
self.formDialog.show = false
self.formDialog.data = {}
self.resetForm()
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
@ -497,7 +506,7 @@
})
self.forms.push(mapLNTicket(response.data))
self.formDialog.show = false
self.formDialog.data = {}
self.resetForm()
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)

View file

@ -205,9 +205,7 @@ async def lnurl_callback(
wallet_id=device.wallet,
amount=lnurldevicepayment.sats / 1000,
memo=device.title,
description_hash=hashlib.sha256(
(await device.lnurlpay_metadata()).encode("utf-8")
).digest(),
description_hash=(await device.lnurlpay_metadata()).encode("utf-8"),
extra={"tag": "PoS"},
)
lnurldevicepayment = await update_lnurldevicepayment(

View file

@ -87,9 +87,7 @@ async def api_lnurl_callback(request: Request, link_id):
wallet_id=link.wallet,
amount=int(amount_received / 1000),
memo=link.description,
description_hash=hashlib.sha256(
link.lnurlpay_metadata.encode("utf-8")
).digest(),
description_hash=link.lnurlpay_metadata.encode("utf-8"),
extra={
"tag": "lnurlp",
"link": link.id,

View file

@ -96,7 +96,7 @@ async def api_link_create_or_update(
data.min *= data.fiat_base_multiplier
data.max *= data.fiat_base_multiplier
if "success_url" in data and data.success_url[:8] != "https://":
if data.success_url is not None and data.success_url.startswith("https://"):
raise HTTPException(
detail="Success URL must be secure https://...",
status_code=HTTPStatus.BAD_REQUEST,

View file

@ -73,9 +73,7 @@ async def lnurl_callback(request: Request, item_id: int):
wallet_id=shop.wallet,
amount=int(amount_received / 1000),
memo=item.name,
description_hash=hashlib.sha256(
(await item.lnurlpay_metadata()).encode("utf-8")
).digest(),
description_hash=(await item.lnurlpay_metadata()).encode("utf-8"),
extra={"tag": "offlineshop", "item": item.id},
)
except Exception as exc:

View file

@ -4,7 +4,7 @@ from fastapi import Depends, Query
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user, get_wallet
from lnbits.core.services import check_invoice_status, create_invoice
from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.decorators import WalletTypeInfo, get_key_type
from . import paywall_ext
@ -87,7 +87,7 @@ async def api_paywal_check_invoice(
status_code=HTTPStatus.NOT_FOUND, detail="Paywall does not exist."
)
try:
status = await check_invoice_status(paywall.wallet, payment_hash)
status = await check_transaction_status(paywall.wallet, payment_hash)
is_paid = not status.pending
except Exception:
return {"paid": False}

View file

@ -77,9 +77,7 @@ async def api_lnurlp_callback(
wallet_id=link.wallet,
amount=int(amount_received / 1000),
memo="Satsdice bet",
description_hash=hashlib.sha256(
link.lnurlpay_metadata.encode("utf-8")
).digest(),
description_hash=link.lnurlpay_metadata.encode("utf-8"),
extra={"tag": "satsdice", "link": link.id, "comment": "comment"},
)

View file

@ -421,7 +421,13 @@
this.formDialog = {
show: false,
fixedAmount: true,
data: {}
data: {
haircut: 0,
min_bet: 10,
max_bet: 1000,
currency: 'satoshis',
comment_chars: 0
}
}
},
updatePayLink(wallet, data) {

View file

@ -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"])

View file

@ -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

View file

@ -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):

View file

@ -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) : ''

View file

@ -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": &lt;admin_key&gt;}</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>[&lt;charge_object&gt;, ...]</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": &lt;string, watchonly_wallet_id&gt;,
"description": &lt;string&gt;, "webhook":&lt;string&gt;, "time":
&lt;integer&gt;, "amount": &lt;integer&gt;, "lnbitswallet":
&lt;string, lnbits_wallet_id&gt;}' -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/&lt;charge_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</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>[&lt;charge_object&gt;, ...]</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/&lt;charge_id&gt; -d '{"onchainwallet":
&lt;string, watchonly_wallet_id&gt;, "description": &lt;string&gt;,
"webhook":&lt;string&gt;, "time": &lt;integer&gt;, "amount":
&lt;integer&gt;, "lnbitswallet": &lt;string, lnbits_wallet_id&gt;}'
-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/&lt;charge_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</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>[&lt;charge_object&gt;, ...]</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/&lt;charge_id&gt; -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": &lt;invoice_key&gt;}</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>[&lt;charge_object&gt;, ...]</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/&lt;charge_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</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/&lt;charge_id&gt; -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/&lt;charge_id&gt;</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>[&lt;charge_object&gt;, ...]</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/&lt;charge_id&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
</q-card>

View file

@ -1,36 +1,42 @@
{% 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="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="row">
<div class="col text-center">
<div
class="col"
color="white"
style="background-color: grey; height: 30px; padding: 5px"
v-if="timetoComplete < 1"
v-if="!charge.timeLeft"
>
<center>Time elapsed</center>
Time elapsed
</div>
<div
class="col"
color="white"
style="background-color: grey; height: 30px; padding: 5px"
v-else-if="charge_paid == 'True'"
v-else-if="charge.paid"
>
<center>Charge paid</center>
Charge paid
</div>
<div v-else>
<q-linear-progress size="30px" :value="newProgress" color="grey">
<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 %} {{ newTimeLeft }} {% endraw %}</span
{% raw %} {{ charge.timeLeft }} {% endraw %}</span
></span
>
</q-item>
@ -38,28 +44,72 @@
</q-linear-progress>
</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>
<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>
</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
><small
>{% raw %} Total to pay: {{ charge_amount }}sats<br />
Amount paid: {{ charge_balance }}</small
><br />
Amount due: {{ charge_amount - charge_balance }}sats {% endraw %}
</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 }}' == 'None' || charge_time_elapsed == 'True'"
v-if="!charge.lnbitswallet || charge.time_elapsed"
style="color: primary; width: 100%"
label="lightning⚡"
>
@ -70,7 +120,7 @@
<q-btn
flat
v-else
@click="payLN"
@click="payInvoice"
style="color: primary; width: 100%"
label="lightning⚡"
>
@ -81,7 +131,7 @@
<q-btn
flat
disable
v-if="'{{ charge.onchainwallet }}' == 'None' || charge_time_elapsed == 'True'"
v-if="!charge.onchainwallet || charge.time_elapsed"
style="color: primary; width: 100%"
label="onchain⛓"
>
@ -92,7 +142,7 @@
<q-btn
flat
v-else
@click="payON"
@click="payOnchain"
style="color: primary; width: 100%"
label="onchain⛓"
>
@ -103,121 +153,147 @@
<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="text-center q-pt-md">
<div v-if="timetoComplete < 1 && charge_paid == 'False'">
<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 == 'True'">
<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 }}' != 'None'"
v-if="charge.webhook"
type="a"
href="{{ charge.completelink }}"
label="{{ charge.completelinktext }}"
:href="charge.completelink"
:label="charge.completelinktext"
></q-btn>
</div>
<div v-else>
<center>
<div class="row text-center q-mb-sm">
<div class="col text-center">
<span class="text-subtitle2"
>Pay this <br />
lightning-network invoice</span
>Pay this lightning-network invoice:</span
>
</center>
<a href="lightning:{{ charge.payment_request }}">
</div>
</div>
<a :href="'lightning:'+charge.payment_request">
<q-responsive :ratio="1" class="q-mx-md">
<qrcode
:value="'{{ charge.payment_request }}'"
:value="charge.payment_request"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
<div class="row q-mt-lg">
<div class="row text-center q-mt-lg">
<div class="col text-center">
<q-btn
outline
color="grey"
@click="copyText('{{ charge.payment_request }}')"
@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'">
<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>
</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 == 'True'">
<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 }}' != None"
v-if="charge.webhook"
type="a"
href="{{ charge.completelink }}"
label="{{ charge.completelinktext }}"
:href="charge.completelink"
:label="charge.completelinktext"
></q-btn>
</div>
<div v-else>
<center>
<div class="row items-center q-mb-sm">
<div class="col text-center">
<span class="text-subtitle2"
>Send {{ charge.amount }}sats<br />
to this onchain address</span
>Send
<span v-text="charge.amount"></span>
sats to this onchain address</span
>
</center>
<a href="bitcoin:{{ charge.onchainaddress }}">
</div>
</div>
<a :href="'bitcoin:'+charge.onchainaddress">
<q-responsive :ratio="1" class="q-mx-md">
<qrcode
:value="'{{ charge.onchainaddress }}'"
:value="charge.onchainaddress"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
<div class="row q-mt-lg">
<div class="row items-center q-mt-lg">
<div class="col text-center">
<q-btn
outline
color="grey"
@click="copyText('{{ charge.onchainaddress }}')"
@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) {
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>

View file

@ -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'"
@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-td>
<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-linear-progress>
</q-td>
<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-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'"
color="gray"
outline
type="a"
@click="refreshBalance(props.row)"
target="_blank"
class="float-left"
>Refresh Balance</q-btn
>
<q-tooltip> Time elapsed </q-tooltip>
</q-btn>
</div>
<div class="col-4 q-pr-lg">
<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"
icon="cancel"
@click="deleteChargeLink(props.row.id)"
>Delete</q-btn
>
<q-tooltip> Delete charge </q-tooltip>
</q-btn>
</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>
</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
@ -246,7 +356,7 @@
filled
dense
emit-value
v-model="formDialogCharge.data.onchainwallet"
v-model="onchainwallet"
:options="walletLinks"
label="Onchain Wallet"
/>
@ -284,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',
@ -341,10 +430,10 @@
field: 'description'
},
{
name: 'timeleft',
name: 'timeLeft',
align: 'left',
label: 'Time left',
field: 'date'
field: 'timeLeft'
},
{
name: 'time to pay',
@ -364,6 +453,12 @@
label: 'Balance',
field: 'balance'
},
{
name: 'pendingBalance',
align: 'left',
label: 'Pending Balance',
field: 'pendingBalance'
},
{
name: 'onchain address',
align: 'left',
@ -393,172 +488,218 @@
rowsPerPage: 10
}
},
formDialog: {
show: false,
data: {}
},
formDialogCharge: {
show: false,
data: {
onchain: false,
onchainwallet: '',
lnbits: false,
description: '',
time: null,
amount: null
}
},
qrCodeDialog: {
show: false,
data: null
}
}
},
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
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: function () {
var self = this
LNbits.api
.request(
getWalletLinks: async function () {
try {
const {data} = await 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) {
this.walletLinks = data.map(w => ({
id: w.id,
label: w.title + ' - ' + w.id
}))
} catch (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(
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
)
.then(function (response) {
self.ChargeLinks = response.data.map(mapCharge)
})
.catch(function (error) {
this.chargeLinks = data.map(c =>
mapCharge(
c,
this.chargeLinks.find(old => old.id === c.id)
)
)
} catch (error) {
LNbits.utils.notifyApiError(error)
})
}
},
sendFormDataCharge: function () {
var self = this
var wallet = this.g.user.wallets[0].inkey
var data = this.formDialogCharge.data
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)
},
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(
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/' +
self.ChargeLinks[i]['id'],
'/satspay/api/v1/charges/balance/' + activeLinkIds,
'filla'
)
.then(function (response) {})
}, 2000)
}
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
await this.getCharges()
}
self.getCharges()
}, 20000)
},
createCharge: function (wallet, data) {
var self = this
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
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 = {
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
}
})
.catch(function (error) {
} catch (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) {
this.chargeLinks = _.reject(this.chargeLinks, function (obj) {
return obj.id === chargeId
})
})
.catch(function (error) {
} 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>

View file

@ -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,24 @@ 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
mempool_endpoint = (
onchainwallet_config.mempool_endpoint if onchainwallet_config 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": mempool_endpoint,
},
)

View file

@ -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},
}

View file

@ -5,7 +5,7 @@ from fastapi.params import Depends
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user
from lnbits.core.services import check_invoice_status, create_invoice
from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.decorators import WalletTypeInfo, get_key_type
from lnbits.extensions.subdomains.models import CreateDomain, CreateSubdomain
@ -161,7 +161,7 @@ async def api_subdomain_make_subdomain(domain_id, data: CreateSubdomain):
async def api_subdomain_send_subdomain(payment_hash):
subdomain = await get_subdomain(payment_hash)
try:
status = await check_invoice_status(subdomain.wallet, payment_hash)
status = await check_transaction_status(subdomain.wallet, payment_hash)
is_paid = not status.pending
except Exception:
return {"paid": False}

View file

@ -57,7 +57,7 @@ async def api_create_tip(data: createTips):
name = name.replace('"', "''")
if not name:
name = "Anonymous"
description = f'"{name}": {message}'
description = f"{name}: {message}"
charge = await create_charge(
user=charge_details["user"],
data=CreateCharge(

View file

@ -8,7 +8,7 @@ You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnb
### Wallet Account
- a user can add one or more `xPubs` or `descriptors`
- the `xPub` fingerprint must be unique per user
- the `xPub` 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)
@ -17,6 +17,7 @@ You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnb
- 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`)
- an account can be added `From Hardware Device`
### Scan Blockchain
- when the user clicks `Scan Blockchain`, the wallet will loop over the all addresses (for each account)
@ -48,33 +49,32 @@ You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnb
- shows the UTXOs for all wallets
- there can be multiple UTXOs for the same address
### Make Payment
### New 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
- `Show Change` allows to select from which account the change address will be selected (defaults to the first one)
- `Show Custom Fee` allows to manually select the fee
- it defaults to the `Medium` value at the moment the `New 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`)
### Check & Send
- creates the PSBT and sends it to the Hardware Wallet
- a confirmation will be shown for each Output and for the Fee
- after the user confirms the addresses and amounts, the transaction will be signed on the Hardware Device
### Share PSBT
- Show the PSBT without sending it to the Hardware Wallet
## Screensots
- screenshot 1:
![image](https://user-images.githubusercontent.com/2951406/177181611-eeeac70c-c245-4b45-b80b-8bbb511f6d1d.png)
- screenshot 2:
![image](https://user-images.githubusercontent.com/2951406/177331468-f9b43626-548a-4608-b0d0-44007f402404.png)
![image](https://user-images.githubusercontent.com/2951406/183087898-b91f5243-8ed9-4a14-9e57-7bb4f1fd43ef.png)
- screenshot 3:
![image](https://user-images.githubusercontent.com/2951406/177333755-4a9118fb-3eaf-43d6-bc7e-c3d8c80bc61e.png)

View file

@ -4,8 +4,8 @@ from typing import List, Optional
from lnbits.helpers import urlsafe_short_hash
from . import db
from .helpers import derive_address, parse_key
from .models import Address, Config, Mempool, WalletAccount
from .helpers import derive_address
from .models import Address, Config, WalletAccount
##########################WALLETS####################
@ -22,9 +22,10 @@ async def create_watch_wallet(w: WalletAccount) -> WalletAccount:
title,
type,
address_no,
balance
balance,
network
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
wallet_id,
@ -35,6 +36,7 @@ async def create_watch_wallet(w: WalletAccount) -> WalletAccount:
w.type,
w.address_no,
w.balance,
w.network,
),
)
@ -48,9 +50,10 @@ async def get_watch_wallet(wallet_id: str) -> Optional[WalletAccount]:
return WalletAccount.from_row(row) if row else None
async def get_watch_wallets(user: str) -> List[WalletAccount]:
async def get_watch_wallets(user: str, network: str) -> List[WalletAccount]:
rows = await db.fetchall(
"""SELECT * FROM watchonly.wallets WHERE "user" = ?""", (user,)
"""SELECT * FROM watchonly.wallets WHERE "user" = ? AND network = ?""",
(user, network),
)
return [WalletAccount(**row) for row in rows]
@ -238,41 +241,3 @@ async def get_config(user: str) -> Optional[Config]:
"""SELECT json_data FROM watchonly.config WHERE "user" = ?""", (user,)
)
return json.loads(row[0], object_hook=lambda d: Config(**d)) if row else None
######################MEMPOOL#######################
### TODO: fix statspay dependcy and remove
async def create_mempool(user: str) -> Optional[Mempool]:
await db.execute(
"""
INSERT INTO watchonly.mempool ("user",endpoint)
VALUES (?, ?)
""",
(user, "https://mempool.space"),
)
row = await db.fetchone(
"""SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,)
)
return Mempool.from_row(row) if row else None
### TODO: fix statspay dependcy and remove
async def update_mempool(user: str, **kwargs) -> Optional[Mempool]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"""UPDATE watchonly.mempool SET {q} WHERE "user" = ?""",
(*kwargs.values(), user),
)
row = await db.fetchone(
"""SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,)
)
return Mempool.from_row(row) if row else None
### TODO: fix statspay dependcy and remove
async def get_mempool(user: str) -> Mempool:
row = await db.fetchone(
"""SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,)
)
return Mempool.from_row(row) if row else None

View file

@ -77,7 +77,19 @@ async def m004_create_config_table(db):
);"""
)
### TODO: fix statspay dependcy first
# await db.execute(
# "DROP TABLE watchonly.wallets;"
# )
async def m005_add_network_column_to_wallets(db):
"""
Add network' column to the 'wallets' table
"""
await db.execute(
"ALTER TABLE watchonly.wallets ADD COLUMN network TEXT DEFAULT 'Mainnet';"
)
async def m006_drop_mempool_table(db):
"""
Mempool data is now part of `config`
"""
await db.execute("DROP TABLE watchonly.mempool;")

View file

@ -1,5 +1,5 @@
from sqlite3 import Row
from typing import List
from typing import List, Optional
from fastapi.param_functions import Query
from pydantic import BaseModel
@ -8,6 +8,7 @@ from pydantic import BaseModel
class CreateWallet(BaseModel):
masterpub: str = Query("")
title: str = Query("")
network: str = "Mainnet"
class WalletAccount(BaseModel):
@ -19,22 +20,13 @@ class WalletAccount(BaseModel):
address_no: int
balance: int
type: str = ""
network: str = "Mainnet"
@classmethod
def from_row(cls, row: Row) -> "WalletAccount":
return cls(**dict(row))
### TODO: fix statspay dependcy and remove
class Mempool(BaseModel):
user: str
endpoint: str
@classmethod
def from_row(cls, row: Row) -> "Mempool":
return cls(**dict(row))
class Address(BaseModel):
id: str
address: str
@ -57,7 +49,7 @@ class TransactionInput(BaseModel):
address: str
branch_index: int
address_index: int
masterpub_fingerprint: str
wallet: str
tx_hex: str
@ -66,10 +58,11 @@ class TransactionOutput(BaseModel):
address: str
branch_index: int = None
address_index: int = None
masterpub_fingerprint: str = None
wallet: str = None
class MasterPublicKey(BaseModel):
id: str
public_key: str
fingerprint: str
@ -82,8 +75,23 @@ class CreatePsbt(BaseModel):
tx_size: int
class ExtractPsbt(BaseModel):
psbtBase64 = "" # // todo snake case
inputs: List[TransactionInput]
class SignedTransaction(BaseModel):
tx_hex: Optional[str]
tx_json: Optional[str]
class BroadcastTransaction(BaseModel):
tx_hex: str
class Config(BaseModel):
mempool_endpoint = "https://mempool.space"
receive_gap_limit = 20
change_gap_limit = 5
sats_denominated = True
network = "Mainnet"

View file

@ -0,0 +1,204 @@
<div>
<div class="row items-center no-wrap q-mb-md">
<div class="col q-pr-lg">
<q-select
filled
clearable
dense
emit-value
v-model="selectedWallet"
:options="accounts"
label="Wallet Account"
></q-select>
</div>
<div class="col q-pr-lg">
<q-select
filled
clearable
dense
emit-value
multiple
:options="filterOptions"
v-model="filterValues"
label="Filter"
></q-select>
</div>
<div class="col-auto">
<q-input
borderless
dense
debounce="300"
v-model="addressesTable.filter"
placeholder="Search"
>
<template v-slot:append>
<q-icon name="search"></q-icon>
</template>
</q-input>
</div>
</div>
<q-table
style="height: 400px"
flat
dense
:data="getFilteredAddresses()"
row-key="id"
virtual-scroll
:columns="addressesTable.columns"
:pagination.sync="addressesTable.pagination"
:filter="addressesTable.filter"
>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="accent"
round
dense
@click="props.row.expanded= !props.row.expanded"
:icon="props.row.expanded? 'remove' : 'add'"
/>
</q-td>
<q-td key="address" :props="props">
<div>
<a
style="color: unset"
:href="'https://'+ mempoolEndpoint + '/address/' + props.row.address"
target="_blank"
>
{{props.row.address}}</a
>
<q-badge
v-if="props.row.branch_index === 1"
color="orange"
class="q-mr-md"
outline
>
change
</q-badge>
<q-btn
v-if="props.row.gapLimitExceeded"
color="yellow"
icon="warning"
title="Gap Limit Exceeded"
@click="props.row.expanded= !props.row.expanded"
outline
class="q-ml-md"
size="xs"
>
</q-btn>
</div>
</q-td>
<q-td
key="amount"
:props="props"
:class="props.row.amount > 0 ? 'text-green-13 text-weight-bold' : ''"
>
<div>{{satBtc(props.row.amount)}}</div>
</q-td>
<q-td key="note" :props="props" :class="">
<div>{{props.row.note}}</div>
</q-td>
<q-td key="wallet" :props="props" :class="">
<div>{{getWalletName(props.row.wallet)}}</div>
</q-td>
</q-tr>
<q-tr v-show="props.row.expanded" :props="props">
<q-td colspan="100%">
<div class="row items-center q-mt-md q-mb-lg">
<div class="col-2 q-pr-lg"></div>
<div class="col-4 q-pr-lg">
<q-btn
unelevated
dense
size="md"
icon="qr_code"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="showAddressDetails(props.row)"
>
QR Code</q-btn
>
</div>
<div class="col-2 q-pr-lg">
<q-btn
outline
dense
size="md"
icon="refresh"
color="grey"
@click="scanAddress(props.row)"
>
Rescan</q-btn
>
</div>
<div class="col-2 q-pr-lg">
<q-btn
outline
dense
size="md"
icon="history"
color="grey"
@click="searchInTab('history', props.row.address)"
>History</q-btn
>
</div>
<div class="col-2 q-pr-lg">
<q-btn
outline
dense
size="md"
color="grey"
@click="searchInTab('utxos', props.row.address)"
>View Coins</q-btn
>
</div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-2 q-pr-lg">Note:</div>
<div class="col-8 q-pr-lg">
<q-input
filled
dense
v-model.trim="props.row.note"
type="text"
label="Note"
></q-input>
</div>
<div class="col-2 q-pr-lg">
<q-btn
outline
color="grey"
@click="updateNoteForAddress(props.row, props.row.note)"
>Update
</q-btn>
</div>
</div>
<div v-if="props.row.error" class="row items-center no-wrap q-mb-md">
<div class="col-2 q-pr-lg"></div>
<div class="col-10 q-pr-lg">
<q-badge color="red">{{props.row.error}}</q-badge>
</div>
</div>
<div
v-if="props.row.gapLimitExceeded"
class="row items-center no-wrap q-mb-md"
>
<div class="col-2 q-pr-lg"></div>
<div class="col-10 q-pr-lg">
<q-badge color="yellow" text-color="black"
>Gap limit of 20 addresses exceeded. Other wallets might not
detect funds at this address.</q-badge
>
</div>
</div>
</q-td>
</q-tr>
</template>
</q-table>
</div>

View file

@ -0,0 +1,121 @@
async function addressList(path) {
const template = await loadTemplateAsync(path)
Vue.component('address-list', {
name: 'address-list',
template,
props: [
'addresses',
'accounts',
'mempool-endpoint',
'inkey',
'sats-denominated'
],
data: function () {
return {
show: false,
history: [],
selectedWallet: null,
note: '',
filterOptions: [
'Show Change Addresses',
'Show Gap Addresses',
'Only With Amount'
],
filterValues: [],
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: ''
}
}
},
methods: {
satBtc(val, showUnit = true) {
return satOrBtc(val, showUnit, this.satsDenominated)
},
getWalletName: function (walletId) {
const wallet = (this.accounts || []).find(wl => wl.id === walletId)
return wallet ? wallet.title : 'unknown'
},
getFilteredAddresses: function () {
const selectedWalletId = this.selectedWallet?.id
const filter = this.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.accounts || []).reduce((r, w) => {
r[`_${w.id}`] = w.address_no
return r
}, {})
const fAddresses = this.addresses.filter(
a =>
(includeChangeAddrs || !a.isChange) &&
(includeGapAddrs ||
a.isChange ||
a.addressIndex <= walletsLimit[`_${a.wallet}`]) &&
!(excludeNoAmount && a.amount === 0) &&
(!selectedWalletId || a.wallet === selectedWalletId)
)
return fAddresses
},
scanAddress: async function (addressData) {
this.$emit('scan:address', addressData)
},
showAddressDetails: function (addressData) {
this.$emit('show-address-details', addressData)
},
searchInTab: function (tab, value) {
this.$emit('search:tab', {tab, value})
},
updateNoteForAddress: async function (addressData, note) {
this.$emit('update:note', {addressId: addressData.id, note})
}
},
created: async function () {}
})
}

View file

@ -0,0 +1,61 @@
<div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-2 q-pr-lg">Fee Rate:</div>
<div class="col-3 q-pr-lg">
<q-input
filled
dense
v-model.number="feeRate"
:rules="[val => !!val || 'Field is required']"
type="number"
label="sats/vbyte"
></q-input>
</div>
<div class="col-7">
<q-slider
v-model="feeRate"
color="orange"
markers
snap
label
label-always
:label-value="getFeeRateLabel(feeRate)"
:min="1"
:max="recommededFees.fastestFee"
/>
</div>
</div>
<div
v-if="feeRate < recommededFees.hourFee || feeRate > recommededFees.fastestFee"
class="row items-center no-wrap q-mb-md"
>
<div class="col-2 q-pr-lg"></div>
<div class="col-10 q-pr-lg">
<q-badge v-if="feeRate < recommededFees.hourFee" color="pink" size="lg">
Warning! The fee is too low. The transaction might take a long time to
confirm.
</q-badge>
<q-badge v-if="feeRate > recommededFees.fastestFee" color="pink">
Warning! The fee is too high. You might be overpaying for this
transaction.
</q-badge>
</div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-2 q-pr-lg">Fee:</div>
<div class="col-3 q-pr-lg">{{feeValue}} sats</div>
<div class="col-7">
<q-btn
outline
dense
size="md"
icon="refresh"
color="grey"
class="float-right"
@click="refreshRecommendedFees()"
>Refresh Fee Rates</q-btn
>
</div>
</div>
</div>

View file

@ -0,0 +1,64 @@
async function feeRate(path) {
const template = await loadTemplateAsync(path)
Vue.component('fee-rate', {
name: 'fee-rate',
template,
props: ['rate', 'fee-value', 'sats-denominated', 'mempool-endpoint'],
computed: {
feeRate: {
get: function () {
return this['rate']
},
set: function (value) {
this.$emit('update:rate', +value)
}
}
},
data: function () {
return {
recommededFees: {
fastestFee: 1,
halfHourFee: 1,
hourFee: 1,
economyFee: 1,
minimumFee: 1
}
}
},
methods: {
satBtc(val, showUnit = true) {
return satOrBtc(val, showUnit, this.satsDenominated)
},
refreshRecommendedFees: async function () {
const fn = async () => {
const {
bitcoin: {fees: feesAPI}
} = mempoolJS({
hostname: this.mempoolEndpoint
})
return feesAPI.getFeesRecommended()
}
this.recommededFees = await retryWithDelay(fn)
},
getFeeRateLabel: function (feeRate) {
const fees = this.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)`
}
},
created: async function () {
await this.refreshRecommendedFees()
this.feeRate = this.recommededFees.halfHourFee
}
})
}

View file

@ -0,0 +1,144 @@
<div>
<div class="row items-center no-wrap q-mb-md">
<div class="col q-pr-lg"></div>
<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>
</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="exportHistoryToCSV"
>Export to CSV</q-item-section
>
</q-item>
</q-list>
</q-menu>
</q-btn>
</div>
</div>
<q-table
style="height: 400px"
flat
dense
:data="getFilteredAddressesHistory()"
row-key="id"
virtual-scroll
:columns="historyTable.columns"
:pagination.sync="historyTable.pagination"
:filter="filter"
>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="accent"
round
dense
@click="props.row.expanded = !props.row.expanded"
:icon="props.row.expanded ? 'remove' : 'add'"
/>
</q-td>
<q-td key="status" :props="props">
<q-badge
v-if="props.row.sent"
@click="props.row.expanded = !props.row.expanded"
color="orange"
class="q-mr-md cursor-pointer"
>
{{props.row.confirmed ? 'Sent' : 'Sending...'}}
</q-badge>
<q-badge
v-if="props.row.received"
@click="props.row.expanded = !props.row.expanded"
color="green"
class="q-mr-md cursor-pointer"
>
{{props.row.confirmed ? 'Received' : 'Receiving...'}}
</q-badge>
</q-td>
<q-td
key="amount"
:props="props"
:class="props.row.amount && props.row.received > 0 ? 'text-green-13 text-weight-bold' : ''"
>
<div>{{satBtc(props.row.totalAmount || props.row.amount)}}</div>
</q-td>
<q-td key="address" :props="props">
<a
v-if="!props.row.sameTxItems"
style="color: unset"
:href="'https://' + mempoolEndpoint + '/address/' + props.row.address"
target="_blank"
>
{{props.row.address}}</a
>
<q-badge
v-if="props.row.sameTxItems"
@click="props.row.expanded = !props.row.expanded"
outline
color="blue"
class="cursor-pointer"
>
...
</q-badge>
</q-td>
<q-td key="date" :props="props"> {{ props.row.date }} </q-td>
</q-tr>
<q-tr v-show="props.row.expanded" :props="props">
<q-td colspan="100%">
<div class="row items-center no-wrap q-mb-md">
<div class="col-2 q-pr-lg">Transaction Id</div>
<div class="col-10 q-pr-lg">
<a
style="color: unset"
:href="'https://' +mempoolEndpoint + '/tx/' + props.row.txId"
target="_blank"
>
{{props.row.txId}}</a
>
</div>
</div>
<div
v-if="props.row.sameTxItems"
class="row items-center no-wrap q-mb-md"
>
<div class="col-2 q-pr-lg">UTXOs</div>
<div class="col-4 q-pr-lg">{{satBtc(props.row.amount)}}</div>
<div class="col-6 q-pr-lg">{{props.row.address}}</div>
</div>
<div
v-for="s in props.row.sameTxItems || []"
class="row items-center no-wrap q-mb-md"
>
<div class="col-2 q-pr-lg"></div>
<div class="col-4 q-pr-lg">{{satBtc(s.amount)}}</div>
<div class="col-6 q-pr-lg">{{s.address}}</div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-2 q-pr-lg">Fee</div>
<div class="col-4 q-pr-lg">{{satBtc(props.row.fee)}}</div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-2 q-pr-lg">Block Height</div>
<div class="col-4 q-pr-lg">{{props.row.height}}</div>
</div>
</q-td>
</q-tr>
</template>
</q-table>
</div>

View file

@ -0,0 +1,94 @@
async function history(path) {
const template = await loadTemplateAsync(path)
Vue.component('history', {
name: 'history',
template,
props: ['history', 'mempool-endpoint', 'sats-denominated', 'filter'],
data: function () {
return {
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
}
}
}
},
methods: {
satBtc(val, showUnit = true) {
return satOrBtc(val, showUnit, this.satsDenominated)
},
getFilteredAddressesHistory: function () {
return this.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'
)
}
},
created: async function () {}
})
}

View file

@ -0,0 +1,5 @@
<div class="checkbox-wrapper" @click="check">
<div :class="{ checkbox: true, checked: checked }"></div>
<div class="title">{{ title }}</div>
<q-btn color="primary">XXX</q-btn>
</div>

View file

@ -0,0 +1,16 @@
async function initMyCheckbox(path) {
const t = await loadTemplateAsync(path)
Vue.component('my-checkbox', {
name: 'my-checkbox',
template: t,
data() {
return {checked: false, title: 'Check me'}
},
methods: {
check() {
this.checked = !this.checked
console.log('### checked', this.checked)
}
}
})
}

View file

@ -0,0 +1,312 @@
<div>
<q-form @submit="checkAndSend" ref="paymentFormRef" class="q-gutter-md">
<q-card class="q-mt-lg">
<q-card-section>
<send-to
:data.sync="sendToList"
:fee-rate="feeRate"
:tx-size="txSizeNoChange"
:selected-amount="selectedAmount"
:sats-denominated="satsDenominated"
@update:outputs="handleOutputsChange"
></send-to>
</q-card-section>
</q-card>
<q-card class="q-mt-lg">
<q-card-section>
<div class="row items-center no-wrap">
<div class="col-4">
<q-toggle
label="Show Custom Fee"
color="secodary"
class="float-left"
v-model="showCustomFee"
></q-toggle>
</div>
<div class="col-8">
<div class="float-right">
<span>Fee Rate:</span>
<span class="text-subtitle2 q-ml-md">
{{feeRate}} sats/vbyte</span
>
<span class="q-ml-lg">Fee:</span>
<span class="text-subtitle2 q-ml-md"> {{satBtc(feeValue)}} </span>
</div>
</div>
</div>
<div v-show="showCustomFee" class="row items-center no-wrap q-mt-md">
<div class="col-12">
<q-separator class="q-mb-md"></q-separator>
<fee-rate
:fee-value="feeValue"
:rate.sync="feeRate"
:mempool-endpoint="mempoolEndpoint"
:sats-denominated="satsDenominated"
></fee-rate>
</div>
</div>
</q-card-section>
</q-card>
<q-card class="q-mt-lg">
<q-card-section>
<div class="row items-center no-wrap">
<div class="col-4">
<q-toggle
label="Show Coin Select"
color="secodary"
class="float-left"
v-model="showCoinSelect"
></q-toggle>
</div>
<div class="col-8">
<div class="float-right">
<span>Balance:</span>
<span class="text-subtitle2 q-ml-md"> {{satBtc(balance)}} </span>
<span class="q-ml-lg">Selected:</span>
<span class="text-subtitle2 q-ml-md">
{{satBtc(selectedAmount)}}
</span>
</div>
</div>
</div>
<div v-show="showCoinSelect" class="row items-center no-wrap q-mt-md">
<div class="col-12">
<q-separator class="q-mb-md"></q-separator>
<utxo-list
ref="utxoList"
:utxos="utxos"
:selectable="true"
:payed-amount="totalPayedAmount"
:mempool-endpoint="mempoolEndpoint"
:sats-denominated="satsDenominated"
:accounts="accounts"
></utxo-list>
</div>
</div>
</q-card-section>
</q-card>
<q-card class="q-mt-lg">
<q-card-section>
<div class="row items-center no-wrap">
<div class="col-4">
<q-toggle
label="Show Change"
color="secodary"
class="float-left"
v-model="showChange"
></q-toggle>
</div>
<div class="col-4">
<q-badge
v-if="changeAmount > 0 && changeAmount < DUST_LIMIT"
class="text-subtitle2 float-right"
color="yellow"
text-color="black"
>
Below dust limit. Will be used as fee.
</q-badge>
</div>
<div class="col-4">
<div class="float-right">
<span>Change:</span>
<span v-if="changeAmount < 0" class="text-subtitle2 q-ml-md">
{{satBtc(0)}}
</span>
<span v-if="changeAmount >= 0" class="text-subtitle2 q-ml-md">
{{satBtc(changeAmount)}}
</span>
</div>
</div>
</div>
<div v-show="showChange" class="row items-center no-wrap q-mt-md">
<div class="col-12">
<q-separator class="q-mb-md"></q-separator>
<div class="row items-center no-wrap">
<div class="col-2 q-pr-lg">Change Account:</div>
<div class="col-3 q-pr-lg">
<q-select
filled
dense
emit-value
v-model="changeWallet"
:options="accounts"
@input="selectChangeAddress"
:rules="[val => !!val || 'Field is required']"
label="Wallet Account"
></q-select>
</div>
<div class="col-7">
<q-input
filled
dense
readonly
v-model.trim="changeAddress.address"
:rules="[val => !!val || 'Field is required']"
type="text"
label="Change Address"
></q-input>
</div>
</div>
</div>
</div>
</q-card-section>
</q-card>
<div class="row items-center no-wrap q-mb-md q-pt-lg">
<div class="col-3">
<q-btn-dropdown
split
unelevated
:disabled="changeAmount < 0 || showChecking"
label="Check & Send"
color="green"
type="submit"
class="btn-full"
>
<q-list>
<q-item :disabled="changeAmount < 0" clickable v-close-popup>
<q-item-section>
<q-item-label>Serial Port</q-item-label>
<q-item-label caption>
Sign using a Serial Port device</q-item-label
>
</q-item-section>
</q-item>
<q-item @click="showPsbtDialog" clickable v-close-popup>
<q-item-section>
<q-item-label>Share PSBT</q-item-label>
<q-item-label caption
>Share the PSBT as text or Animated QR Code</q-item-label
>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</div>
<div class="col-9">
<q-spinner
v-if="showChecking"
size="2.55em"
color="primary"
></q-spinner>
<q-badge
v-if="changeAmount < 0"
class="text-subtitle2 float-right"
color="yellow"
text-color="black"
>
The payed amount is higher than the selected amount!
</q-badge>
</div>
</div>
</q-form>
<q-dialog v-model="showPsbt" position="top">
<q-card class="q-pa-lg q-pt-xl">
<q-input
filled
dense
v-model.trim="psbtBase64"
type="textarea"
rows="25"
cols="200"
label="PSBT"
></q-input>
<div class="row q-mt-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="showFinalTx" position="top">
<q-card class="q-pa-lg q-pt-xl">
<div class="row items-center no-wrap q-mb-sm">
<div class="col-12">
<span class="text-subtitle1">Transaction Details</span>
</div>
</div>
<q-separator class="q-mb-lg"></q-separator>
<div v-if="signedTx" class="row items-center no-wrap q-mb-md">
<div class="col-12">
<div class="row items-center no-wrap q-mb-sm">
<div class="col-3 q-pr-lg">Version</div>
<div class="col-9">{{signedTx.version}}</div>
</div>
<div class="row items-center no-wrap q-mb-sm">
<div class="col-3 q-pr-lg">Locktime</div>
<div class="col-9">{{signedTx.locktime}}</div>
</div>
<div class="row items-center no-wrap q-mb-sm">
<div class="col-3 q-pr-lg">Fee</div>
<div class="col-9">
<q-badge color="orange">{{satBtc(signedTx.fee)}} </q-badge>
</div>
</div>
<q-separator class="q-mb-lg"></q-separator>
<span class="text-subtitle2">Outputs</span>
<q-separator class="q-mb-lg"></q-separator>
<div
v-for="out in signedTx.outputs"
class="row items-center no-wrap q-mb-sm"
>
<div class="col-3 q-pr-lg">
<q-badge color="orange">{{satBtc(out.amount)}}</q-badge>
</div>
<div class="col-9">
<q-badge outline color="blue">{{out.address}}</q-badge>
</div>
</div>
</div>
</div>
<q-separator class="q-mb-lg"></q-separator>
<div class="row q-mt-lg">
<div class="col-12">
<q-input
filled
dense
v-model.trim="signedTxHex"
type="textarea"
cols="300"
rows="1"
label="Signed Tx Hex"
></q-input>
</div>
</div>
<div class="row q-mt-lg">
<div class="col-12">
<q-input
filled
dense
v-model.trim="psbtBase64Signed"
ype="textarea"
cols="300"
rows="1"
label="PSBT"
></q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
unelevated
color="secondary"
class="float-left"
@click="broadcastTransaction"
>Send</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>

View file

@ -0,0 +1,336 @@
async function payment(path) {
const t = await loadTemplateAsync(path)
Vue.component('payment', {
name: 'payment',
template: t,
props: [
'accounts',
'addresses',
'utxos',
'mempool-endpoint',
'sats-denominated',
'serial-signer-ref',
'adminkey'
],
watch: {
immediate: true,
accounts() {
this.updateChangeAddress()
},
addresses() {
this.updateChangeAddress()
}
},
data: function () {
return {
DUST_LIMIT: 546,
tx: null,
psbtBase64: null,
psbtBase64Signed: null,
signedTx: null,
signedTxHex: null,
sentTxId: null,
signedTxId: null,
paymentTab: 'destination',
sendToList: [{address: '', amount: undefined}],
changeWallet: null,
changeAddress: {},
showCustomFee: false,
showCoinSelect: false,
showChecking: false,
showChange: false,
showPsbt: false,
showFinalTx: false,
feeRate: 1
}
},
computed: {
txSize: function () {
const tx = this.createTx()
return Math.round(txSize(tx))
},
txSizeNoChange: function () {
const tx = this.createTx(true)
return Math.round(txSize(tx))
},
feeValue: function () {
return this.feeRate * this.txSize
},
selectedAmount: function () {
return this.utxos
.filter(utxo => utxo.selected)
.reduce((t, a) => t + (a.amount || 0), 0)
},
changeAmount: function () {
return (
this.selectedAmount -
this.totalPayedAmount -
this.feeRate * this.txSize
)
},
balance: function () {
return this.utxos.reduce((t, a) => t + (a.amount || 0), 0)
},
totalPayedAmount: function () {
return this.sendToList.reduce((t, a) => t + (a.amount || 0), 0)
}
},
methods: {
satBtc(val, showUnit = true) {
return satOrBtc(val, showUnit, this.satsDenominated)
},
checkAndSend: async function () {
this.showChecking = true
try {
if (!this.serialSignerRef.isConnected()) {
const portOpen = await this.serialSignerRef.openSerialPort()
if (!portOpen) return
}
if (!this.serialSignerRef.isAuthenticated()) {
await this.serialSignerRef.hwwShowPasswordDialog()
const authenticated = await this.serialSignerRef.isAuthenticating()
if (!authenticated) return
}
await this.createPsbt()
if (this.psbtBase64) {
const txData = {
outputs: this.tx.outputs,
feeRate: this.tx.fee_rate,
feeValue: this.feeValue
}
await this.serialSignerRef.hwwSendPsbt(this.psbtBase64, txData)
await this.serialSignerRef.isSendingPsbt()
}
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Cannot check and sign PSBT!',
caption: `${error}`,
timeout: 10000
})
} finally {
this.showChecking = false
this.psbtBase64 = null
}
},
showPsbtDialog: async function () {
try {
const valid = await this.$refs.paymentFormRef.validate()
if (!valid) return
const data = await this.createPsbt()
if (data) {
this.showPsbt = true
}
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to create PSBT!',
caption: `${error}`,
timeout: 10000
})
}
},
createPsbt: async function () {
try {
console.log('### this.createPsbt')
this.tx = this.createTx()
for (const input of this.tx.inputs) {
input.tx_hex = await this.fetchTxHex(input.tx_id)
}
const changeOutput = this.tx.outputs.find(o => o.branch_index === 1)
if (changeOutput) changeOutput.amount = this.changeAmount
const {data} = await LNbits.api.request(
'POST',
'/watchonly/api/v1/psbt',
this.adminkey,
this.tx
)
this.psbtBase64 = data
return data
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
createTx: function (excludeChange = false) {
const tx = {
fee_rate: this.feeRate,
masterpubs: this.accounts.map(w => ({
id: w.id,
public_key: w.masterpub,
fingerprint: w.fingerprint
}))
}
tx.inputs = this.utxos
.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.sendToList.map(out => ({
address: out.address,
amount: out.amount
}))
if (!excludeChange) {
const change = this.createChangeOutput()
const diffAmount = this.selectedAmount - this.totalPayedAmount
if (diffAmount >= this.DUST_LIMIT) {
tx.outputs.push(change)
}
}
tx.tx_size = Math.round(txSize(tx))
tx.inputs = _.shuffle(tx.inputs)
tx.outputs = _.shuffle(tx.outputs)
return tx
},
createChangeOutput: function () {
const change = this.changeAddress
const walletAcount =
this.accounts.find(w => w.id === change.wallet) || {}
return {
address: change.address,
address_index: change.addressIndex,
branch_index: change.isChange ? 1 : 0,
wallet: walletAcount.id
}
},
selectChangeAddress: function (account) {
if (!account) this.changeAddress = ''
this.changeAddress =
this.addresses.find(
a => a.wallet === account.id && a.isChange && !a.hasActivity
) || {}
},
updateChangeAddress: function () {
if (this.changeWallet) {
const changeAccount = (this.accounts || []).find(
w => w.id === this.changeWallet.id
)
// change account deleted
if (!changeAccount) {
this.changeWallet = this.accounts[0]
}
} else {
this.changeWallet = this.accounts[0]
}
this.selectChangeAddress(this.changeWallet)
},
updateSignedPsbt: async function (psbtBase64) {
try {
this.showChecking = true
this.psbtBase64Signed = psbtBase64
console.log('### payment updateSignedPsbt psbtBase64', psbtBase64)
const data = await this.extractTxFromPsbt(psbtBase64)
this.showFinalTx = true
if (data) {
this.signedTx = JSON.parse(data.tx_json)
this.signedTxHex = data.tx_hex
} else {
this.signedTx = null
this.signedTxHex = null
}
} finally {
this.showChecking = false
}
},
extractTxFromPsbt: async function (psbtBase64) {
console.log('### extractTxFromPsbt psbtBase64', psbtBase64)
try {
const {data} = await LNbits.api.request(
'PUT',
'/watchonly/api/v1/psbt/extract',
this.adminkey,
{
psbtBase64,
inputs: this.tx.inputs
}
)
console.log('### extractTxFromPsbt data', data)
return data
} catch (error) {
console.log('### error', error)
this.$q.notify({
type: 'warning',
message: 'Cannot finalize PSBT!',
timeout: 10000
})
LNbits.utils.notifyApiError(error)
}
},
broadcastTransaction: async function () {
try {
const {data} = await LNbits.api.request(
'POST',
'/watchonly/api/v1/tx',
this.adminkey,
{tx_hex: this.signedTxHex}
)
this.sentTxId = data
this.$q.notify({
type: 'positive',
message: 'Transaction broadcasted!',
caption: `${data}`,
timeout: 10000
})
// todo: event rescan with amount
// todo: display tx id
} catch (error) {
this.sentTxId = null
this.$q.notify({
type: 'warning',
message: 'Failed to broadcast!',
caption: `${error}`,
timeout: 10000
})
} finally {
this.showFinalTx = false
}
},
fetchTxHex: async function (txId) {
const {
bitcoin: {transactions: transactionsAPI}
} = mempoolJS({
hostname: this.mempoolEndpoint
})
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
}
},
handleOutputsChange: function () {
this.$refs.utxoList.refreshUtxoSelection(this.totalPayedAmount)
},
getTotalPaymentAmount: function () {
return this.sendToList.reduce((t, a) => t + (a.amount || 0), 0)
}
},
created: async function () {}
})
}

View file

@ -0,0 +1,77 @@
<div class="row items-center no-wrap q-mb-md">
<div class="col-12">
<q-table
flat
dense
hide-header
:data="data"
:columns="paymentTable.columns"
:pagination.sync="paymentTable.pagination"
>
<template v-slot:body="props">
<q-tr :props="props">
<div class="row no-wrap">
<div class="col-1">
<q-btn
flat
dense
size="l"
@click="deletePaymentAddress(props.row)"
icon="cancel"
color="pink"
class="q-mt-sm"
></q-btn>
</div>
<div class="col-7 q-pr-lg">
<q-input
filled
dense
v-model.trim="props.row.address"
type="text"
label="Address"
:rules="[val => !!val || 'Field is required']"
@input="handleOutputsChange"
></q-input>
</div>
<div class="col-3 q-pr-lg">
<q-input
filled
dense
v-model.number="props.row.amount"
type="number"
step="1"
label="Amount (sats)"
:rules="[val => !!val || 'Field is required', val => +val > DUST_LIMIT || 'Amount to small (below dust limit)']"
@input="handleOutputsChange"
></q-input>
</div>
<div class="col-1">
<q-btn outline color="grey" @click="sendMaxToAddress(props.row)"
>Max</q-btn
>
</div>
</div>
</q-tr>
</template>
</q-table>
<div class="row items-center no-wrap">
<div class="col-3 q-pr-lg">
<q-btn
unelevated
color="secondary"
@click="addPaymentAddress"
class="btn-full"
>Add</q-btn
>
</div>
<div class="col-9">
<div class="float-right">
<span>Payed Amount: </span>
<span class="text-subtitle2 q-ml-lg">
{{satBtc(getTotalPaymentAmount())}}
</span>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,81 @@
async function sendTo(path) {
const template = await loadTemplateAsync(path)
Vue.component('send-to', {
name: 'send-to',
template,
props: [
'data',
'tx-size',
'selected-amount',
'fee-rate',
'sats-denominated'
],
computed: {
dataLocal: {
get: function () {
return this.data
},
set: function (value) {
console.log('### computed update data', value)
this.$emit('update:data', value)
}
}
},
data: function () {
return {
DUST_LIMIT: 546,
paymentTable: {
columns: [
{
name: 'data',
align: 'left'
}
],
pagination: {
rowsPerPage: 10
},
filter: ''
}
}
},
methods: {
satBtc(val, showUnit = true) {
return satOrBtc(val, showUnit, this.satsDenominated)
},
addPaymentAddress: function () {
this.dataLocal.push({address: '', amount: undefined})
this.handleOutputsChange()
},
deletePaymentAddress: function (v) {
const index = this.dataLocal.indexOf(v)
if (index !== -1) {
this.dataLocal.splice(index, 1)
}
this.handleOutputsChange()
},
sendMaxToAddress: function (paymentAddress = {}) {
const feeValue = this.feeRate * this.txSize
const inputAmount = this.selectedAmount
const currentAmount = Math.max(0, paymentAddress.amount || 0)
const payedAmount = this.getTotalPaymentAmount() - currentAmount
paymentAddress.amount = Math.max(
0,
inputAmount - payedAmount - feeValue
)
},
handleOutputsChange: function () {
this.$emit('update:outputs')
},
getTotalPaymentAmount: function () {
return this.dataLocal.reduce((t, a) => t + (a.amount || 0), 0)
}
},
created: async function () {}
})
}

View file

@ -0,0 +1,67 @@
<div>
<div class="row q-mt-md">
<div class="col-12">
<q-input
filled
dense
v-model.trim="config.baudRate"
type="number"
label="Baud Rate"
></q-input>
</div>
</div>
<div class="row q-mt-md">
<div class="col-12">
<q-input
filled
dense
v-model.trim="config.bufferSize"
type="number"
label="Buffer Size"
></q-input>
</div>
</div>
<div class="row q-mt-md">
<div class="col-12">
<q-input
filled
dense
v-model.trim="config.flowControl"
label="Flow Control"
></q-input>
</div>
</div>
<div class="row q-mt-md">
<div class="col-12">
<q-input
filled
dense
v-model.trim="config.parity"
label="Parity"
></q-input>
</div>
</div>
<div class="row q-mt-md">
<div class="col-12">
<q-input
filled
dense
v-model.trim="config.dataBits"
type="number"
label="Data Bits"
></q-input>
</div>
</div>
<div class="row q-mt-md">
<div class="col-12">
<q-input
filled
dense
v-model.trim="config.stopBits"
type="number"
label="Stop Bits"
></q-input>
</div>
</div>
</div>

View file

@ -0,0 +1,24 @@
async function serialPortConfig(path) {
const t = await loadTemplateAsync(path)
Vue.component('serial-port-config', {
name: 'serial-port-config',
template: t,
data() {
return {
config: {
baudRate: 9600,
bufferSize: 255,
dataBits: 8,
flowControl: 'none',
parity: 'none',
stopBits: 1
}
}
},
methods: {
getConfig: function () {
return this.config
}
}
})
}

View file

@ -0,0 +1,451 @@
<div>
<q-btn-dropdown
split
unelevated
color="primary"
icon="usb"
:text-color="selectedPort ? hww.authenticated ? 'green' : 'orange' : 'white'"
@click="openSerialPortDialog"
>
<q-list>
<q-item
v-if="selectedPort && !hww.authenticated"
clickable
v-close-popup
@click="hwwShowPasswordDialog()"
>
<q-item-section>
<q-item-label>Login</q-item-label>
<q-item-label caption
>Enter password for Hardware Wallet.</q-item-label
>
</q-item-section>
</q-item>
<q-item
v-if="hww.authenticated"
clickable
v-close-popup
@click="hwwLogout()"
>
<q-item-section>
<q-item-label>Logout</q-item-label>
<q-item-label caption>Clear password for HWW.</q-item-label>
</q-item-section>
</q-item>
<q-item
v-if="!selectedPort"
clickable
v-close-popup
@click="openSerialPortConfig"
>
<q-item-section>
<q-item-label>Config & Connect</q-item-label>
<q-item-label caption
>Set the Serial Port communication parameters.</q-item-label
>
</q-item-section>
</q-item>
<q-item
v-if="selectedPort"
clickable
v-close-popup
@click="closeSerialPort()"
>
<q-item-section>
<q-item-label>Disconnect</q-item-label>
<q-item-label caption>Disconnect from Serial Port.</q-item-label>
</q-item-section>
</q-item>
<q-item
v-if="selectedPort"
clickable
v-close-popup
@click="hwwShowRestoreDialog()"
>
<q-item-section>
<q-item-label>Restore</q-item-label>
<q-item-label caption
>Restore wallet from existing word list.</q-item-label
>
</q-item-section>
</q-item>
<q-item
v-if="hww.authenticated"
clickable
v-close-popup
@click="hwwShowSeed()"
>
<q-item-section>
<q-item-label>Show Seed</q-item-label>
<q-item-label caption
>Show seed on the Hardware Wallet display.</q-item-label
>
</q-item-section>
</q-item>
<q-item
v-if="selectedPort"
@click="hwwShowWipeDialog()"
clickable
v-close-popup
>
<q-item-section>
<q-item-label>Wipe</q-item-label>
<q-item-label caption
>Clean-up the wallet. New random seed.</q-item-label
>
</q-item-section>
</q-item>
<q-item v-if="selectedPort" @click="hwwHelp()" clickable v-close-popup>
<q-item-section>
<q-item-label>Help</q-item-label>
<q-item-label caption>View available comands.</q-item-label>
</q-item-section>
</q-item>
<q-item
v-if="selectedPort"
@click="showConsole = true"
clickable
v-close-popup
>
<q-item-section>
<q-item-label>Console</q-item-label>
<q-item-label caption
>Show the serial port communication messages</q-item-label
>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<q-dialog v-model="hww.showConfigDialog" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="hwwConfigAndConnect" class="q-gutter-md">
<span>Enter Config</span>
<serial-port-config
ref="serialPortConfig"
:config="hww.config"
></serial-port-config>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">Connect</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="hww.showPasswordDialog" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="hwwLogin" class="q-gutter-md">
<span>Enter password for Hardware Wallet (8 numbers/letters)</span>
<q-input
filled
dense
v-model.trim="hww.password"
type="password"
label="Password"
></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="!selectedPort"
type="submit"
>Login</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="hww.showConfirmationDialog" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="hwwSignPsbt" class="q-gutter-md">
<div v-if="tx">
<div v-if="!hww.confirm.showFee" class="row q-mt-lg">
<div class="col-12">
<span class="text-subtitle2"
>Output {{hww.confirm.outputIndex}}</span
>
<q-badge
v-if="tx.outputs[hww.confirm.outputIndex].branch_index === 1"
color="orange"
text-color="black"
>
<span>change</span>
</q-badge>
</div>
</div>
<div v-if="!hww.confirm.showFee" class="row q-mt-lg">
<div class="col-3">
<span>Address:</span>
</div>
<div class="col-9">
<span>{{tx.outputs[hww.confirm.outputIndex].address}}</span>
</div>
</div>
<div v-if="!hww.confirm.showFee" class="row q-mt-lg">
<div class="col-3">
<span>Amount:</span>
</div>
<div class="col-9">
<span
>{{satBtc(tx.outputs[hww.confirm.outputIndex].amount)}}</span
>
</div>
</div>
<div v-if="hww.confirm.showFee" class="row q-mt-lg">
<div class="col-3">
<span>Fee: </span>
</div>
<div class="col-9">
<span>{{satBtc(tx.feeValue)}}</span>
</div>
</div>
<div v-if="hww.confirm.showFee" class="row q-mt-lg">
<div class="col-3">
<span>Fee Rate:</span>
</div>
<div class="col-9">
<span>{{tx.feeRate}} sats/vbyte</span>
</div>
</div>
</div>
<div class="row q-mt-lg">
<div class="col-12">
<q-badge class="text-subtitle2" color="yellow" text-color="black">
<span>Check data on the display of the hardware device.</span>
</q-badge>
</div>
</div>
<div class="row q-mt-lg">
<div class="col-6">
<q-btn
v-if="hww.confirm.showFee"
unelevated
color="green"
:disable="!selectedPort"
type="submit"
class="float-left"
label="Confirm"
>
<q-spinner v-if="hww.signingPsbt" color="primary"></q-spinner>
</q-btn>
</div>
<div class="col-3">
<q-btn
unelevated
color="secondary"
label="Next"
class="float-left"
v-if="!hww.confirm.showFee"
@click="hwwConfirmNext"
>
</q-btn>
</div>
<div class="col-3">
<q-btn
@click="cancelOperation"
v-close-popup
flat
color="grey"
class="float-right"
>Cancel</q-btn
>
</div>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="hww.showWipeDialog" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="hwwWipe" class="q-gutter-md">
<q-badge color="pink" text-color="black">
This action will remove all data from the Hardware Wallet. Please
create a back-up for the seed!
</q-badge>
<span>Enter new password for Hardware Wallet (8 numbers/letters)</span>
<q-input
filled
dense
v-model.trim="hww.password"
type="password"
label="Password"
></q-input>
<q-input
filled
dense
v-model.trim="hww.confirmedPassword"
type="password"
label="Confirm Password"
></q-input>
<q-badge color="pink" text-color="black">
This action cannot be reversed!
</q-badge>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="!hww.password || hww.password.length < 8 || (hww.password !== hww.confirmedPassword)"
type="submit"
>Wipe</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="showConsole" position="top">
<q-card class="q-pa-lg q-pt-xl">
<q-input
filled
dense
for="serial-port-console"
v-model.trim="receivedData"
type="textarea"
rows="25"
cols="200"
label="Console"
></q-input>
<div class="row q-mt-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="hww.showSeedDialog" position="top">
<q-card class="q-pa-lg q-pt-xl">
<span>Check word at position {{hww.seedWordPosition}} on display</span>
<div class="row q-mt-lg">
<div class="col-4">
<q-btn
v-if="hww.seedWordPosition!== 1"
unelevated
color="primary"
@click="showPrevSeedWord"
>Prev</q-btn
>
</div>
<div class="col-4">
<q-btn
v-if="hww.seedWordPosition!== 24"
unelevated
color="primary"
@click="showNextSeedWord"
>Next</q-btn
>
</div>
<div class="col-4">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="hww.showRestoreDialog" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="hwwRestore" class="q-gutter-md">
<q-badge
color="pink"
text-color="black"
class="text-subtitle2"
multi-line
>
For test purposes only. Do not enter word list with real funds!!!
</q-badge>
<br /><br /><br />
<span>Enter new word list separated by space</span>
<q-input
v-model.trim="hww.mnemonic"
filled
:type="hww.showMnemonic ? 'text' : 'password'"
filled
dense
label="Word List"
>
<template v-slot:append>
<q-icon
:name="hww.showMnemonic ? 'visibility' : 'visibility_off'"
class="cursor-pointer"
@click="hww.showMnemonic = !hww.showMnemonic"
/>
</template>
</q-input>
<br />
<span>Enter new password (8 numbers/letters)</span>
<q-input
v-model.trim="hww.password"
filled
:type="hww.showPassword ? 'text' : 'password'"
filled
dense
label="New Password"
>
<template v-slot:append>
<q-icon
:name="hww.showPassword ? 'visibility' : 'visibility_off'"
class="cursor-pointer"
@click="hww.showPassword = !hww.showPassword"
/>
</template>
</q-input>
<q-input
filled
dense
v-model.trim="hww.confirmedPassword"
type="password"
label="Confirm Password"
></q-input>
<br /><br />
<q-badge
color="pink"
text-color="black"
class="text-subtitle2"
multi-line
>
For test purposes only. Do not enter word list with real funds!!!
</q-badge>
<q-separator></q-separator>
<q-badge
color="pink"
text-color="black"
class="text-subtitle2"
multi-line
>
ALL existing data on the Hardware Device will be lost.
</q-badge>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="!hww.mnemonic || !hww.password || hww.password.length < 8 || (hww.password !== hww.confirmedPassword)"
type="submit"
>Restore</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>

View file

@ -0,0 +1,601 @@
async function serialSigner(path) {
const t = await loadTemplateAsync(path)
Vue.component('serial-signer', {
name: 'serial-signer',
template: t,
props: ['sats-denominated', 'network'],
data: function () {
return {
selectedPort: null,
writableStreamClosed: null,
writer: null,
readableStreamClosed: null,
reader: null,
receivedData: '',
config: {},
hww: {
password: null,
showPassword: false,
mnemonic: null,
showMnemonic: false,
authenticated: false,
showPasswordDialog: false,
showConfigDialog: false,
showWipeDialog: false,
showRestoreDialog: false,
showConfirmationDialog: false,
showSignedPsbt: false,
sendingPsbt: false,
signingPsbt: false,
loginResolve: null,
psbtSentResolve: null,
xpubResolve: null,
seedWordPosition: 1,
showSeedDialog: false,
confirm: {
outputIndex: 0,
showFee: false
}
},
tx: null, // todo: move to hww
showConsole: false
}
},
methods: {
satBtc(val, showUnit = true) {
return satOrBtc(val, showUnit, this.satsDenominated)
},
openSerialPortDialog: async function () {
await this.openSerialPort()
},
openSerialPort: async function (config = {baudRate: 9600}) {
if (!this.checkSerialPortSupported()) return false
if (this.selectedPort) {
this.$q.notify({
type: 'warning',
message: 'Already connected. Disconnect first!',
timeout: 10000
})
return true
}
try {
navigator.serial.addEventListener('connect', event => {
console.log('### navigator.serial event: connected!', event)
})
navigator.serial.addEventListener('disconnect', () => {
console.log('### navigator.serial event: disconnected!', event)
this.hww.authenticated = false
this.$q.notify({
type: 'warning',
message: 'Disconnected from Serial Port!',
timeout: 10000
})
})
this.selectedPort = await navigator.serial.requestPort()
// Wait for the serial port to open.
await this.selectedPort.open(config)
this.startSerialPortReading()
const textEncoder = new TextEncoderStream()
this.writableStreamClosed = textEncoder.readable.pipeTo(
this.selectedPort.writable
)
this.writer = textEncoder.writable.getWriter()
return true
} catch (error) {
this.selectedPort = null
this.$q.notify({
type: 'warning',
message: 'Cannot open serial port!',
caption: `${error}`,
timeout: 10000
})
return false
}
},
openSerialPortConfig: async function () {
this.hww.showConfigDialog = true
},
closeSerialPort: async function () {
try {
if (this.writer) this.writer.close()
if (this.writableStreamClosed) await this.writableStreamClosed
if (this.reader) this.reader.cancel()
if (this.readableStreamClosed)
await this.readableStreamClosed.catch(() => {
/* Ignore the error */
})
if (this.selectedPort) await this.selectedPort.close()
this.selectedPort = null
this.$q.notify({
type: 'positive',
message: 'Serial port disconnected!',
timeout: 5000
})
} catch (error) {
this.selectedPort = null
this.$q.notify({
type: 'warning',
message: 'Cannot close serial port!',
caption: `${error}`,
timeout: 10000
})
} finally {
this.hww.authenticated = false
}
},
isConnected: function () {
return !!this.selectedPort
},
isAuthenticated: function () {
return this.hww.authenticated
},
isAuthenticating: function () {
if (this.isAuthenticated()) return false
return new Promise(resolve => {
this.loginResolve = resolve
})
},
isSendingPsbt: async function () {
if (!this.hww.sendingPsbt) return false
return new Promise(resolve => {
this.psbtSentResolve = resolve
})
},
isFetchingXpub: async function () {
return new Promise(resolve => {
this.xpubResolve = resolve
})
},
checkSerialPortSupported: function () {
if (!navigator.serial) {
this.$q.notify({
type: 'warning',
message: 'Serial port communication not supported!',
caption:
'Make sure your browser supports Serial Port and that you are using HTTPS.',
timeout: 10000
})
return false
}
return true
},
startSerialPortReading: async function () {
const port = this.selectedPort
while (port && port.readable) {
const textDecoder = new TextDecoderStream()
this.readableStreamClosed = port.readable.pipeTo(textDecoder.writable)
this.reader = textDecoder.readable.getReader()
const readStringUntil = readFromSerialPort(this.reader)
try {
while (true) {
const {value, done} = await readStringUntil('\n')
if (value) {
this.handleSerialPortResponse(value)
this.updateSerialPortConsole(value)
}
if (done) return
}
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Serial port communication error!',
caption: `${error}`,
timeout: 10000
})
}
}
},
handleSerialPortResponse: function (value) {
const command = value.split(' ')[0]
const commandData = value.substring(command.length).trim()
switch (command) {
case COMMAND_SIGN_PSBT:
this.handleSignResponse(commandData)
break
case COMMAND_PASSWORD:
this.handleLoginResponse(commandData)
break
case COMMAND_PASSWORD_CLEAR:
this.handleLogoutResponse(commandData)
break
case COMMAND_SEND_PSBT:
this.handleSendPsbtResponse(commandData)
break
case COMMAND_WIPE:
this.handleWipeResponse(commandData)
break
case COMMAND_XPUB:
this.handleXpubResponse(commandData)
break
default:
console.log('### console', value)
}
},
updateSerialPortConsole: function (value) {
this.receivedData += value + '\n'
const textArea = document.getElementById('serial-port-console')
if (textArea) textArea.scrollTop = textArea.scrollHeight
},
hwwShowPasswordDialog: async function () {
try {
this.hww.showPasswordDialog = true
await this.writer.write(COMMAND_PASSWORD + '\n')
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to connect to Hardware Wallet!',
caption: `${error}`,
timeout: 10000
})
}
},
hwwShowWipeDialog: async function () {
try {
this.hww.showWipeDialog = true
await this.writer.write(COMMAND_WIPE + '\n')
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to connect to Hardware Wallet!',
caption: `${error}`,
timeout: 10000
})
}
},
hwwShowRestoreDialog: async function () {
try {
this.hww.showRestoreDialog = true
await this.writer.write(COMMAND_WIPE + '\n')
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to connect to Hardware Wallet!',
caption: `${error}`,
timeout: 10000
})
}
},
hwwConfirmNext: async function () {
this.hww.confirm.outputIndex += 1
if (this.hww.confirm.outputIndex >= this.tx.outputs.length) {
this.hww.confirm.showFee = true
}
await this.writer.write(COMMAND_CONFIRM_NEXT + '\n')
},
cancelOperation: async function () {
try {
await this.writer.write(COMMAND_CANCEL + '\n')
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to send cancel!',
caption: `${error}`,
timeout: 10000
})
}
},
hwwConfigAndConnect: async function () {
this.hww.showConfigDialog = false
const config = this.$refs.serialPortConfig.getConfig()
await this.openSerialPort(config)
return true
},
hwwLogin: async function () {
try {
await this.writer.write(
COMMAND_PASSWORD + ' ' + this.hww.password + '\n'
)
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to send password to Hardware Wallet!',
caption: `${error}`,
timeout: 10000
})
} finally {
this.hww.showPasswordDialog = false
this.hww.password = null
this.hww.showPassword = false
}
},
handleLoginResponse: function (res = '') {
this.hww.authenticated = res.trim() === '1'
if (this.loginResolve) {
this.loginResolve(this.hww.authenticated)
}
if (this.hww.authenticated) {
this.$q.notify({
type: 'positive',
message: 'Login successfull!',
timeout: 10000
})
} else {
this.$q.notify({
type: 'warning',
message: 'Wrong password, try again!',
timeout: 10000
})
}
},
hwwLogout: async function () {
try {
await this.writer.write(COMMAND_PASSWORD_CLEAR + '\n')
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to logout from Hardware Wallet!',
caption: `${error}`,
timeout: 10000
})
}
},
handleLogoutResponse: function (res = '') {
this.hww.authenticated = !(res.trim() === '1')
if (this.hww.authenticated) {
this.$q.notify({
type: 'warning',
message: 'Failed to logout from Hardware Wallet',
timeout: 10000
})
}
},
hwwSendPsbt: async function (psbtBase64, tx) {
try {
this.tx = tx
this.hww.sendingPsbt = true
await this.writer.write(
COMMAND_SEND_PSBT + ' ' + this.network + ' ' + psbtBase64 + '\n'
)
this.$q.notify({
type: 'positive',
message: 'Data sent to serial port device!',
timeout: 5000
})
} catch (error) {
this.hww.sendingPsbt = false
this.$q.notify({
type: 'warning',
message: 'Failed to send data to serial port!',
caption: `${error}`,
timeout: 10000
})
}
},
handleSendPsbtResponse: function (res = '') {
try {
const psbtOK = res.trim() === '1'
if (!psbtOK) {
this.$q.notify({
type: 'warning',
message: 'Failed to send PSBT!',
caption: `${res}`,
timeout: 10000
})
return
}
this.hww.confirm.outputIndex = 0
this.hww.showConfirmationDialog = true
this.hww.confirm = {
outputIndex: 0,
showFee: false
}
this.hww.sendingPsbt = false
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to send PSBT!',
caption: `${error}`,
timeout: 10000
})
} finally {
this.psbtSentResolve()
}
},
hwwSignPsbt: async function () {
try {
this.hww.showConfirmationDialog = false
this.hww.signingPsbt = true
await this.writer.write(COMMAND_SIGN_PSBT + '\n')
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to sign PSBT!',
caption: `${error}`,
timeout: 10000
})
}
},
handleSignResponse: function (res = '') {
this.hww.signingPsbt = false
const [count, psbt] = res.trim().split(' ')
if (!psbt || !count || count.trim() === '0') {
this.$q.notify({
type: 'warning',
message: 'No input signed!',
caption: 'Are you using the right seed?',
timeout: 10000
})
return
}
this.updateSignedPsbt(psbt)
this.$q.notify({
type: 'positive',
message: 'Transaction Signed',
message: `Inputs signed: ${count}`,
timeout: 10000
})
},
hwwHelp: async function () {
try {
await this.writer.write(COMMAND_HELP + '\n')
this.$q.notify({
type: 'positive',
message: 'Check display or console for details!',
timeout: 5000
})
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to ask for help!',
caption: `${error}`,
timeout: 10000
})
}
},
hwwWipe: async function () {
try {
this.hww.showWipeDialog = false
await this.writer.write(COMMAND_WIPE + ' ' + this.hww.password + '\n')
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to ask for help!',
caption: `${error}`,
timeout: 10000
})
} finally {
this.hww.password = null
this.hww.confirmedPassword = null
this.hww.showPassword = false
}
},
handleWipeResponse: function (res = '') {
const wiped = res.trim() === '1'
if (wiped) {
this.$q.notify({
type: 'positive',
message: 'Wallet wiped!',
timeout: 10000
})
} else {
this.$q.notify({
type: 'warning',
message: 'Failed to wipe wallet!',
caption: `${error}`,
timeout: 10000
})
}
},
hwwXpub: async function (path) {
try {
console.log(
'### hwwXpub',
COMMAND_XPUB + ' ' + this.network + ' ' + path
)
await this.writer.write(
COMMAND_XPUB + ' ' + this.network + ' ' + path + '\n'
)
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to fetch XPub!',
caption: `${error}`,
timeout: 10000
})
}
},
handleXpubResponse: function (res = '') {
const args = res.trim().split(' ')
if (args.length < 3 || args[0].trim() !== '1') {
this.$q.notify({
type: 'warning',
message: 'Failed to fetch XPub!',
caption: `${res}`,
timeout: 10000
})
this.xpubResolve({})
return
}
const xpub = args[1].trim()
const fingerprint = args[2].trim()
this.xpubResolve({xpub, fingerprint})
},
hwwShowSeed: async function () {
try {
this.hww.showSeedDialog = true
this.hww.seedWordPosition = 1
await this.writer.write(
COMMAND_SEED + ' ' + this.hww.seedWordPosition + '\n'
)
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to show seed!',
caption: `${error}`,
timeout: 10000
})
}
},
showNextSeedWord: async function () {
this.hww.seedWordPosition++
await this.writer.write(
COMMAND_SEED + ' ' + this.hww.seedWordPosition + '\n'
)
},
showPrevSeedWord: async function () {
this.hww.seedWordPosition = Math.max(1, this.hww.seedWordPosition - 1)
console.log('### this.hww.seedWordPosition', this.hww.seedWordPosition)
await this.writer.write(
COMMAND_SEED + ' ' + this.hww.seedWordPosition + '\n'
)
},
handleShowSeedResponse: function (res = '') {
const args = res.trim().split(' ')
if (args.length < 2 || args[0].trim() !== '1') {
this.$q.notify({
type: 'warning',
message: 'Failed to show seed!',
caption: `${res}`,
timeout: 10000
})
return
}
},
hwwRestore: async function () {
try {
await this.writer.write(
COMMAND_RESTORE + ' ' + this.hww.mnemonic + '\n'
)
await this.writer.write(
COMMAND_PASSWORD + ' ' + this.hww.password + '\n'
)
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to restore from seed!',
caption: `${error}`,
timeout: 10000
})
} finally {
this.hww.showRestoreDialog = false
this.hww.mnemonic = null
this.hww.showMnemonic = false
this.hww.password = null
this.hww.confirmedPassword = null
this.hww.showPassword = false
}
},
updateSignedPsbt: async function (value) {
this.$emit('signed:psbt', value)
}
},
created: async function () {}
})
}

View file

@ -0,0 +1,135 @@
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div v-if="selectable" class="col-3 q-pr-lg">
<q-select
filled
dense
emit-value
v-model="utxoSelectionMode"
:options="utxoSelectionModes"
label="Selection Mode"
@input="updateUtxoSelection"
></q-select>
</div>
<div v-if="selectable" class="col-1 q-pr-lg">
<q-btn
outline
icon="refresh"
color="grey"
@click="updateUtxoSelection"
class="q-ml-sm"
></q-btn>
</div>
<div v-if="selectable" class="col-5 q-pr-lg"></div>
<div v-if="!selectable" class="col-9 q-pr-lg"></div>
<div class="col-3 float-right">
<q-input
borderless
dense
debounce="300"
v-model="filter"
placeholder="Search"
>
<template v-slot:append>
<q-icon name="search"></q-icon>
</template>
</q-input>
</div>
</div>
<q-table
flat
dense
:data="utxos"
row-key="id"
:columns="columns"
:pagination.sync="utxosTable.pagination"
:filter="filter"
>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="accent"
round
dense
@click="props.row.expanded= !props.row.expanded"
:icon="props.row.expanded? 'remove' : 'add'"
/>
</q-td>
<q-td v-if="selectable" key="selected" :props="props">
<div>
<q-checkbox v-model="props.row.selected"></q-checkbox>
</div>
</q-td>
<q-td key="status" :props="props">
<div>
<q-badge
v-if="props.row.confirmed"
@click="props.row.expanded = !props.row.expanded"
color="green"
class="q-mr-md cursor-pointer"
>
Confirmed
</q-badge>
<q-badge
v-if="!props.row.confirmed"
@click="props.row.expanded = !props.row.expanded"
color="orange"
class="q-mr-md cursor-pointer"
>
Pending
</q-badge>
</div>
</q-td>
<q-td key="address" :props="props">
<div>
<a
style="color: unset"
:href="'https://' + mempoolEndpoint + '/address/' + props.row.address"
target="_blank"
>
{{props.row.address}}</a
>
<q-badge v-if="props.row.isChange" color="orange" class="q-mr-md">
change
</q-badge>
</div>
</q-td>
<q-td
key="amount"
:props="props"
class="text-green-13 text-weight-bold"
>
<div>{{satBtc(props.row.amount)}}</div>
</q-td>
<q-td key="date" :props="props"> {{ props.row.date }} </q-td>
<q-td key="wallet" :props="props" :class="">
<div>{{getWalletName(props.row.wallet)}}</div>
</q-td>
</q-tr>
<q-tr v-show="props.row.expanded" :props="props">
<q-td colspan="100%">
<div class="row items-center q-mb-md">
<div class="col-2 q-pr-lg">Transaction Id</div>
<div class="col-10 q-pr-lg">
<a
style="color: unset"
:href="'https://' + mempoolEndpoint + '/tx/' + props.row.txId"
target="_blank"
>
{{props.row.txId}}</a
>
</div>
</div>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section></q-card
>

View file

@ -0,0 +1,148 @@
async function utxoList(path) {
const template = await loadTemplateAsync(path)
Vue.component('utxo-list', {
name: 'utxo-list',
template,
props: [
'utxos',
'accounts',
'selectable',
'payed-amount',
'sats-denominated',
'mempool-endpoint',
'filter'
],
data: function () {
return {
utxosTable: {
columns: [
{
name: 'expand',
align: 'left',
label: ''
},
{
name: 'selected',
align: 'left',
label: '',
selectable: true
},
{
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
}
},
utxoSelectionModes: [
'Manual',
'Random',
'Select All',
'Smaller Inputs First',
'Larger Inputs First'
],
utxoSelectionMode: 'Random',
utxoSelectAmount: 0
}
},
computed: {
columns: function () {
return this.utxosTable.columns.filter(c =>
c.selectable ? this.selectable : true
)
}
},
methods: {
satBtc(val, showUnit = true) {
return satOrBtc(val, showUnit, this.satsDenominated)
},
getWalletName: function (walletId) {
const wallet = (this.accounts || []).find(wl => wl.id === walletId)
return wallet ? wallet.title : 'unknown'
},
getTotalSelectedUtxoAmount: function () {
const total = (this.utxos || [])
.filter(u => u.selected)
.reduce((t, a) => t + (a.amount || 0), 0)
return total
},
refreshUtxoSelection: function (totalPayedAmount) {
this.utxoSelectAmount = totalPayedAmount
this.applyUtxoSelectionMode()
},
updateUtxoSelection: function () {
this.utxoSelectAmount = this.payedAmount
this.applyUtxoSelectionMode()
},
applyUtxoSelectionMode: function () {
const mode = this.utxoSelectionMode
const isSelectAll = mode === 'Select All'
if (isSelectAll) {
this.utxos.forEach(u => (u.selected = true))
return
}
const isManual = mode === 'Manual'
if (isManual || !this.utxoSelectAmount) return
this.utxos.forEach(u => (u.selected = false))
const isSmallerFirst = mode === 'Smaller Inputs First'
const isLargerFirst = mode === 'Larger Inputs First'
let selectedUtxos = this.utxos.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 < this.utxoSelectAmount
total += utxo.amount
return total
}, 0)
}
},
created: async function () {}
})
}

View file

@ -0,0 +1,80 @@
<div>
<q-card>
<div class="row items-center no-wrap q-mb-md">
<div class="col-2 q-ml-lg">
<q-btn unelevated @click="show = true" color="primary" icon="settings">
</q-btn>
</div>
<div class="col-8">
<div class="row justify-center q-gutter-x-md items-center">
<div class="text-h3">{{satBtc(total)}}</div>
</div>
</div>
<div class="col-2 float-right">
<slot name="serial"></slot>
</div>
</div>
</q-card>
<q-dialog v-model="show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="updateConfig" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="config.mempool_endpoint"
type="text"
label="Mempool Endpoint"
>
</q-input>
<q-input
filled
dense
v-model.number="config.receive_gap_limit"
type="number"
min="0"
label="Receive Gap Limit"
></q-input>
<q-input
filled
dense
v-model.number="config.change_gap_limit"
type="number"
min="0"
label="Change Gap Limit"
></q-input>
<q-select
filled
dense
emit-value
v-model="config.network"
:options="networOptions"
label="Network"
></q-select>
<q-toggle
:label="config.sats_denominated ? 'sats denominated' : 'BTC denominated'"
color="secodary"
v-model="config.sats_denominated"
></q-toggle>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="
!config.mempool_endpoint "
type="submit"
>Update</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>

View file

@ -0,0 +1,67 @@
async function walletConfig(path) {
const t = await loadTemplateAsync(path)
Vue.component('wallet-config', {
name: 'wallet-config',
template: t,
props: ['total', 'config-data', 'adminkey'],
data: function () {
return {
networOptions: ['Mainnet', 'Testnet'],
internalConfig: {},
show: false
}
},
computed: {
config: {
get() {
return this.internalConfig
},
set(value) {
value.isLoaded = true
this.internalConfig = JSON.parse(JSON.stringify(value))
this.$emit(
'update:config-data',
JSON.parse(JSON.stringify(this.internalConfig))
)
}
}
},
methods: {
satBtc(val, showUnit = true) {
return satOrBtc(val, showUnit, this.config.sats_denominated)
},
updateConfig: async function () {
try {
const {data} = await LNbits.api.request(
'PUT',
'/watchonly/api/v1/config',
this.adminkey,
this.config
)
this.show = false
this.config = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
getConfig: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/watchonly/api/v1/config',
this.adminkey
)
this.config = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
}
},
created: async function () {
await this.getConfig()
}
})
}

View file

@ -0,0 +1,233 @@
<div>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<q-btn-dropdown
split
unelevated
label="Add Wallet Account"
color="primary"
@click="showAddAccountDialog"
>
<q-list>
<q-item @click="showAddAccountDialog" clickable v-close-popup>
<q-item-section>
<q-item-label>New Account</q-item-label>
<q-item-label caption
>Enter account Xpub or Descriptor</q-item-label
>
</q-item-section>
</q-item>
<q-item @click="getXpubFromDevice" clickable v-close-popup>
<q-item-section>
<q-item-label>From Hardware Device</q-item-label>
<q-item-label caption>
Get Xpub from a Hardware Device</q-item-label
>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</div>
<div class="col-auto q-pr-lg"></div>
<div class="col-auto q-pl-lg">
<q-input
borderless
dense
debounce="300"
v-model="filter"
placeholder="Search"
>
<template v-slot:append>
<q-icon name="search"></q-icon>
</template>
</q-input>
</div>
</div>
<q-table
flat
dense
:data="walletAccounts"
row-key="id"
:columns="walletsTable.columns"
:pagination.sync="walletsTable.pagination"
:filter="filter"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="accent"
round
dense
@click="props.row.expanded= !props.row.expanded"
:icon="props.row.expanded? 'remove' : 'add'"
/>
</q-td>
<q-td key="new">
<q-badge
size="lg"
color="secondary"
class="q-mr-md cursor-pointer"
@click="openGetFreshAddressDialog(props.row.id)"
>
New Receive Address
</q-badge>
</q-td>
<q-td key="title" :props="props" :class="">
<div>{{props.row.title}}</div>
</q-td>
<q-td key="amount" :props="props" :class="">
<div>{{getAmmountForWallet(props.row.id)}}</div>
</q-td>
<q-td key="type" :props="props" :class="">
<div>{{props.row.type}}</div>
</q-td>
<q-td key="id" :props="props" :class="">
<div>{{props.row.id}}</div>
</q-td>
</q-tr>
<q-tr v-show="props.row.expanded" :props="props">
<q-td colspan="100%">
<div class="row items-center q-mt-md q-mb-lg">
<div class="col-2 q-pr-lg"></div>
<div class="col-4 q-pr-lg">
<q-btn
unelevated
color="secondary"
@click="openGetFreshAddressDialog(props.row.id)"
>New Receive Address</q-btn
>
</div>
<div class="col-4">
{{getAccountDescription(props.row.type)}}
</div>
<div class="col-2 q-pr-lg"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-2 q-pr-lg">Master Pubkey:</div>
<div class="col-8">
<q-input
v-model="props.row.masterpub"
filled
readonly
type="textarea"
/>
</div>
<div class="col-2 q-pr-lg"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-2 q-pr-lg">Last Address Index:</div>
<div class="col-8">
<span v-if="props.row.address_no >= 0"
>{{props.row.address_no}}</span
>
<span v-if="props.row.address_no < 0">none</span>
</div>
<div class="col-2 q-pr-lg"></div>
</div>
<div class="row items-center no-wrap q-mb-md">
<div class="col-2 q-pr-lg">Fingerprint:</div>
<div class="col-8">{{props.row.fingerprint}}</div>
<div class="col-2 q-pr-lg"></div>
</div>
<div class="row items-center q-mt-md q-mb-lg">
<div class="col-2 q-pr-lg"></div>
<div class="col-4 q-pr-lg">
<q-btn
unelevated
color="pink"
icon="cancel"
@click="deleteWalletAccount(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>
</q-table>
</q-card-section>
</q-card>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="addWalletAccount" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialog.data.title"
type="text"
label="Title"
></q-input>
<q-input
v-if="!formDialog.useSerialPort"
filled
type="textarea"
v-model="formDialog.data.masterpub"
height="50px"
autogrow
label="Account Extended Public Key; xpub, ypub, zpub; Bitcoin Descriptor"
></q-input>
<q-select
v-if="formDialog.useSerialPort"
filled
dense
emit-value
v-model="formDialog.addressType"
:options="addressTypeOptions"
label="Address Type"
@input="handleAddressTypeChanged"
></q-select>
<q-input
v-if="formDialog.useSerialPort"
filled
type="text"
v-model="accountPath"
height="50px"
autogrow
label="Account Path"
></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
label="Add Watch-Only Account"
:disable="
(formDialog.data.masterpub == null && accountPath == null)||
formDialog.data.title == null || showCreating"
type="submit"
>
</q-btn>
<q-spinner v-if="showCreating" color="primary" size="2em"></q-spinner>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>

View file

@ -0,0 +1,290 @@
async function walletList(path) {
const template = await loadTemplateAsync(path)
Vue.component('wallet-list', {
name: 'wallet-list',
template,
props: [
'adminkey',
'inkey',
'sats-denominated',
'addresses',
'network',
'serial-signer-ref'
],
data: function () {
return {
walletAccounts: [],
address: {},
formDialog: {
show: false,
addressType: {
label: 'Segwit (P2WPKH)',
id: 'wpkh',
pathMainnet: "m/84'/0'/0'",
pathTestnet: "m/84'/1'/0'"
},
useSerialPort: false,
data: {
title: '',
masterpub: ''
}
},
accountPath: '',
filter: '',
showCreating: false,
addressTypeOptions: [
{
label: 'Legacy (P2PKH)',
id: 'pkh',
pathMainnet: "m/44'/0'/0'",
pathTestnet: "m/44'/1'/0'"
},
{
label: 'Segwit (P2WPKH)',
id: 'wpkh',
pathMainnet: "m/84'/0'/0'",
pathTestnet: "m/84'/1'/0'"
},
{
label: 'Wrapped Segwit (P2SH-P2WPKH)',
id: 'sh',
pathMainnet: "m/49'/0'/0'",
pathTestnet: "m/49'/1'/0'"
},
{
label: 'Taproot (P2TR)',
id: 'tr',
pathMainnet: "m/86'/0'/0'",
pathTestnet: "m/86'/1'/0'"
}
],
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: ''
}
}
},
watch: {
immediate: true,
async network(newNet, oldNet) {
if (newNet !== oldNet) {
await this.refreshWalletAccounts()
}
}
},
methods: {
satBtc(val, showUnit = true) {
return satOrBtc(val, showUnit, this.satsDenominated)
},
addWalletAccount: async function () {
this.showCreating = true
const data = _.omit(this.formDialog.data, 'wallet')
data.network = this.network
await this.createWalletAccount(data)
this.showCreating = false
},
createWalletAccount: async function (data) {
try {
if (this.formDialog.useSerialPort) {
const {xpub, fingerprint} = await this.fetchXpubFromHww()
if (!xpub) return
const path = this.accountPath.substring(2)
const outputType = this.formDialog.addressType.id
if (outputType === 'sh') {
data.masterpub = `${outputType}(wpkh([${fingerprint}/${path}]${xpub}/{0,1}/*))`
} else {
data.masterpub = `${outputType}([${fingerprint}/${path}]${xpub}/{0,1}/*)`
}
}
const response = await LNbits.api.request(
'POST',
'/watchonly/api/v1/wallet',
this.adminkey,
data
)
this.walletAccounts.push(mapWalletAccount(response.data))
this.formDialog.show = false
await this.refreshWalletAccounts()
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
fetchXpubFromHww: async function () {
const error = findAccountPathIssues(this.accountPath)
if (error) {
this.$q.notify({
type: 'warning',
message: 'Invalid derivation path.',
caption: error,
timeout: 10000
})
return
}
await this.serialSignerRef.hwwXpub(this.accountPath)
return await this.serialSignerRef.isFetchingXpub()
},
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.adminkey
)
this.walletAccounts = _.reject(this.walletAccounts, function (
obj
) {
return obj.id === walletAccountId
})
await this.refreshWalletAccounts()
} catch (error) {
this.$q.notify({
type: 'warning',
message:
'Error while deleting wallet account. Please try again.',
timeout: 10000
})
}
})
},
getWatchOnlyWallets: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
`/watchonly/api/v1/wallet?network=${this.network}`,
this.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 () {
this.walletAccounts = []
const wallets = await this.getWatchOnlyWallets()
this.walletAccounts = wallets.map(w => mapWalletAccount(w))
this.$emit('accounts-update', this.walletAccounts)
},
getAmmountForWallet: function (walletId) {
const amount = this.addresses
.filter(a => a.wallet === walletId)
.reduce((t, a) => t + a.amount || 0, 0)
return this.satBtc(amount)
},
closeFormDialog: function () {
this.formDialog.data = {
is_unique: false
}
},
getAccountDescription: function (accountType) {
return getAccountDescription(accountType)
},
openGetFreshAddressDialog: async function (walletId) {
const {data} = await LNbits.api.request(
'GET',
`/watchonly/api/v1/address/${walletId}`,
this.inkey
)
const addressData = mapAddressesData(data)
addressData.note = `Shared on ${currentDateTime()}`
const lastAcctiveAddress =
this.addresses
.filter(
a =>
a.wallet === addressData.wallet && !a.isChange && a.hasActivity
)
.pop() || {}
addressData.gapLimitExceeded =
!addressData.isChange &&
addressData.addressIndex >
lastAcctiveAddress.addressIndex + DEFAULT_RECEIVE_GAP_LIMIT
const wallet = this.walletAccounts.find(w => w.id === walletId) || {}
wallet.address_no = addressData.addressIndex
this.$emit('new-receive-address', addressData)
},
showAddAccountDialog: function () {
this.formDialog.show = true
this.formDialog.useSerialPort = false
},
getXpubFromDevice: async function () {
try {
if (!this.serialSignerRef.isConnected()) {
const portOpen = await this.serialSignerRef.openSerialPort()
if (!portOpen) return
}
if (!this.serialSignerRef.isAuthenticated()) {
await this.serialSignerRef.hwwShowPasswordDialog()
const authenticated = await this.serialSignerRef.isAuthenticating()
if (!authenticated) return
}
this.formDialog.show = true
this.formDialog.useSerialPort = true
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Cannot fetch Xpub!',
caption: `${error}`,
timeout: 10000
})
}
},
handleAddressTypeChanged: function (value = {}) {
const addressType =
this.addressTypeOptions.find(t => t.id === value.id) || {}
this.accountPath = addressType[`path${this.network}`]
}
},
created: async function () {
if (this.inkey) {
await this.refreshWalletAccounts()
this.handleAddressTypeChanged(this.addressTypeOptions[1])
}
}
})
}

View file

@ -1,18 +1,29 @@
Vue.component(VueQrcode.name, VueQrcode)
const watchOnly = async () => {
Vue.component(VueQrcode.name, VueQrcode)
Vue.filter('reverse', function (value) {
await walletConfig('static/components/wallet-config/wallet-config.html')
await walletList('static/components/wallet-list/wallet-list.html')
await addressList('static/components/address-list/address-list.html')
await history('static/components/history/history.html')
await utxoList('static/components/utxo-list/utxo-list.html')
await feeRate('static/components/fee-rate/fee-rate.html')
await sendTo('static/components/send-to/send-to.html')
await payment('static/components/payment/payment.html')
await serialSigner('static/components/serial-signer/serial-signer.html')
await serialPortConfig(
'static/components/serial-port-config/serial-port-config.html'
)
Vue.filter('reverse', function (value) {
// slice to make a copy of array, then reverse the copy
return value.slice().reverse()
})
})
new Vue({
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
DUST_LIMIT: 546,
filter: '',
scan: {
scanning: false,
scanCount: 0,
@ -23,199 +34,40 @@ new Vue({
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: {}
},
config: {sats_denominated: true},
qrCodeDialog: {
show: false,
data: null
},
...tables,
...tableData
...tableData,
walletAccounts: [],
addresses: [],
history: [],
historyFilter: '',
showAddress: false,
addressNote: '',
showPayment: false,
fetchedUtxos: false,
utxosFilter: '',
network: null
}
},
computed: {
mempoolHostname: function () {
if (!this.config.isLoaded) return
let hostname = new URL(this.config.mempool_endpoint).hostname
if (this.config.network === 'Testnet') {
hostname += '/testnet'
}
return hostname
}
},
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]
@ -232,6 +84,7 @@ new Vue({
}
}
// todo: account deleted
await LNbits.api.request(
'PUT',
`/watchonly/api/v1/address/${addressData.id}`,
@ -248,71 +101,22 @@ new Vue({
LNbits.utils.notifyApiError(err)
}
},
updateNoteForAddress: async function (addressData, note) {
updateNoteForAddress: async function ({addressId, note}) {
try {
const wallet = this.g.user.wallets[0]
await LNbits.api.request(
'PUT',
`/watchonly/api/v1/address/${addressData.id}`,
`/watchonly/api/v1/address/${addressId}`,
wallet.adminkey,
{note: addressData.note}
{note}
)
const updatedAddress =
this.addresses.data.find(a => a.id === addressData.id) || {}
this.addresses.find(a => a.id === addressId) || {}
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) {
@ -331,24 +135,9 @@ new Vue({
})
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
this.history
.filter(s => s.sent)
.forEach((el, i, arr) => {
if (el.isSubItem) return
@ -364,156 +153,43 @@ new Vue({
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'
this.showPayment = true
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)
//################### PSBT ###################
updateSignedPsbt: async function (psbtBase64) {
this.$refs.paymentRef.updateSignedPsbt(psbtBase64)
},
//################### SERIAL PORT ###################
//################### HARDWARE WALLET ###################
//################### UTXOs ###################
scanAllAddresses: async function () {
await this.refreshAddresses()
this.addresses.history = []
let addresses = this.addresses.data
this.history = []
let addresses = this.addresses
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()
const oldAddresses = this.addresses.slice()
await this.refreshAddresses()
const newAddresses = this.addresses.data.slice()
const newAddresses = this.addresses.slice()
// check if gap addresses have been extended
addresses = newAddresses.filter(
newAddr => !oldAddresses.find(oldAddr => oldAddr.id === newAddr.id)
@ -530,8 +206,8 @@ new Vue({
scanAddressWithAmount: async function () {
this.utxos.data = []
this.utxos.total = 0
this.addresses.history = []
const addresses = this.addresses.data.filter(a => a.hasActivity)
this.history = []
const addresses = this.addresses.filter(a => a.hasActivity)
await this.updateUtxosForAddresses(addresses)
},
scanAddress: async function (addressData) {
@ -542,6 +218,49 @@ new Vue({
timeout: 10000
})
},
refreshAddresses: async function () {
if (!this.walletAccounts) return
this.addresses = []
for (const {id, type} of this.walletAccounts) {
const newAddresses = await this.getAddressesForWallet(id)
const uniqueAddresses = newAddresses.filter(
newAddr => !this.addresses.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 + DEFAULT_RECEIVE_GAP_LIMIT
})
this.addresses.push(...uniqueAddresses)
}
this.$emit('update:addresses', this.addresses)
},
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 (error) {
this.$q.notify({
type: 'warning',
message: `Failed to fetch addresses for wallet with id ${walletId}.`,
timeout: 10000
})
LNbits.utils.notifyApiError(error)
}
return []
},
updateUtxosForAddresses: async function (addresses = []) {
this.scan = {scanning: true, scanCount: addresses.length, scanIndex: 0}
@ -549,20 +268,20 @@ new Vue({
for (addrData of addresses) {
const addressHistory = await this.getAddressTxsDelayed(addrData)
// remove old entries
this.addresses.history = this.addresses.history.filter(
this.history = this.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
)
// add new entries
this.history.push(...addressHistory)
this.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)
const utxos = await this.getAddressTxsUtxoDelayed(
addrData.address
)
this.updateUtxosForAddress(addrData, utxos)
}
@ -605,131 +324,76 @@ new Vue({
)
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 accounts = this.walletAccounts
const {
bitcoin: {addresses: addressesAPI}
} = mempoolJS()
const fn = async () =>
addressesAPI.getAddressTxs({
} = mempoolJS({
hostname: this.mempoolHostname
})
const fn = async () => {
if (!accounts.find(w => w.id === addrData.wallet)) return []
return addressesAPI.getAddressTxs({
address: addrData.address
})
}
const addressTxs = await retryWithDelay(fn)
return this.addressHistoryFromTxs(addrData, addressTxs)
},
refreshRecommendedFees: async function () {
const {
bitcoin: {fees: feesAPI}
} = mempoolJS()
const fn = async () => feesAPI.getFeesRecommended()
this.payment.recommededFees = await retryWithDelay(fn)
},
getAddressTxsUtxoDelayed: async function (address) {
const endpoint = this.mempoolHostname
const {
bitcoin: {addresses: addressesAPI}
} = mempoolJS()
} = mempoolJS({
hostname: endpoint
})
const fn = async () =>
addressesAPI.getAddressTxsUtxo({
const fn = async () => {
if (endpoint !== this.mempoolHostname) return []
return addressesAPI.getAddressTxsUtxo({
address
})
return retryWithDelay(fn)
},
fetchTxHex: async function (txId) {
const {
bitcoin: {transactions: transactionsAPI}
} = mempoolJS()
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
}
return retryWithDelay(fn)
},
//################### OTHER ###################
closeFormDialog: function () {
this.formDialog.data = {
is_unique: false
}
},
openQrCodeDialog: function (addressData) {
this.currentAddress = addressData
this.addresses.note = addressData.note || ''
this.addresses.show = true
this.addressNote = addressData.note || ''
this.showAddress = true
},
searchInTab: function (tab, value) {
searchInTab: function ({tab, value}) {
this.tab = tab
this[`${tab}Table`].filter = value
this[`${tab}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'
updateAccounts: async function (accounts) {
this.walletAccounts = accounts
await this.refreshAddresses()
await this.scanAddressWithAmount()
},
getAccountDescription: function (accountType) {
return getAccountDescription(accountType)
showAddressDetails: function (addressData) {
this.openQrCodeDialog(addressData)
},
initUtxos: function (addresses) {
if (!this.fetchedUtxos && addresses.length) {
this.fetchedUtxos = true
this.addresses = addresses
this.scanAddressWithAmount()
}
}
},
created: async function () {
if (this.g.user.wallets.length) {
await this.getConfig()
await this.refreshWalletAccounts()
await this.refreshAddresses()
await this.scanAddressWithAmount()
}
}
})
})
}
watchOnly()

View file

@ -43,7 +43,7 @@ const mapUtxoToPsbtInput = utxo => ({
address: utxo.address,
branch_index: utxo.isChange ? 1 : 0,
address_index: utxo.addressIndex,
masterpub_fingerprint: utxo.masterpubFingerprint,
wallet: utxo.wallet,
accountType: utxo.accountType,
txHex: ''
})
@ -66,15 +66,15 @@ const mapAddressDataToUtxo = (wallet, addressData, utxo) => ({
selected: false
})
const mapWalletAccount = function (obj) {
obj._data = _.clone(obj)
obj.date = obj.time
const mapWalletAccount = function (o) {
return Object.assign({}, o, {
date: o.time
? Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
new Date(o.time * 1000),
'YYYY-MM-DD HH:mm'
)
: ''
obj.label = obj.title // for drop-downs
obj.expanded = false
return obj
: '',
label: o.title,
expanded: false
})
}

View file

@ -1,99 +1,4 @@
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: [
{
@ -117,157 +22,36 @@ const tables = {
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,
tx: null,
psbtBase64: '',
utxoSelectionModes: [
'Manual',
'Random',
'Select All',
'Smaller Inputs First',
'Larger Inputs First'
psbtBase64Signed: '',
signedTx: null,
signedTxHex: null,
sentTxId: null,
signModes: [
{
label: 'Serial Port Device',
value: 'serial-port'
},
{
label: 'Animated QR',
value: 'animated-qr',
disable: true
}
],
utxoSelectionMode: 'Manual',
signMode: '',
show: false,
showAdvanced: false
},

View file

@ -1,3 +1,18 @@
const PSBT_BASE64_PREFIX = 'cHNidP8'
const COMMAND_PASSWORD = '/password'
const COMMAND_PASSWORD_CLEAR = '/password-clear'
const COMMAND_SEND_PSBT = '/psbt'
const COMMAND_SIGN_PSBT = '/sign'
const COMMAND_HELP = '/help'
const COMMAND_WIPE = '/wipe'
const COMMAND_SEED = '/seed'
const COMMAND_RESTORE = '/restore'
const COMMAND_CONFIRM_NEXT = '/confirm-next'
const COMMAND_CANCEL = '/cancel'
const COMMAND_XPUB = '/xpub'
const DEFAULT_RECEIVE_GAP_LIMIT = 20
const blockTimeToDate = blockTime =>
blockTime ? moment(blockTime * 1000).format('LLL') : ''
@ -97,3 +112,72 @@ const ACCOUNT_TYPES = {
}
const getAccountDescription = type => ACCOUNT_TYPES[type] || 'nonstandard'
const readFromSerialPort = reader => {
let partialChunk
let fulliness = []
const readStringUntil = async (separator = '\n') => {
if (fulliness.length) return fulliness.shift().trim()
const chunks = []
if (partialChunk) {
// leftovers from previous read
chunks.push(partialChunk)
partialChunk = undefined
}
while (true) {
const {value, done} = await reader.read()
if (value) {
const values = value.split(separator)
// found one or more separators
if (values.length > 1) {
chunks.push(values.shift()) // first element
partialChunk = values.pop() // last element
fulliness = values // full lines
return {value: chunks.join('').trim(), done: false}
}
chunks.push(value)
}
if (done) return {value: chunks.join('').trim(), done: true}
}
}
return readStringUntil
}
function satOrBtc(val, showUnit = true, showSats = false) {
const value = showSats
? LNbits.utils.formatSat(val)
: val == 0
? 0.0
: (val / 100000000).toFixed(8)
if (!showUnit) return value
return showSats ? value + ' sat' : value + ' BTC'
}
function loadTemplateAsync(path) {
const result = new Promise(resolve => {
const xhttp = new XMLHttpRequest()
xhttp.onreadystatechange = function () {
if (this.readyState == 4) {
if (this.status == 200) resolve(this.responseText)
if (this.status == 404) resolve(`<div>Page not found: ${path}</div>`)
}
}
xhttp.open('GET', path, true)
xhttp.send()
})
return result
}
function findAccountPathIssues(path = '') {
const p = path.split('/')
if (p[0] !== 'm') return "Path must start with 'm/'"
for (let i = 1; i < p.length; i++) {
if (p[i].endsWith('')) p[i] = p[i].substring(0, p[i].length - 1)
if (isNaN(p[i])) return `${p[i]} is not a valid value`
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,8 @@
import json
from http import HTTPStatus
from embit import script
from embit.descriptor import Descriptor, Key
import httpx
from embit import finalizer, script
from embit.ec import PublicKey
from embit.psbt import PSBT, DerivationPath
from embit.transaction import Transaction, TransactionInput, TransactionOutput
@ -15,34 +16,44 @@ from lnbits.extensions.watchonly import watchonly_ext
from .crud import (
create_config,
create_fresh_addresses,
create_mempool,
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_address,
update_config,
update_mempool,
update_watch_wallet,
)
from .helpers import parse_key
from .models import Config, CreatePsbt, CreateWallet, WalletAccount
from .models import (
BroadcastTransaction,
Config,
CreatePsbt,
CreateWallet,
ExtractPsbt,
SignedTransaction,
WalletAccount,
)
###################WALLETS#############################
@watchonly_ext.get("/api/v1/wallet")
async def api_wallets_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)):
async def api_wallets_retrieve(
network: str = Query("Mainnet"), wallet: WalletTypeInfo = Depends(get_key_type)
):
try:
return [wallet.dict() for wallet in await get_watch_wallets(wallet.wallet.user)]
return [
wallet.dict()
for wallet in await get_watch_wallets(wallet.wallet.user, network)
]
except:
return ""
return []
@watchonly_ext.get("/api/v1/wallet/{wallet_id}")
@ -64,7 +75,13 @@ async def api_wallet_create_or_update(
data: CreateWallet, w: WalletTypeInfo = Depends(require_admin_key)
):
try:
(descriptor, _) = parse_key(data.masterpub)
(descriptor, network) = parse_key(data.masterpub)
if data.network != network["name"]:
raise ValueError(
"Account network error. This account is for '{}'".format(
network["name"]
)
)
new_wallet = WalletAccount(
id="none",
@ -75,11 +92,19 @@ async def api_wallet_create_or_update(
title=data.title,
address_no=-1, # so fresh address on empty wallet can get address with index 0
balance=0,
network=network["name"],
)
wallets = await get_watch_wallets(w.wallet.user)
wallets = await get_watch_wallets(w.wallet.user, network["name"])
existing_wallet = next(
(ew for ew in wallets if ew.fingerprint == new_wallet.fingerprint), None
(
ew
for ew in wallets
if ew.fingerprint == new_wallet.fingerprint
and ew.network == new_wallet.network
and ew.masterpub == new_wallet.masterpub
),
None,
)
if existing_wallet:
raise ValueError(
@ -218,12 +243,13 @@ async def api_psbt_create(
descriptors = {}
for _, masterpub in enumerate(data.masterpubs):
descriptors[masterpub.fingerprint] = parse_key(masterpub.public_key)
descriptors[masterpub.id] = parse_key(masterpub.public_key)
inputs_extra = []
bip32_derivations = {}
for i, inp in enumerate(data.inputs):
descriptor = descriptors[inp.masterpub_fingerprint][0]
bip32_derivations = {}
descriptor = descriptors[inp.wallet][0]
d = descriptor.derive(inp.address_index, inp.branch_index)
for k in d.keys:
bip32_derivations[PublicKey.parse(k.sec())] = DerivationPath(
@ -242,12 +268,13 @@ async def api_psbt_create(
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)
print("### ", 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]
descriptor = descriptors[out.wallet][0]
d = descriptor.derive(out.address_index, out.branch_index)
for k in d.keys:
bip32_derivations[PublicKey.parse(k.sec())] = DerivationPath(
@ -264,6 +291,66 @@ async def api_psbt_create(
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
@watchonly_ext.put("/api/v1/psbt/extract")
async def api_psbt_extract_tx(
data: ExtractPsbt, w: WalletTypeInfo = Depends(require_admin_key)
):
res = SignedTransaction()
try:
psbt = PSBT.from_base64(data.psbtBase64)
for i, inp in enumerate(data.inputs):
psbt.inputs[i].non_witness_utxo = Transaction.from_string(inp.tx_hex)
final_psbt = finalizer.finalize_psbt(psbt)
if not final_psbt:
raise ValueError("PSBT cannot be finalized!")
res.tx_hex = final_psbt.to_string()
transaction = Transaction.from_string(res.tx_hex)
tx = {
"locktime": transaction.locktime,
"version": transaction.version,
"outputs": [],
"fee": psbt.fee(),
}
for out in transaction.vout:
tx["outputs"].append(
{"amount": out.value, "address": out.script_pubkey.address()}
)
res.tx_json = json.dumps(tx)
except Exception as e:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
return res.dict()
@watchonly_ext.post("/api/v1/tx")
async def api_tx_broadcast(
data: BroadcastTransaction, w: WalletTypeInfo = Depends(require_admin_key)
):
try:
config = await get_config(w.wallet.user)
if not config:
raise ValueError(
"Cannot broadcast transaction. Mempool endpoint not defined!"
)
endpoint = (
config.mempool_endpoint
if config.network == "Mainnet"
else config.mempool_endpoint + "/testnet"
)
async with httpx.AsyncClient() as client:
r = await client.post(endpoint + "/api/tx", data=data.tx_hex)
tx_id = r.text
print("### broadcast tx_id: ", tx_id)
return tx_id
# return "0f0f0f0f0f0f0f0f0f0f0f00f0f0f0f0f0f0f0f0f0f00f0f0f0f0f0f0.mock.transaction.id"
except Exception as e:
print("### broadcast error: ", str(e))
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
#############################CONFIG##########################
@ -281,23 +368,3 @@ async def api_get_config(w: WalletTypeInfo = Depends(get_key_type)):
if not config:
config = await create_config(user=w.wallet.user)
return config.dict()
#############################MEMPOOL##########################
### TODO: fix statspay dependcy and remove
@watchonly_ext.put("/api/v1/mempool")
async def api_update_mempool(
endpoint: str = Query(...), w: WalletTypeInfo = Depends(require_admin_key)
):
mempool = await update_mempool(**{"endpoint": endpoint}, user=w.wallet.user)
return mempool.dict()
### TODO: fix statspay dependcy and remove
@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()

View file

@ -70,7 +70,7 @@ new Vue({
show: false,
data: {
is_unique: true,
use_custom: true,
use_custom: false,
title: 'Vouchers',
min_withdrawable: 0,
wait_time: 1
@ -125,7 +125,6 @@ new Vue({
var link = _.findWhere(this.withdrawLinks, {id: linkId})
this.qrCodeDialog.data = _.clone(link)
console.log(this.qrCodeDialog.data)
this.qrCodeDialog.data.url =
window.location.protocol + '//' + window.location.host
this.qrCodeDialog.show = true
@ -140,6 +139,11 @@ new Vue({
id: this.formDialog.data.wallet
})
var data = _.omit(this.formDialog.data, 'wallet')
if (!data.use_custom) {
data.custom_url = null
}
if (data.use_custom && !data?.custom_url) {
data.custom_url = CUSTOM_URL
}
@ -168,6 +172,10 @@ new Vue({
data.title = 'vouchers'
data.is_unique = true
if (!data.use_custom) {
data.custom_url = null
}
if (data.use_custom && !data?.custom_url) {
data.custom_url = '/static/images/default_voucher.png'
}

View file

@ -241,7 +241,7 @@
v-model="formDialog.data.custom_url"
type="text"
label="Custom design .png (optional)"
hint="Enter a URL if you want to use a custom design or leave blank for showing only the QR"
hint="Enter a URL if you want to use a custom design or leave blank for LNbits designed vouchers!"
></q-input>
<q-list>
<q-item tag="label" class="rounded-borders">
@ -353,7 +353,7 @@
v-model="simpleformDialog.data.custom_url"
type="text"
label="Custom design .png (optional)"
hint="Enter a URL if you want to use a custom design or leave blank for showing only the QR"
hint="Enter a URL if you want to use a custom design or leave blank for LNbits designed vouchers!"
></q-input>
<div class="row q-mt-lg">

View file

@ -9,7 +9,10 @@
<img src="{{custom_url}}" alt="..." />
<span>{{ amt }} sats</span>
<div class="lnurlw">
<qrcode :value="'{{one}}'" :options="{width: 95, margin: 1}"></qrcode>
<qrcode
:value="theurl + '/?lightning={{one}}'"
:options="{width: 95, margin: 1}"
></qrcode>
</div>
</div>
{% endfor %}

View file

@ -1,18 +1,49 @@
import time
import click
import uvicorn
from lnbits.settings import HOST, PORT
@click.command()
@click.option("--port", default="5000", help="Port to run LNBits on")
@click.option("--host", default="127.0.0.1", help="Host to run LNBits on")
def main(port, host):
@click.command(
context_settings=dict(
ignore_unknown_options=True,
allow_extra_args=True,
)
)
@click.option("--port", default=PORT, help="Port to listen on")
@click.option("--host", default=HOST, help="Host to run LNBits on")
@click.option("--ssl-keyfile", default=None, help="Path to SSL keyfile")
@click.option("--ssl-certfile", default=None, help="Path to SSL certificate")
@click.pass_context
def main(ctx, port: int, host: str, ssl_keyfile: str, ssl_certfile: str):
"""Launched with `poetry run lnbits` at root level"""
uvicorn.run("lnbits.__main__:app", port=port, host=host)
# this beautiful beast parses all command line arguments and passes them to the uvicorn server
d = dict()
for a in ctx.args:
item = a.split("=")
if len(item) > 1: # argument like --key=value
print(a, item)
d[item[0].strip("--").replace("-", "_")] = (
int(item[1]) # need to convert to int if it's a number
if item[1].isdigit()
else item[1]
)
else:
d[a.strip("--")] = True # argument like --key
config = uvicorn.Config(
"lnbits.__main__:app",
port=port,
host=host,
ssl_keyfile=ssl_keyfile,
ssl_certfile=ssl_certfile,
**d
)
server = uvicorn.Server(config)
server.run()
if __name__ == "__main__":
main()
# def main():
# """Launched with `poetry run start` at root level"""
# uvicorn.run("lnbits.__main__:app")

View file

@ -1,9 +1,12 @@
# flake8: noqa
from .clightning import CLightningWallet
from .cliche import ClicheWallet
from .cln import CoreLightningWallet # legacy .env support
from .cln import CoreLightningWallet as CLightningWallet
from .eclair import EclairWallet
from .fake import FakeWallet
from .lnbits import LNbitsWallet
from .lndgrpc import LndWallet
from .lndrest import LndRestWallet
from .lnpay import LNPayWallet
from .lntxbot import LntxbotWallet

143
lnbits/wallets/cliche.py Normal file
View file

@ -0,0 +1,143 @@
import asyncio
import hashlib
import json
from os import getenv
from typing import AsyncGenerator, Dict, Optional
import httpx
from loguru import logger
from websocket import create_connection
from .base import (
InvoiceResponse,
PaymentResponse,
PaymentStatus,
StatusResponse,
Wallet,
)
class ClicheWallet(Wallet):
"""https://github.com/fiatjaf/cliche"""
def __init__(self):
self.endpoint = getenv("CLICHE_ENDPOINT")
async def status(self) -> StatusResponse:
try:
ws = create_connection(self.endpoint)
ws.send("get-info")
r = ws.recv()
except Exception as exc:
return StatusResponse(
f"Failed to connect to {self.endpoint} due to: {exc}", 0
)
try:
data = json.loads(r)
except:
return StatusResponse(
f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'", 0
)
return StatusResponse(None, data["result"]["wallets"][0]["balance"])
async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
) -> InvoiceResponse:
if description_hash:
description_hash_hashed = hashlib.sha256(description_hash).hexdigest()
ws = create_connection(self.endpoint)
ws.send(
f"create-invoice --msatoshi {amount*1000} --description_hash {description_hash_hashed}"
)
r = ws.recv()
else:
ws = create_connection(self.endpoint)
ws.send(f"create-invoice --msatoshi {amount*1000} --description {memo}")
r = ws.recv()
data = json.loads(r)
checking_id = None
payment_request = None
error_message = None
if data.get("error") is not None and data["error"].get("message"):
logger.error(data["error"]["message"])
error_message = data["error"]["message"]
return InvoiceResponse(False, checking_id, payment_request, error_message)
if data.get("result") is not None:
checking_id, payment_request = (
data["result"]["payment_hash"],
data["result"]["invoice"],
)
else:
return InvoiceResponse(
False, checking_id, payment_request, "Could not get payment hash"
)
return InvoiceResponse(True, checking_id, payment_request, error_message)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
ws = create_connection(self.endpoint)
ws.send(f"pay-invoice --invoice {bolt11}")
r = ws.recv()
data = json.loads(r)
checking_id = None
error_message = None
if data.get("error") is not None and data["error"].get("message"):
logger.error(data["error"]["message"])
error_message = data["error"]["message"]
return PaymentResponse(False, None, 0, error_message)
if data.get("result") is not None and data["result"].get("payment_hash"):
checking_id = data["result"]["payment_hash"]
else:
return PaymentResponse(False, checking_id, 0, "Could not get payment hash")
return PaymentResponse(True, checking_id, 0, error_message)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
ws = create_connection(self.endpoint)
ws.send(f"check-payment --hash {checking_id}")
r = ws.recv()
data = json.loads(r)
if data.get("error") is not None and data["error"].get("message"):
logger.error(data["error"]["message"])
return PaymentStatus(None)
statuses = {"pending": None, "complete": True, "failed": False}
return PaymentStatus(statuses[data["result"]["status"]])
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
ws = create_connection(self.endpoint)
ws.send(f"check-payment --hash {checking_id}")
r = ws.recv()
data = json.loads(r)
if data.get("error") is not None and data["error"].get("message"):
logger.error(data["error"]["message"])
return PaymentStatus(None)
statuses = {"pending": None, "complete": True, "failed": False}
return PaymentStatus(statuses[data["result"]["status"]])
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
try:
ws = await create_connection(self.endpoint)
while True:
r = await ws.recv()
data = json.loads(r)
try:
if data["result"]["status"]:
yield data["result"]["payment_hash"]
except:
continue
except:
pass
logger.error("lost connection to cliche's websocket, retrying in 5 seconds")
await asyncio.sleep(5)

View file

@ -1,15 +1,18 @@
try:
from lightning import LightningRpc, RpcError # type: ignore
from pyln.client import LightningRpc, RpcError # type: ignore
except ImportError: # pragma: nocover
LightningRpc = None
import asyncio
import hashlib
import random
import time
from functools import partial, wraps
from os import getenv
from typing import AsyncGenerator, Optional
from loguru import logger
from lnbits import bolt11 as lnbits_bolt11
from .base import (
@ -41,26 +44,20 @@ def _paid_invoices_stream(ln, last_pay_index):
return ln.waitanyinvoice(last_pay_index)
class CLightningWallet(Wallet):
class CoreLightningWallet(Wallet):
def __init__(self):
if LightningRpc is None: # pragma: nocover
raise ImportError(
"The `pylightning` library must be installed to use `CLightningWallet`."
"The `pyln-client` library must be installed to use `CoreLightningWallet`."
)
self.rpc = getenv("CLIGHTNING_RPC")
self.rpc = getenv("CORELIGHTNING_RPC") or getenv("CLIGHTNING_RPC")
self.ln = LightningRpc(self.rpc)
# check description_hash support (could be provided by a plugin)
self.supports_description_hash = False
try:
answer = self.ln.help("invoicewithdescriptionhash")
if answer["help"][0]["command"].startswith(
"invoicewithdescriptionhash msatoshi label description_hash"
):
self.supports_description_hash = True
except:
pass
# check if description_hash is supported (from CLN>=v0.11.0)
self.supports_description_hash = (
"deschashonly" in self.ln.help("invoice")["help"][0]["command"]
)
# check last payindex so we can listen from that point on
self.last_pay_index = 0
@ -87,22 +84,33 @@ class CLightningWallet(Wallet):
description_hash: Optional[bytes] = None,
) -> InvoiceResponse:
label = "lbl{}".format(random.random())
msat = amount * 1000
msat: int = int(amount * 1000)
try:
if description_hash:
if not self.supports_description_hash:
if description_hash and not self.supports_description_hash:
raise Unsupported("description_hash")
r = self.ln.invoice(
msatoshi=msat,
label=label,
description=description_hash.decode("utf-8")
if description_hash
else memo,
exposeprivatechannels=True,
deschashonly=True
if description_hash
else False, # we can't pass None here
)
params = [msat, label, description_hash.hex()]
r = self.ln.call("invoicewithdescriptionhash", params)
return InvoiceResponse(True, label, r["bolt11"], "")
else:
r = self.ln.invoice(msat, label, memo, exposeprivatechannels=True)
return InvoiceResponse(True, label, r["bolt11"], "")
if r.get("code") and r.get("code") < 0:
raise Exception(r.get("message"))
return InvoiceResponse(True, r["payment_hash"], r["bolt11"], "")
except RpcError as exc:
error_message = f"lightningd '{exc.method}' failed with '{exc.error}'."
return InvoiceResponse(False, label, None, error_message)
logger.error("RPC error:", error_message)
return InvoiceResponse(False, None, None, error_message)
except Exception as e:
logger.error("error:", e)
return InvoiceResponse(False, None, None, str(e))
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
invoice = lnbits_bolt11.decode(bolt11)
@ -116,25 +124,32 @@ class CLightningWallet(Wallet):
try:
wrapped = async_wrap(_pay_invoice)
r = await wrapped(self.ln, payload)
except RpcError as exc:
except Exception as exc:
return PaymentResponse(False, None, 0, None, str(exc))
fee_msat = r["msatoshi_sent"] - r["msatoshi"]
preimage = r["payment_preimage"]
return PaymentResponse(True, r["payment_hash"], fee_msat, preimage, None)
return PaymentResponse(
True, r["payment_hash"], fee_msat, r["payment_preimage"], None
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
r = self.ln.listinvoices(checking_id)
try:
r = self.ln.listinvoices(payment_hash=checking_id)
except:
return PaymentStatus(None)
if not r["invoices"]:
return PaymentStatus(False)
if r["invoices"][0]["label"] == checking_id:
return PaymentStatus(None)
if r["invoices"][0]["payment_hash"] == checking_id:
return PaymentStatus(r["invoices"][0]["status"] == "paid")
raise KeyError("supplied an invalid checking_id")
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try:
r = self.ln.call("listpays", {"payment_hash": checking_id})
except:
return PaymentStatus(None)
if not r["pays"]:
return PaymentStatus(False)
return PaymentStatus(None)
if r["pays"][0]["payment_hash"] == checking_id:
status = r["pays"][0]["status"]
if status == "complete":
@ -146,7 +161,13 @@ class CLightningWallet(Wallet):
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True:
try:
wrapped = async_wrap(_paid_invoices_stream)
paid = await wrapped(self.ln, self.last_pay_index)
self.last_pay_index = paid["pay_index"]
yield paid["label"]
yield paid["payment_hash"]
except Exception as exc:
logger.error(
f"lost connection to cln invoices stream: '{exc}', retrying in 5 seconds"
)
await asyncio.sleep(5)

View file

@ -1,5 +1,6 @@
import asyncio
import base64
import hashlib
import json
import urllib.parse
from os import getenv
@ -72,7 +73,7 @@ class EclairWallet(Wallet):
data: Dict = {"amountMsat": amount * 1000}
if description_hash:
data["description_hash"] = description_hash.hex()
data["description_hash"] = hashlib.sha256(description_hash).hexdigest()
else:
data["description"] = memo or ""

View file

@ -61,7 +61,7 @@ class FakeWallet(Wallet):
data["timestamp"] = datetime.now().timestamp()
if description_hash:
data["tags_set"] = ["h"]
data["description_hash"] = description_hash.hex()
data["description_hash"] = description_hash.decode("utf-8")
else:
data["tags_set"] = ["d"]
data["memo"] = memo

View file

@ -1,4 +1,5 @@
import asyncio
import hashlib
import json
from os import getenv
from typing import AsyncGenerator, Dict, Optional
@ -59,7 +60,7 @@ class LNbitsWallet(Wallet):
) -> InvoiceResponse:
data: Dict = {"out": False, "amount": amount}
if description_hash:
data["description_hash"] = description_hash.hex()
data["description_hash"] = hashlib.sha256(description_hash).hexdigest()
else:
data["memo"] = memo or ""

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,871 @@
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
import lnbits.wallets.lnd_grpc_files.lightning_pb2 as lightning__pb2
import lnbits.wallets.lnd_grpc_files.router_pb2 as router__pb2
class RouterStub(object):
"""Router is a service that offers advanced interaction with the router
subsystem of the daemon.
"""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.SendPaymentV2 = channel.unary_stream(
"/routerrpc.Router/SendPaymentV2",
request_serializer=router__pb2.SendPaymentRequest.SerializeToString,
response_deserializer=lightning__pb2.Payment.FromString,
)
self.TrackPaymentV2 = channel.unary_stream(
"/routerrpc.Router/TrackPaymentV2",
request_serializer=router__pb2.TrackPaymentRequest.SerializeToString,
response_deserializer=lightning__pb2.Payment.FromString,
)
self.EstimateRouteFee = channel.unary_unary(
"/routerrpc.Router/EstimateRouteFee",
request_serializer=router__pb2.RouteFeeRequest.SerializeToString,
response_deserializer=router__pb2.RouteFeeResponse.FromString,
)
self.SendToRoute = channel.unary_unary(
"/routerrpc.Router/SendToRoute",
request_serializer=router__pb2.SendToRouteRequest.SerializeToString,
response_deserializer=router__pb2.SendToRouteResponse.FromString,
)
self.SendToRouteV2 = channel.unary_unary(
"/routerrpc.Router/SendToRouteV2",
request_serializer=router__pb2.SendToRouteRequest.SerializeToString,
response_deserializer=lightning__pb2.HTLCAttempt.FromString,
)
self.ResetMissionControl = channel.unary_unary(
"/routerrpc.Router/ResetMissionControl",
request_serializer=router__pb2.ResetMissionControlRequest.SerializeToString,
response_deserializer=router__pb2.ResetMissionControlResponse.FromString,
)
self.QueryMissionControl = channel.unary_unary(
"/routerrpc.Router/QueryMissionControl",
request_serializer=router__pb2.QueryMissionControlRequest.SerializeToString,
response_deserializer=router__pb2.QueryMissionControlResponse.FromString,
)
self.XImportMissionControl = channel.unary_unary(
"/routerrpc.Router/XImportMissionControl",
request_serializer=router__pb2.XImportMissionControlRequest.SerializeToString,
response_deserializer=router__pb2.XImportMissionControlResponse.FromString,
)
self.GetMissionControlConfig = channel.unary_unary(
"/routerrpc.Router/GetMissionControlConfig",
request_serializer=router__pb2.GetMissionControlConfigRequest.SerializeToString,
response_deserializer=router__pb2.GetMissionControlConfigResponse.FromString,
)
self.SetMissionControlConfig = channel.unary_unary(
"/routerrpc.Router/SetMissionControlConfig",
request_serializer=router__pb2.SetMissionControlConfigRequest.SerializeToString,
response_deserializer=router__pb2.SetMissionControlConfigResponse.FromString,
)
self.QueryProbability = channel.unary_unary(
"/routerrpc.Router/QueryProbability",
request_serializer=router__pb2.QueryProbabilityRequest.SerializeToString,
response_deserializer=router__pb2.QueryProbabilityResponse.FromString,
)
self.BuildRoute = channel.unary_unary(
"/routerrpc.Router/BuildRoute",
request_serializer=router__pb2.BuildRouteRequest.SerializeToString,
response_deserializer=router__pb2.BuildRouteResponse.FromString,
)
self.SubscribeHtlcEvents = channel.unary_stream(
"/routerrpc.Router/SubscribeHtlcEvents",
request_serializer=router__pb2.SubscribeHtlcEventsRequest.SerializeToString,
response_deserializer=router__pb2.HtlcEvent.FromString,
)
self.SendPayment = channel.unary_stream(
"/routerrpc.Router/SendPayment",
request_serializer=router__pb2.SendPaymentRequest.SerializeToString,
response_deserializer=router__pb2.PaymentStatus.FromString,
)
self.TrackPayment = channel.unary_stream(
"/routerrpc.Router/TrackPayment",
request_serializer=router__pb2.TrackPaymentRequest.SerializeToString,
response_deserializer=router__pb2.PaymentStatus.FromString,
)
self.HtlcInterceptor = channel.stream_stream(
"/routerrpc.Router/HtlcInterceptor",
request_serializer=router__pb2.ForwardHtlcInterceptResponse.SerializeToString,
response_deserializer=router__pb2.ForwardHtlcInterceptRequest.FromString,
)
self.UpdateChanStatus = channel.unary_unary(
"/routerrpc.Router/UpdateChanStatus",
request_serializer=router__pb2.UpdateChanStatusRequest.SerializeToString,
response_deserializer=router__pb2.UpdateChanStatusResponse.FromString,
)
class RouterServicer(object):
"""Router is a service that offers advanced interaction with the router
subsystem of the daemon.
"""
def SendPaymentV2(self, request, context):
"""
SendPaymentV2 attempts to route a payment described by the passed
PaymentRequest to the final destination. The call returns a stream of
payment updates.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def TrackPaymentV2(self, request, context):
"""
TrackPaymentV2 returns an update stream for the payment identified by the
payment hash.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def EstimateRouteFee(self, request, context):
"""
EstimateRouteFee allows callers to obtain a lower bound w.r.t how much it
may cost to send an HTLC to the target end destination.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def SendToRoute(self, request, context):
"""
Deprecated, use SendToRouteV2. SendToRoute attempts to make a payment via
the specified route. This method differs from SendPayment in that it
allows users to specify a full route manually. This can be used for
things like rebalancing, and atomic swaps. It differs from the newer
SendToRouteV2 in that it doesn't return the full HTLC information.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def SendToRouteV2(self, request, context):
"""
SendToRouteV2 attempts to make a payment via the specified route. This
method differs from SendPayment in that it allows users to specify a full
route manually. This can be used for things like rebalancing, and atomic
swaps.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def ResetMissionControl(self, request, context):
"""
ResetMissionControl clears all mission control state and starts with a clean
slate.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def QueryMissionControl(self, request, context):
"""
QueryMissionControl exposes the internal mission control state to callers.
It is a development feature.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def XImportMissionControl(self, request, context):
"""
XImportMissionControl is an experimental API that imports the state provided
to the internal mission control's state, using all results which are more
recent than our existing values. These values will only be imported
in-memory, and will not be persisted across restarts.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def GetMissionControlConfig(self, request, context):
"""
GetMissionControlConfig returns mission control's current config.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def SetMissionControlConfig(self, request, context):
"""
SetMissionControlConfig will set mission control's config, if the config
provided is valid.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def QueryProbability(self, request, context):
"""
QueryProbability returns the current success probability estimate for a
given node pair and amount.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def BuildRoute(self, request, context):
"""
BuildRoute builds a fully specified route based on a list of hop public
keys. It retrieves the relevant channel policies from the graph in order to
calculate the correct fees and time locks.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def SubscribeHtlcEvents(self, request, context):
"""
SubscribeHtlcEvents creates a uni-directional stream from the server to
the client which delivers a stream of htlc events.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def SendPayment(self, request, context):
"""
Deprecated, use SendPaymentV2. SendPayment attempts to route a payment
described by the passed PaymentRequest to the final destination. The call
returns a stream of payment status updates.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def TrackPayment(self, request, context):
"""
Deprecated, use TrackPaymentV2. TrackPayment returns an update stream for
the payment identified by the payment hash.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def HtlcInterceptor(self, request_iterator, context):
"""*
HtlcInterceptor dispatches a bi-directional streaming RPC in which
Forwarded HTLC requests are sent to the client and the client responds with
a boolean that tells LND if this htlc should be intercepted.
In case of interception, the htlc can be either settled, cancelled or
resumed later by using the ResolveHoldForward endpoint.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def UpdateChanStatus(self, request, context):
"""
UpdateChanStatus attempts to manually set the state of a channel
(enabled, disabled, or auto). A manual "disable" request will cause the
channel to stay disabled until a subsequent manual request of either
"enable" or "auto".
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def add_RouterServicer_to_server(servicer, server):
rpc_method_handlers = {
"SendPaymentV2": grpc.unary_stream_rpc_method_handler(
servicer.SendPaymentV2,
request_deserializer=router__pb2.SendPaymentRequest.FromString,
response_serializer=lightning__pb2.Payment.SerializeToString,
),
"TrackPaymentV2": grpc.unary_stream_rpc_method_handler(
servicer.TrackPaymentV2,
request_deserializer=router__pb2.TrackPaymentRequest.FromString,
response_serializer=lightning__pb2.Payment.SerializeToString,
),
"EstimateRouteFee": grpc.unary_unary_rpc_method_handler(
servicer.EstimateRouteFee,
request_deserializer=router__pb2.RouteFeeRequest.FromString,
response_serializer=router__pb2.RouteFeeResponse.SerializeToString,
),
"SendToRoute": grpc.unary_unary_rpc_method_handler(
servicer.SendToRoute,
request_deserializer=router__pb2.SendToRouteRequest.FromString,
response_serializer=router__pb2.SendToRouteResponse.SerializeToString,
),
"SendToRouteV2": grpc.unary_unary_rpc_method_handler(
servicer.SendToRouteV2,
request_deserializer=router__pb2.SendToRouteRequest.FromString,
response_serializer=lightning__pb2.HTLCAttempt.SerializeToString,
),
"ResetMissionControl": grpc.unary_unary_rpc_method_handler(
servicer.ResetMissionControl,
request_deserializer=router__pb2.ResetMissionControlRequest.FromString,
response_serializer=router__pb2.ResetMissionControlResponse.SerializeToString,
),
"QueryMissionControl": grpc.unary_unary_rpc_method_handler(
servicer.QueryMissionControl,
request_deserializer=router__pb2.QueryMissionControlRequest.FromString,
response_serializer=router__pb2.QueryMissionControlResponse.SerializeToString,
),
"XImportMissionControl": grpc.unary_unary_rpc_method_handler(
servicer.XImportMissionControl,
request_deserializer=router__pb2.XImportMissionControlRequest.FromString,
response_serializer=router__pb2.XImportMissionControlResponse.SerializeToString,
),
"GetMissionControlConfig": grpc.unary_unary_rpc_method_handler(
servicer.GetMissionControlConfig,
request_deserializer=router__pb2.GetMissionControlConfigRequest.FromString,
response_serializer=router__pb2.GetMissionControlConfigResponse.SerializeToString,
),
"SetMissionControlConfig": grpc.unary_unary_rpc_method_handler(
servicer.SetMissionControlConfig,
request_deserializer=router__pb2.SetMissionControlConfigRequest.FromString,
response_serializer=router__pb2.SetMissionControlConfigResponse.SerializeToString,
),
"QueryProbability": grpc.unary_unary_rpc_method_handler(
servicer.QueryProbability,
request_deserializer=router__pb2.QueryProbabilityRequest.FromString,
response_serializer=router__pb2.QueryProbabilityResponse.SerializeToString,
),
"BuildRoute": grpc.unary_unary_rpc_method_handler(
servicer.BuildRoute,
request_deserializer=router__pb2.BuildRouteRequest.FromString,
response_serializer=router__pb2.BuildRouteResponse.SerializeToString,
),
"SubscribeHtlcEvents": grpc.unary_stream_rpc_method_handler(
servicer.SubscribeHtlcEvents,
request_deserializer=router__pb2.SubscribeHtlcEventsRequest.FromString,
response_serializer=router__pb2.HtlcEvent.SerializeToString,
),
"SendPayment": grpc.unary_stream_rpc_method_handler(
servicer.SendPayment,
request_deserializer=router__pb2.SendPaymentRequest.FromString,
response_serializer=router__pb2.PaymentStatus.SerializeToString,
),
"TrackPayment": grpc.unary_stream_rpc_method_handler(
servicer.TrackPayment,
request_deserializer=router__pb2.TrackPaymentRequest.FromString,
response_serializer=router__pb2.PaymentStatus.SerializeToString,
),
"HtlcInterceptor": grpc.stream_stream_rpc_method_handler(
servicer.HtlcInterceptor,
request_deserializer=router__pb2.ForwardHtlcInterceptResponse.FromString,
response_serializer=router__pb2.ForwardHtlcInterceptRequest.SerializeToString,
),
"UpdateChanStatus": grpc.unary_unary_rpc_method_handler(
servicer.UpdateChanStatus,
request_deserializer=router__pb2.UpdateChanStatusRequest.FromString,
response_serializer=router__pb2.UpdateChanStatusResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
"routerrpc.Router", rpc_method_handlers
)
server.add_generic_rpc_handlers((generic_handler,))
# This class is part of an EXPERIMENTAL API.
class Router(object):
"""Router is a service that offers advanced interaction with the router
subsystem of the daemon.
"""
@staticmethod
def SendPaymentV2(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_stream(
request,
target,
"/routerrpc.Router/SendPaymentV2",
router__pb2.SendPaymentRequest.SerializeToString,
lightning__pb2.Payment.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def TrackPaymentV2(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_stream(
request,
target,
"/routerrpc.Router/TrackPaymentV2",
router__pb2.TrackPaymentRequest.SerializeToString,
lightning__pb2.Payment.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def EstimateRouteFee(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/routerrpc.Router/EstimateRouteFee",
router__pb2.RouteFeeRequest.SerializeToString,
router__pb2.RouteFeeResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def SendToRoute(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/routerrpc.Router/SendToRoute",
router__pb2.SendToRouteRequest.SerializeToString,
router__pb2.SendToRouteResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def SendToRouteV2(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/routerrpc.Router/SendToRouteV2",
router__pb2.SendToRouteRequest.SerializeToString,
lightning__pb2.HTLCAttempt.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def ResetMissionControl(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/routerrpc.Router/ResetMissionControl",
router__pb2.ResetMissionControlRequest.SerializeToString,
router__pb2.ResetMissionControlResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def QueryMissionControl(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/routerrpc.Router/QueryMissionControl",
router__pb2.QueryMissionControlRequest.SerializeToString,
router__pb2.QueryMissionControlResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def XImportMissionControl(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/routerrpc.Router/XImportMissionControl",
router__pb2.XImportMissionControlRequest.SerializeToString,
router__pb2.XImportMissionControlResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def GetMissionControlConfig(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/routerrpc.Router/GetMissionControlConfig",
router__pb2.GetMissionControlConfigRequest.SerializeToString,
router__pb2.GetMissionControlConfigResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def SetMissionControlConfig(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/routerrpc.Router/SetMissionControlConfig",
router__pb2.SetMissionControlConfigRequest.SerializeToString,
router__pb2.SetMissionControlConfigResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def QueryProbability(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/routerrpc.Router/QueryProbability",
router__pb2.QueryProbabilityRequest.SerializeToString,
router__pb2.QueryProbabilityResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def BuildRoute(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/routerrpc.Router/BuildRoute",
router__pb2.BuildRouteRequest.SerializeToString,
router__pb2.BuildRouteResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def SubscribeHtlcEvents(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_stream(
request,
target,
"/routerrpc.Router/SubscribeHtlcEvents",
router__pb2.SubscribeHtlcEventsRequest.SerializeToString,
router__pb2.HtlcEvent.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def SendPayment(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_stream(
request,
target,
"/routerrpc.Router/SendPayment",
router__pb2.SendPaymentRequest.SerializeToString,
router__pb2.PaymentStatus.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def TrackPayment(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_stream(
request,
target,
"/routerrpc.Router/TrackPayment",
router__pb2.TrackPaymentRequest.SerializeToString,
router__pb2.PaymentStatus.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def HtlcInterceptor(
request_iterator,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.stream_stream(
request_iterator,
target,
"/routerrpc.Router/HtlcInterceptor",
router__pb2.ForwardHtlcInterceptResponse.SerializeToString,
router__pb2.ForwardHtlcInterceptRequest.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def UpdateChanStatus(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/routerrpc.Router/UpdateChanStatus",
router__pb2.UpdateChanStatusRequest.SerializeToString,
router__pb2.UpdateChanStatusResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)

View file

@ -2,10 +2,11 @@ imports_ok = True
try:
import grpc
from google import protobuf
from grpc import RpcError
except ImportError: # pragma: nocover
imports_ok = False
import asyncio
import base64
import binascii
import hashlib
@ -19,6 +20,8 @@ from .macaroon import AESCipher, load_macaroon
if imports_ok:
import lnbits.wallets.lnd_grpc_files.lightning_pb2 as ln
import lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc as lnrpc
import lnbits.wallets.lnd_grpc_files.router_pb2 as router
import lnbits.wallets.lnd_grpc_files.router_pb2_grpc as routerrpc
from .base import (
InvoiceResponse,
@ -111,6 +114,7 @@ class LndWallet(Wallet):
f"{self.endpoint}:{self.port}", composite_creds
)
self.rpc = lnrpc.LightningStub(channel)
self.routerpc = routerrpc.RouterStub(channel)
def metadata_callback(self, _, callback):
callback([("macaroon", self.macaroon)], None)
@ -118,6 +122,8 @@ class LndWallet(Wallet):
async def status(self) -> StatusResponse:
try:
resp = await self.rpc.ChannelBalance(ln.ChannelBalanceRequest())
except RpcError as exc:
return StatusResponse(str(exc._details), 0)
except Exception as exc:
return StatusResponse(str(exc), 0)
@ -132,7 +138,10 @@ class LndWallet(Wallet):
params: Dict = {"value": amount, "expiry": 600, "private": True}
if description_hash:
params["description_hash"] = description_hash # as bytes directly
params["description_hash"] = hashlib.sha256(
description_hash
).digest() # as bytes directly
else:
params["memo"] = memo or ""
@ -148,18 +157,39 @@ class LndWallet(Wallet):
return InvoiceResponse(True, checking_id, payment_request, None)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
fee_limit_fixed = ln.FeeLimit(fixed=fee_limit_msat // 1000)
req = ln.SendRequest(payment_request=bolt11, fee_limit=fee_limit_fixed)
resp = await self.rpc.SendPaymentSync(req)
# fee_limit_fixed = ln.FeeLimit(fixed=fee_limit_msat // 1000)
req = router.SendPaymentRequest(
payment_request=bolt11,
fee_limit_msat=fee_limit_msat,
timeout_seconds=30,
no_inflight_updates=True,
)
try:
resp = await self.routerpc.SendPaymentV2(req).read()
except RpcError as exc:
return PaymentResponse(False, "", 0, None, exc._details)
except Exception as exc:
return PaymentResponse(False, "", 0, None, str(exc))
if resp.payment_error:
return PaymentResponse(False, "", 0, None, resp.payment_error)
# PaymentStatus from https://github.com/lightningnetwork/lnd/blob/master/channeldb/payments.go#L178
statuses = {
0: None, # NON_EXISTENT
1: None, # IN_FLIGHT
2: True, # SUCCEEDED
3: False, # FAILED
}
r_hash = hashlib.sha256(resp.payment_preimage).digest()
checking_id = stringify_checking_id(r_hash)
fee_msat = resp.payment_route.total_fees_msat
preimage = resp.payment_preimage.hex()
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
if resp.status in [0, 1, 3]:
fee_msat = 0
preimage = ""
checking_id = ""
elif resp.status == 2: # SUCCEEDED
fee_msat = resp.htlcs[-1].route.total_fees_msat
preimage = resp.payment_preimage
checking_id = resp.payment_hash
return PaymentResponse(
statuses[resp.status], checking_id, fee_msat, preimage, None
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
@ -178,9 +208,45 @@ class LndWallet(Wallet):
return PaymentStatus(None)
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
return PaymentStatus(True)
"""
This routine checks the payment status using routerpc.TrackPaymentV2.
"""
try:
r_hash = parse_checking_id(checking_id)
if len(r_hash) != 32:
raise binascii.Error
except binascii.Error:
# this may happen if we switch between backend wallets
# that use different checking_id formats
return PaymentStatus(None)
# for some reason our checking_ids are in base64 but the payment hashes
# returned here are in hex, lnd is weird
checking_id = checking_id.replace("_", "/")
checking_id = base64.b64decode(checking_id).hex()
resp = self.routerpc.TrackPaymentV2(
router.TrackPaymentRequest(payment_hash=r_hash)
)
# HTLCAttempt.HTLCStatus:
# https://github.com/lightningnetwork/lnd/blob/master/lnrpc/lightning.proto#L3641
statuses = {
0: None, # IN_FLIGHT
1: True, # "SUCCEEDED"
2: False, # "FAILED"
}
try:
async for payment in resp:
return PaymentStatus(statuses[payment.htlcs[-1].status])
except: # most likely the payment wasn't found
return PaymentStatus(None)
return PaymentStatus(None)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True:
request = ln.InvoiceSubscription()
try:
async for i in self.rpc.SubscribeInvoices(request):
@ -189,9 +255,8 @@ class LndWallet(Wallet):
checking_id = stringify_checking_id(i.r_hash)
yield checking_id
except error:
logger.error(error)
except Exception as exc:
logger.error(
"lost connection to lnd InvoiceSubscription, please restart lnbits."
f"lost connection to lnd invoices stream: '{exc}', retrying in 5 seconds"
)
await asyncio.sleep(5)

View file

@ -1,5 +1,6 @@
import asyncio
import base64
import hashlib
import json
from os import getenv
from pydoc import describe
@ -75,9 +76,9 @@ class LndRestWallet(Wallet):
) -> InvoiceResponse:
data: Dict = {"value": amount, "private": True}
if description_hash:
data["description_hash"] = base64.b64encode(description_hash).decode(
"ascii"
)
data["description_hash"] = base64.b64encode(
hashlib.sha256(description_hash).digest()
).decode("ascii")
else:
data["memo"] = memo or ""
@ -141,15 +142,10 @@ class LndRestWallet(Wallet):
return PaymentStatus(True)
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
async with httpx.AsyncClient(verify=self.cert) as client:
r = await client.get(
url=f"{self.endpoint}/v1/payments",
headers=self.auth,
params={"max_payments": "20", "reversed": True},
)
if r.is_error:
return PaymentStatus(None)
"""
This routine checks the payment status using routerpc.TrackPaymentV2.
"""
url = f"{self.endpoint}/v2/router/track/{checking_id}"
# check payment.status:
# https://api.lightning.community/rest/index.html?python#peersynctype
@ -160,14 +156,27 @@ class LndRestWallet(Wallet):
"FAILED": False,
}
# for some reason our checking_ids are in base64 but the payment hashes
# returned here are in hex, lnd is weird
checking_id = checking_id.replace("_", "/")
checking_id = base64.b64decode(checking_id).hex()
for p in r.json()["payments"]:
if p["payment_hash"] == checking_id:
return PaymentStatus(statuses[p["status"]])
async with httpx.AsyncClient(
timeout=None, headers=self.auth, verify=self.cert
) as client:
async with client.stream("GET", url) as r:
async for l in r.aiter_lines():
try:
line = json.loads(l)
if line.get("error"):
logger.error(
line["error"]["message"]
if "message" in line["error"]
else line["error"]
)
return PaymentStatus(None)
payment = line.get("result")
if payment is not None and payment.get("status"):
return PaymentStatus(statuses[payment["status"]])
else:
return PaymentStatus(None)
except:
continue
return PaymentStatus(None)
@ -190,10 +199,8 @@ class LndRestWallet(Wallet):
payment_hash = base64.b64decode(inv["r_hash"]).hex()
yield payment_hash
except (OSError, httpx.ConnectError, httpx.ReadError):
pass
except Exception as exc:
logger.error(
"lost connection to lnd invoices stream, retrying in 5 seconds"
f"lost connection to lnd invoices stream: '{exc}', retrying in 5 seconds"
)
await asyncio.sleep(5)

View file

@ -1,4 +1,5 @@
import asyncio
import hashlib
import json
from http import HTTPStatus
from os import getenv
@ -54,7 +55,7 @@ class LNPayWallet(Wallet):
) -> InvoiceResponse:
data: Dict = {"num_satoshis": f"{amount}"}
if description_hash:
data["description_hash"] = description_hash.hex()
data["description_hash"] = hashlib.sha256(description_hash).hexdigest()
else:
data["memo"] = memo or ""

View file

@ -1,4 +1,5 @@
import asyncio
import hashlib
import json
from os import getenv
from typing import AsyncGenerator, Dict, Optional
@ -54,7 +55,7 @@ class LntxbotWallet(Wallet):
) -> InvoiceResponse:
data: Dict = {"amt": str(amount)}
if description_hash:
data["description_hash"] = description_hash.hex()
data["description_hash"] = hashlib.sha256(description_hash).hexdigest()
else:
data["memo"] = memo or ""

View file

@ -65,7 +65,7 @@ class OpenNodeWallet(Wallet):
json={
"amount": amount,
"description": memo or "",
"callback_url": url_for("/webhook_listener", _external=True),
# "callback_url": url_for("/webhook_listener", _external=True),
},
timeout=40,
)

View file

@ -1,4 +1,5 @@
import asyncio
import hashlib
import json
import random
from os import getenv
@ -101,7 +102,7 @@ class SparkWallet(Wallet):
r = await self.invoicewithdescriptionhash(
msatoshi=amount * 1000,
label=label,
description_hash=description_hash.hex(),
description_hash=hashlib.sha256(description_hash).hexdigest(),
)
else:
r = await self.invoice(

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