mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2025-02-26 07:31:22 +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
|
||||
|
|
96
.github/workflows/tests.yml
vendored
96
.github/workflows/tests.yml
vendored
|
@ -4,71 +4,6 @@ on: [push, pull_request]
|
|||
|
||||
jobs:
|
||||
venv-sqlite:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.7, 3.8, 3.9]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
env:
|
||||
VIRTUAL_ENV: ./venv
|
||||
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
||||
run: |
|
||||
python -m venv ${{ env.VIRTUAL_ENV }}
|
||||
./venv/bin/python -m pip install --upgrade pip
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
|
||||
- name: Run tests
|
||||
run: make test
|
||||
venv-postgres:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
# maps tcp port 5432 on service container to the host
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.8]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
env:
|
||||
VIRTUAL_ENV: ./venv
|
||||
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
||||
run: |
|
||||
python -m venv ${{ env.VIRTUAL_ENV }}
|
||||
./venv/bin/python -m pip install --upgrade pip
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
|
||||
- name: Run tests
|
||||
env:
|
||||
LNBITS_DATABASE_URL: postgres://postgres:postgres@0.0.0.0:5432/postgres
|
||||
run: make test
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
poetry-sqlite:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
|
@ -88,9 +23,29 @@ jobs:
|
|||
./venv/bin/python -m pip install --upgrade pip
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
|
||||
- name: Run tests
|
||||
run: make test-venv
|
||||
sqlite:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.9]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: abatilo/actions-poetry@v2.1.3
|
||||
- name: Install dependencies
|
||||
env:
|
||||
VIRTUAL_ENV: ./venv
|
||||
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
run: make test
|
||||
poetry-postgres:
|
||||
postgres:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
|
@ -116,15 +71,10 @@ jobs:
|
|||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: abatilo/actions-poetry@v2.1.3
|
||||
- name: Install dependencies
|
||||
env:
|
||||
VIRTUAL_ENV: ./venv
|
||||
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
||||
run: |
|
||||
python -m venv ${{ env.VIRTUAL_ENV }}
|
||||
./venv/bin/python -m pip install --upgrade pip
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
|
||||
poetry install
|
||||
- name: Run tests
|
||||
env:
|
||||
LNBITS_DATABASE_URL: postgres://postgres:postgres@0.0.0.0:5432/postgres
|
||||
|
|
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
|
||||
|
|
19
build.py
19
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"])
|
||||
|
|
|
@ -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,12 +16,22 @@ By default, LNbits will use SQLite as its database. You can also use PostgreSQL
|
|||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits-legend/
|
||||
|
||||
# for making sure python 3.9 is installed, skip if installed
|
||||
sudo apt update
|
||||
sudo apt install software-properties-common
|
||||
sudo add-apt-repository ppa:deadsnakes/ppa
|
||||
sudo apt install python3.9
|
||||
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
poetry install
|
||||
export PATH="/home/ubuntu/.local/bin:$PATH" # or whatever is suggested in the poetry install notes printed to terminal
|
||||
poetry env use python3.9
|
||||
poetry install --no-dev
|
||||
|
||||
mkdir data
|
||||
cp .env.example .env
|
||||
sudo nano .env # set funding source
|
||||
|
||||
# You may need to install python 3.9, update your python following this guide https://linuxize.com/post/how-to-install-python-3-9-on-ubuntu-20-04/
|
||||
|
||||
mkdir data && cp .env.example .env
|
||||
```
|
||||
|
||||
#### Running the server
|
||||
|
@ -76,6 +84,17 @@ mkdir data && cp .env.example .env
|
|||
|
||||
If you want to host LNbits on the internet, run with the option `--host 0.0.0.0`.
|
||||
|
||||
## Option 4: Docker
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits-legend
|
||||
docker build -t lnbits-legend .
|
||||
cp .env.example .env
|
||||
mkdir data
|
||||
docker run --detach --publish 5000:5000 --name lnbits-legend --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits-legend
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
Problems installing? These commands have helped us install LNbits.
|
||||
|
@ -213,16 +232,22 @@ chmod +x mkcert-v*-linux-amd64
|
|||
sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert
|
||||
```
|
||||
#### Create certificate
|
||||
To create a certificate, first `cd` into your lnbits folder and execute the following command ([more info](https://kifarunix.com/how-to-create-self-signed-ssl-certificate-with-mkcert-on-ubuntu-18-04/))
|
||||
To create a certificate, first `cd` into your LNbits folder and execute the following command on Linux:
|
||||
```sh
|
||||
openssl req -new -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out cert.pem -keyout key.pem
|
||||
```
|
||||
This will create two new files (`key.pem` and `cert.pem `).
|
||||
|
||||
Alternatively, you can use mkcert ([more info](https://kifarunix.com/how-to-create-self-signed-ssl-certificate-with-mkcert-on-ubuntu-18-04/)):
|
||||
```sh
|
||||
# add your local IP (192.x.x.x) as well if you want to use it in your local network
|
||||
mkcert localhost 127.0.0.1 ::1
|
||||
```
|
||||
|
||||
This will create two new files (`localhost-key.pem` and `localhost.pem `) which you can then pass to uvicorn when you start LNbits:
|
||||
You can then pass the certificate files to uvicorn when you start LNbits:
|
||||
|
||||
```sh
|
||||
./venv/bin/uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./localhost-key.pem --ssl-certfile ./localhost.pem
|
||||
./venv/bin/uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./key.pem --ssl-certfile ./cert.pem
|
||||
```
|
||||
|
||||
|
||||
|
@ -235,9 +260,9 @@ If you want to run LNbits on your Umbrel but want it to be reached through clear
|
|||
To install using docker you first need to build the docker image as:
|
||||
|
||||
```
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits/ # ${PWD} referred as <lnbits_repo>
|
||||
docker build -t lnbits .
|
||||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits-legend
|
||||
docker build -t lnbits-legend .
|
||||
```
|
||||
|
||||
You can launch the docker in a different directory, but make sure to copy `.env.example` from lnbits there
|
||||
|
@ -248,17 +273,15 @@ cp <lnbits_repo>/.env.example .env
|
|||
|
||||
and change the configuration in `.env` as required.
|
||||
|
||||
Then create the data directory for the user ID 1000, which is the user that runs the lnbits within the docker container.
|
||||
|
||||
Then create the data directory
|
||||
```
|
||||
mkdir data
|
||||
sudo chown 1000:1000 ./data/
|
||||
```
|
||||
|
||||
Then the image can be run as:
|
||||
|
||||
```
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits
|
||||
docker run --detach --publish 5000:5000 --name lnbits-legend -e "LNBITS_BACKEND_WALLET_CLASS='FakeWallet'" --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits-legend
|
||||
```
|
||||
|
||||
Finally you can access your lnbits on your machine at port 5000.
|
||||
|
|
|
@ -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,12 +337,15 @@ async def perform_lnurlauth(
|
|||
)
|
||||
|
||||
|
||||
async def check_invoice_status(
|
||||
async def check_transaction_status(
|
||||
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
|
||||
) -> PaymentStatus:
|
||||
payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
||||
if not payment:
|
||||
return PaymentStatus(None)
|
||||
if payment.is_out:
|
||||
status = await WALLET.get_payment_status(payment.checking_id)
|
||||
else:
|
||||
status = await WALLET.get_invoice_status(payment.checking_id)
|
||||
if not payment.pending:
|
||||
return status
|
||||
|
|
|
@ -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(
|
||||
(
|
||||
description_hash=(
|
||||
LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
|
||||
).encode("utf-8")
|
||||
).digest(),
|
||||
).encode("utf-8"),
|
||||
extra={"tag": "copilot", "copilotid": cp.id, "comment": comment},
|
||||
)
|
||||
payResponse = {"pr": payment_request, "routes": []}
|
||||
|
|
|
@ -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,36 +1,42 @@
|
|||
{% extends "public.html" %} {% block page %}
|
||||
<div class="q-pa-sm theCard">
|
||||
<q-card class="my-card">
|
||||
<div class="column">
|
||||
<center>
|
||||
<div class="col theHeading">{{ charge.description }}</div>
|
||||
</center>
|
||||
<div class="col">
|
||||
<div class="row items-center q-mt-md">
|
||||
<div class="col-lg-4 col-md-3 col-sm-1"></div>
|
||||
<div class="col-lg-4 col-md-6 col-sm-10">
|
||||
<q-card>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col text-center q-mt-md">
|
||||
<span class="text-h4" v-text="charge.description"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<div
|
||||
class="col"
|
||||
color="white"
|
||||
style="background-color: grey; height: 30px; padding: 5px"
|
||||
v-if="timetoComplete < 1"
|
||||
v-if="!charge.timeLeft"
|
||||
>
|
||||
<center>Time elapsed</center>
|
||||
Time elapsed
|
||||
</div>
|
||||
<div
|
||||
class="col"
|
||||
color="white"
|
||||
style="background-color: grey; height: 30px; padding: 5px"
|
||||
v-else-if="charge_paid == 'True'"
|
||||
v-else-if="charge.paid"
|
||||
>
|
||||
<center>Charge paid</center>
|
||||
Charge paid
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-linear-progress size="30px" :value="newProgress" color="grey">
|
||||
<q-linear-progress
|
||||
size="30px"
|
||||
:value="charge.progress"
|
||||
color="secondary"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item style="padding: 3px">
|
||||
<q-spinner color="white" size="0.8em"></q-spinner
|
||||
><span style="font-size: 15px; color: white"
|
||||
><span class="q-pr-xl q-pl-md"> Awaiting payment...</span>
|
||||
<span class="q-pl-xl" style="color: white">
|
||||
{% raw %} {{ newTimeLeft }} {% endraw %}</span
|
||||
{% raw %} {{ charge.timeLeft }} {% endraw %}</span
|
||||
></span
|
||||
>
|
||||
</q-item>
|
||||
|
@ -38,28 +44,72 @@
|
|||
</q-linear-progress>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col" style="margin: 2px 15px; max-height: 100px">
|
||||
<center>
|
||||
<q-btn flat dense outline @click="copyText('{{ charge.id }}')"
|
||||
>Charge ID: {{ charge.id }}</q-btn
|
||||
</div>
|
||||
<div class="row q-ml-md q-mt-md q-mb-lg">
|
||||
<div class="col">
|
||||
<div class="row">
|
||||
<div class="col-4 q-pr-lg">Charge Id:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-btn flat dense outline @click="copyText(charge.id)"
|
||||
><span v-text="charge.id"></span
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center">
|
||||
<div class="col-4 q-pr-lg">Total to pay:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-badge color="blue">
|
||||
<span v-text="charge.amount" class="text-subtitle2"></span> sat
|
||||
</q-badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-sm">
|
||||
<div class="col-4 q-pr-lg">Amount paid:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-badge color="orange">
|
||||
<span v-text="charge.balance" class="text-subtitle2"></span>
|
||||
sat</q-badge
|
||||
>
|
||||
</center>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pendingFunds" class="row items-center q-mt-sm">
|
||||
<div class="col-4 q-pr-lg">Amount pending:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-badge color="gray">
|
||||
<span v-text="pendingFunds" class="text-subtitle2"></span> sat
|
||||
</q-badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-sm">
|
||||
<div class="col-4 q-pr-lg">Amount due:</div>
|
||||
<div class="col-8 q-pr-lg">
|
||||
<q-badge v-if="charge.amount - charge.balance > 0" color="green">
|
||||
<span
|
||||
><small
|
||||
>{% raw %} Total to pay: {{ charge_amount }}sats<br />
|
||||
Amount paid: {{ charge_balance }}</small
|
||||
><br />
|
||||
Amount due: {{ charge_amount - charge_balance }}sats {% endraw %}
|
||||
</span>
|
||||
v-text="charge.amount - charge.balance"
|
||||
class="text-subtitle2"
|
||||
></span>
|
||||
sat
|
||||
</q-badge>
|
||||
<q-badge
|
||||
v-else="charge.amount - charge.balance <= 0"
|
||||
color="green"
|
||||
class="text-subtitle2"
|
||||
>
|
||||
none</q-badge
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
flat
|
||||
disable
|
||||
v-if="'{{ charge.lnbitswallet }}' == 'None' || charge_time_elapsed == 'True'"
|
||||
v-if="!charge.lnbitswallet || charge.time_elapsed"
|
||||
style="color: primary; width: 100%"
|
||||
label="lightning⚡"
|
||||
>
|
||||
|
@ -70,7 +120,7 @@
|
|||
<q-btn
|
||||
flat
|
||||
v-else
|
||||
@click="payLN"
|
||||
@click="payInvoice"
|
||||
style="color: primary; width: 100%"
|
||||
label="lightning⚡"
|
||||
>
|
||||
|
@ -81,7 +131,7 @@
|
|||
<q-btn
|
||||
flat
|
||||
disable
|
||||
v-if="'{{ charge.onchainwallet }}' == 'None' || charge_time_elapsed == 'True'"
|
||||
v-if="!charge.onchainwallet || charge.time_elapsed"
|
||||
style="color: primary; width: 100%"
|
||||
label="onchain⛓️"
|
||||
>
|
||||
|
@ -92,7 +142,7 @@
|
|||
<q-btn
|
||||
flat
|
||||
v-else
|
||||
@click="payON"
|
||||
@click="payOnchain"
|
||||
style="color: primary; width: 100%"
|
||||
label="onchain⛓️"
|
||||
>
|
||||
|
@ -103,121 +153,147 @@
|
|||
<q-separator></q-separator>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
<q-card class="q-pa-lg" v-if="lnbtc">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="text-center q-pt-md">
|
||||
<div v-if="timetoComplete < 1 && charge_paid == 'False'">
|
||||
<div class="row items-center q-mt-sm">
|
||||
<div class="col-md-2 col-sm-0"></div>
|
||||
<div class="col-md-8 col-sm-12">
|
||||
<div v-if="!charge.timeLeft && !charge.paid">
|
||||
<q-icon
|
||||
name="block"
|
||||
style="color: #ccc; font-size: 21.4em"
|
||||
></q-icon>
|
||||
</div>
|
||||
<div v-else-if="charge_paid == 'True'">
|
||||
<div v-else-if="charge.paid">
|
||||
<q-icon
|
||||
name="check"
|
||||
style="color: green; font-size: 21.4em"
|
||||
></q-icon>
|
||||
<q-btn
|
||||
outline
|
||||
v-if="'{{ charge.webhook }}' != 'None'"
|
||||
v-if="charge.webhook"
|
||||
type="a"
|
||||
href="{{ charge.completelink }}"
|
||||
label="{{ charge.completelinktext }}"
|
||||
:href="charge.completelink"
|
||||
:label="charge.completelinktext"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<center>
|
||||
<div class="row text-center q-mb-sm">
|
||||
<div class="col text-center">
|
||||
<span class="text-subtitle2"
|
||||
>Pay this <br />
|
||||
lightning-network invoice</span
|
||||
>Pay this lightning-network invoice:</span
|
||||
>
|
||||
</center>
|
||||
<a href="lightning:{{ charge.payment_request }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a :href="'lightning:'+charge.payment_request">
|
||||
<q-responsive :ratio="1" class="q-mx-md">
|
||||
<qrcode
|
||||
:value="'{{ charge.payment_request }}'"
|
||||
:value="charge.payment_request"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
<div class="row q-mt-lg">
|
||||
<div class="row text-center q-mt-lg">
|
||||
<div class="col text-center">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText('{{ charge.payment_request }}')"
|
||||
@click="copyText(charge.payment_request)"
|
||||
>Copy invoice</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-0"></div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card class="q-pa-lg" v-if="onbtc">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="text-center q-pt-md">
|
||||
<div v-if="timetoComplete < 1 && charge_paid == 'False'">
|
||||
<div v-if="charge.timeLeft && !charge.paid" class="row items-center">
|
||||
<div class="col text-center">
|
||||
<a
|
||||
style="color: unset"
|
||||
:href="mempool_endpoint + '/address/' + charge.onchainaddress"
|
||||
target="_blank"
|
||||
><span
|
||||
class="text-subtitle1"
|
||||
v-text="charge.onchainaddress"
|
||||
></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-md">
|
||||
<div class="col-md-2 col-sm-0"></div>
|
||||
<div class="col-md-8 col-sm-12 text-center">
|
||||
<div v-if="!charge.timeLeft && !charge.paid">
|
||||
<q-icon
|
||||
name="block"
|
||||
style="color: #ccc; font-size: 21.4em"
|
||||
></q-icon>
|
||||
</div>
|
||||
<div v-else-if="charge_paid == 'True'">
|
||||
<div v-else-if="charge.paid">
|
||||
<q-icon
|
||||
name="check"
|
||||
style="color: green; font-size: 21.4em"
|
||||
></q-icon>
|
||||
<q-btn
|
||||
outline
|
||||
v-if="'{{ charge.webhook }}' != None"
|
||||
v-if="charge.webhook"
|
||||
type="a"
|
||||
href="{{ charge.completelink }}"
|
||||
label="{{ charge.completelinktext }}"
|
||||
:href="charge.completelink"
|
||||
:label="charge.completelinktext"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<center>
|
||||
<div class="row items-center q-mb-sm">
|
||||
<div class="col text-center">
|
||||
<span class="text-subtitle2"
|
||||
>Send {{ charge.amount }}sats<br />
|
||||
to this onchain address</span
|
||||
>Send
|
||||
|
||||
<span v-text="charge.amount"></span>
|
||||
sats to this onchain address</span
|
||||
>
|
||||
</center>
|
||||
<a href="bitcoin:{{ charge.onchainaddress }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a :href="'bitcoin:'+charge.onchainaddress">
|
||||
<q-responsive :ratio="1" class="q-mx-md">
|
||||
<qrcode
|
||||
:value="'{{ charge.onchainaddress }}'"
|
||||
:value="charge.onchainaddress"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
<div class="row q-mt-lg">
|
||||
<div class="row items-center q-mt-lg">
|
||||
<div class="col text-center">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText('{{ charge.onchainaddress }}')"
|
||||
@click="copyText(charge.onchainaddress)"
|
||||
>Copy address</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-0"></div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-lg- 4 col-md-3 col-sm-1"></div>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %}
|
||||
|
||||
<style>
|
||||
.theCard {
|
||||
width: 360px;
|
||||
margin: 10px auto;
|
||||
}
|
||||
.theHeading {
|
||||
margin: 15px;
|
||||
font-size: 25px;
|
||||
}
|
||||
</style>
|
||||
<script src="https://mempool.space/mempool.js"></script>
|
||||
<script src="{{ url_for('satspay_static', path='js/utils.js') }}"></script>
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
|
@ -226,16 +302,14 @@
|
|||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
charge: JSON.parse('{{charge_data | tojson}}'),
|
||||
mempool_endpoint: '{{mempool_endpoint}}',
|
||||
pendingFunds: 0,
|
||||
ws: null,
|
||||
newProgress: 0.4,
|
||||
counter: 1,
|
||||
newTimeLeft: '',
|
||||
timetoComplete: 100,
|
||||
lnbtc: true,
|
||||
onbtc: false,
|
||||
charge_time_elapsed: '{{charge.time_elapsed}}',
|
||||
charge_amount: '{{charge.amount}}',
|
||||
charge_balance: '{{charge.balance}}',
|
||||
charge_paid: '{{charge.paid}}',
|
||||
wallet: {
|
||||
inkey: ''
|
||||
},
|
||||
|
@ -245,90 +319,141 @@
|
|||
methods: {
|
||||
startPaymentNotifier() {
|
||||
this.cancelListener()
|
||||
|
||||
this.cancelListener = LNbits.event.onInvoicePaid(
|
||||
if (!this.lnbitswallet) return
|
||||
this.cancelListener = LNbits.events.onInvoicePaid(
|
||||
this.wallet,
|
||||
payment => {
|
||||
this.checkBalance()
|
||||
this.checkInvoiceBalance()
|
||||
}
|
||||
)
|
||||
},
|
||||
checkBalance: function () {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
checkBalances: async function () {
|
||||
if (!this.charge.hasStaleBalance) await this.refreshCharge()
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charges/balance/{{ charge.id }}',
|
||||
'filla'
|
||||
`/satspay/api/v1/charge/balance/${this.charge.id}`
|
||||
)
|
||||
.then(function (response) {
|
||||
self.charge_time_elapsed = response.data.time_elapsed
|
||||
self.charge_amount = response.data.amount
|
||||
self.charge_balance = response.data.balance
|
||||
if (self.charge_balance >= self.charge_amount) {
|
||||
self.charge_paid = 'True'
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
this.charge = mapCharge(data, this.charge)
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}
|
||||
},
|
||||
payLN: function () {
|
||||
refreshCharge: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/satspay/api/v1/charge/${this.charge.id}`
|
||||
)
|
||||
this.charge = mapCharge(data, this.charge)
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
checkPendingOnchain: async function () {
|
||||
const {
|
||||
bitcoin: {addresses: addressesAPI}
|
||||
} = mempoolJS({
|
||||
hostname: new URL(this.mempool_endpoint).hostname
|
||||
})
|
||||
|
||||
try {
|
||||
const utxos = await addressesAPI.getAddressTxsUtxo({
|
||||
address: this.charge.onchainaddress
|
||||
})
|
||||
const newBalance = utxos.reduce((t, u) => t + u.value, 0)
|
||||
this.charge.hasStaleBalance = this.charge.balance === newBalance
|
||||
|
||||
this.pendingFunds = utxos
|
||||
.filter(u => !u.status.confirmed)
|
||||
.reduce((t, u) => t + u.value, 0)
|
||||
} catch (error) {
|
||||
console.error('cannot check pending funds')
|
||||
}
|
||||
},
|
||||
payInvoice: function () {
|
||||
this.lnbtc = true
|
||||
this.onbtc = false
|
||||
},
|
||||
payON: function () {
|
||||
payOnchain: function () {
|
||||
this.lnbtc = false
|
||||
this.onbtc = true
|
||||
},
|
||||
getTheTime: function () {
|
||||
var timeToComplete =
|
||||
parseInt('{{ charge.time }}') * 60 -
|
||||
(Date.now() / 1000 - parseInt('{{ charge.timestamp }}'))
|
||||
this.timetoComplete = timeToComplete
|
||||
var timeLeft = Quasar.utils.date.formatDate(
|
||||
new Date((timeToComplete - 3600) * 1000),
|
||||
'HH:mm:ss'
|
||||
)
|
||||
this.newTimeLeft = timeLeft
|
||||
},
|
||||
getThePercentage: function () {
|
||||
var timeToComplete =
|
||||
parseInt('{{ charge.time }}') * 60 -
|
||||
(Date.now() / 1000 - parseInt('{{ charge.timestamp }}'))
|
||||
this.newProgress =
|
||||
1 - timeToComplete / (parseInt('{{ charge.time }}') * 60)
|
||||
},
|
||||
|
||||
timerCount: function () {
|
||||
self = this
|
||||
var refreshIntervalId = setInterval(function () {
|
||||
if (self.charge_paid == 'True' || self.timetoComplete < 1) {
|
||||
loopRefresh: function () {
|
||||
// invoice only
|
||||
const refreshIntervalId = setInterval(async () => {
|
||||
if (this.charge.paid || !this.charge.timeLeft) {
|
||||
clearInterval(refreshIntervalId)
|
||||
}
|
||||
self.getTheTime()
|
||||
self.getThePercentage()
|
||||
self.counter++
|
||||
if (self.counter % 10 === 0) {
|
||||
self.checkBalance()
|
||||
if (this.counter % 10 === 0) {
|
||||
await this.checkBalances()
|
||||
await this.checkPendingOnchain()
|
||||
}
|
||||
this.counter++
|
||||
}, 1000)
|
||||
},
|
||||
initWs: async function () {
|
||||
const {
|
||||
bitcoin: {websocket}
|
||||
} = mempoolJS({
|
||||
hostname: new URL(this.mempool_endpoint).hostname
|
||||
})
|
||||
|
||||
this.ws = new WebSocket('wss://mempool.space/api/v1/ws')
|
||||
this.ws.addEventListener('open', x => {
|
||||
if (this.charge.onchainaddress) {
|
||||
this.trackAddress(this.charge.onchainaddress)
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.addEventListener('message', async ({data}) => {
|
||||
const res = JSON.parse(data.toString())
|
||||
if (res['address-transactions']) {
|
||||
await this.checkBalances()
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'New payment received!',
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
loopPingWs: function () {
|
||||
setInterval(() => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) this.initWs()
|
||||
this.ws.send(JSON.stringify({action: 'ping'}))
|
||||
}, 30 * 1000)
|
||||
},
|
||||
trackAddress: async function (address, retry = 0) {
|
||||
try {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) this.initWs()
|
||||
this.ws.send(JSON.stringify({'track-address': address}))
|
||||
} catch (error) {
|
||||
await sleep(1000)
|
||||
if (retry > 10) throw error
|
||||
this.trackAddress(address, retry + 1)
|
||||
}
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
console.log('{{ charge.onchainaddress }}' == 'None')
|
||||
if ('{{ charge.lnbitswallet }}' == 'None') {
|
||||
this.lnbtc = false
|
||||
this.onbtc = true
|
||||
}
|
||||
created: async function () {
|
||||
if (this.charge.lnbitswallet) this.payInvoice()
|
||||
else this.payOnchain()
|
||||
await this.checkBalances()
|
||||
|
||||
// empty for onchain
|
||||
this.wallet.inkey = '{{ wallet_inkey }}'
|
||||
this.getTheTime()
|
||||
this.getThePercentage()
|
||||
var timerCount = this.timerCount
|
||||
if ('{{ charge.paid }}' == 'False') {
|
||||
timerCount()
|
||||
}
|
||||
this.startPaymentNotifier()
|
||||
|
||||
if (!this.charge.paid) {
|
||||
this.loopRefresh()
|
||||
}
|
||||
|
||||
if (this.charge.onchainaddress) {
|
||||
this.loopPingWs()
|
||||
this.checkPendingOnchain()
|
||||
this.trackAddress(this.charge.onchainaddress)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -18,46 +18,54 @@
|
|||
<h5 class="text-subtitle1 q-my-none">Charges</h5>
|
||||
</div>
|
||||
|
||||
<div class="col-auto">
|
||||
<div class="col q-pr-lg">
|
||||
<q-input
|
||||
borderless
|
||||
dense
|
||||
debounce="300"
|
||||
v-model="filter"
|
||||
placeholder="Search"
|
||||
class="float-right"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon name="search"></q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-btn flat color="grey" @click="exportchargeCSV"
|
||||
>Export to CSV</q-btn
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn outline color="grey" label="...">
|
||||
<q-menu auto-close>
|
||||
<q-list style="min-width: 100px">
|
||||
<q-item clickable>
|
||||
<q-item-section @click="exportchargeCSV"
|
||||
>Export to CSV</q-item-section
|
||||
>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
flat
|
||||
dense
|
||||
:data="ChargeLinks"
|
||||
:data="chargeLinks"
|
||||
row-key="id"
|
||||
:columns="ChargesTable.columns"
|
||||
:pagination.sync="ChargesTable.pagination"
|
||||
:columns="chargesTable.columns"
|
||||
:pagination.sync="chargesTable.pagination"
|
||||
:filter="filter"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width></q-th>
|
||||
|
||||
<q-th
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
auto-width
|
||||
>
|
||||
<div v-if="col.name == 'id'"></div>
|
||||
<div v-else>{{ col.label }}</div>
|
||||
</q-th>
|
||||
<q-th auto-width>Status </q-th>
|
||||
<q-th auto-width>Title</q-th>
|
||||
<q-th auto-width>Time Left (hh:mm)</q-th>
|
||||
<q-th auto-width>Time To Pay (hh:mm)</q-th>
|
||||
<q-th auto-width>Amount To Pay</q-th>
|
||||
<q-th auto-width>Balance</q-th>
|
||||
<q-th auto-width>Pending Balance</q-th>
|
||||
<q-th auto-width>Onchain Address</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
|
@ -66,73 +74,179 @@
|
|||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
unelevated
|
||||
size="sm"
|
||||
color="accent"
|
||||
round
|
||||
dense
|
||||
size="xs"
|
||||
icon="link"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
@click="props.row.expanded= !props.row.expanded"
|
||||
:icon="props.row.expanded? 'remove' : 'add'"
|
||||
/>
|
||||
</q-td>
|
||||
|
||||
<q-td auto-width>
|
||||
<q-badge
|
||||
v-if="props.row.time_elapsed && props.row.balance < props.row.amount"
|
||||
color="red"
|
||||
>
|
||||
<a
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>expired</a
|
||||
>
|
||||
</q-badge>
|
||||
|
||||
<q-badge
|
||||
v-else-if="props.row.balance >= props.row.amount"
|
||||
color="green"
|
||||
>
|
||||
<a
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>paid</a
|
||||
>
|
||||
</q-badge>
|
||||
|
||||
<q-badge v-else color="blue"
|
||||
><a
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>waiting</a
|
||||
>
|
||||
</q-badge>
|
||||
</q-td>
|
||||
<q-td key="description" :props="props" :class="">
|
||||
<a
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>{{props.row.description}}</a
|
||||
>
|
||||
</q-td>
|
||||
<q-td key="timeLeft" :props="props" :class="">
|
||||
<div>{{props.row.timeLeft}}</div>
|
||||
<q-linear-progress
|
||||
v-if="props.row.timeLeft"
|
||||
:value="props.row.progress"
|
||||
color="secondary"
|
||||
>
|
||||
</q-linear-progress>
|
||||
</q-td>
|
||||
<q-td key="time to pay" :props="props" :class="">
|
||||
<div>{{props.row.time}}</div>
|
||||
</q-td>
|
||||
<q-td key="amount" :props="props" :class="">
|
||||
<div>{{props.row.amount}}</div>
|
||||
</q-td>
|
||||
<q-td key="balance" :props="props" :class="">
|
||||
<div>{{props.row.balance}}</div>
|
||||
</q-td>
|
||||
<q-td key="pendingBalance" :props="props" :class="">
|
||||
<div>
|
||||
{{props.row.pendingBalance ? props.row.pendingBalance : ''}}
|
||||
</div>
|
||||
</q-td>
|
||||
<q-td key="onchain address" :props="props" :class="">
|
||||
<a
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>{{props.row.onchainaddress}}</a
|
||||
>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
<q-tr v-show="props.row.expanded" :props="props">
|
||||
<q-td colspan="100%">
|
||||
<div
|
||||
v-if="props.row.onchainwallet"
|
||||
class="row items-center q-mt-md q-mb-lg"
|
||||
>
|
||||
<div class="col-2 q-pr-lg">Onchain Wallet:</div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
{{getOnchainWalletName(props.row.onchainwallet)}}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="props.row.lnbitswallet"
|
||||
class="row items-center q-mt-md q-mb-lg"
|
||||
>
|
||||
<div class="col-2 q-pr-lg">LNbits Wallet:</div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
{{getLNbitsWalletName(props.row.lnbitswallet)}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="props.row.completelink || props.row.completelinktext"
|
||||
class="row items-center q-mt-md q-mb-lg"
|
||||
>
|
||||
<div class="col-2 q-pr-lg">Completed Link:</div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
<a
|
||||
:href="props.row.completelink"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>{{props.row.completelinktext ||
|
||||
props.row.completelink}}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="props.row.webhook"
|
||||
class="row items-center q-mt-md q-mb-lg"
|
||||
>
|
||||
<div class="col-2 q-pr-lg">Webhook:</div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
<a
|
||||
:href="props.row.webhook"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>{{props.row.webhook || props.row.webhook}}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-md q-mb-lg">
|
||||
<div class="col-2 q-pr-lg">ID:</div>
|
||||
<div class="col-4 q-pr-lg">{{props.row.id}}</div>
|
||||
</div>
|
||||
<div class="row items-center q-mt-md q-mb-lg">
|
||||
<div class="col-2 q-pr-lg"></div>
|
||||
<div class="col-6 q-pr-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="gray"
|
||||
outline
|
||||
type="a"
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
class="float-left q-mr-lg"
|
||||
>Details</q-btn
|
||||
>
|
||||
<q-tooltip> Payment link </q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
v-if="props.row.time_elapsed && props.row.balance < props.row.amount"
|
||||
unelevated
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
icon="error"
|
||||
:color="($q.dark.isActive) ? 'red' : 'red'"
|
||||
color="gray"
|
||||
outline
|
||||
type="a"
|
||||
@click="refreshBalance(props.row)"
|
||||
target="_blank"
|
||||
class="float-left"
|
||||
>Refresh Balance</q-btn
|
||||
>
|
||||
<q-tooltip> Time elapsed </q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
</div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
<q-btn
|
||||
v-else-if="props.row.balance >= props.row.amount"
|
||||
unelevated
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
icon="check"
|
||||
:color="($q.dark.isActive) ? 'green' : 'green'"
|
||||
>
|
||||
<q-tooltip> PAID! </q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="cached"
|
||||
flat
|
||||
:color="($q.dark.isActive) ? 'blue' : 'blue'"
|
||||
>
|
||||
<q-tooltip> Processing </q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteChargeLink(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
icon="cancel"
|
||||
@click="deleteChargeLink(props.row.id)"
|
||||
>Delete</q-btn
|
||||
>
|
||||
<q-tooltip> Delete charge </q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
auto-width
|
||||
>
|
||||
<div v-if="col.name == 'id'"></div>
|
||||
<div v-else>{{ col.value }}</div>
|
||||
</div>
|
||||
<div class="col-4"></div>
|
||||
<div class="col-2 q-pr-lg"></div>
|
||||
</div>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
|
@ -155,11 +269,7 @@
|
|||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<q-dialog
|
||||
v-model="formDialogCharge.show"
|
||||
position="top"
|
||||
@hide="closeFormDialog"
|
||||
>
|
||||
<q-dialog v-model="formDialogCharge.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="sendFormDataCharge" class="q-gutter-md">
|
||||
<q-input
|
||||
|
@ -246,7 +356,7 @@
|
|||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialogCharge.data.onchainwallet"
|
||||
v-model="onchainwallet"
|
||||
:options="walletLinks"
|
||||
label="Onchain Wallet"
|
||||
/>
|
||||
|
@ -284,49 +394,28 @@
|
|||
<!-- lnbits/static/vendor
|
||||
<script src="/vendor/vue-qrcode@1.0.2/vue-qrcode.min.js"></script> -->
|
||||
<style></style>
|
||||
<!-- todo: use config mempool -->
|
||||
<script src="https://mempool.space/mempool.js"></script>
|
||||
<script src="{{ url_for('satspay_static', path='js/utils.js') }}"></script>
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
var mapCharge = obj => {
|
||||
obj._data = _.clone(obj)
|
||||
obj.theTime = obj.time * 60 - (Date.now() / 1000 - obj.timestamp)
|
||||
obj.time = obj.time + 'mins'
|
||||
|
||||
if (obj.time_elapsed) {
|
||||
obj.date = 'Time elapsed'
|
||||
} else {
|
||||
obj.date = Quasar.utils.date.formatDate(
|
||||
new Date((obj.theTime - 3600) * 1000),
|
||||
'HH:mm:ss'
|
||||
)
|
||||
}
|
||||
obj.displayUrl = ['/satspay/', obj.id].join('')
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
filter: '',
|
||||
watchonlyactive: false,
|
||||
balance: null,
|
||||
checker: null,
|
||||
walletLinks: [],
|
||||
ChargeLinks: [],
|
||||
ChargeLinksObj: [],
|
||||
chargeLinks: [],
|
||||
onchainwallet: '',
|
||||
currentaddress: '',
|
||||
Addresses: {
|
||||
show: false,
|
||||
data: null
|
||||
},
|
||||
rescanning: false,
|
||||
mempool: {
|
||||
endpoint: ''
|
||||
},
|
||||
|
||||
ChargesTable: {
|
||||
chargesTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'theId',
|
||||
|
@ -341,10 +430,10 @@
|
|||
field: 'description'
|
||||
},
|
||||
{
|
||||
name: 'timeleft',
|
||||
name: 'timeLeft',
|
||||
align: 'left',
|
||||
label: 'Time left',
|
||||
field: 'date'
|
||||
field: 'timeLeft'
|
||||
},
|
||||
{
|
||||
name: 'time to pay',
|
||||
|
@ -364,6 +453,12 @@
|
|||
label: 'Balance',
|
||||
field: 'balance'
|
||||
},
|
||||
{
|
||||
name: 'pendingBalance',
|
||||
align: 'left',
|
||||
label: 'Pending Balance',
|
||||
field: 'pendingBalance'
|
||||
},
|
||||
{
|
||||
name: 'onchain address',
|
||||
align: 'left',
|
||||
|
@ -393,172 +488,218 @@
|
|||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
formDialogCharge: {
|
||||
show: false,
|
||||
data: {
|
||||
onchain: false,
|
||||
onchainwallet: '',
|
||||
lnbits: false,
|
||||
description: '',
|
||||
time: null,
|
||||
amount: null
|
||||
}
|
||||
},
|
||||
qrCodeDialog: {
|
||||
show: false,
|
||||
data: null
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
cancelCharge: function (data) {
|
||||
var self = this
|
||||
self.formDialogCharge.data.description = ''
|
||||
self.formDialogCharge.data.onchainwallet = ''
|
||||
self.formDialogCharge.data.lnbitswallet = ''
|
||||
self.formDialogCharge.data.time = null
|
||||
self.formDialogCharge.data.amount = null
|
||||
self.formDialogCharge.data.webhook = ''
|
||||
self.formDialogCharge.data.completelink = ''
|
||||
self.formDialogCharge.show = false
|
||||
this.formDialogCharge.data.description = ''
|
||||
this.formDialogCharge.data.onchainwallet = ''
|
||||
this.formDialogCharge.data.lnbitswallet = ''
|
||||
this.formDialogCharge.data.time = null
|
||||
this.formDialogCharge.data.amount = null
|
||||
this.formDialogCharge.data.webhook = ''
|
||||
this.formDialogCharge.data.completelink = ''
|
||||
this.formDialogCharge.show = false
|
||||
},
|
||||
|
||||
getWalletLinks: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
getWalletLinks: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/wallet',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
for (i = 0; i < response.data.length; i++) {
|
||||
self.walletLinks.push(response.data[i].id)
|
||||
}
|
||||
return
|
||||
})
|
||||
.catch(function (error) {
|
||||
this.walletLinks = data.map(w => ({
|
||||
id: w.id,
|
||||
label: w.title + ' - ' + w.id
|
||||
}))
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
closeFormDialog: function () {
|
||||
this.formDialog.data = {
|
||||
is_unique: false
|
||||
}
|
||||
},
|
||||
openQrCodeDialog: function (linkId) {
|
||||
var self = this
|
||||
var getAddresses = this.getAddresses
|
||||
getAddresses(linkId)
|
||||
self.current = linkId
|
||||
self.Addresses.show = true
|
||||
},
|
||||
getCharges: function () {
|
||||
var self = this
|
||||
var getAddressBalance = this.getAddressBalance
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
getWalletConfig: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/config',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
this.mempool.endpoint = data.mempool_endpoint
|
||||
const url = new URL(this.mempool.endpoint)
|
||||
this.mempool.hostname = url.hostname
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
getOnchainWalletName: function (walletId) {
|
||||
const wallet = this.walletLinks.find(w => w.id === walletId)
|
||||
if (!wallet) return 'unknown'
|
||||
return wallet.label
|
||||
},
|
||||
getLNbitsWalletName: function (walletId) {
|
||||
const wallet = this.g.user.walletOptions.find(w => w.value === walletId)
|
||||
if (!wallet) return 'unknown'
|
||||
return wallet.label
|
||||
},
|
||||
|
||||
getCharges: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charges',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.ChargeLinks = response.data.map(mapCharge)
|
||||
})
|
||||
.catch(function (error) {
|
||||
this.chargeLinks = data.map(c =>
|
||||
mapCharge(
|
||||
c,
|
||||
this.chargeLinks.find(old => old.id === c.id)
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}
|
||||
},
|
||||
sendFormDataCharge: function () {
|
||||
var self = this
|
||||
var wallet = this.g.user.wallets[0].inkey
|
||||
var data = this.formDialogCharge.data
|
||||
const wallet = this.g.user.wallets[0].inkey
|
||||
const data = this.formDialogCharge.data
|
||||
data.amount = parseInt(data.amount)
|
||||
data.time = parseInt(data.time)
|
||||
data.onchainwallet = this.onchainwallet?.id
|
||||
this.createCharge(wallet, data)
|
||||
},
|
||||
timerCount: function () {
|
||||
self = this
|
||||
var refreshIntervalId = setInterval(function () {
|
||||
for (i = 0; i < self.ChargeLinks.length - 1; i++) {
|
||||
if (self.ChargeLinks[i]['paid'] == 'True') {
|
||||
setTimeout(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
refreshActiveChargesBalance: async function () {
|
||||
try {
|
||||
const activeLinkIds = this.chargeLinks
|
||||
.filter(c => !c.paid && !c.time_elapsed && !c.hasStaleBalance)
|
||||
.map(c => c.id)
|
||||
.join(',')
|
||||
if (activeLinkIds) {
|
||||
await LNbits.api.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charges/balance/' +
|
||||
self.ChargeLinks[i]['id'],
|
||||
'/satspay/api/v1/charges/balance/' + activeLinkIds,
|
||||
'filla'
|
||||
)
|
||||
.then(function (response) {})
|
||||
}, 2000)
|
||||
}
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
await this.getCharges()
|
||||
}
|
||||
self.getCharges()
|
||||
}, 20000)
|
||||
},
|
||||
createCharge: function (wallet, data) {
|
||||
var self = this
|
||||
refreshBalance: async function (charge) {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charge/balance/' + charge.id,
|
||||
'filla'
|
||||
)
|
||||
charge.balance = data.balance
|
||||
} catch (error) {}
|
||||
},
|
||||
rescanOnchainAddresses: async function () {
|
||||
if (this.rescanning) return
|
||||
this.rescanning = true
|
||||
|
||||
LNbits.api
|
||||
.request('POST', '/satspay/api/v1/charge', wallet, data)
|
||||
.then(function (response) {
|
||||
self.ChargeLinks.push(mapCharge(response.data))
|
||||
self.formDialogCharge.show = false
|
||||
self.formDialogCharge.data = {
|
||||
const {
|
||||
bitcoin: {addresses: addressesAPI}
|
||||
} = mempoolJS({hostname: this.mempool.hostname})
|
||||
|
||||
try {
|
||||
const onchainActiveCharges = this.chargeLinks.filter(
|
||||
c => c.onchainaddress && !c.paid && !c.time_elapsed
|
||||
)
|
||||
for (const charge of onchainActiveCharges) {
|
||||
const fn = async () =>
|
||||
addressesAPI.getAddressTxsUtxo({
|
||||
address: charge.onchainaddress
|
||||
})
|
||||
|
||||
const utxos = await retryWithDelay(fn)
|
||||
const newBalance = utxos.reduce((t, u) => t + u.value, 0)
|
||||
|
||||
charge.pendingBalance = utxos
|
||||
.filter(u => !u.status.confirmed)
|
||||
.reduce((t, u) => t + u.value, 0)
|
||||
|
||||
charge.hasStaleBalance = charge.balance === newBalance
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
this.rescanning = false
|
||||
}
|
||||
},
|
||||
createCharge: async function (wallet, data) {
|
||||
try {
|
||||
const resp = await LNbits.api.request(
|
||||
'POST',
|
||||
'/satspay/api/v1/charge',
|
||||
wallet,
|
||||
data
|
||||
)
|
||||
|
||||
this.chargeLinks.unshift(mapCharge(resp.data))
|
||||
this.formDialogCharge.show = false
|
||||
this.formDialogCharge.data = {
|
||||
onchain: false,
|
||||
lnbits: false,
|
||||
description: '',
|
||||
time: null,
|
||||
amount: null
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
deleteChargeLink: function (chargeId) {
|
||||
var self = this
|
||||
var link = _.findWhere(this.ChargeLinks, {id: chargeId})
|
||||
const link = _.findWhere(this.chargeLinks, {id: chargeId})
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this pay link?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
.onOk(async () => {
|
||||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'DELETE',
|
||||
'/satspay/api/v1/charge/' + chargeId,
|
||||
self.g.user.wallets[0].adminkey
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.ChargeLinks = _.reject(self.ChargeLinks, function (obj) {
|
||||
|
||||
this.chargeLinks = _.reject(this.chargeLinks, function (obj) {
|
||||
return obj.id === chargeId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
exportchargeCSV: function () {
|
||||
var self = this
|
||||
LNbits.utils.exportCSV(self.ChargesTable.columns, this.ChargeLinks)
|
||||
LNbits.utils.exportCSV(
|
||||
this.chargesTable.columns,
|
||||
this.chargeLinks,
|
||||
'charges'
|
||||
)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
console.log(this.g.user)
|
||||
var self = this
|
||||
var getCharges = this.getCharges
|
||||
getCharges()
|
||||
var getWalletLinks = this.getWalletLinks
|
||||
getWalletLinks()
|
||||
var timerCount = this.timerCount
|
||||
timerCount()
|
||||
created: async function () {
|
||||
await this.getCharges()
|
||||
await this.getWalletLinks()
|
||||
await this.getWalletConfig()
|
||||
setInterval(() => this.refreshActiveChargesBalance(), 10 * 2000)
|
||||
await this.rescanOnchainAddresses()
|
||||
setInterval(() => this.rescanOnchainAddresses(), 10 * 1000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -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,7 +8,7 @@ You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnb
|
|||
|
||||
### Wallet Account
|
||||
- a user can add one or more `xPubs` or `descriptors`
|
||||
- the `xPub` fingerprint must be unique per user
|
||||
- the `xPub` must be unique per user
|
||||
- such and entry is called an `Wallet Account`
|
||||
- the addresses in a `Wallet Account` are split into `Receive Addresses` and `Change Address`
|
||||
- the user interacts directly only with the `Receive Addresses` (by sharing them)
|
||||
|
@ -17,6 +17,7 @@ You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnb
|
|||
- when a `Wallet Account` is created, there are generated `20 Receive Addresses` and `5 Change Address`
|
||||
- the limits can be change from the `Config` page (see `screenshot 1`)
|
||||
- regular wallets only scan up to `20` empty receive addresses. If the user generates addresses beyond this limit a warning is shown (see `screenshot 4`)
|
||||
- an account can be added `From Hardware Device`
|
||||
|
||||
### Scan Blockchain
|
||||
- when the user clicks `Scan Blockchain`, the wallet will loop over the all addresses (for each account)
|
||||
|
@ -48,33 +49,32 @@ You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnb
|
|||
- shows the UTXOs for all wallets
|
||||
- there can be multiple UTXOs for the same address
|
||||
|
||||
### Make Payment
|
||||
### New Payment
|
||||
- create a new `Partially Signed Bitcoin Transaction`
|
||||
- multiple `Send Addresses` can be added
|
||||
- the `Max` button next to an address is for sending the remaining funds to this address (no change)
|
||||
- the user can select the inputs (UTXOs) manually, or it can use of the basic selection algorithms
|
||||
- amounts have to be provided for the `Send Addresses` beforehand (so the algorithm knows the amount to be selected)
|
||||
- `Show Advanced` allows to (see `screenshot 2`):
|
||||
- select from which account the change address will be selected (defaults to the first one)
|
||||
- select the `Fee Rate`
|
||||
- it defaults to the `Medium` value at the moment the `Make Payment` button was clicked
|
||||
- `Show Change` allows to select from which account the change address will be selected (defaults to the first one)
|
||||
- `Show Custom Fee` allows to manually select the fee
|
||||
- it defaults to the `Medium` value at the moment the `New Payment` button was clicked
|
||||
- it can be refreshed
|
||||
- warnings are shown if the fee is too Low or to High
|
||||
|
||||
### Create PSBT
|
||||
- based on the Inputs & Outputs selected by the user a PSBT will be generated
|
||||
- this wallet is watch-only, therefore does not support signing
|
||||
- it is not mandatory for the `Selected Amount` to be grater than `Payed Amount`
|
||||
- the generated PSBT can be combined with other PSBTs that add more inputs.
|
||||
- the generated PSBT can be imported for signing into different wallets like Electrum
|
||||
- import the PSBT into Electrum and check the In/Outs/Fee (see `screenshot 3`)
|
||||
### Check & Send
|
||||
- creates the PSBT and sends it to the Hardware Wallet
|
||||
- a confirmation will be shown for each Output and for the Fee
|
||||
- after the user confirms the addresses and amounts, the transaction will be signed on the Hardware Device
|
||||
|
||||
### Share PSBT
|
||||
- Show the PSBT without sending it to the Hardware Wallet
|
||||
|
||||
## Screensots
|
||||
- screenshot 1:
|
||||

|
||||
|
||||
- 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])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
|
@ -1,18 +1,29 @@
|
|||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
const watchOnly = async () => {
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
Vue.filter('reverse', function (value) {
|
||||
await walletConfig('static/components/wallet-config/wallet-config.html')
|
||||
await walletList('static/components/wallet-list/wallet-list.html')
|
||||
await addressList('static/components/address-list/address-list.html')
|
||||
await history('static/components/history/history.html')
|
||||
await utxoList('static/components/utxo-list/utxo-list.html')
|
||||
await feeRate('static/components/fee-rate/fee-rate.html')
|
||||
await sendTo('static/components/send-to/send-to.html')
|
||||
await payment('static/components/payment/payment.html')
|
||||
await serialSigner('static/components/serial-signer/serial-signer.html')
|
||||
await serialPortConfig(
|
||||
'static/components/serial-port-config/serial-port-config.html'
|
||||
)
|
||||
|
||||
Vue.filter('reverse', function (value) {
|
||||
// slice to make a copy of array, then reverse the copy
|
||||
return value.slice().reverse()
|
||||
})
|
||||
})
|
||||
|
||||
new Vue({
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
DUST_LIMIT: 546,
|
||||
filter: '',
|
||||
|
||||
scan: {
|
||||
scanning: false,
|
||||
scanCount: 0,
|
||||
|
@ -23,199 +34,40 @@ new Vue({
|
|||
|
||||
tab: 'addresses',
|
||||
|
||||
config: {
|
||||
data: {
|
||||
mempool_endpoint: 'https://mempool.space',
|
||||
receive_gap_limit: 20,
|
||||
change_gap_limit: 5
|
||||
},
|
||||
DEFAULT_RECEIVE_GAP_LIMIT: 20,
|
||||
show: false
|
||||
},
|
||||
|
||||
formDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
config: {sats_denominated: true},
|
||||
|
||||
qrCodeDialog: {
|
||||
show: false,
|
||||
data: null
|
||||
},
|
||||
...tables,
|
||||
...tableData
|
||||
...tableData,
|
||||
|
||||
walletAccounts: [],
|
||||
addresses: [],
|
||||
history: [],
|
||||
historyFilter: '',
|
||||
|
||||
showAddress: false,
|
||||
addressNote: '',
|
||||
showPayment: false,
|
||||
fetchedUtxos: false,
|
||||
utxosFilter: '',
|
||||
network: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
mempoolHostname: function () {
|
||||
if (!this.config.isLoaded) return
|
||||
let hostname = new URL(this.config.mempool_endpoint).hostname
|
||||
if (this.config.network === 'Testnet') {
|
||||
hostname += '/testnet'
|
||||
}
|
||||
return hostname
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
//################### CONFIG ###################
|
||||
getConfig: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/config',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
this.config.data = data
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
updateConfig: async function () {
|
||||
const wallet = this.g.user.wallets[0]
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
'/watchonly/api/v1/config',
|
||||
wallet.adminkey,
|
||||
this.config.data
|
||||
)
|
||||
this.config.show = false
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
|
||||
//################### WALLETS ###################
|
||||
getWalletName: function (walletId) {
|
||||
const wallet = this.walletAccounts.find(wl => wl.id === walletId)
|
||||
return wallet ? wallet.title : 'unknown'
|
||||
},
|
||||
addWalletAccount: async function () {
|
||||
const wallet = this.g.user.wallets[0]
|
||||
const data = _.omit(this.formDialog.data, 'wallet')
|
||||
await this.createWalletAccount(wallet, data)
|
||||
},
|
||||
createWalletAccount: async function (wallet, data) {
|
||||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'POST',
|
||||
'/watchonly/api/v1/wallet',
|
||||
wallet.adminkey,
|
||||
data
|
||||
)
|
||||
this.walletAccounts.push(mapWalletAccount(response.data))
|
||||
this.formDialog.show = false
|
||||
|
||||
await this.refreshWalletAccounts()
|
||||
await this.refreshAddresses()
|
||||
|
||||
if (!this.payment.changeWallett) {
|
||||
this.payment.changeWallet = this.walletAccounts[0]
|
||||
this.selectChangeAddress(this.payment.changeWallet)
|
||||
}
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
deleteWalletAccount: function (walletAccountId) {
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'Are you sure you want to delete this watch only wallet?'
|
||||
)
|
||||
.onOk(async () => {
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'DELETE',
|
||||
'/watchonly/api/v1/wallet/' + walletAccountId,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
this.walletAccounts = _.reject(this.walletAccounts, function (obj) {
|
||||
return obj.id === walletAccountId
|
||||
})
|
||||
await this.refreshWalletAccounts()
|
||||
await this.refreshAddresses()
|
||||
if (
|
||||
this.payment.changeWallet &&
|
||||
this.payment.changeWallet.id === walletAccountId
|
||||
) {
|
||||
this.payment.changeWallet = this.walletAccounts[0]
|
||||
this.selectChangeAddress(this.payment.changeWallet)
|
||||
}
|
||||
await this.scanAddressWithAmount()
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Error while deleting wallet account. Please try again.',
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
getAddressesForWallet: async function (walletId) {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/addresses/' + walletId,
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
return data.map(mapAddressesData)
|
||||
} catch (err) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: `Failed to fetch addresses for wallet with id ${walletId}.`,
|
||||
timeout: 10000
|
||||
})
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
return []
|
||||
},
|
||||
getWatchOnlyWallets: async function () {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/wallet',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
return data
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Failed to fetch wallets.',
|
||||
timeout: 10000
|
||||
})
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
return []
|
||||
},
|
||||
refreshWalletAccounts: async function () {
|
||||
const wallets = await this.getWatchOnlyWallets()
|
||||
this.walletAccounts = wallets.map(w => mapWalletAccount(w))
|
||||
},
|
||||
getAmmountForWallet: function (walletId) {
|
||||
const amount = this.addresses.data
|
||||
.filter(a => a.wallet === walletId)
|
||||
.reduce((t, a) => t + a.amount || 0, 0)
|
||||
return this.satBtc(amount)
|
||||
},
|
||||
|
||||
//################### ADDRESSES ###################
|
||||
|
||||
refreshAddresses: async function () {
|
||||
const wallets = await this.getWatchOnlyWallets()
|
||||
this.addresses.data = []
|
||||
for (const {id, type} of wallets) {
|
||||
const newAddresses = await this.getAddressesForWallet(id)
|
||||
const uniqueAddresses = newAddresses.filter(
|
||||
newAddr =>
|
||||
!this.addresses.data.find(a => a.address === newAddr.address)
|
||||
)
|
||||
|
||||
const lastAcctiveAddress =
|
||||
uniqueAddresses.filter(a => !a.isChange && a.hasActivity).pop() || {}
|
||||
|
||||
uniqueAddresses.forEach(a => {
|
||||
a.expanded = false
|
||||
a.accountType = type
|
||||
a.gapLimitExceeded =
|
||||
!a.isChange &&
|
||||
a.addressIndex >
|
||||
lastAcctiveAddress.addressIndex +
|
||||
this.config.DEFAULT_RECEIVE_GAP_LIMIT
|
||||
})
|
||||
this.addresses.data.push(...uniqueAddresses)
|
||||
}
|
||||
},
|
||||
updateAmountForAddress: async function (addressData, amount = 0) {
|
||||
try {
|
||||
const wallet = this.g.user.wallets[0]
|
||||
|
@ -232,6 +84,7 @@ new Vue({
|
|||
}
|
||||
}
|
||||
|
||||
// todo: account deleted
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
`/watchonly/api/v1/address/${addressData.id}`,
|
||||
|
@ -248,71 +101,22 @@ new Vue({
|
|||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
},
|
||||
updateNoteForAddress: async function (addressData, note) {
|
||||
updateNoteForAddress: async function ({addressId, note}) {
|
||||
try {
|
||||
const wallet = this.g.user.wallets[0]
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
`/watchonly/api/v1/address/${addressData.id}`,
|
||||
`/watchonly/api/v1/address/${addressId}`,
|
||||
wallet.adminkey,
|
||||
{note: addressData.note}
|
||||
{note}
|
||||
)
|
||||
const updatedAddress =
|
||||
this.addresses.data.find(a => a.id === addressData.id) || {}
|
||||
this.addresses.find(a => a.id === addressId) || {}
|
||||
updatedAddress.note = note
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
},
|
||||
getFilteredAddresses: function () {
|
||||
const selectedWalletId = this.addresses.selectedWallet?.id
|
||||
const filter = this.addresses.filterValues || []
|
||||
const includeChangeAddrs = filter.includes('Show Change Addresses')
|
||||
const includeGapAddrs = filter.includes('Show Gap Addresses')
|
||||
const excludeNoAmount = filter.includes('Only With Amount')
|
||||
|
||||
const walletsLimit = this.walletAccounts.reduce((r, w) => {
|
||||
r[`_${w.id}`] = w.address_no
|
||||
return r
|
||||
}, {})
|
||||
|
||||
const addresses = this.addresses.data.filter(
|
||||
a =>
|
||||
(includeChangeAddrs || !a.isChange) &&
|
||||
(includeGapAddrs ||
|
||||
a.isChange ||
|
||||
a.addressIndex <= walletsLimit[`_${a.wallet}`]) &&
|
||||
!(excludeNoAmount && a.amount === 0) &&
|
||||
(!selectedWalletId || a.wallet === selectedWalletId)
|
||||
)
|
||||
return addresses
|
||||
},
|
||||
openGetFreshAddressDialog: async function (walletId) {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/watchonly/api/v1/address/${walletId}`,
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
const addressData = mapAddressesData(data)
|
||||
|
||||
addressData.note = `Shared on ${currentDateTime()}`
|
||||
const lastAcctiveAddress =
|
||||
this.addresses.data
|
||||
.filter(
|
||||
a => a.wallet === addressData.wallet && !a.isChange && a.hasActivity
|
||||
)
|
||||
.pop() || {}
|
||||
addressData.gapLimitExceeded =
|
||||
!addressData.isChange &&
|
||||
addressData.addressIndex >
|
||||
lastAcctiveAddress.addressIndex +
|
||||
this.config.DEFAULT_RECEIVE_GAP_LIMIT
|
||||
|
||||
this.openQrCodeDialog(addressData)
|
||||
const wallet = this.walletAccounts.find(w => w.id === walletId) || {}
|
||||
wallet.address_no = addressData.addressIndex
|
||||
await this.refreshAddresses()
|
||||
},
|
||||
|
||||
//################### ADDRESS HISTORY ###################
|
||||
addressHistoryFromTxs: function (addressData, txs) {
|
||||
|
@ -331,24 +135,9 @@ new Vue({
|
|||
})
|
||||
return addressHistory
|
||||
},
|
||||
getFilteredAddressesHistory: function () {
|
||||
return this.addresses.history.filter(
|
||||
a => (!a.isChange || a.sent) && !a.isSubItem
|
||||
)
|
||||
},
|
||||
exportHistoryToCSV: function () {
|
||||
const history = this.getFilteredAddressesHistory().map(a => ({
|
||||
...a,
|
||||
action: a.sent ? 'Sent' : 'Received'
|
||||
}))
|
||||
LNbits.utils.exportCSV(
|
||||
this.historyTable.exportColums,
|
||||
history,
|
||||
'address-history'
|
||||
)
|
||||
},
|
||||
|
||||
markSameTxAddressHistory: function () {
|
||||
this.addresses.history
|
||||
this.history
|
||||
.filter(s => s.sent)
|
||||
.forEach((el, i, arr) => {
|
||||
if (el.isSubItem) return
|
||||
|
@ -364,156 +153,43 @@ new Vue({
|
|||
el.sameTxItems = sameTxItems
|
||||
})
|
||||
},
|
||||
showAddressHistoryDetails: function (addressHistory) {
|
||||
addressHistory.expanded = true
|
||||
},
|
||||
|
||||
//################### PAYMENT ###################
|
||||
createTx: function (excludeChange = false) {
|
||||
const tx = {
|
||||
fee_rate: this.payment.feeRate,
|
||||
tx_size: this.payment.txSize,
|
||||
masterpubs: this.walletAccounts.map(w => ({
|
||||
public_key: w.masterpub,
|
||||
fingerprint: w.fingerprint
|
||||
}))
|
||||
}
|
||||
tx.inputs = this.utxos.data
|
||||
.filter(utxo => utxo.selected)
|
||||
.map(mapUtxoToPsbtInput)
|
||||
.sort((a, b) =>
|
||||
a.tx_id < b.tx_id ? -1 : a.tx_id > b.tx_id ? 1 : a.vout - b.vout
|
||||
)
|
||||
|
||||
tx.outputs = this.payment.data.map(out => ({
|
||||
address: out.address,
|
||||
amount: out.amount
|
||||
}))
|
||||
|
||||
if (excludeChange) {
|
||||
this.payment.changeAmount = 0
|
||||
} else {
|
||||
const change = this.createChangeOutput()
|
||||
this.payment.changeAmount = change.amount
|
||||
if (change.amount >= this.DUST_LIMIT) {
|
||||
tx.outputs.push(change)
|
||||
}
|
||||
}
|
||||
// Only sort by amount on UI level (no lib for address decode)
|
||||
// Should sort by scriptPubKey (as byte array) on the backend
|
||||
tx.outputs.sort((a, b) => a.amount - b.amount)
|
||||
|
||||
return tx
|
||||
},
|
||||
createChangeOutput: function () {
|
||||
const change = this.payment.changeAddress
|
||||
const fee = this.payment.feeRate * this.payment.txSize
|
||||
const inputAmount = this.getTotalSelectedUtxoAmount()
|
||||
const payedAmount = this.getTotalPaymentAmount()
|
||||
const walletAcount =
|
||||
this.walletAccounts.find(w => w.id === change.wallet) || {}
|
||||
|
||||
return {
|
||||
address: change.address,
|
||||
amount: inputAmount - payedAmount - fee,
|
||||
addressIndex: change.addressIndex,
|
||||
addressIndex: change.addressIndex,
|
||||
masterpub_fingerprint: walletAcount.fingerprint
|
||||
}
|
||||
},
|
||||
computeFee: function () {
|
||||
const tx = this.createTx()
|
||||
this.payment.txSize = Math.round(txSize(tx))
|
||||
return this.payment.feeRate * this.payment.txSize
|
||||
},
|
||||
createPsbt: async function () {
|
||||
const wallet = this.g.user.wallets[0]
|
||||
try {
|
||||
this.computeFee()
|
||||
const tx = this.createTx()
|
||||
txSize(tx)
|
||||
for (const input of tx.inputs) {
|
||||
input.tx_hex = await this.fetchTxHex(input.tx_id)
|
||||
}
|
||||
|
||||
const {data} = await LNbits.api.request(
|
||||
'POST',
|
||||
'/watchonly/api/v1/psbt',
|
||||
wallet.adminkey,
|
||||
tx
|
||||
)
|
||||
|
||||
this.payment.psbtBase64 = data
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
},
|
||||
deletePaymentAddress: function (v) {
|
||||
const index = this.payment.data.indexOf(v)
|
||||
if (index !== -1) {
|
||||
this.payment.data.splice(index, 1)
|
||||
}
|
||||
},
|
||||
initPaymentData: async function () {
|
||||
if (!this.payment.show) return
|
||||
await this.refreshAddresses()
|
||||
},
|
||||
|
||||
this.payment.showAdvanced = false
|
||||
this.payment.changeWallet = this.walletAccounts[0]
|
||||
this.selectChangeAddress(this.payment.changeWallet)
|
||||
|
||||
await this.refreshRecommendedFees()
|
||||
this.payment.feeRate = this.payment.recommededFees.halfHourFee
|
||||
},
|
||||
getFeeRateLabel: function (feeRate) {
|
||||
const fees = this.payment.recommededFees
|
||||
if (feeRate >= fees.fastestFee) return `High Priority (${feeRate} sat/vB)`
|
||||
if (feeRate >= fees.halfHourFee)
|
||||
return `Medium Priority (${feeRate} sat/vB)`
|
||||
if (feeRate >= fees.hourFee) return `Low Priority (${feeRate} sat/vB)`
|
||||
return `No Priority (${feeRate} sat/vB)`
|
||||
},
|
||||
addPaymentAddress: function () {
|
||||
this.payment.data.push({address: '', amount: undefined})
|
||||
},
|
||||
getTotalPaymentAmount: function () {
|
||||
return this.payment.data.reduce((t, a) => t + (a.amount || 0), 0)
|
||||
},
|
||||
selectChangeAddress: function (wallet = {}) {
|
||||
this.payment.changeAddress =
|
||||
this.addresses.data.find(
|
||||
a => a.wallet === wallet.id && a.isChange && !a.hasActivity
|
||||
) || {}
|
||||
},
|
||||
goToPaymentView: async function () {
|
||||
this.payment.show = true
|
||||
this.tab = 'utxos'
|
||||
this.showPayment = true
|
||||
await this.initPaymentData()
|
||||
},
|
||||
sendMaxToAddress: function (paymentAddress = {}) {
|
||||
paymentAddress.amount = 0
|
||||
const tx = this.createTx(true)
|
||||
this.payment.txSize = Math.round(txSize(tx))
|
||||
const fee = this.payment.feeRate * this.payment.txSize
|
||||
const inputAmount = this.getTotalSelectedUtxoAmount()
|
||||
const payedAmount = this.getTotalPaymentAmount()
|
||||
paymentAddress.amount = Math.max(0, inputAmount - payedAmount - fee)
|
||||
|
||||
//################### PSBT ###################
|
||||
|
||||
updateSignedPsbt: async function (psbtBase64) {
|
||||
this.$refs.paymentRef.updateSignedPsbt(psbtBase64)
|
||||
},
|
||||
|
||||
//################### SERIAL PORT ###################
|
||||
|
||||
//################### HARDWARE WALLET ###################
|
||||
|
||||
//################### UTXOs ###################
|
||||
scanAllAddresses: async function () {
|
||||
await this.refreshAddresses()
|
||||
this.addresses.history = []
|
||||
let addresses = this.addresses.data
|
||||
this.history = []
|
||||
let addresses = this.addresses
|
||||
this.utxos.data = []
|
||||
this.utxos.total = 0
|
||||
// Loop while new funds are found on the gap adresses.
|
||||
// Use 1000 limit as a safety check (scan 20 000 addresses max)
|
||||
for (let i = 0; i < 1000 && addresses.length; i++) {
|
||||
await this.updateUtxosForAddresses(addresses)
|
||||
const oldAddresses = this.addresses.data.slice()
|
||||
const oldAddresses = this.addresses.slice()
|
||||
await this.refreshAddresses()
|
||||
const newAddresses = this.addresses.data.slice()
|
||||
const newAddresses = this.addresses.slice()
|
||||
// check if gap addresses have been extended
|
||||
addresses = newAddresses.filter(
|
||||
newAddr => !oldAddresses.find(oldAddr => oldAddr.id === newAddr.id)
|
||||
|
@ -530,8 +206,8 @@ new Vue({
|
|||
scanAddressWithAmount: async function () {
|
||||
this.utxos.data = []
|
||||
this.utxos.total = 0
|
||||
this.addresses.history = []
|
||||
const addresses = this.addresses.data.filter(a => a.hasActivity)
|
||||
this.history = []
|
||||
const addresses = this.addresses.filter(a => a.hasActivity)
|
||||
await this.updateUtxosForAddresses(addresses)
|
||||
},
|
||||
scanAddress: async function (addressData) {
|
||||
|
@ -542,6 +218,49 @@ new Vue({
|
|||
timeout: 10000
|
||||
})
|
||||
},
|
||||
refreshAddresses: async function () {
|
||||
if (!this.walletAccounts) return
|
||||
this.addresses = []
|
||||
for (const {id, type} of this.walletAccounts) {
|
||||
const newAddresses = await this.getAddressesForWallet(id)
|
||||
const uniqueAddresses = newAddresses.filter(
|
||||
newAddr => !this.addresses.find(a => a.address === newAddr.address)
|
||||
)
|
||||
|
||||
const lastAcctiveAddress =
|
||||
uniqueAddresses.filter(a => !a.isChange && a.hasActivity).pop() ||
|
||||
{}
|
||||
|
||||
uniqueAddresses.forEach(a => {
|
||||
a.expanded = false
|
||||
a.accountType = type
|
||||
a.gapLimitExceeded =
|
||||
!a.isChange &&
|
||||
a.addressIndex >
|
||||
lastAcctiveAddress.addressIndex + DEFAULT_RECEIVE_GAP_LIMIT
|
||||
})
|
||||
this.addresses.push(...uniqueAddresses)
|
||||
}
|
||||
this.$emit('update:addresses', this.addresses)
|
||||
},
|
||||
getAddressesForWallet: async function (walletId) {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/addresses/' + walletId,
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
return data.map(mapAddressesData)
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: `Failed to fetch addresses for wallet with id ${walletId}.`,
|
||||
timeout: 10000
|
||||
})
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
return []
|
||||
},
|
||||
updateUtxosForAddresses: async function (addresses = []) {
|
||||
this.scan = {scanning: true, scanCount: addresses.length, scanIndex: 0}
|
||||
|
||||
|
@ -549,20 +268,20 @@ new Vue({
|
|||
for (addrData of addresses) {
|
||||
const addressHistory = await this.getAddressTxsDelayed(addrData)
|
||||
// remove old entries
|
||||
this.addresses.history = this.addresses.history.filter(
|
||||
this.history = this.history.filter(
|
||||
h => h.address !== addrData.address
|
||||
)
|
||||
|
||||
// add new entrie
|
||||
this.addresses.history.push(...addressHistory)
|
||||
this.addresses.history.sort((a, b) =>
|
||||
!a.height ? -1 : b.height - a.height
|
||||
)
|
||||
// add new entries
|
||||
this.history.push(...addressHistory)
|
||||
this.history.sort((a, b) => (!a.height ? -1 : b.height - a.height))
|
||||
this.markSameTxAddressHistory()
|
||||
|
||||
if (addressHistory.length) {
|
||||
// search only if it ever had any activity
|
||||
const utxos = await this.getAddressTxsUtxoDelayed(addrData.address)
|
||||
const utxos = await this.getAddressTxsUtxoDelayed(
|
||||
addrData.address
|
||||
)
|
||||
this.updateUtxosForAddress(addrData, utxos)
|
||||
}
|
||||
|
||||
|
@ -605,131 +324,76 @@ new Vue({
|
|||
)
|
||||
this.updateAmountForAddress(addressData, addressTotal)
|
||||
},
|
||||
getTotalSelectedUtxoAmount: function () {
|
||||
const total = this.utxos.data
|
||||
.filter(u => u.selected)
|
||||
.reduce((t, a) => t + (a.amount || 0), 0)
|
||||
return total
|
||||
},
|
||||
applyUtxoSelectionMode: function () {
|
||||
const payedAmount = this.getTotalPaymentAmount()
|
||||
const mode = this.payment.utxoSelectionMode
|
||||
this.utxos.data.forEach(u => (u.selected = false))
|
||||
const isManual = mode === 'Manual'
|
||||
if (isManual || !payedAmount) return
|
||||
|
||||
const isSelectAll = mode === 'Select All'
|
||||
if (isSelectAll || payedAmount >= this.utxos.total) {
|
||||
this.utxos.data.forEach(u => (u.selected = true))
|
||||
return
|
||||
}
|
||||
const isSmallerFirst = mode === 'Smaller Inputs First'
|
||||
const isLargerFirst = mode === 'Larger Inputs First'
|
||||
|
||||
let selectedUtxos = this.utxos.data.slice()
|
||||
if (isSmallerFirst || isLargerFirst) {
|
||||
const sortFn = isSmallerFirst
|
||||
? (a, b) => a.amount - b.amount
|
||||
: (a, b) => b.amount - a.amount
|
||||
selectedUtxos.sort(sortFn)
|
||||
} else {
|
||||
// default to random order
|
||||
selectedUtxos = _.shuffle(selectedUtxos)
|
||||
}
|
||||
selectedUtxos.reduce((total, utxo) => {
|
||||
utxo.selected = total < payedAmount
|
||||
total += utxo.amount
|
||||
return total
|
||||
}, 0)
|
||||
},
|
||||
|
||||
//################### MEMPOOL API ###################
|
||||
getAddressTxsDelayed: async function (addrData) {
|
||||
const accounts = this.walletAccounts
|
||||
const {
|
||||
bitcoin: {addresses: addressesAPI}
|
||||
} = mempoolJS()
|
||||
|
||||
const fn = async () =>
|
||||
addressesAPI.getAddressTxs({
|
||||
} = mempoolJS({
|
||||
hostname: this.mempoolHostname
|
||||
})
|
||||
const fn = async () => {
|
||||
if (!accounts.find(w => w.id === addrData.wallet)) return []
|
||||
return addressesAPI.getAddressTxs({
|
||||
address: addrData.address
|
||||
})
|
||||
}
|
||||
const addressTxs = await retryWithDelay(fn)
|
||||
return this.addressHistoryFromTxs(addrData, addressTxs)
|
||||
},
|
||||
|
||||
refreshRecommendedFees: async function () {
|
||||
const {
|
||||
bitcoin: {fees: feesAPI}
|
||||
} = mempoolJS()
|
||||
|
||||
const fn = async () => feesAPI.getFeesRecommended()
|
||||
this.payment.recommededFees = await retryWithDelay(fn)
|
||||
},
|
||||
getAddressTxsUtxoDelayed: async function (address) {
|
||||
const endpoint = this.mempoolHostname
|
||||
const {
|
||||
bitcoin: {addresses: addressesAPI}
|
||||
} = mempoolJS()
|
||||
} = mempoolJS({
|
||||
hostname: endpoint
|
||||
})
|
||||
|
||||
const fn = async () =>
|
||||
addressesAPI.getAddressTxsUtxo({
|
||||
const fn = async () => {
|
||||
if (endpoint !== this.mempoolHostname) return []
|
||||
return addressesAPI.getAddressTxsUtxo({
|
||||
address
|
||||
})
|
||||
return retryWithDelay(fn)
|
||||
},
|
||||
fetchTxHex: async function (txId) {
|
||||
const {
|
||||
bitcoin: {transactions: transactionsAPI}
|
||||
} = mempoolJS()
|
||||
|
||||
try {
|
||||
const response = await transactionsAPI.getTxHex({txid: txId})
|
||||
return response
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: `Failed to fetch transaction details for tx id: '${txId}'`,
|
||||
timeout: 10000
|
||||
})
|
||||
LNbits.utils.notifyApiError(error)
|
||||
throw error
|
||||
}
|
||||
return retryWithDelay(fn)
|
||||
},
|
||||
|
||||
//################### OTHER ###################
|
||||
closeFormDialog: function () {
|
||||
this.formDialog.data = {
|
||||
is_unique: false
|
||||
}
|
||||
},
|
||||
|
||||
openQrCodeDialog: function (addressData) {
|
||||
this.currentAddress = addressData
|
||||
this.addresses.note = addressData.note || ''
|
||||
this.addresses.show = true
|
||||
this.addressNote = addressData.note || ''
|
||||
this.showAddress = true
|
||||
},
|
||||
searchInTab: function (tab, value) {
|
||||
searchInTab: function ({tab, value}) {
|
||||
this.tab = tab
|
||||
this[`${tab}Table`].filter = value
|
||||
this[`${tab}Filter`] = value
|
||||
},
|
||||
|
||||
satBtc(val, showUnit = true) {
|
||||
const value = this.config.data.sats_denominated
|
||||
? LNbits.utils.formatSat(val)
|
||||
: val == 0
|
||||
? 0.0
|
||||
: (val / 100000000).toFixed(8)
|
||||
if (!showUnit) return value
|
||||
return this.config.data.sats_denominated ? value + ' sat' : value + ' BTC'
|
||||
updateAccounts: async function (accounts) {
|
||||
this.walletAccounts = accounts
|
||||
await this.refreshAddresses()
|
||||
await this.scanAddressWithAmount()
|
||||
},
|
||||
getAccountDescription: function (accountType) {
|
||||
return getAccountDescription(accountType)
|
||||
showAddressDetails: function (addressData) {
|
||||
this.openQrCodeDialog(addressData)
|
||||
},
|
||||
initUtxos: function (addresses) {
|
||||
if (!this.fetchedUtxos && addresses.length) {
|
||||
this.fetchedUtxos = true
|
||||
this.addresses = addresses
|
||||
this.scanAddressWithAmount()
|
||||
}
|
||||
}
|
||||
},
|
||||
created: async function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
await this.getConfig()
|
||||
await this.refreshWalletAccounts()
|
||||
await this.refreshAddresses()
|
||||
await this.scanAddressWithAmount()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
watchOnly()
|
||||
|
|
|
@ -43,7 +43,7 @@ const mapUtxoToPsbtInput = utxo => ({
|
|||
address: utxo.address,
|
||||
branch_index: utxo.isChange ? 1 : 0,
|
||||
address_index: utxo.addressIndex,
|
||||
masterpub_fingerprint: utxo.masterpubFingerprint,
|
||||
wallet: utxo.wallet,
|
||||
accountType: utxo.accountType,
|
||||
txHex: ''
|
||||
})
|
||||
|
@ -66,15 +66,15 @@ const mapAddressDataToUtxo = (wallet, addressData, utxo) => ({
|
|||
selected: false
|
||||
})
|
||||
|
||||
const mapWalletAccount = function (obj) {
|
||||
obj._data = _.clone(obj)
|
||||
obj.date = obj.time
|
||||
const mapWalletAccount = function (o) {
|
||||
return Object.assign({}, o, {
|
||||
date: o.time
|
||||
? Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
new Date(o.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
: ''
|
||||
obj.label = obj.title // for drop-downs
|
||||
obj.expanded = false
|
||||
return obj
|
||||
: '',
|
||||
label: o.title,
|
||||
expanded: false
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
if description_hash and not self.supports_description_hash:
|
||||
raise Unsupported("description_hash")
|
||||
r = self.ln.invoice(
|
||||
msatoshi=msat,
|
||||
label=label,
|
||||
description=description_hash.decode("utf-8")
|
||||
if description_hash
|
||||
else memo,
|
||||
exposeprivatechannels=True,
|
||||
deschashonly=True
|
||||
if description_hash
|
||||
else False, # we can't pass None here
|
||||
)
|
||||
|
||||
params = [msat, label, description_hash.hex()]
|
||||
r = self.ln.call("invoicewithdescriptionhash", params)
|
||||
return InvoiceResponse(True, label, r["bolt11"], "")
|
||||
else:
|
||||
r = self.ln.invoice(msat, label, memo, exposeprivatechannels=True)
|
||||
return InvoiceResponse(True, label, r["bolt11"], "")
|
||||
if r.get("code") and r.get("code") < 0:
|
||||
raise Exception(r.get("message"))
|
||||
|
||||
return InvoiceResponse(True, r["payment_hash"], r["bolt11"], "")
|
||||
except RpcError as exc:
|
||||
error_message = f"lightningd '{exc.method}' failed with '{exc.error}'."
|
||||
return InvoiceResponse(False, label, None, error_message)
|
||||
logger.error("RPC error:", error_message)
|
||||
return InvoiceResponse(False, None, None, error_message)
|
||||
except Exception as e:
|
||||
logger.error("error:", e)
|
||||
return InvoiceResponse(False, None, None, str(e))
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
invoice = lnbits_bolt11.decode(bolt11)
|
||||
|
@ -116,25 +124,32 @@ class CLightningWallet(Wallet):
|
|||
try:
|
||||
wrapped = async_wrap(_pay_invoice)
|
||||
r = await wrapped(self.ln, payload)
|
||||
except RpcError as exc:
|
||||
except Exception as exc:
|
||||
return PaymentResponse(False, None, 0, None, str(exc))
|
||||
|
||||
fee_msat = r["msatoshi_sent"] - r["msatoshi"]
|
||||
preimage = r["payment_preimage"]
|
||||
return PaymentResponse(True, r["payment_hash"], fee_msat, preimage, None)
|
||||
return PaymentResponse(
|
||||
True, r["payment_hash"], fee_msat, r["payment_preimage"], None
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
r = self.ln.listinvoices(checking_id)
|
||||
try:
|
||||
r = self.ln.listinvoices(payment_hash=checking_id)
|
||||
except:
|
||||
return PaymentStatus(None)
|
||||
if not r["invoices"]:
|
||||
return PaymentStatus(False)
|
||||
if r["invoices"][0]["label"] == checking_id:
|
||||
return PaymentStatus(None)
|
||||
if r["invoices"][0]["payment_hash"] == checking_id:
|
||||
return PaymentStatus(r["invoices"][0]["status"] == "paid")
|
||||
raise KeyError("supplied an invalid checking_id")
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
r = self.ln.call("listpays", {"payment_hash": checking_id})
|
||||
except:
|
||||
return PaymentStatus(None)
|
||||
if not r["pays"]:
|
||||
return PaymentStatus(False)
|
||||
return PaymentStatus(None)
|
||||
if r["pays"][0]["payment_hash"] == checking_id:
|
||||
status = r["pays"][0]["status"]
|
||||
if status == "complete":
|
||||
|
@ -146,7 +161,13 @@ class CLightningWallet(Wallet):
|
|||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
while True:
|
||||
try:
|
||||
wrapped = async_wrap(_paid_invoices_stream)
|
||||
paid = await wrapped(self.ln, self.last_pay_index)
|
||||
self.last_pay_index = paid["pay_index"]
|
||||
yield paid["label"]
|
||||
yield paid["payment_hash"]
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"lost connection to cln invoices stream: '{exc}', retrying in 5 seconds"
|
||||
)
|
||||
await asyncio.sleep(5)
|
|
@ -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,9 +208,45 @@ class LndWallet(Wallet):
|
|||
return PaymentStatus(None)
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
return PaymentStatus(True)
|
||||
"""
|
||||
This routine checks the payment status using routerpc.TrackPaymentV2.
|
||||
"""
|
||||
try:
|
||||
r_hash = parse_checking_id(checking_id)
|
||||
if len(r_hash) != 32:
|
||||
raise binascii.Error
|
||||
except binascii.Error:
|
||||
# this may happen if we switch between backend wallets
|
||||
# that use different checking_id formats
|
||||
return PaymentStatus(None)
|
||||
|
||||
# for some reason our checking_ids are in base64 but the payment hashes
|
||||
# returned here are in hex, lnd is weird
|
||||
checking_id = checking_id.replace("_", "/")
|
||||
checking_id = base64.b64decode(checking_id).hex()
|
||||
|
||||
resp = self.routerpc.TrackPaymentV2(
|
||||
router.TrackPaymentRequest(payment_hash=r_hash)
|
||||
)
|
||||
|
||||
# HTLCAttempt.HTLCStatus:
|
||||
# https://github.com/lightningnetwork/lnd/blob/master/lnrpc/lightning.proto#L3641
|
||||
statuses = {
|
||||
0: None, # IN_FLIGHT
|
||||
1: True, # "SUCCEEDED"
|
||||
2: False, # "FAILED"
|
||||
}
|
||||
|
||||
try:
|
||||
async for payment in resp:
|
||||
return PaymentStatus(statuses[payment.htlcs[-1].status])
|
||||
except: # most likely the payment wasn't found
|
||||
return PaymentStatus(None)
|
||||
|
||||
return PaymentStatus(None)
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
while True:
|
||||
request = ln.InvoiceSubscription()
|
||||
try:
|
||||
async for i in self.rpc.SubscribeInvoices(request):
|
||||
|
@ -189,9 +255,8 @@ class LndWallet(Wallet):
|
|||
|
||||
checking_id = stringify_checking_id(i.r_hash)
|
||||
yield checking_id
|
||||
except error:
|
||||
logger.error(error)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"lost connection to lnd InvoiceSubscription, please restart lnbits."
|
||||
f"lost connection to lnd invoices stream: '{exc}', retrying in 5 seconds"
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from os import getenv
|
||||
from pydoc import describe
|
||||
|
@ -75,9 +76,9 @@ class LndRestWallet(Wallet):
|
|||
) -> InvoiceResponse:
|
||||
data: Dict = {"value": amount, "private": True}
|
||||
if description_hash:
|
||||
data["description_hash"] = base64.b64encode(description_hash).decode(
|
||||
"ascii"
|
||||
)
|
||||
data["description_hash"] = base64.b64encode(
|
||||
hashlib.sha256(description_hash).digest()
|
||||
).decode("ascii")
|
||||
else:
|
||||
data["memo"] = memo or ""
|
||||
|
||||
|
@ -141,15 +142,10 @@ class LndRestWallet(Wallet):
|
|||
return PaymentStatus(True)
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
async with httpx.AsyncClient(verify=self.cert) as client:
|
||||
r = await client.get(
|
||||
url=f"{self.endpoint}/v1/payments",
|
||||
headers=self.auth,
|
||||
params={"max_payments": "20", "reversed": True},
|
||||
)
|
||||
|
||||
if r.is_error:
|
||||
return PaymentStatus(None)
|
||||
"""
|
||||
This routine checks the payment status using routerpc.TrackPaymentV2.
|
||||
"""
|
||||
url = f"{self.endpoint}/v2/router/track/{checking_id}"
|
||||
|
||||
# check payment.status:
|
||||
# https://api.lightning.community/rest/index.html?python#peersynctype
|
||||
|
@ -160,14 +156,27 @@ class LndRestWallet(Wallet):
|
|||
"FAILED": False,
|
||||
}
|
||||
|
||||
# for some reason our checking_ids are in base64 but the payment hashes
|
||||
# returned here are in hex, lnd is weird
|
||||
checking_id = checking_id.replace("_", "/")
|
||||
checking_id = base64.b64decode(checking_id).hex()
|
||||
|
||||
for p in r.json()["payments"]:
|
||||
if p["payment_hash"] == checking_id:
|
||||
return PaymentStatus(statuses[p["status"]])
|
||||
async with httpx.AsyncClient(
|
||||
timeout=None, headers=self.auth, verify=self.cert
|
||||
) as client:
|
||||
async with client.stream("GET", url) as r:
|
||||
async for l in r.aiter_lines():
|
||||
try:
|
||||
line = json.loads(l)
|
||||
if line.get("error"):
|
||||
logger.error(
|
||||
line["error"]["message"]
|
||||
if "message" in line["error"]
|
||||
else line["error"]
|
||||
)
|
||||
return PaymentStatus(None)
|
||||
payment = line.get("result")
|
||||
if payment is not None and payment.get("status"):
|
||||
return PaymentStatus(statuses[payment["status"]])
|
||||
else:
|
||||
return PaymentStatus(None)
|
||||
except:
|
||||
continue
|
||||
|
||||
return PaymentStatus(None)
|
||||
|
||||
|
@ -190,10 +199,8 @@ class LndRestWallet(Wallet):
|
|||
|
||||
payment_hash = base64.b64decode(inv["r_hash"]).hex()
|
||||
yield payment_hash
|
||||
except (OSError, httpx.ConnectError, httpx.ReadError):
|
||||
pass
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"lost connection to lnd invoices stream, retrying in 5 seconds"
|
||||
f"lost connection to lnd invoices stream: '{exc}', retrying in 5 seconds"
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
|
|
|
@ -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