mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2025-02-26 15:42:30 +01:00
commit
17d9bb52a0
68 changed files with 4058 additions and 2287 deletions
3
.github/workflows/mypy.yml
vendored
3
.github/workflows/mypy.yml
vendored
|
@ -5,10 +5,9 @@ on: [push, pull_request]
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ 'false' == 'true' }} # skip mypy for now
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v1
|
||||||
- uses: jpetrucciani/mypy-check@master
|
- uses: jpetrucciani/mypy-check@master
|
||||||
with:
|
with:
|
||||||
mypy_flags: '--install-types --non-interactive'
|
mypy_flags: '--install-types --non-interactive'
|
||||||
path: lnbits
|
path: 'lnbits'
|
||||||
|
|
2
.github/workflows/regtest.yml
vendored
2
.github/workflows/regtest.yml
vendored
|
@ -18,7 +18,6 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||||
cd docker
|
cd docker
|
||||||
git checkout removelnbits
|
|
||||||
chmod +x ./tests
|
chmod +x ./tests
|
||||||
./tests
|
./tests
|
||||||
sudo chmod -R a+rwx .
|
sudo chmod -R a+rwx .
|
||||||
|
@ -59,7 +58,6 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||||
cd docker
|
cd docker
|
||||||
git checkout removelnbits
|
|
||||||
chmod +x ./tests
|
chmod +x ./tests
|
||||||
./tests
|
./tests
|
||||||
sudo chmod -R a+rwx .
|
sudo chmod -R a+rwx .
|
||||||
|
|
59
.github/workflows/tests.yml
vendored
59
.github/workflows/tests.yml
vendored
|
@ -68,11 +68,11 @@ jobs:
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v3
|
||||||
with:
|
with:
|
||||||
file: ./coverage.xml
|
file: ./coverage.xml
|
||||||
pipenv-sqlite:
|
poetry-sqlite:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [3.7, 3.8, 3.9]
|
python-version: [3.9]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
@ -80,9 +80,56 @@ jobs:
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
env:
|
||||||
|
VIRTUAL_ENV: ./venv
|
||||||
|
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
||||||
run: |
|
run: |
|
||||||
pip install pipenv
|
python -m venv ${{ env.VIRTUAL_ENV }}
|
||||||
pipenv install --dev
|
./venv/bin/python -m pip install --upgrade pip
|
||||||
pipenv install importlib-metadata
|
./venv/bin/pip install -r requirements.txt
|
||||||
|
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: make test-pipenv
|
run: make test
|
||||||
|
poetry-postgres:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:latest
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: postgres
|
||||||
|
ports:
|
||||||
|
# maps tcp port 5432 on service container to the host
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: [3.9]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- name: Install dependencies
|
||||||
|
env:
|
||||||
|
VIRTUAL_ENV: ./venv
|
||||||
|
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
||||||
|
run: |
|
||||||
|
python -m venv ${{ env.VIRTUAL_ENV }}
|
||||||
|
./venv/bin/python -m pip install --upgrade pip
|
||||||
|
./venv/bin/pip install -r requirements.txt
|
||||||
|
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
|
||||||
|
- name: Run tests
|
||||||
|
env:
|
||||||
|
LNBITS_DATABASE_URL: postgres://postgres:postgres@0.0.0.0:5432/postgres
|
||||||
|
run: make test
|
||||||
|
- name: Upload coverage to Codecov
|
||||||
|
uses: codecov/codecov-action@v3
|
||||||
|
with:
|
||||||
|
file: ./coverage.xml
|
44
Pipfile
44
Pipfile
|
@ -1,44 +0,0 @@
|
||||||
[[source]]
|
|
||||||
name = "pypi"
|
|
||||||
url = "https://pypi.org/simple"
|
|
||||||
verify_ssl = true
|
|
||||||
|
|
||||||
[requires]
|
|
||||||
python_version = "3.8"
|
|
||||||
|
|
||||||
[packages]
|
|
||||||
bitstring = "*"
|
|
||||||
cerberus = "*"
|
|
||||||
ecdsa = "*"
|
|
||||||
environs = "*"
|
|
||||||
lnurl = "==0.3.6"
|
|
||||||
loguru = "*"
|
|
||||||
pyscss = "*"
|
|
||||||
shortuuid = "*"
|
|
||||||
typing-extensions = "*"
|
|
||||||
httpx = "*"
|
|
||||||
sqlalchemy-aio = "*"
|
|
||||||
embit = "*"
|
|
||||||
pyqrcode = "*"
|
|
||||||
pypng = "*"
|
|
||||||
sqlalchemy = "==1.3.23"
|
|
||||||
psycopg2-binary = "*"
|
|
||||||
aiofiles = "*"
|
|
||||||
asyncio = "*"
|
|
||||||
fastapi = "*"
|
|
||||||
uvicorn = {extras = ["standard"], version = "*"}
|
|
||||||
sse-starlette = "*"
|
|
||||||
jinja2 = "==3.0.1"
|
|
||||||
pyngrok = "*"
|
|
||||||
secp256k1 = "==0.14.0"
|
|
||||||
cffi = "==1.15.0"
|
|
||||||
pycryptodomex = "*"
|
|
||||||
|
|
||||||
[dev-packages]
|
|
||||||
black = "==20.8b1"
|
|
||||||
pytest = "*"
|
|
||||||
pytest-cov = "*"
|
|
||||||
mypy = "*"
|
|
||||||
pytest-asyncio = "*"
|
|
||||||
requests = "*"
|
|
||||||
mock = "*"
|
|
1157
Pipfile.lock
generated
1157
Pipfile.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -8,7 +8,7 @@ nav_order: 1
|
||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
This guide has been moved to the [installation guide](../guide/installation.md).
|
This guide has been moved to the [installation guide](../guide/installation.md).
|
||||||
To install the developer packages, use `pipenv install --dev`.
|
To install the developer packages for running tests etc before pr'ing, use `./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock black mypy isort`.
|
||||||
|
|
||||||
## Notes:
|
## Notes:
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ nav_order: 2
|
||||||
|
|
||||||
# Basic installation
|
# Basic installation
|
||||||
|
|
||||||
You can choose between four package managers, `poetry`, `pipenv`, `venv` and `nix`.
|
You can choose between four package managers, `poetry`, `nix` and `venv`.
|
||||||
|
|
||||||
By default, LNbits will use SQLite as its database. You can also use PostgreSQL which is recommended for applications with a high load (see guide below).
|
By default, LNbits will use SQLite as its database. You can also use PostgreSQL which is recommended for applications with a high load (see guide below).
|
||||||
|
|
||||||
|
@ -33,64 +33,13 @@ poetry run lnbits
|
||||||
# To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0'
|
# To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Option 2: pipenv
|
## Option 2: Nix
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/lnbits/lnbits-legend.git
|
git clone https://github.com/lnbits/lnbits-legend.git
|
||||||
cd lnbits-legend/
|
cd lnbits-legend/
|
||||||
|
# Modern debian distros usually include Nix, however you can install with:
|
||||||
sudo apt update && sudo apt install -y pipenv
|
# 'sh <(curl -L https://nixos.org/nix/install) --daemon', or use setup here https://nixos.org/download.html#nix-verify-installation
|
||||||
pipenv install --dev
|
|
||||||
# pipenv --python 3.9 install --dev (if you wish to use a version of Python higher than 3.7)
|
|
||||||
pipenv shell
|
|
||||||
# pipenv --python 3.9 shell (if you wish to use a version of Python higher than 3.7)
|
|
||||||
|
|
||||||
# If any of the modules fails to install, try checking and upgrading your setupTool module
|
|
||||||
# pip install -U setuptools wheel
|
|
||||||
|
|
||||||
# install libffi/libpq in case "pipenv install" fails
|
|
||||||
# sudo apt-get install -y libffi-dev libpq-dev
|
|
||||||
|
|
||||||
mkdir data && cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Running the server
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pipenv run python -m uvicorn lnbits.__main__:app --port 5000 --host 0.0.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
Add the flag `--reload` for development (includes hot-reload).
|
|
||||||
|
|
||||||
|
|
||||||
## Option 3: venv
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git clone https://github.com/lnbits/lnbits-legend.git
|
|
||||||
cd lnbits-legend/
|
|
||||||
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3-venv'
|
|
||||||
python3 -m venv venv
|
|
||||||
# If you have problems here, try `sudo apt install -y pkg-config libpq-dev`
|
|
||||||
./venv/bin/pip install -r requirements.txt
|
|
||||||
# create the data folder and the .env file
|
|
||||||
mkdir data && cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Running the server
|
|
||||||
|
|
||||||
```sh
|
|
||||||
./venv/bin/uvicorn lnbits.__main__:app --port 5000
|
|
||||||
```
|
|
||||||
|
|
||||||
If you want to host LNbits on the internet, run with the option `--host 0.0.0.0`.
|
|
||||||
|
|
||||||
## Option 4: Nix
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git clone https://github.com/lnbits/lnbits-legend.git
|
|
||||||
cd lnbits-legend/
|
|
||||||
# Install nix, modern debian distros usually already include
|
|
||||||
sh <(curl -L https://nixos.org/nix/install) --daemon
|
|
||||||
|
|
||||||
nix build .#lnbits
|
nix build .#lnbits
|
||||||
mkdir data
|
mkdir data
|
||||||
|
@ -104,6 +53,29 @@ mkdir data
|
||||||
LNBITS_DATA_FOLDER=data LNBITS_BACKEND_WALLET_CLASS=LNbitsWallet LNBITS_ENDPOINT=https://legend.lnbits.com LNBITS_KEY=7b1a78d6c78f48b09a202f2dcb2d22eb ./result/bin/lnbits --port 9000
|
LNBITS_DATA_FOLDER=data LNBITS_BACKEND_WALLET_CLASS=LNbitsWallet LNBITS_ENDPOINT=https://legend.lnbits.com LNBITS_KEY=7b1a78d6c78f48b09a202f2dcb2d22eb ./result/bin/lnbits --port 9000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Option 3: venv
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/lnbits/lnbits-legend.git
|
||||||
|
cd lnbits-legend/
|
||||||
|
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3-venv'
|
||||||
|
python3 -m venv venv
|
||||||
|
# If you have problems here, try `sudo apt install -y pkg-config libpq-dev`
|
||||||
|
./venv/bin/pip install -r requirements.txt
|
||||||
|
# create the data folder and the .env file
|
||||||
|
mkdir data && cp .env.example .env
|
||||||
|
# build the static files
|
||||||
|
./venv/bin/python build.py
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Running the server
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./venv/bin/uvicorn lnbits.__main__:app --port 5000
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want to host LNbits on the internet, run with the option `--host 0.0.0.0`.
|
||||||
|
|
||||||
### Troubleshooting
|
### Troubleshooting
|
||||||
|
|
||||||
Problems installing? These commands have helped us install LNbits.
|
Problems installing? These commands have helped us install LNbits.
|
||||||
|
@ -112,10 +84,10 @@ Problems installing? These commands have helped us install LNbits.
|
||||||
sudo apt install pkg-config libffi-dev libpq-dev
|
sudo apt install pkg-config libffi-dev libpq-dev
|
||||||
|
|
||||||
# if the secp256k1 build fails:
|
# if the secp256k1 build fails:
|
||||||
# if you used pipenv (option 1)
|
# if you used venv
|
||||||
pipenv install setuptools wheel
|
|
||||||
# if you used venv (option 2)
|
|
||||||
./venv/bin/pip install setuptools wheel
|
./venv/bin/pip install setuptools wheel
|
||||||
|
# if you used poetry
|
||||||
|
poetry add setuptools wheel
|
||||||
# build essentials for debian/ubuntu
|
# build essentials for debian/ubuntu
|
||||||
sudo apt install python3-dev gcc build-essential
|
sudo apt install python3-dev gcc build-essential
|
||||||
```
|
```
|
||||||
|
|
|
@ -17,7 +17,6 @@ from loguru import logger
|
||||||
import lnbits.settings
|
import lnbits.settings
|
||||||
from lnbits.core.tasks import register_task_listeners
|
from lnbits.core.tasks import register_task_listeners
|
||||||
|
|
||||||
from .commands import db_migrate, handle_assets
|
|
||||||
from .core import core_app
|
from .core import core_app
|
||||||
from .core.views.generic import core_html_routes
|
from .core.views.generic import core_html_routes
|
||||||
from .helpers import (
|
from .helpers import (
|
||||||
|
@ -93,7 +92,6 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||||
check_funding_source(app)
|
check_funding_source(app)
|
||||||
register_assets(app)
|
register_assets(app)
|
||||||
register_routes(app)
|
register_routes(app)
|
||||||
# register_commands(app)
|
|
||||||
register_async_tasks(app)
|
register_async_tasks(app)
|
||||||
register_exception_handlers(app)
|
register_exception_handlers(app)
|
||||||
|
|
||||||
|
@ -146,12 +144,6 @@ def register_routes(app: FastAPI) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def register_commands(app: FastAPI):
|
|
||||||
"""Register Click commands."""
|
|
||||||
app.cli.add_command(db_migrate)
|
|
||||||
app.cli.add_command(handle_assets)
|
|
||||||
|
|
||||||
|
|
||||||
def register_assets(app: FastAPI):
|
def register_assets(app: FastAPI):
|
||||||
"""Serve each vendored asset separately or a bundle."""
|
"""Serve each vendored asset separately or a bundle."""
|
||||||
|
|
||||||
|
|
|
@ -61,7 +61,7 @@ def decode(pr: str) -> Invoice:
|
||||||
invoice = Invoice()
|
invoice = Invoice()
|
||||||
|
|
||||||
# decode the amount from the hrp
|
# decode the amount from the hrp
|
||||||
m = re.search("[^\d]+", hrp[2:])
|
m = re.search(r"[^\d]+", hrp[2:])
|
||||||
if m:
|
if m:
|
||||||
amountstr = hrp[2 + m.end() :]
|
amountstr = hrp[2 + m.end() :]
|
||||||
if amountstr != "":
|
if amountstr != "":
|
||||||
|
@ -296,7 +296,7 @@ def _unshorten_amount(amount: str) -> int:
|
||||||
# BOLT #11:
|
# BOLT #11:
|
||||||
# A reader SHOULD fail if `amount` contains a non-digit, or is followed by
|
# A reader SHOULD fail if `amount` contains a non-digit, or is followed by
|
||||||
# anything except a `multiplier` in the table above.
|
# anything except a `multiplier` in the table above.
|
||||||
if not re.fullmatch("\d+[pnum]?", str(amount)):
|
if not re.fullmatch(r"\d+[pnum]?", str(amount)):
|
||||||
raise ValueError("Invalid amount '{}'".format(amount))
|
raise ValueError("Invalid amount '{}'".format(amount))
|
||||||
|
|
||||||
if unit in units:
|
if unit in units:
|
||||||
|
|
|
@ -113,7 +113,7 @@ async def create_wallet(
|
||||||
async def update_wallet(
|
async def update_wallet(
|
||||||
wallet_id: str, new_name: str, conn: Optional[Connection] = None
|
wallet_id: str, new_name: str, conn: Optional[Connection] = None
|
||||||
) -> Optional[Wallet]:
|
) -> Optional[Wallet]:
|
||||||
await (conn or db).execute(
|
return await (conn or db).execute(
|
||||||
"""
|
"""
|
||||||
UPDATE wallets SET
|
UPDATE wallets SET
|
||||||
name = ?
|
name = ?
|
||||||
|
|
|
@ -106,6 +106,8 @@ class Payment(BaseModel):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tag(self) -> Optional[str]:
|
def tag(self) -> Optional[str]:
|
||||||
|
if self.extra is None:
|
||||||
|
return ""
|
||||||
return self.extra.get("tag")
|
return self.extra.get("tag")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
@ -109,18 +109,15 @@ async def pay_invoice(
|
||||||
raise ValueError("Amount in invoice is too high.")
|
raise ValueError("Amount in invoice is too high.")
|
||||||
|
|
||||||
# put all parameters that don't change here
|
# put all parameters that don't change here
|
||||||
PaymentKwargs = TypedDict(
|
class PaymentKwargs(TypedDict):
|
||||||
"PaymentKwargs",
|
wallet_id: str
|
||||||
{
|
payment_request: str
|
||||||
"wallet_id": str,
|
payment_hash: str
|
||||||
"payment_request": str,
|
amount: int
|
||||||
"payment_hash": str,
|
memo: str
|
||||||
"amount": int,
|
extra: Optional[Dict]
|
||||||
"memo": str,
|
|
||||||
"extra": Optional[Dict],
|
payment_kwargs: PaymentKwargs = PaymentKwargs(
|
||||||
},
|
|
||||||
)
|
|
||||||
payment_kwargs: PaymentKwargs = dict(
|
|
||||||
wallet_id=wallet_id,
|
wallet_id=wallet_id,
|
||||||
payment_request=payment_request,
|
payment_request=payment_request,
|
||||||
payment_hash=invoice.payment_hash,
|
payment_hash=invoice.payment_hash,
|
||||||
|
@ -272,6 +269,7 @@ async def perform_lnurlauth(
|
||||||
cb = urlparse(callback)
|
cb = urlparse(callback)
|
||||||
|
|
||||||
k1 = unhexlify(parse_qs(cb.query)["k1"][0])
|
k1 = unhexlify(parse_qs(cb.query)["k1"][0])
|
||||||
|
|
||||||
key = wallet.wallet.lnurlauth_key(cb.netloc)
|
key = wallet.wallet.lnurlauth_key(cb.netloc)
|
||||||
|
|
||||||
def int_to_bytes_suitable_der(x: int) -> bytes:
|
def int_to_bytes_suitable_der(x: int) -> bytes:
|
||||||
|
|
|
@ -55,7 +55,7 @@ async def dispatch_webhook(payment: Payment):
|
||||||
data = payment.dict()
|
data = payment.dict()
|
||||||
try:
|
try:
|
||||||
logger.debug("sending webhook", payment.webhook)
|
logger.debug("sending webhook", payment.webhook)
|
||||||
r = await client.post(payment.webhook, json=data, timeout=40)
|
r = await client.post(payment.webhook, json=data, timeout=40) # type: ignore
|
||||||
await mark_webhook_sent(payment, r.status_code)
|
await mark_webhook_sent(payment, r.status_code)
|
||||||
except (httpx.ConnectError, httpx.RequestError):
|
except (httpx.ConnectError, httpx.RequestError):
|
||||||
await mark_webhook_sent(payment, -1)
|
await mark_webhook_sent(payment, -1)
|
||||||
|
|
|
@ -3,10 +3,12 @@ import hashlib
|
||||||
import json
|
import json
|
||||||
from binascii import unhexlify
|
from binascii import unhexlify
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Dict, List, Optional, Union
|
from io import BytesIO
|
||||||
|
from typing import Dict, List, Optional, Tuple, Union
|
||||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import pyqrcode
|
||||||
from fastapi import Depends, Header, Query, Request
|
from fastapi import Depends, Header, Query, Request
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
from fastapi.params import Body
|
from fastapi.params import Body
|
||||||
|
@ -14,6 +16,7 @@ from loguru import logger
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from pydantic.fields import Field
|
from pydantic.fields import Field
|
||||||
from sse_starlette.sse import EventSourceResponse
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
from starlette.responses import HTMLResponse, StreamingResponse
|
||||||
|
|
||||||
from lnbits import bolt11, lnurl
|
from lnbits import bolt11, lnurl
|
||||||
from lnbits.core.models import Payment, Wallet
|
from lnbits.core.models import Payment, Wallet
|
||||||
|
@ -185,7 +188,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||||
assert (
|
assert (
|
||||||
data.lnurl_balance_check is not None
|
data.lnurl_balance_check is not None
|
||||||
), "lnurl_balance_check is required"
|
), "lnurl_balance_check is required"
|
||||||
save_balance_check(wallet.id, data.lnurl_balance_check)
|
await save_balance_check(wallet.id, data.lnurl_balance_check)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
try:
|
||||||
|
@ -248,7 +251,7 @@ async def api_payments_pay_invoice(bolt11: str, wallet: Wallet):
|
||||||
)
|
)
|
||||||
async def api_payments_create(
|
async def api_payments_create(
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
invoiceData: CreateInvoiceData = Body(...),
|
invoiceData: CreateInvoiceData = Body(...), # type: ignore
|
||||||
):
|
):
|
||||||
if invoiceData.out is True and wallet.wallet_type == 0:
|
if invoiceData.out is True and wallet.wallet_type == 0:
|
||||||
if not invoiceData.bolt11:
|
if not invoiceData.bolt11:
|
||||||
|
@ -291,7 +294,7 @@ async def api_payments_pay_lnurl(
|
||||||
timeout=40,
|
timeout=40,
|
||||||
)
|
)
|
||||||
if r.is_error:
|
if r.is_error:
|
||||||
raise httpx.ConnectError
|
raise httpx.ConnectError("LNURL callback connection error")
|
||||||
except (httpx.ConnectError, httpx.RequestError):
|
except (httpx.ConnectError, httpx.RequestError):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
@ -354,7 +357,7 @@ async def subscribe(request: Request, wallet: Wallet):
|
||||||
logger.debug("adding sse listener", payment_queue)
|
logger.debug("adding sse listener", payment_queue)
|
||||||
api_invoice_listeners.append(payment_queue)
|
api_invoice_listeners.append(payment_queue)
|
||||||
|
|
||||||
send_queue: asyncio.Queue[tuple[str, Payment]] = asyncio.Queue(0)
|
send_queue: asyncio.Queue[Tuple[str, Payment]] = asyncio.Queue(0)
|
||||||
|
|
||||||
async def payment_received() -> None:
|
async def payment_received() -> None:
|
||||||
while True:
|
while True:
|
||||||
|
@ -393,16 +396,13 @@ async def api_payments_sse(
|
||||||
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
||||||
# We use X_Api_Key here because we want this call to work with and without keys
|
# We use X_Api_Key here because we want this call to work with and without keys
|
||||||
# If a valid key is given, we also return the field "details", otherwise not
|
# If a valid key is given, we also return the field "details", otherwise not
|
||||||
wallet = None
|
wallet = await get_wallet_for_key(X_Api_Key) if type(X_Api_Key) == str else None
|
||||||
try:
|
|
||||||
if X_Api_Key.extra:
|
# we have to specify the wallet id here, because postgres and sqlite return internal payments in different order
|
||||||
logger.warning("No key")
|
# and get_standalone_payment otherwise just fetches the first one, causing unpredictable results
|
||||||
except:
|
|
||||||
wallet = await get_wallet_for_key(X_Api_Key)
|
|
||||||
payment = await get_standalone_payment(
|
payment = await get_standalone_payment(
|
||||||
payment_hash, wallet_id=wallet.id if wallet else None
|
payment_hash, wallet_id=wallet.id if wallet else None
|
||||||
) # we have to specify the wallet id here, because postgres and sqlite return internal payments in different order
|
)
|
||||||
# and get_standalone_payment otherwise just fetches the first one, causing unpredictable results
|
|
||||||
if payment is None:
|
if payment is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||||
|
@ -488,7 +488,8 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tag = data["tag"]
|
tag: str = data.get("tag")
|
||||||
|
params.update(**data)
|
||||||
if tag == "channelRequest":
|
if tag == "channelRequest":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
@ -498,10 +499,7 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
||||||
"message": "unsupported",
|
"message": "unsupported",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
elif tag == "withdrawRequest":
|
||||||
params.update(**data)
|
|
||||||
|
|
||||||
if tag == "withdrawRequest":
|
|
||||||
params.update(kind="withdraw")
|
params.update(kind="withdraw")
|
||||||
params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"])
|
params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"])
|
||||||
|
|
||||||
|
@ -519,8 +517,7 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
||||||
query=urlencode(qs, doseq=True)
|
query=urlencode(qs, doseq=True)
|
||||||
)
|
)
|
||||||
params.update(callback=urlunparse(parsed_callback))
|
params.update(callback=urlunparse(parsed_callback))
|
||||||
|
elif tag == "payRequest":
|
||||||
if tag == "payRequest":
|
|
||||||
params.update(kind="pay")
|
params.update(kind="pay")
|
||||||
params.update(fixed=data["minSendable"] == data["maxSendable"])
|
params.update(fixed=data["minSendable"] == data["maxSendable"])
|
||||||
|
|
||||||
|
@ -538,8 +535,8 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
||||||
params.update(image=data_uri)
|
params.update(image=data_uri)
|
||||||
if k == "text/email" or k == "text/identifier":
|
if k == "text/email" or k == "text/identifier":
|
||||||
params.update(targetUser=v)
|
params.update(targetUser=v)
|
||||||
|
|
||||||
params.update(commentAllowed=data.get("commentAllowed", 0))
|
params.update(commentAllowed=data.get("commentAllowed", 0))
|
||||||
|
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
|
@ -612,8 +609,8 @@ class ConversionData(BaseModel):
|
||||||
async def api_fiat_as_sats(data: ConversionData):
|
async def api_fiat_as_sats(data: ConversionData):
|
||||||
output = {}
|
output = {}
|
||||||
if data.from_ == "sat":
|
if data.from_ == "sat":
|
||||||
output["sats"] = int(data.amount)
|
|
||||||
output["BTC"] = data.amount / 100000000
|
output["BTC"] = data.amount / 100000000
|
||||||
|
output["sats"] = int(data.amount)
|
||||||
for currency in data.to.split(","):
|
for currency in data.to.split(","):
|
||||||
output[currency.strip().upper()] = await satoshis_amount_as_fiat(
|
output[currency.strip().upper()] = await satoshis_amount_as_fiat(
|
||||||
data.amount, currency.strip()
|
data.amount, currency.strip()
|
||||||
|
@ -624,3 +621,24 @@ async def api_fiat_as_sats(data: ConversionData):
|
||||||
output["sats"] = await fiat_amount_as_satoshis(data.amount, data.from_)
|
output["sats"] = await fiat_amount_as_satoshis(data.amount, data.from_)
|
||||||
output["BTC"] = output["sats"] / 100000000
|
output["BTC"] = output["sats"] / 100000000
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
@core_app.get("/api/v1/qrcode/{data}", response_class=StreamingResponse)
|
||||||
|
async def img(request: Request, data):
|
||||||
|
qr = pyqrcode.create(data)
|
||||||
|
stream = BytesIO()
|
||||||
|
qr.svg(stream, scale=3)
|
||||||
|
stream.seek(0)
|
||||||
|
|
||||||
|
async def _generator(stream: BytesIO):
|
||||||
|
yield stream.getvalue()
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
_generator(stream),
|
||||||
|
headers={
|
||||||
|
"Content-Type": "image/svg+xml",
|
||||||
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Expires": "0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
|
@ -55,9 +55,9 @@ async def home(request: Request, lightning: str = None):
|
||||||
)
|
)
|
||||||
async def extensions(
|
async def extensions(
|
||||||
request: Request,
|
request: Request,
|
||||||
user: User = Depends(check_user_exists),
|
user: User = Depends(check_user_exists), # type: ignore
|
||||||
enable: str = Query(None),
|
enable: str = Query(None), # type: ignore
|
||||||
disable: str = Query(None),
|
disable: str = Query(None), # type: ignore
|
||||||
):
|
):
|
||||||
extension_to_enable = enable
|
extension_to_enable = enable
|
||||||
extension_to_disable = disable
|
extension_to_disable = disable
|
||||||
|
@ -88,7 +88,7 @@ async def extensions(
|
||||||
|
|
||||||
# Update user as his extensions have been updated
|
# Update user as his extensions have been updated
|
||||||
if extension_to_enable or extension_to_disable:
|
if extension_to_enable or extension_to_disable:
|
||||||
user = await get_user(user.id)
|
user = await get_user(user.id) # type: ignore
|
||||||
|
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
"core/extensions.html", {"request": request, "user": user.dict()}
|
"core/extensions.html", {"request": request, "user": user.dict()}
|
||||||
|
@ -109,10 +109,10 @@ nothing: create everything<br>
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
async def wallet(
|
async def wallet(
|
||||||
request: Request = Query(None),
|
request: Request = Query(None), # type: ignore
|
||||||
nme: Optional[str] = Query(None),
|
nme: Optional[str] = Query(None), # type: ignore
|
||||||
usr: Optional[UUID4] = Query(None),
|
usr: Optional[UUID4] = Query(None), # type: ignore
|
||||||
wal: Optional[UUID4] = Query(None),
|
wal: Optional[UUID4] = Query(None), # type: ignore
|
||||||
):
|
):
|
||||||
user_id = usr.hex if usr else None
|
user_id = usr.hex if usr else None
|
||||||
wallet_id = wal.hex if wal else None
|
wallet_id = wal.hex if wal else None
|
||||||
|
@ -121,7 +121,7 @@ async def wallet(
|
||||||
|
|
||||||
if not user_id:
|
if not user_id:
|
||||||
user = await get_user((await create_account()).id)
|
user = await get_user((await create_account()).id)
|
||||||
logger.info(f"Create user {user.id}")
|
logger.info(f"Create user {user.id}") # type: ignore
|
||||||
else:
|
else:
|
||||||
user = await get_user(user_id)
|
user = await get_user(user_id)
|
||||||
if not user:
|
if not user:
|
||||||
|
@ -135,22 +135,22 @@ async def wallet(
|
||||||
if LNBITS_ADMIN_USERS and user_id in LNBITS_ADMIN_USERS:
|
if LNBITS_ADMIN_USERS and user_id in LNBITS_ADMIN_USERS:
|
||||||
user.admin = True
|
user.admin = True
|
||||||
if not wallet_id:
|
if not wallet_id:
|
||||||
if user.wallets and not wallet_name:
|
if user.wallets and not wallet_name: # type: ignore
|
||||||
wallet = user.wallets[0]
|
wallet = user.wallets[0] # type: ignore
|
||||||
else:
|
else:
|
||||||
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name)
|
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name) # type: ignore
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Created new wallet {wallet_name if wallet_name else '(no name)'} for user {user.id}"
|
f"Created new wallet {wallet_name if wallet_name else '(no name)'} for user {user.id}" # type: ignore
|
||||||
)
|
)
|
||||||
|
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
f"/wallet?usr={user.id}&wal={wallet.id}",
|
f"/wallet?usr={user.id}&wal={wallet.id}", # type: ignore
|
||||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
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 wallet {wallet_name}{'of user '+ user.id if user else ''}")
|
||||||
wallet = user.get_wallet(wallet_id)
|
userwallet = user.get_wallet(wallet_id) # type: ignore
|
||||||
if not wallet:
|
if not userwallet:
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
"error.html", {"request": request, "err": "Wallet not found"}
|
"error.html", {"request": request, "err": "Wallet not found"}
|
||||||
)
|
)
|
||||||
|
@ -159,10 +159,10 @@ async def wallet(
|
||||||
"core/wallet.html",
|
"core/wallet.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"user": user.dict(),
|
"user": user.dict(), # type: ignore
|
||||||
"wallet": wallet.dict(),
|
"wallet": userwallet.dict(),
|
||||||
"service_fee": service_fee,
|
"service_fee": service_fee,
|
||||||
"web_manifest": f"/manifest/{user.id}.webmanifest",
|
"web_manifest": f"/manifest/{user.id}.webmanifest", # type: ignore
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -216,20 +216,20 @@ async def lnurl_full_withdraw_callback(request: Request):
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/deletewallet", response_class=RedirectResponse)
|
@core_html_routes.get("/deletewallet", response_class=RedirectResponse)
|
||||||
async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query(...)):
|
async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query(...)): # type: ignore
|
||||||
user = await get_user(usr)
|
user = await get_user(usr)
|
||||||
user_wallet_ids = [u.id for u in user.wallets]
|
user_wallet_ids = [u.id for u in user.wallets] # type: ignore
|
||||||
|
|
||||||
if wal not in user_wallet_ids:
|
if wal not in user_wallet_ids:
|
||||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
||||||
else:
|
else:
|
||||||
await delete_wallet(user_id=user.id, wallet_id=wal)
|
await delete_wallet(user_id=user.id, wallet_id=wal) # type: ignore
|
||||||
user_wallet_ids.remove(wal)
|
user_wallet_ids.remove(wal)
|
||||||
logger.debug("Deleted wallet {wal} of user {user.id}")
|
logger.debug("Deleted wallet {wal} of user {user.id}")
|
||||||
|
|
||||||
if user_wallet_ids:
|
if user_wallet_ids:
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]),
|
url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]), # type: ignore
|
||||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -242,7 +242,7 @@ async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query
|
||||||
async def lnurl_balance_notify(request: Request, service: str):
|
async def lnurl_balance_notify(request: Request, service: str):
|
||||||
bc = await get_balance_check(request.query_params.get("wal"), service)
|
bc = await get_balance_check(request.query_params.get("wal"), service)
|
||||||
if bc:
|
if bc:
|
||||||
redeem_lnurl_withdraw(bc.wallet, bc.url)
|
await redeem_lnurl_withdraw(bc.wallet, bc.url)
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get(
|
@core_html_routes.get(
|
||||||
|
@ -252,7 +252,7 @@ async def lnurlwallet(request: Request):
|
||||||
async with db.connect() as conn:
|
async with db.connect() as conn:
|
||||||
account = await create_account(conn=conn)
|
account = await create_account(conn=conn)
|
||||||
user = await get_user(account.id, conn=conn)
|
user = await get_user(account.id, conn=conn)
|
||||||
wallet = await create_wallet(user_id=user.id, conn=conn)
|
wallet = await create_wallet(user_id=user.id, conn=conn) # type: ignore
|
||||||
|
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
redeem_lnurl_withdraw(
|
redeem_lnurl_withdraw(
|
||||||
|
@ -265,7 +265,7 @@ async def lnurlwallet(request: Request):
|
||||||
)
|
)
|
||||||
|
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
f"/wallet?usr={user.id}&wal={wallet.id}",
|
f"/wallet?usr={user.id}&wal={wallet.id}", # type: ignore
|
||||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
from cerberus import Validator # type: ignore
|
from cerberus import Validator # type: ignore
|
||||||
from fastapi import status
|
from fastapi import status
|
||||||
|
@ -29,20 +30,21 @@ class KeyChecker(SecurityBase):
|
||||||
self._key_type = "invoice"
|
self._key_type = "invoice"
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
if api_key:
|
if api_key:
|
||||||
self.model: APIKey = APIKey(
|
key = APIKey(
|
||||||
**{"in": APIKeyIn.query},
|
**{"in": APIKeyIn.query},
|
||||||
name="X-API-KEY",
|
name="X-API-KEY",
|
||||||
description="Wallet API Key - QUERY",
|
description="Wallet API Key - QUERY",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.model: APIKey = APIKey(
|
key = APIKey(
|
||||||
**{"in": APIKeyIn.header},
|
**{"in": APIKeyIn.header},
|
||||||
name="X-API-KEY",
|
name="X-API-KEY",
|
||||||
description="Wallet API Key - HEADER",
|
description="Wallet API Key - HEADER",
|
||||||
)
|
)
|
||||||
self.wallet = None
|
self.wallet = None # type: ignore
|
||||||
|
self.model: APIKey = key
|
||||||
|
|
||||||
async def __call__(self, request: Request) -> Wallet:
|
async def __call__(self, request: Request):
|
||||||
try:
|
try:
|
||||||
key_value = (
|
key_value = (
|
||||||
self._api_key
|
self._api_key
|
||||||
|
@ -52,7 +54,7 @@ class KeyChecker(SecurityBase):
|
||||||
# FIXME: Find another way to validate the key. A fetch from DB should be avoided here.
|
# FIXME: Find another way to validate the key. A fetch from DB should be avoided here.
|
||||||
# Also, we should not return the wallet here - thats silly.
|
# Also, we should not return the wallet here - thats silly.
|
||||||
# Possibly store it in a Redis DB
|
# Possibly store it in a Redis DB
|
||||||
self.wallet = await get_wallet_for_key(key_value, self._key_type)
|
self.wallet = await get_wallet_for_key(key_value, self._key_type) # type: ignore
|
||||||
if not self.wallet:
|
if not self.wallet:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.UNAUTHORIZED,
|
status_code=HTTPStatus.UNAUTHORIZED,
|
||||||
|
@ -120,8 +122,8 @@ api_key_query = APIKeyQuery(
|
||||||
|
|
||||||
async def get_key_type(
|
async def get_key_type(
|
||||||
r: Request,
|
r: Request,
|
||||||
api_key_header: str = Security(api_key_header),
|
api_key_header: str = Security(api_key_header), # type: ignore
|
||||||
api_key_query: str = Security(api_key_query),
|
api_key_query: str = Security(api_key_query), # type: ignore
|
||||||
) -> WalletTypeInfo:
|
) -> WalletTypeInfo:
|
||||||
# 0: admin
|
# 0: admin
|
||||||
# 1: invoice
|
# 1: invoice
|
||||||
|
@ -134,9 +136,9 @@ async def get_key_type(
|
||||||
token = api_key_header if api_key_header else api_key_query
|
token = api_key_header if api_key_header else api_key_query
|
||||||
|
|
||||||
try:
|
try:
|
||||||
checker = WalletAdminKeyChecker(api_key=token)
|
admin_checker = WalletAdminKeyChecker(api_key=token)
|
||||||
await checker.__call__(r)
|
await admin_checker.__call__(r)
|
||||||
wallet = WalletTypeInfo(0, checker.wallet)
|
wallet = WalletTypeInfo(0, admin_checker.wallet) # type: ignore
|
||||||
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (
|
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (
|
||||||
LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS
|
LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS
|
||||||
):
|
):
|
||||||
|
@ -153,9 +155,9 @@ async def get_key_type(
|
||||||
raise
|
raise
|
||||||
|
|
||||||
try:
|
try:
|
||||||
checker = WalletInvoiceKeyChecker(api_key=token)
|
invoice_checker = WalletInvoiceKeyChecker(api_key=token)
|
||||||
await checker.__call__(r)
|
await invoice_checker.__call__(r)
|
||||||
wallet = WalletTypeInfo(1, checker.wallet)
|
wallet = WalletTypeInfo(1, invoice_checker.wallet) # type: ignore
|
||||||
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (
|
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (
|
||||||
LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS
|
LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS
|
||||||
):
|
):
|
||||||
|
@ -167,15 +169,16 @@ async def get_key_type(
|
||||||
if e.status_code == HTTPStatus.BAD_REQUEST:
|
if e.status_code == HTTPStatus.BAD_REQUEST:
|
||||||
raise
|
raise
|
||||||
if e.status_code == HTTPStatus.UNAUTHORIZED:
|
if e.status_code == HTTPStatus.UNAUTHORIZED:
|
||||||
return WalletTypeInfo(2, None)
|
return WalletTypeInfo(2, None) # type: ignore
|
||||||
except:
|
except:
|
||||||
raise
|
raise
|
||||||
|
return wallet
|
||||||
|
|
||||||
|
|
||||||
async def require_admin_key(
|
async def require_admin_key(
|
||||||
r: Request,
|
r: Request,
|
||||||
api_key_header: str = Security(api_key_header),
|
api_key_header: str = Security(api_key_header), # type: ignore
|
||||||
api_key_query: str = Security(api_key_query),
|
api_key_query: str = Security(api_key_query), # type: ignore
|
||||||
):
|
):
|
||||||
token = api_key_header if api_key_header else api_key_query
|
token = api_key_header if api_key_header else api_key_query
|
||||||
|
|
||||||
|
@ -193,8 +196,8 @@ async def require_admin_key(
|
||||||
|
|
||||||
async def require_invoice_key(
|
async def require_invoice_key(
|
||||||
r: Request,
|
r: Request,
|
||||||
api_key_header: str = Security(api_key_header),
|
api_key_header: str = Security(api_key_header), # type: ignore
|
||||||
api_key_query: str = Security(api_key_query),
|
api_key_query: str = Security(api_key_query), # type: ignore
|
||||||
):
|
):
|
||||||
token = api_key_header if api_key_header else api_key_query
|
token = api_key_header if api_key_header else api_key_query
|
||||||
|
|
||||||
|
|
|
@ -133,7 +133,8 @@ async def api_ticket_send_ticket(event_id, payment_hash, data: CreateTicket):
|
||||||
|
|
||||||
if not ticket:
|
if not ticket:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail=f"Event could not be fetched."
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail=f"Event could not be fetched.",
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"paid": True, "ticket_id": ticket.id}
|
return {"paid": True, "ticket_id": ticket.id}
|
||||||
|
|
|
@ -4,12 +4,7 @@
|
||||||
label="API info"
|
label="API info"
|
||||||
:content-inset-level="0.5"
|
:content-inset-level="0.5"
|
||||||
>
|
>
|
||||||
<q-btn
|
<q-btn flat label="Swagger API" type="a" href="../docs#/lnurlpayout"></q-btn>
|
||||||
flat
|
|
||||||
label="Swagger API"
|
|
||||||
type="a"
|
|
||||||
href="../docs#/lnurlpayout"
|
|
||||||
></q-btn>
|
|
||||||
<q-expansion-item group="api" dense expand-separator label="List lnurlpayout">
|
<q-expansion-item group="api" dense expand-separator label="List lnurlpayout">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
|
@ -38,7 +33,6 @@
|
||||||
expand-separator
|
expand-separator
|
||||||
label="Create a lnurlpayout"
|
label="Create a lnurlpayout"
|
||||||
>
|
>
|
||||||
|
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<code
|
<code
|
||||||
|
|
|
@ -52,14 +52,20 @@ async def set_method(shop: int, method: str, wordlist: str = "") -> Optional[Sho
|
||||||
|
|
||||||
|
|
||||||
async def add_item(
|
async def add_item(
|
||||||
shop: int, name: str, description: str, image: Optional[str], price: int, unit: str
|
shop: int,
|
||||||
|
name: str,
|
||||||
|
description: str,
|
||||||
|
image: Optional[str],
|
||||||
|
price: int,
|
||||||
|
unit: str,
|
||||||
|
fiat_base_multiplier: int,
|
||||||
) -> int:
|
) -> int:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO offlineshop.items (shop, name, description, image, price, unit)
|
INSERT INTO offlineshop.items (shop, name, description, image, price, unit, fiat_base_multiplier)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(shop, name, description, image, price, unit),
|
(shop, name, description, image, price, unit, fiat_base_multiplier),
|
||||||
)
|
)
|
||||||
return result._result_proxy.lastrowid
|
return result._result_proxy.lastrowid
|
||||||
|
|
||||||
|
@ -72,6 +78,7 @@ async def update_item(
|
||||||
image: Optional[str],
|
image: Optional[str],
|
||||||
price: int,
|
price: int,
|
||||||
unit: str,
|
unit: str,
|
||||||
|
fiat_base_multiplier: int,
|
||||||
) -> int:
|
) -> int:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
|
@ -80,10 +87,11 @@ async def update_item(
|
||||||
description = ?,
|
description = ?,
|
||||||
image = ?,
|
image = ?,
|
||||||
price = ?,
|
price = ?,
|
||||||
unit = ?
|
unit = ?,
|
||||||
|
fiat_base_multiplier = ?
|
||||||
WHERE shop = ? AND id = ?
|
WHERE shop = ? AND id = ?
|
||||||
""",
|
""",
|
||||||
(name, description, image, price, unit, shop, item_id),
|
(name, description, image, price, unit, fiat_base_multiplier, shop, item_id),
|
||||||
)
|
)
|
||||||
return item_id
|
return item_id
|
||||||
|
|
||||||
|
@ -92,12 +100,12 @@ async def get_item(id: int) -> Optional[Item]:
|
||||||
row = await db.fetchone(
|
row = await db.fetchone(
|
||||||
"SELECT * FROM offlineshop.items WHERE id = ? LIMIT 1", (id,)
|
"SELECT * FROM offlineshop.items WHERE id = ? LIMIT 1", (id,)
|
||||||
)
|
)
|
||||||
return Item(**dict(row)) if row else None
|
return Item.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def get_items(shop: int) -> List[Item]:
|
async def get_items(shop: int) -> List[Item]:
|
||||||
rows = await db.fetchall("SELECT * FROM offlineshop.items WHERE shop = ?", (shop,))
|
rows = await db.fetchall("SELECT * FROM offlineshop.items WHERE shop = ?", (shop,))
|
||||||
return [Item(**dict(row)) for row in rows]
|
return [Item.from_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
async def delete_item_from_shop(shop: int, item_id: int):
|
async def delete_item_from_shop(shop: int, item_id: int):
|
||||||
|
|
|
@ -27,3 +27,13 @@ async def m001_initial(db):
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def m002_fiat_base_multiplier(db):
|
||||||
|
"""
|
||||||
|
Store the multiplier for fiat prices. We store the price in cents and
|
||||||
|
remember to multiply by 100 when we use it to convert to Dollars.
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE offlineshop.items ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;"
|
||||||
|
)
|
||||||
|
|
|
@ -2,6 +2,7 @@ import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from sqlite3 import Row
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from lnurl import encode as lnurl_encode # type: ignore
|
from lnurl import encode as lnurl_encode # type: ignore
|
||||||
|
@ -87,8 +88,16 @@ class Item(BaseModel):
|
||||||
description: str
|
description: str
|
||||||
image: Optional[str]
|
image: Optional[str]
|
||||||
enabled: bool
|
enabled: bool
|
||||||
price: int
|
price: float
|
||||||
unit: str
|
unit: str
|
||||||
|
fiat_base_multiplier: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row) -> "Item":
|
||||||
|
data = dict(row)
|
||||||
|
if data["unit"] != "sat" and data["fiat_base_multiplier"]:
|
||||||
|
data["price"] /= data["fiat_base_multiplier"]
|
||||||
|
return cls(**data)
|
||||||
|
|
||||||
def lnurl(self, req: Request) -> str:
|
def lnurl(self, req: Request) -> str:
|
||||||
return lnurl_encode(req.url_for("offlineshop.lnurl_response", item_id=self.id))
|
return lnurl_encode(req.url_for("offlineshop.lnurl_response", item_id=self.id))
|
||||||
|
|
|
@ -124,7 +124,8 @@ new Vue({
|
||||||
description,
|
description,
|
||||||
image,
|
image,
|
||||||
price,
|
price,
|
||||||
unit
|
unit,
|
||||||
|
fiat_base_multiplier: unit == 'sat' ? 1 : 100
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import Query
|
||||||
from fastapi.params import Depends
|
from fastapi.params import Depends
|
||||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
|
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
|
||||||
from pydantic.main import BaseModel
|
from pydantic.main import BaseModel
|
||||||
|
@ -34,7 +35,6 @@ async def api_shop_from_wallet(
|
||||||
):
|
):
|
||||||
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
|
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
|
||||||
items = await get_items(shop.id)
|
items = await get_items(shop.id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return {
|
return {
|
||||||
**shop.dict(),
|
**shop.dict(),
|
||||||
|
@ -51,8 +51,9 @@ class CreateItemsData(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
image: Optional[str]
|
image: Optional[str]
|
||||||
price: int
|
price: float
|
||||||
unit: str
|
unit: str
|
||||||
|
fiat_base_multiplier: int = Query(100, ge=1)
|
||||||
|
|
||||||
|
|
||||||
@offlineshop_ext.post("/api/v1/offlineshop/items")
|
@offlineshop_ext.post("/api/v1/offlineshop/items")
|
||||||
|
@ -61,9 +62,18 @@ async def api_add_or_update_item(
|
||||||
data: CreateItemsData, item_id=None, wallet: WalletTypeInfo = Depends(get_key_type)
|
data: CreateItemsData, item_id=None, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
):
|
):
|
||||||
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
|
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
|
||||||
|
if data.unit != "sat":
|
||||||
|
data.price = data.price * 100
|
||||||
if item_id == None:
|
if item_id == None:
|
||||||
|
|
||||||
await add_item(
|
await add_item(
|
||||||
shop.id, data.name, data.description, data.image, data.price, data.unit
|
shop.id,
|
||||||
|
data.name,
|
||||||
|
data.description,
|
||||||
|
data.image,
|
||||||
|
data.price,
|
||||||
|
data.unit,
|
||||||
|
data.fiat_base_multiplier,
|
||||||
)
|
)
|
||||||
return HTMLResponse(status_code=HTTPStatus.CREATED)
|
return HTMLResponse(status_code=HTTPStatus.CREATED)
|
||||||
else:
|
else:
|
||||||
|
@ -75,6 +85,7 @@ async def api_add_or_update_item(
|
||||||
data.image,
|
data.image,
|
||||||
data.price,
|
data.price,
|
||||||
data.unit,
|
data.unit,
|
||||||
|
data.fiat_base_multiplier,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -225,7 +225,8 @@
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<q-checkbox :value="false" label="Onchain" disabled>
|
<q-checkbox :value="false" label="Onchain" disabled>
|
||||||
<q-tooltip>
|
<q-tooltip>
|
||||||
Watch-Only extension MUST be activated and have a wallet
|
Onchain Wallet (watch-only) extension MUST be activated and
|
||||||
|
have a wallet
|
||||||
</q-tooltip>
|
</q-tooltip>
|
||||||
</q-checkbox>
|
</q-checkbox>
|
||||||
</div>
|
</div>
|
||||||
|
|
28
lnbits/extensions/scrub/README.md
Normal file
28
lnbits/extensions/scrub/README.md
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
# Scrub
|
||||||
|
|
||||||
|
## Automatically forward funds (Scrub) that get paid to the wallet to an LNURLpay or Lightning Address
|
||||||
|
|
||||||
|
SCRUB is a small but handy extension that allows a user to take advantage of all the functionalities inside **LNbits** and upon a payment received to your LNbits wallet, automatically forward it to your desired wallet via LNURL or LNAddress!
|
||||||
|
|
||||||
|
[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Create an scrub (New Scrub link)\
|
||||||
|

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

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

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

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

|

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

|

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

|

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

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

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

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

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

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

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

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

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