mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2025-02-25 23:21:21 +01:00
commit
75e2c9b2c0
116 changed files with 9128 additions and 25987 deletions
|
@ -6,6 +6,10 @@ tests
|
|||
venv
|
||||
tools
|
||||
|
||||
lnbits/static/css/*
|
||||
lnbits/static/bundle.js
|
||||
lnbits/static/bundle.css
|
||||
|
||||
*.md
|
||||
*.log
|
||||
|
||||
|
|
12
.env.example
12
.env.example
|
@ -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
|
||||
|
|
35
.github/workflows/formatting.yml
vendored
35
.github/workflows/formatting.yml
vendored
|
@ -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
|
||||
|
|
17
.github/workflows/migrations.yml
vendored
17
.github/workflows/migrations.yml
vendored
|
@ -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
|
||||
|
|
17
.github/workflows/mypy.yml
vendored
17
.github/workflows/mypy.yml
vendored
|
@ -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
|
||||
|
|
77
.github/workflows/regtest.yml
vendored
77
.github/workflows/regtest.yml
vendored
|
@ -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
|
||||
|
|
100
.github/workflows/tests.yml
vendored
100
.github/workflows/tests.yml
vendored
|
@ -7,7 +7,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.7, 3.8, 3.9]
|
||||
python-version: [3.9]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
|
@ -23,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
|
||||
venv-postgres:
|
||||
postgres:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
|
@ -44,22 +64,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
|
||||
- name: Run tests
|
||||
env:
|
||||
LNBITS_DATABASE_URL: postgres://postgres:postgres@0.0.0.0:5432/postgres
|
||||
|
@ -68,68 +83,3 @@ jobs:
|
|||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
poetry-sqlite:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.9]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
env:
|
||||
VIRTUAL_ENV: ./venv
|
||||
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
||||
run: |
|
||||
python -m venv ${{ env.VIRTUAL_ENV }}
|
||||
./venv/bin/python -m pip install --upgrade pip
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
|
||||
- name: Run tests
|
||||
run: make test
|
||||
poetry-postgres:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
# maps tcp port 5432 on service container to the host
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.9]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
env:
|
||||
VIRTUAL_ENV: ./venv
|
||||
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
||||
run: |
|
||||
python -m venv ${{ env.VIRTUAL_ENV }}
|
||||
./venv/bin/python -m pip install --upgrade pip
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
|
||||
- name: Run tests
|
||||
env:
|
||||
LNBITS_DATABASE_URL: postgres://postgres:postgres@0.0.0.0:5432/postgres
|
||||
run: make test
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
51
Dockerfile
51
Dockerfile
|
@ -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"]
|
||||
|
|
45
Makefile
45
Makefile
|
@ -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
|
||||
|
|
|
@ -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
|
|||

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

|
||||
|
||||
|
@ -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
|
||||
|
|
21
build.py
21
build.py
|
@ -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"])
|
||||
build()
|
||||
|
|
|
@ -1 +1 @@
|
|||
lnbits.org
|
||||
legend.lnbits.org
|
|
@ -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"
|
||||
|
|
|
@ -9,4 +9,4 @@ nav_order: 3
|
|||
API reference
|
||||
=============
|
||||
|
||||
Coming soon...
|
||||
[Swagger Docs](https://legend.lnbits.org/devs/swagger.html)
|
||||
|
|
|
@ -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
|
||||
```
|
||||
|
|
|
@ -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.
|
|
@ -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
29
docs/devs/swagger.html
Normal 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>
|
|
@ -4,8 +4,6 @@ title: Basic installation
|
|||
nav_order: 2
|
||||
---
|
||||
|
||||
|
||||
|
||||
# Basic installation
|
||||
|
||||
You can choose between four package managers, `poetry`, `nix` and `venv`.
|
||||
|
@ -18,16 +16,26 @@ 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
|
||||
|
||||
# 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
|
||||
sudo nano .env # set funding source
|
||||
|
||||
mkdir data && cp .env.example .env
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
#### Running the server
|
||||
|
||||
|
||||
```sh
|
||||
poetry run lnbits
|
||||
# To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0'
|
||||
|
@ -41,7 +49,7 @@ cd lnbits-legend/
|
|||
# Modern debian distros usually include Nix, however you can install with:
|
||||
# 'sh <(curl -L https://nixos.org/nix/install) --daemon', or use setup here https://nixos.org/download.html#nix-verify-installation
|
||||
|
||||
nix build .#lnbits
|
||||
nix build .#lnbits
|
||||
mkdir data
|
||||
|
||||
```
|
||||
|
@ -74,20 +82,31 @@ mkdir data && cp .env.example .env
|
|||
./venv/bin/uvicorn lnbits.__main__:app --port 5000
|
||||
```
|
||||
|
||||
If you want to host LNbits on the internet, run with the option `--host 0.0.0.0`.
|
||||
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.
|
||||
Problems installing? These commands have helped us install LNbits.
|
||||
|
||||
```sh
|
||||
sudo apt install pkg-config libffi-dev libpq-dev
|
||||
|
||||
# if the secp256k1 build fails:
|
||||
# if you used venv
|
||||
./venv/bin/pip install setuptools wheel
|
||||
./venv/bin/pip install setuptools wheel
|
||||
# if you used poetry
|
||||
poetry add setuptools wheel
|
||||
poetry add setuptools wheel
|
||||
# build essentials for debian/ubuntu
|
||||
sudo apt install python3-dev gcc build-essential
|
||||
```
|
||||
|
@ -122,13 +141,13 @@ LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
|
|||
|
||||
# Using LNbits
|
||||
|
||||
Now you can visit your LNbits at http://localhost:5000/.
|
||||
Now you can visit your LNbits at http://localhost:5000/.
|
||||
|
||||
Now modify the `.env` file with any settings you prefer and add a proper [funding source](./wallets.md) by modifying the value of `LNBITS_BACKEND_WALLET_CLASS` and providing the extra information and credentials related to the chosen funding source.
|
||||
Now modify the `.env` file with any settings you prefer and add a proper [funding source](./wallets.md) by modifying the value of `LNBITS_BACKEND_WALLET_CLASS` and providing the extra information and credentials related to the chosen funding source.
|
||||
|
||||
Then you can restart it and it will be using the new settings.
|
||||
|
||||
You might also need to install additional packages or perform additional setup steps, depending on the chosen backend. See [the short guide](./wallets.md) on each different funding source.
|
||||
You might also need to install additional packages or perform additional setup steps, depending on the chosen backend. See [the short guide](./wallets.md) on each different funding source.
|
||||
|
||||
Take a look at [Polar](https://lightningpolar.com/) for an excellent way of spinning up a Lightning Network dev environment.
|
||||
|
||||
|
@ -170,21 +189,21 @@ Systemd is great for taking care of your LNbits instance. It will start it on bo
|
|||
Description=LNbits
|
||||
# you can uncomment these lines if you know what you're doing
|
||||
# it will make sure that lnbits starts after lnd (replace with your own backend service)
|
||||
#Wants=lnd.service
|
||||
#After=lnd.service
|
||||
#Wants=lnd.service
|
||||
#After=lnd.service
|
||||
|
||||
[Service]
|
||||
# replace with the absolute path of your lnbits installation
|
||||
WorkingDirectory=/home/bitcoin/lnbits
|
||||
WorkingDirectory=/home/bitcoin/lnbits
|
||||
# same here
|
||||
ExecStart=/home/bitcoin/lnbits/venv/bin/uvicorn lnbits.__main__:app --port 5000
|
||||
ExecStart=/home/bitcoin/lnbits/venv/bin/uvicorn lnbits.__main__:app --port 5000
|
||||
# replace with the user that you're running lnbits on
|
||||
User=bitcoin
|
||||
User=bitcoin
|
||||
Restart=always
|
||||
TimeoutSec=120
|
||||
RestartSec=30
|
||||
# this makes sure that you receive logs in real time
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
@ -198,12 +217,12 @@ sudo systemctl start lnbits.service
|
|||
```
|
||||
|
||||
## Using https without reverse proxy
|
||||
The most common way of using LNbits via https is to use a reverse proxy such as Caddy, nginx, or ngriok. However, you can also run LNbits via https without additional software. This is useful for development purposes or if you want to use LNbits in your local network.
|
||||
The most common way of using LNbits via https is to use a reverse proxy such as Caddy, nginx, or ngriok. However, you can also run LNbits via https without additional software. This is useful for development purposes or if you want to use LNbits in your local network.
|
||||
|
||||
We have to create a self-signed certificate using `mkcert`. Note that this certiciate is not "trusted" by most browsers but that's fine (since you know that you have created it) and encryption is always better than clear text.
|
||||
|
||||
#### Install mkcert
|
||||
You can find the install instructions for `mkcert` [here](https://github.com/FiloSottile/mkcert).
|
||||
You can find the install instructions for `mkcert` [here](https://github.com/FiloSottile/mkcert).
|
||||
|
||||
Install mkcert on Ubuntu:
|
||||
```sh
|
||||
|
@ -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
|
||||
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.
|
||||
|
|
|
@ -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**
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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,13 +337,16 @@ 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)
|
||||
status = await WALLET.get_invoice_status(payment.checking_id)
|
||||
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
|
||||
if payment.is_out and status.failed:
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
(
|
||||
LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
|
||||
).encode("utf-8")
|
||||
).digest(),
|
||||
description_hash=(
|
||||
LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
|
||||
).encode("utf-8"),
|
||||
extra={"tag": "copilot", "copilotid": cp.id, "comment": comment},
|
||||
)
|
||||
payResponse = {"pr": payment_request, "routes": []}
|
||||
|
|
|
@ -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},
|
||||
)
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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"},
|
||||
)
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
|
@ -11,6 +12,14 @@ db = Database("ext_satspay")
|
|||
|
||||
satspay_ext: APIRouter = APIRouter(prefix="/satspay", tags=["satspay"])
|
||||
|
||||
satspay_static_files = [
|
||||
{
|
||||
"path": "/satspay/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/satspay/static"),
|
||||
"name": "satspay_static",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def satspay_renderer():
|
||||
return template_renderer(["lnbits/extensions/satspay/templates"])
|
||||
|
|
|
@ -6,7 +6,7 @@ from lnbits.core.services import create_invoice
|
|||
from lnbits.core.views.api import api_payment
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from ..watchonly.crud import get_fresh_address, get_mempool, get_watch_wallet
|
||||
from ..watchonly.crud import get_config, get_fresh_address
|
||||
|
||||
# from lnbits.db import open_ext_db
|
||||
from . import db
|
||||
|
@ -18,7 +18,6 @@ from .models import Charges, CreateCharge
|
|||
async def create_charge(user: str, data: CreateCharge) -> Charges:
|
||||
charge_id = urlsafe_short_hash()
|
||||
if data.onchainwallet:
|
||||
wallet = await get_watch_wallet(data.onchainwallet)
|
||||
onchain = await get_fresh_address(data.onchainwallet)
|
||||
onchainaddress = onchain.address
|
||||
else:
|
||||
|
@ -89,7 +88,8 @@ async def get_charge(charge_id: str) -> Charges:
|
|||
|
||||
async def get_charges(user: str) -> List[Charges]:
|
||||
rows = await db.fetchall(
|
||||
"""SELECT * FROM satspay.charges WHERE "user" = ?""", (user,)
|
||||
"""SELECT * FROM satspay.charges WHERE "user" = ? ORDER BY "timestamp" DESC """,
|
||||
(user,),
|
||||
)
|
||||
return [Charges.from_row(row) for row in rows]
|
||||
|
||||
|
@ -102,14 +102,16 @@ async def check_address_balance(charge_id: str) -> List[Charges]:
|
|||
charge = await get_charge(charge_id)
|
||||
if not charge.paid:
|
||||
if charge.onchainaddress:
|
||||
mempool = await get_mempool(charge.user)
|
||||
config = await get_config(charge.user)
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(
|
||||
mempool.endpoint + "/api/address/" + charge.onchainaddress
|
||||
config.mempool_endpoint
|
||||
+ "/api/address/"
|
||||
+ charge.onchainaddress
|
||||
)
|
||||
respAmount = r.json()["chain_stats"]["funded_txo_sum"]
|
||||
if respAmount >= charge.balance:
|
||||
if respAmount > charge.balance:
|
||||
await update_charge(charge_id=charge_id, balance=respAmount)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from sqlite3 import Row
|
||||
from typing import Optional
|
||||
|
||||
|
@ -38,12 +38,16 @@ class Charges(BaseModel):
|
|||
def from_row(cls, row: Row) -> "Charges":
|
||||
return cls(**dict(row))
|
||||
|
||||
@property
|
||||
def time_left(self):
|
||||
now = datetime.utcnow().timestamp()
|
||||
start = datetime.fromtimestamp(self.timestamp)
|
||||
expiration = (start + timedelta(minutes=self.time)).timestamp()
|
||||
return (expiration - now) / 60
|
||||
|
||||
@property
|
||||
def time_elapsed(self):
|
||||
if (self.timestamp + (self.time * 60)) >= time.time():
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
return self.time_left < 0
|
||||
|
||||
@property
|
||||
def paid(self):
|
||||
|
|
31
lnbits/extensions/satspay/static/js/utils.js
Normal file
31
lnbits/extensions/satspay/static/js/utils.js
Normal 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) : ''
|
|
@ -8,172 +8,10 @@
|
|||
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
|
||||
>
|
||||
</p>
|
||||
<br />
|
||||
<br />
|
||||
<a target="_blank" href="/docs#/satspay" class="text-white"
|
||||
>Swagger REST API Documentation</a
|
||||
>
|
||||
</q-card-section>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/satspay"></q-btn>
|
||||
<q-expansion-item group="api" dense expand-separator label="Create charge">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">POST</span> /satspay/api/v1/charge</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<charge_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}satspay/api/v1/charge -d
|
||||
'{"onchainwallet": <string, watchonly_wallet_id>,
|
||||
"description": <string>, "webhook":<string>, "time":
|
||||
<integer>, "amount": <integer>, "lnbitswallet":
|
||||
<string, lnbits_wallet_id>}' -H "Content-type:
|
||||
application/json" -H "X-Api-Key: {{user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Update charge">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">PUT</span>
|
||||
/satspay/api/v1/charge/<charge_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<charge_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url
|
||||
}}satspay/api/v1/charge/<charge_id> -d '{"onchainwallet":
|
||||
<string, watchonly_wallet_id>, "description": <string>,
|
||||
"webhook":<string>, "time": <integer>, "amount":
|
||||
<integer>, "lnbitswallet": <string, lnbits_wallet_id>}'
|
||||
-H "Content-type: application/json" -H "X-Api-Key:
|
||||
{{user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item group="api" dense expand-separator label="Get charge">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/satspay/api/v1/charge/<charge_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<charge_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}satspay/api/v1/charge/<charge_id> -H "X-Api-Key: {{
|
||||
user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Get charges">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span> /satspay/api/v1/charges</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<charge_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}satspay/api/v1/charges -H
|
||||
"X-Api-Key: {{ user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Delete a pay link"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">DELETE</span>
|
||||
/satspay/api/v1/charge/<charge_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
|
||||
<code></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X DELETE {{ request.base_url
|
||||
}}satspay/api/v1/charge/<charge_id> -H "X-Api-Key: {{
|
||||
user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Get balances"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/satspay/api/v1/charges/balance/<charge_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<charge_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}satspay/api/v1/charges/balance/<charge_id> -H "X-Api-Key: {{
|
||||
user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
||||
</q-card>
|
||||
|
|
|
@ -1,223 +1,299 @@
|
|||
{% extends "public.html" %} {% block page %}
|
||||
<div class="q-pa-sm theCard">
|
||||
<q-card class="my-card">
|
||||
<div class="column">
|
||||
<center>
|
||||
<div class="col theHeading">{{ charge.description }}</div>
|
||||
</center>
|
||||
<div class="col">
|
||||
<div
|
||||
class="col"
|
||||
color="white"
|
||||
style="background-color: grey; height: 30px; padding: 5px"
|
||||
v-if="timetoComplete < 1"
|
||||
>
|
||||
<center>Time elapsed</center>
|
||||
</div>
|
||||
<div
|
||||
class="col"
|
||||
color="white"
|
||||
style="background-color: grey; height: 30px; padding: 5px"
|
||||
v-else-if="charge_paid == 'True'"
|
||||
>
|
||||
<center>Charge paid</center>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-linear-progress size="30px" :value="newProgress" color="grey">
|
||||
<q-item-section>
|
||||
<q-item style="padding: 3px">
|
||||
<q-spinner color="white" size="0.8em"></q-spinner
|
||||
><span style="font-size: 15px; color: white"
|
||||
><span class="q-pr-xl q-pl-md"> Awaiting payment...</span>
|
||||
<span class="q-pl-xl" style="color: white">
|
||||
{% raw %} {{ newTimeLeft }} {% endraw %}</span
|
||||
></span
|
||||
>
|
||||
</q-item>
|
||||
</q-item-section>
|
||||
</q-linear-progress>
|
||||
<div class="row items-center q-mt-md">
|
||||
<div class="col-lg-4 col-md-3 col-sm-1"></div>
|
||||
<div class="col-lg-4 col-md-6 col-sm-10">
|
||||
<q-card>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col text-center q-mt-md">
|
||||
<span class="text-h4" v-text="charge.description"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col" style="margin: 2px 15px; max-height: 100px">
|
||||
<center>
|
||||
<q-btn flat dense outline @click="copyText('{{ charge.id }}')"
|
||||
>Charge ID: {{ charge.id }}</q-btn
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<div
|
||||
color="white"
|
||||
style="background-color: grey; height: 30px; padding: 5px"
|
||||
v-if="!charge.timeLeft"
|
||||
>
|
||||
</center>
|
||||
<span
|
||||
><small
|
||||
>{% raw %} Total to pay: {{ charge_amount }}sats<br />
|
||||
Amount paid: {{ charge_balance }}</small
|
||||
><br />
|
||||
Amount due: {{ charge_amount - charge_balance }}sats {% endraw %}
|
||||
</span>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<div class="col">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
flat
|
||||
disable
|
||||
v-if="'{{ charge.lnbitswallet }}' == 'None' || charge_time_elapsed == 'True'"
|
||||
style="color: primary; width: 100%"
|
||||
label="lightning⚡"
|
||||
>
|
||||
<q-tooltip>
|
||||
bitcoin lightning payment method not available
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
v-else
|
||||
@click="payLN"
|
||||
style="color: primary; width: 100%"
|
||||
label="lightning⚡"
|
||||
>
|
||||
<q-tooltip> pay with lightning </q-tooltip>
|
||||
</q-btn>
|
||||
Time elapsed
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
flat
|
||||
disable
|
||||
v-if="'{{ charge.onchainwallet }}' == 'None' || charge_time_elapsed == 'True'"
|
||||
style="color: primary; width: 100%"
|
||||
label="onchain⛓️"
|
||||
>
|
||||
<q-tooltip>
|
||||
bitcoin onchain payment method not available
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
v-else
|
||||
@click="payON"
|
||||
style="color: primary; width: 100%"
|
||||
label="onchain⛓️"
|
||||
>
|
||||
<q-tooltip> pay onchain </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
</div>
|
||||
</div>
|
||||
<q-card class="q-pa-lg" v-if="lnbtc">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="text-center q-pt-md">
|
||||
<div v-if="timetoComplete < 1 && charge_paid == 'False'">
|
||||
<q-icon
|
||||
name="block"
|
||||
style="color: #ccc; font-size: 21.4em"
|
||||
></q-icon>
|
||||
</div>
|
||||
<div v-else-if="charge_paid == 'True'">
|
||||
<q-icon
|
||||
name="check"
|
||||
style="color: green; font-size: 21.4em"
|
||||
></q-icon>
|
||||
<q-btn
|
||||
outline
|
||||
v-if="'{{ charge.webhook }}' != 'None'"
|
||||
type="a"
|
||||
href="{{ charge.completelink }}"
|
||||
label="{{ charge.completelinktext }}"
|
||||
></q-btn>
|
||||
<div
|
||||
color="white"
|
||||
style="background-color: grey; height: 30px; padding: 5px"
|
||||
v-else-if="charge.paid"
|
||||
>
|
||||
Charge paid
|
||||
</div>
|
||||
<div v-else>
|
||||
<center>
|
||||
<span class="text-subtitle2"
|
||||
>Pay this <br />
|
||||
lightning-network invoice</span
|
||||
<q-linear-progress
|
||||
size="30px"
|
||||
:value="charge.progress"
|
||||
color="secondary"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item style="padding: 3px">
|
||||
<q-spinner color="white" size="0.8em"></q-spinner
|
||||
><span style="font-size: 15px; color: white"
|
||||
><span class="q-pr-xl q-pl-md"> Awaiting payment...</span>
|
||||
<span class="q-pl-xl" style="color: white">
|
||||
{% raw %} {{ charge.timeLeft }} {% endraw %}</span
|
||||
></span
|
||||
>
|
||||
</q-item>
|
||||
</q-item-section>
|
||||
</q-linear-progress>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-ml-md q-mt-md q-mb-lg">
|
||||
<div class="col">
|
||||
<div class="row">
|
||||
<div class="col-4 q-pr-lg">Charge Id:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-btn flat dense outline @click="copyText(charge.id)"
|
||||
><span v-text="charge.id"></span
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center">
|
||||
<div class="col-4 q-pr-lg">Total to pay:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-badge color="blue">
|
||||
<span v-text="charge.amount" class="text-subtitle2"></span> sat
|
||||
</q-badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-sm">
|
||||
<div class="col-4 q-pr-lg">Amount paid:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-badge color="orange">
|
||||
<span v-text="charge.balance" class="text-subtitle2"></span>
|
||||
sat</q-badge
|
||||
>
|
||||
</center>
|
||||
<a href="lightning:{{ charge.payment_request }}">
|
||||
<q-responsive :ratio="1" class="q-mx-md">
|
||||
<qrcode
|
||||
:value="'{{ charge.payment_request }}'"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText('{{ charge.payment_request }}')"
|
||||
>Copy invoice</q-btn
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pendingFunds" class="row items-center q-mt-sm">
|
||||
<div class="col-4 q-pr-lg">Amount pending:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-badge color="gray">
|
||||
<span v-text="pendingFunds" class="text-subtitle2"></span> sat
|
||||
</q-badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-sm">
|
||||
<div class="col-4 q-pr-lg">Amount due:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-badge v-if="charge.amount - charge.balance > 0" color="green">
|
||||
<span
|
||||
v-text="charge.amount - charge.balance"
|
||||
class="text-subtitle2"
|
||||
></span>
|
||||
sat
|
||||
</q-badge>
|
||||
<q-badge
|
||||
v-else="charge.amount - charge.balance <= 0"
|
||||
color="green"
|
||||
class="text-subtitle2"
|
||||
>
|
||||
none</q-badge
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
flat
|
||||
disable
|
||||
v-if="!charge.lnbitswallet || charge.time_elapsed"
|
||||
style="color: primary; width: 100%"
|
||||
label="lightning⚡"
|
||||
>
|
||||
<q-tooltip>
|
||||
bitcoin lightning payment method not available
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
v-else
|
||||
@click="payInvoice"
|
||||
style="color: primary; width: 100%"
|
||||
label="lightning⚡"
|
||||
>
|
||||
<q-tooltip> pay with lightning </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
flat
|
||||
disable
|
||||
v-if="!charge.onchainwallet || charge.time_elapsed"
|
||||
style="color: primary; width: 100%"
|
||||
label="onchain⛓️"
|
||||
>
|
||||
<q-tooltip>
|
||||
bitcoin onchain payment method not available
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
v-else
|
||||
@click="payOnchain"
|
||||
style="color: primary; width: 100%"
|
||||
label="onchain⛓️"
|
||||
>
|
||||
<q-tooltip> pay onchain </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
<q-card class="q-pa-lg" v-if="lnbtc">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="row items-center q-mt-sm">
|
||||
<div class="col-md-2 col-sm-0"></div>
|
||||
<div class="col-md-8 col-sm-12">
|
||||
<div v-if="!charge.timeLeft && !charge.paid">
|
||||
<q-icon
|
||||
name="block"
|
||||
style="color: #ccc; font-size: 21.4em"
|
||||
></q-icon>
|
||||
</div>
|
||||
<div v-else-if="charge.paid">
|
||||
<q-icon
|
||||
name="check"
|
||||
style="color: green; font-size: 21.4em"
|
||||
></q-icon>
|
||||
<q-btn
|
||||
outline
|
||||
v-if="charge.webhook"
|
||||
type="a"
|
||||
:href="charge.completelink"
|
||||
:label="charge.completelinktext"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="row text-center q-mb-sm">
|
||||
<div class="col text-center">
|
||||
<span class="text-subtitle2"
|
||||
>Pay this lightning-network invoice:</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a :href="'lightning:'+charge.payment_request">
|
||||
<q-responsive :ratio="1" class="q-mx-md">
|
||||
<qrcode
|
||||
:value="charge.payment_request"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
<div class="row text-center q-mt-lg">
|
||||
<div class="col text-center">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(charge.payment_request)"
|
||||
>Copy invoice</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-0"></div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card class="q-pa-lg" v-if="onbtc">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="text-center q-pt-md">
|
||||
<div v-if="timetoComplete < 1 && charge_paid == 'False'">
|
||||
<q-icon
|
||||
name="block"
|
||||
style="color: #ccc; font-size: 21.4em"
|
||||
></q-icon>
|
||||
</div>
|
||||
<div v-else-if="charge_paid == 'True'">
|
||||
<q-icon
|
||||
name="check"
|
||||
style="color: green; font-size: 21.4em"
|
||||
></q-icon>
|
||||
<q-btn
|
||||
outline
|
||||
v-if="'{{ charge.webhook }}' != None"
|
||||
type="a"
|
||||
href="{{ charge.completelink }}"
|
||||
label="{{ charge.completelinktext }}"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<center>
|
||||
<span class="text-subtitle2"
|
||||
>Send {{ charge.amount }}sats<br />
|
||||
to this onchain address</span
|
||||
>
|
||||
</center>
|
||||
<a href="bitcoin:{{ charge.onchainaddress }}">
|
||||
<q-responsive :ratio="1" class="q-mx-md">
|
||||
<qrcode
|
||||
:value="'{{ charge.onchainaddress }}'"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
<div v-if="charge.timeLeft && !charge.paid" class="row items-center">
|
||||
<div class="col text-center">
|
||||
<a
|
||||
style="color: unset"
|
||||
:href="mempool_endpoint + '/address/' + charge.onchainaddress"
|
||||
target="_blank"
|
||||
><span
|
||||
class="text-subtitle1"
|
||||
v-text="charge.onchainaddress"
|
||||
></span>
|
||||
</a>
|
||||
<div class="row q-mt-lg">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-md">
|
||||
<div class="col-md-2 col-sm-0"></div>
|
||||
<div class="col-md-8 col-sm-12 text-center">
|
||||
<div v-if="!charge.timeLeft && !charge.paid">
|
||||
<q-icon
|
||||
name="block"
|
||||
style="color: #ccc; font-size: 21.4em"
|
||||
></q-icon>
|
||||
</div>
|
||||
<div v-else-if="charge.paid">
|
||||
<q-icon
|
||||
name="check"
|
||||
style="color: green; font-size: 21.4em"
|
||||
></q-icon>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText('{{ charge.onchainaddress }}')"
|
||||
>Copy address</q-btn
|
||||
>
|
||||
v-if="charge.webhook"
|
||||
type="a"
|
||||
:href="charge.completelink"
|
||||
:label="charge.completelinktext"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="row items-center q-mb-sm">
|
||||
<div class="col text-center">
|
||||
<span class="text-subtitle2"
|
||||
>Send
|
||||
|
||||
<span v-text="charge.amount"></span>
|
||||
sats to this onchain address</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a :href="'bitcoin:'+charge.onchainaddress">
|
||||
<q-responsive :ratio="1" class="q-mx-md">
|
||||
<qrcode
|
||||
:value="charge.onchainaddress"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
<div class="row items-center q-mt-lg">
|
||||
<div class="col text-center">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(charge.onchainaddress)"
|
||||
>Copy address</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-0"></div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-lg- 4 col-md-3 col-sm-1"></div>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %}
|
||||
|
||||
<style>
|
||||
.theCard {
|
||||
width: 360px;
|
||||
margin: 10px auto;
|
||||
}
|
||||
.theHeading {
|
||||
margin: 15px;
|
||||
font-size: 25px;
|
||||
}
|
||||
</style>
|
||||
<script src="https://mempool.space/mempool.js"></script>
|
||||
<script src="{{ url_for('satspay_static', path='js/utils.js') }}"></script>
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
|
@ -226,16 +302,14 @@
|
|||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
charge: JSON.parse('{{charge_data | tojson}}'),
|
||||
mempool_endpoint: '{{mempool_endpoint}}',
|
||||
pendingFunds: 0,
|
||||
ws: null,
|
||||
newProgress: 0.4,
|
||||
counter: 1,
|
||||
newTimeLeft: '',
|
||||
timetoComplete: 100,
|
||||
lnbtc: true,
|
||||
onbtc: false,
|
||||
charge_time_elapsed: '{{charge.time_elapsed}}',
|
||||
charge_amount: '{{charge.amount}}',
|
||||
charge_balance: '{{charge.balance}}',
|
||||
charge_paid: '{{charge.paid}}',
|
||||
wallet: {
|
||||
inkey: ''
|
||||
},
|
||||
|
@ -245,90 +319,141 @@
|
|||
methods: {
|
||||
startPaymentNotifier() {
|
||||
this.cancelListener()
|
||||
|
||||
this.cancelListener = LNbits.event.onInvoicePaid(
|
||||
if (!this.lnbitswallet) return
|
||||
this.cancelListener = LNbits.events.onInvoicePaid(
|
||||
this.wallet,
|
||||
payment => {
|
||||
this.checkBalance()
|
||||
this.checkInvoiceBalance()
|
||||
}
|
||||
)
|
||||
},
|
||||
checkBalance: function () {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
checkBalances: async function () {
|
||||
if (!this.charge.hasStaleBalance) await this.refreshCharge()
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charges/balance/{{ charge.id }}',
|
||||
'filla'
|
||||
`/satspay/api/v1/charge/balance/${this.charge.id}`
|
||||
)
|
||||
.then(function (response) {
|
||||
self.charge_time_elapsed = response.data.time_elapsed
|
||||
self.charge_amount = response.data.amount
|
||||
self.charge_balance = response.data.balance
|
||||
if (self.charge_balance >= self.charge_amount) {
|
||||
self.charge_paid = 'True'
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
this.charge = mapCharge(data, this.charge)
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
payLN: function () {
|
||||
refreshCharge: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/satspay/api/v1/charge/${this.charge.id}`
|
||||
)
|
||||
this.charge = mapCharge(data, this.charge)
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
checkPendingOnchain: async function () {
|
||||
const {
|
||||
bitcoin: {addresses: addressesAPI}
|
||||
} = mempoolJS({
|
||||
hostname: new URL(this.mempool_endpoint).hostname
|
||||
})
|
||||
|
||||
try {
|
||||
const utxos = await addressesAPI.getAddressTxsUtxo({
|
||||
address: this.charge.onchainaddress
|
||||
})
|
||||
const newBalance = utxos.reduce((t, u) => t + u.value, 0)
|
||||
this.charge.hasStaleBalance = this.charge.balance === newBalance
|
||||
|
||||
this.pendingFunds = utxos
|
||||
.filter(u => !u.status.confirmed)
|
||||
.reduce((t, u) => t + u.value, 0)
|
||||
} catch (error) {
|
||||
console.error('cannot check pending funds')
|
||||
}
|
||||
},
|
||||
payInvoice: function () {
|
||||
this.lnbtc = true
|
||||
this.onbtc = false
|
||||
},
|
||||
payON: function () {
|
||||
payOnchain: function () {
|
||||
this.lnbtc = false
|
||||
this.onbtc = true
|
||||
},
|
||||
getTheTime: function () {
|
||||
var timeToComplete =
|
||||
parseInt('{{ charge.time }}') * 60 -
|
||||
(Date.now() / 1000 - parseInt('{{ charge.timestamp }}'))
|
||||
this.timetoComplete = timeToComplete
|
||||
var timeLeft = Quasar.utils.date.formatDate(
|
||||
new Date((timeToComplete - 3600) * 1000),
|
||||
'HH:mm:ss'
|
||||
)
|
||||
this.newTimeLeft = timeLeft
|
||||
},
|
||||
getThePercentage: function () {
|
||||
var timeToComplete =
|
||||
parseInt('{{ charge.time }}') * 60 -
|
||||
(Date.now() / 1000 - parseInt('{{ charge.timestamp }}'))
|
||||
this.newProgress =
|
||||
1 - timeToComplete / (parseInt('{{ charge.time }}') * 60)
|
||||
},
|
||||
|
||||
timerCount: function () {
|
||||
self = this
|
||||
var refreshIntervalId = setInterval(function () {
|
||||
if (self.charge_paid == 'True' || self.timetoComplete < 1) {
|
||||
loopRefresh: function () {
|
||||
// invoice only
|
||||
const refreshIntervalId = setInterval(async () => {
|
||||
if (this.charge.paid || !this.charge.timeLeft) {
|
||||
clearInterval(refreshIntervalId)
|
||||
}
|
||||
self.getTheTime()
|
||||
self.getThePercentage()
|
||||
self.counter++
|
||||
if (self.counter % 10 === 0) {
|
||||
self.checkBalance()
|
||||
if (this.counter % 10 === 0) {
|
||||
await this.checkBalances()
|
||||
await this.checkPendingOnchain()
|
||||
}
|
||||
this.counter++
|
||||
}, 1000)
|
||||
},
|
||||
initWs: async function () {
|
||||
const {
|
||||
bitcoin: {websocket}
|
||||
} = mempoolJS({
|
||||
hostname: new URL(this.mempool_endpoint).hostname
|
||||
})
|
||||
|
||||
this.ws = new WebSocket('wss://mempool.space/api/v1/ws')
|
||||
this.ws.addEventListener('open', x => {
|
||||
if (this.charge.onchainaddress) {
|
||||
this.trackAddress(this.charge.onchainaddress)
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.addEventListener('message', async ({data}) => {
|
||||
const res = JSON.parse(data.toString())
|
||||
if (res['address-transactions']) {
|
||||
await this.checkBalances()
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'New payment received!',
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
loopPingWs: function () {
|
||||
setInterval(() => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) this.initWs()
|
||||
this.ws.send(JSON.stringify({action: 'ping'}))
|
||||
}, 30 * 1000)
|
||||
},
|
||||
trackAddress: async function (address, retry = 0) {
|
||||
try {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) this.initWs()
|
||||
this.ws.send(JSON.stringify({'track-address': address}))
|
||||
} catch (error) {
|
||||
await sleep(1000)
|
||||
if (retry > 10) throw error
|
||||
this.trackAddress(address, retry + 1)
|
||||
}
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
console.log('{{ charge.onchainaddress }}' == 'None')
|
||||
if ('{{ charge.lnbitswallet }}' == 'None') {
|
||||
this.lnbtc = false
|
||||
this.onbtc = true
|
||||
}
|
||||
created: async function () {
|
||||
if (this.charge.lnbitswallet) this.payInvoice()
|
||||
else this.payOnchain()
|
||||
await this.checkBalances()
|
||||
|
||||
// empty for onchain
|
||||
this.wallet.inkey = '{{ wallet_inkey }}'
|
||||
this.getTheTime()
|
||||
this.getThePercentage()
|
||||
var timerCount = this.timerCount
|
||||
if ('{{ charge.paid }}' == 'False') {
|
||||
timerCount()
|
||||
}
|
||||
this.startPaymentNotifier()
|
||||
|
||||
if (!this.charge.paid) {
|
||||
this.loopRefresh()
|
||||
}
|
||||
|
||||
if (this.charge.onchainaddress) {
|
||||
this.loopPingWs()
|
||||
this.checkPendingOnchain()
|
||||
this.trackAddress(this.charge.onchainaddress)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -18,46 +18,54 @@
|
|||
<h5 class="text-subtitle1 q-my-none">Charges</h5>
|
||||
</div>
|
||||
|
||||
<div class="col-auto">
|
||||
<div class="col q-pr-lg">
|
||||
<q-input
|
||||
borderless
|
||||
dense
|
||||
debounce="300"
|
||||
v-model="filter"
|
||||
placeholder="Search"
|
||||
class="float-right"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon name="search"></q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-btn flat color="grey" @click="exportchargeCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn outline color="grey" label="...">
|
||||
<q-menu auto-close>
|
||||
<q-list style="min-width: 100px">
|
||||
<q-item clickable>
|
||||
<q-item-section @click="exportchargeCSV"
|
||||
>Export to CSV</q-item-section
|
||||
>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
flat
|
||||
dense
|
||||
:data="ChargeLinks"
|
||||
:data="chargeLinks"
|
||||
row-key="id"
|
||||
:columns="ChargesTable.columns"
|
||||
:pagination.sync="ChargesTable.pagination"
|
||||
:columns="chargesTable.columns"
|
||||
:pagination.sync="chargesTable.pagination"
|
||||
:filter="filter"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width></q-th>
|
||||
|
||||
<q-th
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
auto-width
|
||||
>
|
||||
<div v-if="col.name == 'id'"></div>
|
||||
<div v-else>{{ col.label }}</div>
|
||||
</q-th>
|
||||
<q-th auto-width>Status </q-th>
|
||||
<q-th auto-width>Title</q-th>
|
||||
<q-th auto-width>Time Left (hh:mm)</q-th>
|
||||
<q-th auto-width>Time To Pay (hh:mm)</q-th>
|
||||
<q-th auto-width>Amount To Pay</q-th>
|
||||
<q-th auto-width>Balance</q-th>
|
||||
<q-th auto-width>Pending Balance</q-th>
|
||||
<q-th auto-width>Onchain Address</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
|
@ -66,73 +74,179 @@
|
|||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
unelevated
|
||||
size="sm"
|
||||
color="accent"
|
||||
round
|
||||
dense
|
||||
size="xs"
|
||||
icon="link"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
@click="props.row.expanded= !props.row.expanded"
|
||||
:icon="props.row.expanded? 'remove' : 'add'"
|
||||
/>
|
||||
</q-td>
|
||||
|
||||
<q-td auto-width>
|
||||
<q-badge
|
||||
v-if="props.row.time_elapsed && props.row.balance < props.row.amount"
|
||||
color="red"
|
||||
>
|
||||
<a
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>expired</a
|
||||
>
|
||||
</q-badge>
|
||||
|
||||
<q-badge
|
||||
v-else-if="props.row.balance >= props.row.amount"
|
||||
color="green"
|
||||
>
|
||||
<a
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>paid</a
|
||||
>
|
||||
</q-badge>
|
||||
|
||||
<q-badge v-else color="blue"
|
||||
><a
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>waiting</a
|
||||
>
|
||||
</q-badge>
|
||||
</q-td>
|
||||
<q-td key="description" :props="props" :class="">
|
||||
<a
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>{{props.row.description}}</a
|
||||
>
|
||||
<q-tooltip> Payment link </q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
v-if="props.row.time_elapsed && props.row.balance < props.row.amount"
|
||||
unelevated
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
icon="error"
|
||||
:color="($q.dark.isActive) ? 'red' : 'red'"
|
||||
<q-td key="timeLeft" :props="props" :class="">
|
||||
<div>{{props.row.timeLeft}}</div>
|
||||
<q-linear-progress
|
||||
v-if="props.row.timeLeft"
|
||||
:value="props.row.progress"
|
||||
color="secondary"
|
||||
>
|
||||
<q-tooltip> Time elapsed </q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
v-else-if="props.row.balance >= props.row.amount"
|
||||
unelevated
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
icon="check"
|
||||
:color="($q.dark.isActive) ? 'green' : 'green'"
|
||||
>
|
||||
<q-tooltip> PAID! </q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="cached"
|
||||
flat
|
||||
:color="($q.dark.isActive) ? 'blue' : 'blue'"
|
||||
>
|
||||
<q-tooltip> Processing </q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteChargeLink(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
>
|
||||
<q-tooltip> Delete charge </q-tooltip>
|
||||
</q-btn>
|
||||
</q-linear-progress>
|
||||
</q-td>
|
||||
<q-td
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
auto-width
|
||||
>
|
||||
<div v-if="col.name == 'id'"></div>
|
||||
<div v-else>{{ col.value }}</div>
|
||||
<q-td key="time to pay" :props="props" :class="">
|
||||
<div>{{props.row.time}}</div>
|
||||
</q-td>
|
||||
<q-td key="amount" :props="props" :class="">
|
||||
<div>{{props.row.amount}}</div>
|
||||
</q-td>
|
||||
<q-td key="balance" :props="props" :class="">
|
||||
<div>{{props.row.balance}}</div>
|
||||
</q-td>
|
||||
<q-td key="pendingBalance" :props="props" :class="">
|
||||
<div>
|
||||
{{props.row.pendingBalance ? props.row.pendingBalance : ''}}
|
||||
</div>
|
||||
</q-td>
|
||||
<q-td key="onchain address" :props="props" :class="">
|
||||
<a
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>{{props.row.onchainaddress}}</a
|
||||
>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
<q-tr v-show="props.row.expanded" :props="props">
|
||||
<q-td colspan="100%">
|
||||
<div
|
||||
v-if="props.row.onchainwallet"
|
||||
class="row items-center q-mt-md q-mb-lg"
|
||||
>
|
||||
<div class="col-2 q-pr-lg">Onchain Wallet:</div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
{{getOnchainWalletName(props.row.onchainwallet)}}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="props.row.lnbitswallet"
|
||||
class="row items-center q-mt-md q-mb-lg"
|
||||
>
|
||||
<div class="col-2 q-pr-lg">LNbits Wallet:</div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
{{getLNbitsWalletName(props.row.lnbitswallet)}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="props.row.completelink || props.row.completelinktext"
|
||||
class="row items-center q-mt-md q-mb-lg"
|
||||
>
|
||||
<div class="col-2 q-pr-lg">Completed Link:</div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
<a
|
||||
:href="props.row.completelink"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>{{props.row.completelinktext ||
|
||||
props.row.completelink}}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="props.row.webhook"
|
||||
class="row items-center q-mt-md q-mb-lg"
|
||||
>
|
||||
<div class="col-2 q-pr-lg">Webhook:</div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
<a
|
||||
:href="props.row.webhook"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>{{props.row.webhook || props.row.webhook}}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-md q-mb-lg">
|
||||
<div class="col-2 q-pr-lg">ID:</div>
|
||||
<div class="col-4 q-pr-lg">{{props.row.id}}</div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-md q-mb-lg">
|
||||
<div class="col-2 q-pr-lg"></div>
|
||||
<div class="col-6 q-pr-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="gray"
|
||||
outline
|
||||
type="a"
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
class="float-left q-mr-lg"
|
||||
>Details</q-btn
|
||||
>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="gray"
|
||||
outline
|
||||
type="a"
|
||||
@click="refreshBalance(props.row)"
|
||||
target="_blank"
|
||||
class="float-left"
|
||||
>Refresh Balance</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="pink"
|
||||
icon="cancel"
|
||||
@click="deleteChargeLink(props.row.id)"
|
||||
>Delete</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-4"></div>
|
||||
<div class="col-2 q-pr-lg"></div>
|
||||
</div>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
|
@ -155,11 +269,7 @@
|
|||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<q-dialog
|
||||
v-model="formDialogCharge.show"
|
||||
position="top"
|
||||
@hide="closeFormDialog"
|
||||
>
|
||||
<q-dialog v-model="formDialogCharge.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="sendFormDataCharge" class="q-gutter-md">
|
||||
<q-input
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
cancelCharge: function (data) {
|
||||
this.formDialogCharge.data.description = ''
|
||||
this.formDialogCharge.data.onchainwallet = ''
|
||||
this.formDialogCharge.data.lnbitswallet = ''
|
||||
this.formDialogCharge.data.time = null
|
||||
this.formDialogCharge.data.amount = null
|
||||
this.formDialogCharge.data.webhook = ''
|
||||
this.formDialogCharge.data.completelink = ''
|
||||
this.formDialogCharge.show = false
|
||||
},
|
||||
|
||||
getWalletLinks: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/wallet',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
this.walletLinks = data.map(w => ({
|
||||
id: w.id,
|
||||
label: w.title + ' - ' + w.id
|
||||
}))
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
|
||||
getWalletConfig: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/config',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
this.mempool.endpoint = data.mempool_endpoint
|
||||
const url = new URL(this.mempool.endpoint)
|
||||
this.mempool.hostname = url.hostname
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
getOnchainWalletName: function (walletId) {
|
||||
const wallet = this.walletLinks.find(w => w.id === walletId)
|
||||
if (!wallet) return 'unknown'
|
||||
return wallet.label
|
||||
},
|
||||
getLNbitsWalletName: function (walletId) {
|
||||
const wallet = this.g.user.walletOptions.find(w => w.value === walletId)
|
||||
if (!wallet) return 'unknown'
|
||||
return wallet.label
|
||||
},
|
||||
|
||||
getCharges: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charges',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
this.chargeLinks = data.map(c =>
|
||||
mapCharge(
|
||||
c,
|
||||
this.chargeLinks.find(old => old.id === c.id)
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
sendFormDataCharge: function () {
|
||||
const wallet = this.g.user.wallets[0].inkey
|
||||
const data = this.formDialogCharge.data
|
||||
data.amount = parseInt(data.amount)
|
||||
data.time = parseInt(data.time)
|
||||
data.onchainwallet = this.onchainwallet?.id
|
||||
this.createCharge(wallet, data)
|
||||
},
|
||||
refreshActiveChargesBalance: async function () {
|
||||
try {
|
||||
const activeLinkIds = this.chargeLinks
|
||||
.filter(c => !c.paid && !c.time_elapsed && !c.hasStaleBalance)
|
||||
.map(c => c.id)
|
||||
.join(',')
|
||||
if (activeLinkIds) {
|
||||
await LNbits.api.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charges/balance/' + activeLinkIds,
|
||||
'filla'
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
await this.getCharges()
|
||||
}
|
||||
},
|
||||
refreshBalance: async function (charge) {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charge/balance/' + charge.id,
|
||||
'filla'
|
||||
)
|
||||
charge.balance = data.balance
|
||||
} catch (error) {}
|
||||
},
|
||||
rescanOnchainAddresses: async function () {
|
||||
if (this.rescanning) return
|
||||
this.rescanning = true
|
||||
|
||||
const {
|
||||
bitcoin: {addresses: addressesAPI}
|
||||
} = mempoolJS({hostname: this.mempool.hostname})
|
||||
|
||||
try {
|
||||
const onchainActiveCharges = this.chargeLinks.filter(
|
||||
c => c.onchainaddress && !c.paid && !c.time_elapsed
|
||||
)
|
||||
for (const charge of onchainActiveCharges) {
|
||||
const fn = async () =>
|
||||
addressesAPI.getAddressTxsUtxo({
|
||||
address: charge.onchainaddress
|
||||
})
|
||||
|
||||
const utxos = await retryWithDelay(fn)
|
||||
const newBalance = utxos.reduce((t, u) => t + u.value, 0)
|
||||
|
||||
charge.pendingBalance = utxos
|
||||
.filter(u => !u.status.confirmed)
|
||||
.reduce((t, u) => t + u.value, 0)
|
||||
|
||||
charge.hasStaleBalance = charge.balance === newBalance
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
this.rescanning = false
|
||||
}
|
||||
},
|
||||
createCharge: async function (wallet, data) {
|
||||
try {
|
||||
const resp = await LNbits.api.request(
|
||||
'POST',
|
||||
'/satspay/api/v1/charge',
|
||||
wallet,
|
||||
data
|
||||
)
|
||||
|
||||
this.chargeLinks.unshift(mapCharge(resp.data))
|
||||
this.formDialogCharge.show = false
|
||||
this.formDialogCharge.data = {
|
||||
onchain: false,
|
||||
lnbits: false,
|
||||
description: '',
|
||||
time: null,
|
||||
amount: null
|
||||
}
|
||||
},
|
||||
qrCodeDialog: {
|
||||
show: false,
|
||||
data: null
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
cancelCharge: function (data) {
|
||||
var self = this
|
||||
self.formDialogCharge.data.description = ''
|
||||
self.formDialogCharge.data.onchainwallet = ''
|
||||
self.formDialogCharge.data.lnbitswallet = ''
|
||||
self.formDialogCharge.data.time = null
|
||||
self.formDialogCharge.data.amount = null
|
||||
self.formDialogCharge.data.webhook = ''
|
||||
self.formDialogCharge.data.completelink = ''
|
||||
self.formDialogCharge.show = false
|
||||
},
|
||||
|
||||
getWalletLinks: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/wallet',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
for (i = 0; i < response.data.length; i++) {
|
||||
self.walletLinks.push(response.data[i].id)
|
||||
}
|
||||
return
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
closeFormDialog: function () {
|
||||
this.formDialog.data = {
|
||||
is_unique: false
|
||||
}
|
||||
},
|
||||
openQrCodeDialog: function (linkId) {
|
||||
var self = this
|
||||
var getAddresses = this.getAddresses
|
||||
getAddresses(linkId)
|
||||
self.current = linkId
|
||||
self.Addresses.show = true
|
||||
},
|
||||
getCharges: function () {
|
||||
var self = this
|
||||
var getAddressBalance = this.getAddressBalance
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charges',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.ChargeLinks = response.data.map(mapCharge)
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
sendFormDataCharge: function () {
|
||||
var self = this
|
||||
var wallet = this.g.user.wallets[0].inkey
|
||||
var data = this.formDialogCharge.data
|
||||
data.amount = parseInt(data.amount)
|
||||
data.time = parseInt(data.time)
|
||||
this.createCharge(wallet, data)
|
||||
},
|
||||
timerCount: function () {
|
||||
self = this
|
||||
var refreshIntervalId = setInterval(function () {
|
||||
for (i = 0; i < self.ChargeLinks.length - 1; i++) {
|
||||
if (self.ChargeLinks[i]['paid'] == 'True') {
|
||||
setTimeout(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charges/balance/' +
|
||||
self.ChargeLinks[i]['id'],
|
||||
'filla'
|
||||
)
|
||||
.then(function (response) {})
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
self.getCharges()
|
||||
}, 20000)
|
||||
},
|
||||
createCharge: function (wallet, data) {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request('POST', '/satspay/api/v1/charge', wallet, data)
|
||||
.then(function (response) {
|
||||
self.ChargeLinks.push(mapCharge(response.data))
|
||||
self.formDialogCharge.show = false
|
||||
self.formDialogCharge.data = {
|
||||
onchain: false,
|
||||
lnbits: false,
|
||||
description: '',
|
||||
time: null,
|
||||
amount: null
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
|
||||
deleteChargeLink: function (chargeId) {
|
||||
var self = this
|
||||
var link = _.findWhere(this.ChargeLinks, {id: chargeId})
|
||||
const link = _.findWhere(this.chargeLinks, {id: chargeId})
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this pay link?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
.onOk(async () => {
|
||||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'DELETE',
|
||||
'/satspay/api/v1/charge/' + chargeId,
|
||||
self.g.user.wallets[0].adminkey
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.ChargeLinks = _.reject(self.ChargeLinks, function (obj) {
|
||||
return obj.id === chargeId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
|
||||
this.chargeLinks = _.reject(this.chargeLinks, function (obj) {
|
||||
return obj.id === chargeId
|
||||
})
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
})
|
||||
},
|
||||
exportchargeCSV: function () {
|
||||
var self = this
|
||||
LNbits.utils.exportCSV(self.ChargesTable.columns, this.ChargeLinks)
|
||||
LNbits.utils.exportCSV(
|
||||
this.chargesTable.columns,
|
||||
this.chargeLinks,
|
||||
'charges'
|
||||
)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
console.log(this.g.user)
|
||||
var self = this
|
||||
var getCharges = this.getCharges
|
||||
getCharges()
|
||||
var getWalletLinks = this.getWalletLinks
|
||||
getWalletLinks()
|
||||
var timerCount = this.timerCount
|
||||
timerCount()
|
||||
created: async function () {
|
||||
await this.getCharges()
|
||||
await this.getWalletLinks()
|
||||
await this.getWalletConfig()
|
||||
setInterval(() => this.refreshActiveChargesBalance(), 10 * 2000)
|
||||
await this.rescanOnchainAddresses()
|
||||
setInterval(() => this.rescanOnchainAddresses(), 10 * 1000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -9,6 +9,7 @@ from starlette.responses import HTMLResponse
|
|||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists
|
||||
from lnbits.extensions.watchonly.crud import get_config
|
||||
|
||||
from . import satspay_ext, satspay_renderer
|
||||
from .crud import get_charge
|
||||
|
@ -24,14 +25,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,
|
||||
},
|
||||
)
|
||||
|
|
|
@ -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},
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -8,15 +8,16 @@ 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)
|
||||
- see [BIP44](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki#account-discovery) for more details
|
||||
- same `xPub` will always generate the same addresses (deterministic)
|
||||
- when a `Wallet Account` is created, there are generated `20 Receive Addresses` and `5 Change Address`
|
||||
- the limits can be change from the `Config` page (see `screenshot 1`)
|
||||
- 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
|
||||
- it can be refreshed
|
||||
- warnings are shown if the fee is too Low or to High
|
||||
- `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:
|
||||

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

|
||||

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

|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;")
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
|
@ -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 () {}
|
||||
})
|
||||
}
|
|
@ -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>
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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>
|
|
@ -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 () {}
|
||||
})
|
||||
}
|
|
@ -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>
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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>
|
336
lnbits/extensions/watchonly/static/components/payment/payment.js
Normal file
336
lnbits/extensions/watchonly/static/components/payment/payment.js
Normal 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 () {}
|
||||
})
|
||||
}
|
|
@ -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>
|
|
@ -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 () {}
|
||||
})
|
||||
}
|
|
@ -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>
|
|
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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>
|
|
@ -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 () {}
|
||||
})
|
||||
}
|
|
@ -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
|
||||
>
|
|
@ -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 () {}
|
||||
})
|
||||
}
|
|
@ -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>
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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>
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -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
|
||||
? Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
: ''
|
||||
obj.label = obj.title // for drop-downs
|
||||
obj.expanded = false
|
||||
return obj
|
||||
const mapWalletAccount = function (o) {
|
||||
return Object.assign({}, o, {
|
||||
date: o.time
|
||||
? Quasar.utils.date.formatDate(
|
||||
new Date(o.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
: '',
|
||||
label: o.title,
|
||||
expanded: false
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
},
|
||||
|
|
|
@ -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
|
@ -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()
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
143
lnbits/wallets/cliche.py
Normal 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)
|
|
@ -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:
|
||||
raise Unsupported("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:
|
||||
r = self.ln.call("listpays", {"payment_hash": checking_id})
|
||||
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:
|
||||
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"]
|
||||
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["payment_hash"]
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"lost connection to cln invoices stream: '{exc}', retrying in 5 seconds"
|
||||
)
|
||||
await asyncio.sleep(5)
|
|
@ -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 ""
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
665
lnbits/wallets/lnd_grpc_files/router_pb2.py
Normal file
665
lnbits/wallets/lnd_grpc_files/router_pb2.py
Normal file
File diff suppressed because one or more lines are too long
871
lnbits/wallets/lnd_grpc_files/router_pb2_grpc.py
Normal file
871
lnbits/wallets/lnd_grpc_files/router_pb2_grpc.py
Normal 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,
|
||||
)
|
|
@ -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,20 +208,55 @@ 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]:
|
||||
request = ln.InvoiceSubscription()
|
||||
try:
|
||||
async for i in self.rpc.SubscribeInvoices(request):
|
||||
if not i.settled:
|
||||
continue
|
||||
while True:
|
||||
request = ln.InvoiceSubscription()
|
||||
try:
|
||||
async for i in self.rpc.SubscribeInvoices(request):
|
||||
if not i.settled:
|
||||
continue
|
||||
|
||||
checking_id = stringify_checking_id(i.r_hash)
|
||||
yield checking_id
|
||||
except error:
|
||||
logger.error(error)
|
||||
|
||||
logger.error(
|
||||
"lost connection to lnd InvoiceSubscription, please restart lnbits."
|
||||
)
|
||||
checking_id = stringify_checking_id(i.r_hash)
|
||||
yield checking_id
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"lost connection to lnd invoices stream: '{exc}', retrying in 5 seconds"
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
|
|
|
@ -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
|
||||
|
||||
logger.error(
|
||||
"lost connection to lnd invoices stream, retrying in 5 seconds"
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"lost connection to lnd invoices stream: '{exc}', retrying in 5 seconds"
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
|
|
|
@ -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 ""
|
||||
|
||||
|
|
|
@ -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 ""
|
||||
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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
Loading…
Add table
Reference in a new issue