diff --git a/.dockerignore b/.dockerignore index 51cee13c..005f64cc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,10 @@ tests venv tools +lnbits/static/css/* +lnbits/static/bundle.js +lnbits/static/bundle.css + *.md *.log diff --git a/.env.example b/.env.example index 5926ee65..237bdfee 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,8 @@ LNBITS_DATA_FOLDER="./data" LNBITS_FORCE_HTTPS=true LNBITS_SERVICE_FEE="0.0" +LNBITS_RESERVE_FEE_MIN=2000 # value in millisats +LNBITS_RESERVE_FEE_PERCENT=1.0 # value in percent # Change theme LNBITS_SITE_TITLE="LNbits" @@ -34,19 +36,23 @@ LNBITS_SITE_DESCRIPTION="Some description about your service, will display if ti LNBITS_THEME_OPTIONS="classic, bitcoin, freedom, mint, autumn, monochrome, salvador" # LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg" -# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, -# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet +# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, ClicheWallet +# LndRestWallet, CoreLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet LNBITS_BACKEND_WALLET_CLASS=VoidWallet # VoidWallet is just a fallback that works without any actual Lightning capabilities, # just so you can see the UI before dealing with this file. # Set one of these blocks depending on the wallet kind you chose above: + +# ClicheWallet +CLICHE_ENDPOINT=ws://127.0.0.1:12000 + # SparkWallet SPARK_URL=http://localhost:9737/rpc SPARK_TOKEN=myaccesstoken -# CLightningWallet -CLIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc" +# CoreLightningWallet +CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc" # LnbitsWallet LNBITS_ENDPOINT=https://legend.lnbits.com diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index ad72b9d5..696d1aa2 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -custom: https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK +custom: https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK diff --git a/.github/workflows/formatting.yml b/.github/workflows/formatting.yml index 23d7ae3e..e106ace3 100644 --- a/.github/workflows/formatting.yml +++ b/.github/workflows/formatting.yml @@ -7,30 +7,19 @@ on: branches: [ main ] jobs: - black: + checks: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - run: sudo apt-get install python3-venv - - run: python3 -m venv venv - - run: ./venv/bin/pip install black - - run: make checkblack - isort: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 + - uses: abatilo/actions-poetry@v2.1.3 + - name: Install packages + run: poetry install + - name: Check black + run: make checkblack + - name: Check isort + run: make checkisort - uses: actions/setup-node@v3 - - run: sudo apt-get install python3-venv - - run: python3 -m venv venv - - run: ./venv/bin/pip install isort - - run: make checkisort - - prettier: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 - - run: sudo apt-get install python3-venv - - run: python3 -m venv venv - - run: npm install prettier - - run: ./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js + - name: Check prettier + run: | + npm install prettier + make checkprettier diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index 45de9727..8e72cf62 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -22,28 +22,25 @@ jobs: --health-retries 5 strategy: matrix: - python-version: [3.8] + python-version: [3.9] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} + - uses: abatilo/actions-poetry@v2.1.3 - name: Install dependencies - env: - VIRTUAL_ENV: ./venv - PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }} run: | - python -m venv ${{ env.VIRTUAL_ENV }} - ./venv/bin/python -m pip install --upgrade pip - ./venv/bin/pip install -r requirements.txt - ./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock + poetry install + sudo apt install unzip - name: Run migrations run: | rm -rf ./data mkdir -p ./data export LNBITS_DATA_FOLDER="./data" - timeout 5s ./venv/bin/uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5001 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi + unzip tests/data/mock_data.zip -d ./data + timeout 5s poetry run lnbits --host 0.0.0.0 --port 5001 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi export LNBITS_DATABASE_URL="postgres://postgres:postgres@0.0.0.0:5432/postgres" - timeout 5s ./venv/bin/uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5001 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi - ./venv/bin/python tools/conv.py --dont-ignore-missing + timeout 5s poetry run lnbits --host 0.0.0.0 --port 5001 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi + poetry run python tools/conv.py diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index 4d6c6d4d..61601731 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -5,10 +5,18 @@ on: [push, pull_request] jobs: check: runs-on: ubuntu-latest - if: ${{ 'false' == 'true' }} # skip mypy for now + strategy: + matrix: + python-version: [3.9] steps: - - uses: actions/checkout@v1 - - uses: jpetrucciani/mypy-check@master + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 with: - mypy_flags: '--install-types --non-interactive' - path: lnbits + python-version: ${{ matrix.python-version }} + - uses: abatilo/actions-poetry@v2.1.3 + - name: Install dependencies + run: | + poetry install + - name: Run tests + run: poetry run mypy diff --git a/.github/workflows/regtest.yml b/.github/workflows/regtest.yml index bdce0501..e35c9f93 100644 --- a/.github/workflows/regtest.yml +++ b/.github/workflows/regtest.yml @@ -7,37 +7,25 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7] + python-version: [3.9] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} + - uses: abatilo/actions-poetry@v2.1.3 - name: Setup Regtest run: | docker build -t lnbits-legend . git clone https://github.com/lnbits/legend-regtest-enviroment.git docker cd docker - source docker-scripts.sh - lnbits-regtest-start - echo "sleeping 60 seconds" - sleep 60 - echo "continue" - lnbits-regtest-init - bitcoin-cli-sim -generate 1 - lncli-sim 1 listpeers + chmod +x ./tests + ./tests sudo chmod -R a+rwx . - name: Install dependencies - env: - VIRTUAL_ENV: ./venv - PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }} run: | - python -m venv ${{ env.VIRTUAL_ENV }} - ./venv/bin/python -m pip install --upgrade pip - ./venv/bin/pip install -r requirements.txt - ./venv/bin/pip install pylightning - ./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock + poetry install - name: Run tests env: PYTHONUNBUFFERED: 1 @@ -45,53 +33,91 @@ jobs: LNBITS_DATA_FOLDER: ./data LNBITS_BACKEND_WALLET_CLASS: LndRestWallet LND_REST_ENDPOINT: https://localhost:8081/ - LND_REST_CERT: docker/data/lnd-1/tls.cert - LND_REST_MACAROON: docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon + LND_REST_CERT: ./docker/data/lnd-1/tls.cert + LND_REST_MACAROON: ./docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon run: | sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data make test-real-wallet - CLightningWallet: + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + LndWallet: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7] + python-version: [3.8] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} + - uses: abatilo/actions-poetry@v2.1.3 - name: Setup Regtest run: | docker build -t lnbits-legend . git clone https://github.com/lnbits/legend-regtest-enviroment.git docker cd docker - source docker-scripts.sh - lnbits-regtest-start - echo "sleeping 60 seconds" - sleep 60 - echo "continue" - lnbits-regtest-init - bitcoin-cli-sim -generate 1 - lncli-sim 1 listpeers + chmod +x ./tests + ./tests sudo chmod -R a+rwx . - name: Install dependencies - env: - VIRTUAL_ENV: ./venv - PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }} run: | - python -m venv ${{ env.VIRTUAL_ENV }} - ./venv/bin/python -m pip install --upgrade pip - ./venv/bin/pip install -r requirements.txt - ./venv/bin/pip install pylightning - ./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock + poetry install + poetry add grpcio protobuf - name: Run tests env: PYTHONUNBUFFERED: 1 PORT: 5123 LNBITS_DATA_FOLDER: ./data - LNBITS_BACKEND_WALLET_CLASS: CLightningWallet - CLIGHTNING_RPC: docker/data/clightning-1/regtest/lightning-rpc + LNBITS_BACKEND_WALLET_CLASS: LndWallet + LND_GRPC_ENDPOINT: localhost + LND_GRPC_PORT: 10009 + LND_GRPC_CERT: docker/data/lnd-1/tls.cert + LND_GRPC_MACAROON: docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon run: | sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data - make test-real-wallet \ No newline at end of file + make test-real-wallet + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + CoreLightningWallet: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.9] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - uses: abatilo/actions-poetry@v2.1.3 + - name: Setup Regtest + run: | + docker build -t lnbits-legend . + git clone https://github.com/lnbits/legend-regtest-enviroment.git docker + cd docker + chmod +x ./tests + ./tests + sudo chmod -R a+rwx . + - name: Install dependencies + run: | + poetry install + poetry add pyln-client + - name: Run tests + env: + PYTHONUNBUFFERED: 1 + PORT: 5123 + LNBITS_DATA_FOLDER: ./data + LNBITS_BACKEND_WALLET_CLASS: CoreLightningWallet + CORELIGHTNING_RPC: ./docker/data/clightning-1/regtest/lightning-rpc + run: | + sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data + make test-real-wallet + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7b0b38e2..298d7ff0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,11 +3,11 @@ name: tests on: [push, pull_request] jobs: - sqlite: + venv-sqlite: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8] + python-version: [3.9] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} @@ -23,6 +23,26 @@ jobs: ./venv/bin/python -m pip install --upgrade pip ./venv/bin/pip install -r requirements.txt ./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock + - name: Run tests + run: make test-venv + sqlite: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.9] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - uses: abatilo/actions-poetry@v2.1.3 + - name: Install dependencies + env: + VIRTUAL_ENV: ./venv + PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }} + run: | + poetry install - name: Run tests run: make test postgres: @@ -44,22 +64,17 @@ jobs: --health-retries 5 strategy: matrix: - python-version: [3.7] + python-version: [3.9] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} + - uses: abatilo/actions-poetry@v2.1.3 - name: Install dependencies - env: - VIRTUAL_ENV: ./venv - PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }} run: | - python -m venv ${{ env.VIRTUAL_ENV }} - ./venv/bin/python -m pip install --upgrade pip - ./venv/bin/pip install -r requirements.txt - ./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock + poetry install - name: Run tests env: LNBITS_DATABASE_URL: postgres://postgres:postgres@0.0.0.0:5432/postgres @@ -68,21 +83,3 @@ jobs: uses: codecov/codecov-action@v3 with: file: ./coverage.xml - pipenv: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: [3.7] - 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 - run: | - pip install pipenv - pipenv install --dev - pipenv install importlib-metadata - - name: Run tests - run: make test-pipenv \ No newline at end of file diff --git a/.gitignore b/.gitignore index 07ee15b8..c2a305e7 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ __pycache__ .webassets-cache htmlcov test-reports -tests/data +tests/data/*.sqlite3 *.swo *.swp @@ -31,6 +31,10 @@ venv __bundle__ +coverage.xml node_modules lnbits/static/bundle.* docker + +# Nix +*result* diff --git a/Dockerfile b/Dockerfile index f9eb1dd1..c4fcb959 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,45 +1,12 @@ -# Build image -FROM python:3.7-slim as builder - -# Setup virtualenv -ENV VIRTUAL_ENV=/opt/venv -RUN python -m venv $VIRTUAL_ENV -ENV PATH="$VIRTUAL_ENV/bin:$PATH" - -# Install build deps +FROM python:3.9-slim RUN apt-get update -RUN apt-get install -y --no-install-recommends build-essential pkg-config libpq-dev -RUN python -m pip install --upgrade pip -RUN pip install wheel - -# Install runtime deps -COPY requirements.txt /tmp/requirements.txt -RUN pip install -r /tmp/requirements.txt - -# Install c-lightning specific deps -RUN pip install pylightning - -# Install LND specific deps -RUN pip install lndgrpc - -# Production image -FROM python:3.7-slim as lnbits - -# Run as non-root -USER 1000:1000 - -# Copy over virtualenv -ENV VIRTUAL_ENV="/opt/venv" -COPY --from=builder --chown=1000:1000 $VIRTUAL_ENV $VIRTUAL_ENV -ENV PATH="$VIRTUAL_ENV/bin:$PATH" - -# Copy in app source +RUN apt-get install -y curl +RUN curl -sSL https://install.python-poetry.org | python3 - +ENV PATH="/root/.local/bin:$PATH" WORKDIR /app -COPY --chown=1000:1000 lnbits /app/lnbits - -ENV LNBITS_PORT="5000" -ENV LNBITS_HOST="0.0.0.0" - +COPY . . +RUN poetry config virtualenvs.create false +RUN poetry install --no-dev --no-root +RUN poetry run python build.py EXPOSE 5000 - -CMD ["sh", "-c", "uvicorn lnbits.__main__:app --port $LNBITS_PORT --host $LNBITS_HOST"] +CMD ["poetry", "run", "lnbits", "--port", "5000", "--host", "0.0.0.0"] diff --git a/Makefile b/Makefile index 5cc3f050..d91d0421 100644 --- a/Makefile +++ b/Makefile @@ -4,61 +4,47 @@ all: format check requirements.txt format: prettier isort black -check: mypy checkprettier checkblack +check: mypy checkprettier checkisort checkblack prettier: $(shell find lnbits -name "*.js" -name ".html") - ./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js + ./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html -black: $(shell find lnbits -name "*.py") - ./venv/bin/black lnbits +black: + poetry run black . -mypy: $(shell find lnbits -name "*.py") - ./venv/bin/mypy lnbits - ./venv/bin/mypy lnbits/core - ./venv/bin/mypy lnbits/extensions/* +mypy: + poetry run mypy -isort: $(shell find lnbits -name "*.py") - ./venv/bin/isort --profile black lnbits +isort: + poetry run isort . checkprettier: $(shell find lnbits -name "*.js" -name ".html") - ./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js + ./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html -checkblack: $(shell find lnbits -name "*.py") - ./venv/bin/black --check lnbits +checkblack: + poetry run black --check . -checkisort: $(shell find lnbits -name "*.py") - ./venv/bin/isort --profile black --check-only lnbits - -Pipfile.lock: Pipfile - ./venv/bin/pipenv lock - -requirements.txt: Pipfile.lock - cat Pipfile.lock | jq -r '.default | map_values(.version) | to_entries | map("\(.key)\(.value)") | join("\n")' > requirements.txt +checkisort: + poetry run isort --check-only . test: - rm -rf ./tests/data - mkdir -p ./tests/data + LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \ + FAKE_WALLET_SECRET="ToTheMoon1" \ + LNBITS_DATA_FOLDER="./tests/data" \ + PYTHONUNBUFFERED=1 \ + poetry run pytest + +test-real-wallet: + LNBITS_DATA_FOLDER="./tests/data" \ + PYTHONUNBUFFERED=1 \ + poetry run pytest + +test-venv: LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \ FAKE_WALLET_SECRET="ToTheMoon1" \ LNBITS_DATA_FOLDER="./tests/data" \ PYTHONUNBUFFERED=1 \ ./venv/bin/pytest --durations=1 -s --cov=lnbits --cov-report=xml tests -test-real-wallet: - rm -rf ./tests/data - mkdir -p ./tests/data - LNBITS_DATA_FOLDER="./tests/data" \ - PYTHONUNBUFFERED=1 \ - ./venv/bin/pytest --durations=1 -s --cov=lnbits --cov-report=xml tests - -test-pipenv: - rm -rf ./tests/data - mkdir -p ./tests/data - LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \ - FAKE_WALLET_SECRET="ToTheMoon1" \ - LNBITS_DATA_FOLDER="./tests/data" \ - PYTHONUNBUFFERED=1 \ - pipenv run pytest --durations=1 -s --cov=lnbits --cov-report=xml tests - bak: # LNBITS_DATABASE_URL=postgres://postgres:postgres@0.0.0.0:5432/postgres diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 8ef241f1..00000000 --- a/Pipfile +++ /dev/null @@ -1,43 +0,0 @@ -[[source]] -name = "pypi" -url = "https://pypi.org/simple" -verify_ssl = true - -[requires] -python_version = "3.7" - -[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 = "*" -pycryptodomex = "*" - -[dev-packages] -black = "==20.8b1" -pytest = "*" -pytest-cov = "*" -mypy = "*" -pytest-asyncio = "*" -requests = "*" -mock = "*" diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 6a89abb3..00000000 --- a/Pipfile.lock +++ /dev/null @@ -1,1167 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "81bd288eea338c3bf1b70b8d30c1185b84c13a25a595bcddd77f74f7bc090032" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.7" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "aiofiles": { - "hashes": [ - "sha256:7a973fc22b29e9962d0897805ace5856e6a566ab1f0c8e5c91ff6c866519c937", - "sha256:8334f23235248a3b2e83b2c3a78a22674f39969b96397126cc93664d9a901e59" - ], - "index": "pypi", - "version": "==0.8.0" - }, - "anyio": { - "hashes": [ - "sha256:413adf95f93886e442aea925f3ee43baa5a765a64a0f52c6081894f9992fdd0b", - "sha256:cb29b9c70620506a9a8f87a309591713446953302d7d995344d0d7c6c0c9a7be" - ], - "version": "==3.6.1" - }, - "asyncio": { - "hashes": [ - "sha256:83360ff8bc97980e4ff25c964c7bd3923d333d177aa4f7fb736b019f26c7cb41", - "sha256:b62c9157d36187eca799c378e572c969f0da87cd5fc42ca372d92cdb06e7e1de", - "sha256:c46a87b48213d7464f22d9a497b9eef8c1928b68320a2fa94240f969f6fec08c", - "sha256:c4d18b22701821de07bd6aea8b53d21449ec0ec5680645e5317062ea21817d2d" - ], - "index": "pypi", - "version": "==3.4.3" - }, - "attrs": { - "hashes": [ - "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4", - "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd" - ], - "version": "==21.4.0" - }, - "bech32": { - "hashes": [ - "sha256:7d6db8214603bd7871fcfa6c0826ef68b85b0abd90fa21c285a9c5e21d2bd899", - "sha256:990dc8e5a5e4feabbdf55207b5315fdd9b73db40be294a19b3752cde9e79d981" - ], - "version": "==1.2.0" - }, - "bitstring": { - "hashes": [ - "sha256:0de167daa6a00c9386255a7cac931b45e6e24e0ad7ea64f1f92a64ac23ad4578", - "sha256:a5848a3f63111785224dca8bb4c0a75b62ecdef56a042c8d6be74b16f7e860e7", - "sha256:e3e340e58900a948787a05e8c08772f1ccbe133f6f41fe3f0fa19a18a22bbf4f" - ], - "index": "pypi", - "version": "==3.1.9" - }, - "cerberus": { - "hashes": [ - "sha256:d1b21b3954b2498d9a79edf16b3170a3ac1021df88d197dc2ce5928ba519237c" - ], - "index": "pypi", - "version": "==1.3.4" - }, - "certifi": { - "hashes": [ - "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d", - "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412" - ], - "version": "==2022.6.15" - }, - "cffi": { - "hashes": [ - "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5", - "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef", - "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104", - "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426", - "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405", - "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375", - "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a", - "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e", - "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc", - "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf", - "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185", - "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497", - "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3", - "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35", - "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c", - "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83", - "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21", - "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca", - "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984", - "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac", - "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd", - "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee", - "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a", - "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2", - "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192", - "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7", - "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585", - "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f", - "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e", - "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27", - "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b", - "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e", - "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e", - "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d", - "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c", - "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415", - "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82", - "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02", - "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314", - "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325", - "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c", - "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3", - "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914", - "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045", - "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d", - "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9", - "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5", - "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2", - "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c", - "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3", - "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2", - "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8", - "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d", - "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d", - "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9", - "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162", - "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76", - "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4", - "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e", - "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9", - "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6", - "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b", - "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01", - "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0" - ], - "version": "==1.15.1" - }, - "click": { - "hashes": [ - "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", - "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48" - ], - "version": "==8.1.3" - }, - "ecdsa": { - "hashes": [ - "sha256:190348041559e21b22a1d65cee485282ca11a6f81d503fddb84d5017e9ed1e49", - "sha256:80600258e7ed2f16b9aa1d7c295bd70194109ad5a30fdee0eaeefef1d4c559dd" - ], - "index": "pypi", - "version": "==0.18.0" - }, - "embit": { - "hashes": [ - "sha256:5644ae6ed07bb71bf7fb15daf7f5af73d889180e623f5ff1f35a20ad01f0405e" - ], - "index": "pypi", - "version": "==0.5.0" - }, - "environs": { - "hashes": [ - "sha256:1e549569a3de49c05f856f40bce86979e7d5ffbbc4398e7f338574c220189124", - "sha256:a76307b36fbe856bdca7ee9161e6c466fd7fcffc297109a118c59b54e27e30c9" - ], - "index": "pypi", - "version": "==9.5.0" - }, - "fastapi": { - "hashes": [ - "sha256:15fcabd5c78c266fa7ae7d8de9b384bfc2375ee0503463a6febbe3bab69d6f65", - "sha256:3233d4a789ba018578658e2af1a4bb5e38bdd122ff722b313666a9b2c6786a83" - ], - "index": "pypi", - "version": "==0.78.0" - }, - "h11": { - "hashes": [ - "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6", - "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042" - ], - "version": "==0.12.0" - }, - "httpcore": { - "hashes": [ - "sha256:1105b8b73c025f23ff7c36468e4432226cbb959176eab66864b8e31c4ee27fa6", - "sha256:18b68ab86a3ccf3e7dc0f43598eaddcf472b602aba29f9aa6ab85fe2ada3980b" - ], - "version": "==0.15.0" - }, - "httptools": { - "hashes": [ - "sha256:1a99346ebcb801b213c591540837340bdf6fd060a8687518d01c607d338b7424", - "sha256:1ee0b459257e222b878a6c09ccf233957d3a4dcb883b0847640af98d2d9aac23", - "sha256:20a45bcf22452a10fa8d58b7dbdb474381f6946bf5b8933e3662d572bc61bae4", - "sha256:29bf97a5c532da9c7a04de2c7a9c31d1d54f3abd65a464119b680206bbbb1055", - "sha256:2c9a930c378b3d15d6b695fb95ebcff81a7395b4f9775c4f10a076beb0b2c1ff", - "sha256:2db44a0b294d317199e9f80123e72c6b005c55b625b57fae36de68670090fa48", - "sha256:3194f6d6443befa8d4db16c1946b2fc428a3ceb8ab32eb6f09a59f86104dc1a0", - "sha256:34d2903dd2a3dd85d33705b6fde40bf91fc44411661283763fd0746723963c83", - "sha256:48e48530d9b995a84d1d89ae6b3ec4e59ea7d494b150ac3bbc5e2ac4acce92cd", - "sha256:54bbd295f031b866b9799dd39cb45deee81aca036c9bff9f58ca06726f6494f1", - "sha256:5d1fe6b6661022fd6cac541f54a4237496b246e6f1c0a6b41998ee08a1135afe", - "sha256:645373c070080e632480a3d251d892cb795be3d3a15f86975d0f1aca56fd230d", - "sha256:6a1a7dfc1f9c78a833e2c4904757a0f47ce25d08634dd2a52af394eefe5f9777", - "sha256:701e66b59dd21a32a274771238025d58db7e2b6ecebbab64ceff51b8e31527ae", - "sha256:72aa3fbe636b16d22e04b5a9d24711b043495e0ecfe58080addf23a1a37f3409", - "sha256:7af6bdbd21a2a25d6784f6d67f44f5df33ef39b6159543b9f9064d365c01f919", - "sha256:7ee9f226acab9085037582c059d66769862706e8e8cd2340470ceb8b3850873d", - "sha256:7f7bfb74718f52d5ed47d608d507bf66d3bc01d4a8b3e6dd7134daaae129357b", - "sha256:8e2eb957787cbb614a0f006bfc5798ff1d90ac7c4dd24854c84edbdc8c02369e", - "sha256:903f739c9fb78dab8970b0f3ea51f21955b24b45afa77b22ff0e172fc11ef111", - "sha256:98993805f1e3cdb53de4eed02b55dcc953cdf017ba7bbb2fd89226c086a6d855", - "sha256:9967d9758df505975913304c434cb9ab21e2c609ad859eb921f2f615a038c8de", - "sha256:a113789e53ac1fa26edf99856a61e4c493868e125ae0dd6354cf518948fbbd5c", - "sha256:a522d12e2ddbc2e91842ffb454a1aeb0d47607972c7d8fc88bd0838d97fb8a2a", - "sha256:abe829275cdd4174b4c4e65ad718715d449e308d59793bf3a931ee1bf7e7b86c", - "sha256:c286985b5e194ca0ebb2908d71464b9be8f17cc66d6d3e330e8d5407248f56ad", - "sha256:cd1295f52971097f757edfbfce827b6dbbfb0f7a74901ee7d4933dff5ad4c9af", - "sha256:ceafd5e960b39c7e0d160a1936b68eb87c5e79b3979d66e774f0c77d4d8faaed", - "sha256:d1f27bb0f75bef722d6e22dc609612bfa2f994541621cd2163f8c943b6463dfe", - "sha256:d3a4e165ca6204f34856b765d515d558dc84f1352033b8721e8d06c3e44930c3", - "sha256:d9b90bf58f3ba04e60321a23a8723a1ff2a9377502535e70495e5ada8e6e6722", - "sha256:f72b5d24d6730035128b238decdc4c0f2104b7056a7ca55cf047c106842ec890", - "sha256:fcddfe70553be717d9745990dfdb194e22ee0f60eb8f48c0794e7bfeda30d2d5", - "sha256:fdb9f9ed79bc6f46b021b3319184699ba1a22410a82204e6e89c774530069683" - ], - "version": "==0.4.0" - }, - "httpx": { - "hashes": [ - "sha256:42974f577483e1e932c3cdc3cd2303e883cbfba17fe228b0f63589764d7b9c4b", - "sha256:f28eac771ec9eb4866d3fb4ab65abd42d38c424739e80c08d8d20570de60b0ef" - ], - "index": "pypi", - "version": "==0.23.0" - }, - "idna": { - "hashes": [ - "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", - "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" - ], - "version": "==3.3" - }, - "jinja2": { - "hashes": [ - "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4", - "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4" - ], - "index": "pypi", - "version": "==3.0.1" - }, - "lnurl": { - "hashes": [ - "sha256:579982fd8c4d25bc84c61c74ec45cb7999fa1fa2426f5d5aeb0160ba333b9c92", - "sha256:8af07460115a48f3122a5a9c9a6062bee3897d5f6ab4c9a60f6561a83a8234f6" - ], - "index": "pypi", - "version": "==0.3.6" - }, - "loguru": { - "hashes": [ - "sha256:066bd06758d0a513e9836fd9c6b5a75bfb3fd36841f4b996bc60b547a309d41c", - "sha256:4e2414d534a2ab57573365b3e6d0234dfb1d84b68b7f3b948e6fb743860a77c3" - ], - "index": "pypi", - "version": "==0.6.0" - }, - "markupsafe": { - "hashes": [ - "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003", - "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88", - "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5", - "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7", - "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a", - "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603", - "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1", - "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135", - "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247", - "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6", - "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601", - "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77", - "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02", - "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e", - "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63", - "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f", - "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980", - "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b", - "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812", - "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff", - "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96", - "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1", - "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925", - "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a", - "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6", - "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e", - "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f", - "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4", - "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f", - "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3", - "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c", - "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a", - "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417", - "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a", - "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a", - "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37", - "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452", - "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933", - "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a", - "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7" - ], - "version": "==2.1.1" - }, - "marshmallow": { - "hashes": [ - "sha256:00040ab5ea0c608e8787137627a8efae97fabd60552a05dc889c888f814e75eb", - "sha256:635fb65a3285a31a30f276f30e958070f5214c7196202caa5c7ecf28f5274bc7" - ], - "version": "==3.17.0" - }, - "outcome": { - "hashes": [ - "sha256:6f82bd3de45da303cf1f771ecafa1633750a358436a8bb60e06a1ceb745d2672", - "sha256:c4ab89a56575d6d38a05aa16daeaa333109c1f96167aba8901ab18b6b5e0f7f5" - ], - "version": "==1.2.0" - }, - "packaging": { - "hashes": [ - "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", - "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" - ], - "version": "==21.3" - }, - "psycopg2-binary": { - "hashes": [ - "sha256:01310cf4cf26db9aea5158c217caa92d291f0500051a6469ac52166e1a16f5b7", - "sha256:083a55275f09a62b8ca4902dd11f4b33075b743cf0d360419e2051a8a5d5ff76", - "sha256:090f3348c0ab2cceb6dfbe6bf721ef61262ddf518cd6cc6ecc7d334996d64efa", - "sha256:0a29729145aaaf1ad8bafe663131890e2111f13416b60e460dae0a96af5905c9", - "sha256:0c9d5450c566c80c396b7402895c4369a410cab5a82707b11aee1e624da7d004", - "sha256:10bb90fb4d523a2aa67773d4ff2b833ec00857f5912bafcfd5f5414e45280fb1", - "sha256:12b11322ea00ad8db8c46f18b7dfc47ae215e4df55b46c67a94b4effbaec7094", - "sha256:152f09f57417b831418304c7f30d727dc83a12761627bb826951692cc6491e57", - "sha256:15803fa813ea05bef089fa78835118b5434204f3a17cb9f1e5dbfd0b9deea5af", - "sha256:15c4e4cfa45f5a60599d9cec5f46cd7b1b29d86a6390ec23e8eebaae84e64554", - "sha256:183a517a3a63503f70f808b58bfbf962f23d73b6dccddae5aa56152ef2bcb232", - "sha256:1f14c8b0942714eb3c74e1e71700cbbcb415acbc311c730370e70c578a44a25c", - "sha256:1f6b813106a3abdf7b03640d36e24669234120c72e91d5cbaeb87c5f7c36c65b", - "sha256:280b0bb5cbfe8039205c7981cceb006156a675362a00fe29b16fbc264e242834", - "sha256:2d872e3c9d5d075a2e104540965a1cf898b52274a5923936e5bfddb58c59c7c2", - "sha256:2f9ffd643bc7349eeb664eba8864d9e01f057880f510e4681ba40a6532f93c71", - "sha256:3303f8807f342641851578ee7ed1f3efc9802d00a6f83c101d21c608cb864460", - "sha256:35168209c9d51b145e459e05c31a9eaeffa9a6b0fd61689b48e07464ffd1a83e", - "sha256:3a79d622f5206d695d7824cbf609a4f5b88ea6d6dab5f7c147fc6d333a8787e4", - "sha256:404224e5fef3b193f892abdbf8961ce20e0b6642886cfe1fe1923f41aaa75c9d", - "sha256:46f0e0a6b5fa5851bbd9ab1bc805eef362d3a230fbdfbc209f4a236d0a7a990d", - "sha256:47133f3f872faf28c1e87d4357220e809dfd3fa7c64295a4a148bcd1e6e34ec9", - "sha256:526ea0378246d9b080148f2d6681229f4b5964543c170dd10bf4faaab6e0d27f", - "sha256:53293533fcbb94c202b7c800a12c873cfe24599656b341f56e71dd2b557be063", - "sha256:539b28661b71da7c0e428692438efbcd048ca21ea81af618d845e06ebfd29478", - "sha256:57804fc02ca3ce0dbfbef35c4b3a4a774da66d66ea20f4bda601294ad2ea6092", - "sha256:63638d875be8c2784cfc952c9ac34e2b50e43f9f0a0660b65e2a87d656b3116c", - "sha256:6472a178e291b59e7f16ab49ec8b4f3bdada0a879c68d3817ff0963e722a82ce", - "sha256:68641a34023d306be959101b345732360fc2ea4938982309b786f7be1b43a4a1", - "sha256:6e82d38390a03da28c7985b394ec3f56873174e2c88130e6966cb1c946508e65", - "sha256:761df5313dc15da1502b21453642d7599d26be88bff659382f8f9747c7ebea4e", - "sha256:7af0dd86ddb2f8af5da57a976d27cd2cd15510518d582b478fbb2292428710b4", - "sha256:7b1e9b80afca7b7a386ef087db614faebbf8839b7f4db5eb107d0f1a53225029", - "sha256:874a52ecab70af13e899f7847b3e074eeb16ebac5615665db33bce8a1009cf33", - "sha256:887dd9aac71765ac0d0bac1d0d4b4f2c99d5f5c1382d8b770404f0f3d0ce8a39", - "sha256:8b344adbb9a862de0c635f4f0425b7958bf5a4b927c8594e6e8d261775796d53", - "sha256:8fc53f9af09426a61db9ba357865c77f26076d48669f2e1bb24d85a22fb52307", - "sha256:91920527dea30175cc02a1099f331aa8c1ba39bf8b7762b7b56cbf54bc5cce42", - "sha256:93cd1967a18aa0edd4b95b1dfd554cf15af657cb606280996d393dadc88c3c35", - "sha256:99485cab9ba0fa9b84f1f9e1fef106f44a46ef6afdeec8885e0b88d0772b49e8", - "sha256:9d29409b625a143649d03d0fd7b57e4b92e0ecad9726ba682244b73be91d2fdb", - "sha256:a29b3ca4ec9defec6d42bf5feb36bb5817ba3c0230dd83b4edf4bf02684cd0ae", - "sha256:a9e1f75f96ea388fbcef36c70640c4efbe4650658f3d6a2967b4cc70e907352e", - "sha256:accfe7e982411da3178ec690baaceaad3c278652998b2c45828aaac66cd8285f", - "sha256:adf20d9a67e0b6393eac162eb81fb10bc9130a80540f4df7e7355c2dd4af9fba", - "sha256:af9813db73395fb1fc211bac696faea4ca9ef53f32dc0cfa27e4e7cf766dcf24", - "sha256:b1c8068513f5b158cf7e29c43a77eb34b407db29aca749d3eb9293ee0d3103ca", - "sha256:bda845b664bb6c91446ca9609fc69f7db6c334ec5e4adc87571c34e4f47b7ddb", - "sha256:c381bda330ddf2fccbafab789d83ebc6c53db126e4383e73794c74eedce855ef", - "sha256:c3ae8e75eb7160851e59adc77b3a19a976e50622e44fd4fd47b8b18208189d42", - "sha256:d1c1b569ecafe3a69380a94e6ae09a4789bbb23666f3d3a08d06bbd2451f5ef1", - "sha256:def68d7c21984b0f8218e8a15d514f714d96904265164f75f8d3a70f9c295667", - "sha256:dffc08ca91c9ac09008870c9eb77b00a46b3378719584059c034b8945e26b272", - "sha256:e3699852e22aa68c10de06524a3721ade969abf382da95884e6a10ff798f9281", - "sha256:e847774f8ffd5b398a75bc1c18fbb56564cda3d629fe68fd81971fece2d3c67e", - "sha256:ffb7a888a047696e7f8240d649b43fb3644f14f0ee229077e7f6b9f9081635bd" - ], - "index": "pypi", - "version": "==2.9.3" - }, - "pycparser": { - "hashes": [ - "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", - "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" - ], - "version": "==2.21" - }, - "pycryptodomex": { - "hashes": [ - "sha256:04cc393045a8f19dd110c975e30f38ed7ab3faf21ede415ea67afebd95a22380", - "sha256:0776bfaf2c48154ab54ea45392847c1283d2fcf64e232e85565f858baedfc1fa", - "sha256:0fadb9f7fa3150577800eef35f62a8a24b9ddf1563ff060d9bd3af22d3952c8c", - "sha256:18e2ab4813883ae63396c0ffe50b13554b32bb69ec56f0afaf052e7a7ae0d55b", - "sha256:191e73bc84a8064ad1874dba0ebadedd7cce4dedee998549518f2c74a003b2e1", - "sha256:35a8f7afe1867118330e2e0e0bf759c409e28557fb1fc2fbb1c6c937297dbe9a", - "sha256:3709f13ca3852b0b07fc04a2c03b379189232b24007c466be0f605dd4723e9d4", - "sha256:4540904c09704b6f831059c0dfb38584acb82cb97b0125cd52688c1f1e3fffa6", - "sha256:463119d7d22d0fc04a0f9122e9d3e6121c6648bcb12a052b51bd1eed1b996aa2", - "sha256:46b3f05f2f7ac7841053da4e0f69616929ca3c42f238c405f6c3df7759ad2780", - "sha256:48697790203909fab02a33226fda546604f4e2653f9d47bc5d3eb40879fa7c64", - "sha256:5676a132169a1c1a3712edf25250722ebc8c9102aa9abd814df063ca8362454f", - "sha256:65204412d0c6a8e3c41e21e93a5e6054a74fea501afa03046a388cf042e3377a", - "sha256:67e1e6a92151023ccdfcfbc0afb3314ad30080793b4c27956ea06ab1fb9bcd8a", - "sha256:6f5b6ba8aefd624834bc177a2ac292734996bb030f9d1b388e7504103b6fcddf", - "sha256:7341f1bb2dadb0d1a0047f34c3a58208a92423cdbd3244d998e4b28df5eac0ed", - "sha256:78d9621cf0ea35abf2d38fa2ca6d0634eab6c991a78373498ab149953787e5e5", - "sha256:8eecdf9cdc7343001d047f951b9cc805cd68cb6cd77b20ea46af5bffc5bd3dfb", - "sha256:94c7b60e1f52e1a87715571327baea0733708ab4723346598beca4a3b6879794", - "sha256:996e1ba717077ce1e6d4849af7a1426f38b07b3d173b879e27d5e26d2e958beb", - "sha256:a07a64709e366c2041cd5cfbca592b43998bf4df88f7b0ca73dca37071ccf1bd", - "sha256:b6306403228edde6e289f626a3908a2f7f67c344e712cf7c0a508bab3ad9e381", - "sha256:b9279adc16e4b0f590ceff581f53a80179b02cba9056010d733eb4196134a870", - "sha256:c4cb9cb492ea7dcdf222a8d19a1d09002798ea516aeae8877245206d27326d86", - "sha256:dd452a5af7014e866206d41751886c9b4bf379a339fdf2dbfc7dd16c0fb4f8e0", - "sha256:e2b12968522a0358b8917fc7b28865acac002f02f4c4c6020fcb264d76bfd06d", - "sha256:e3164a18348bd53c69b4435ebfb4ac8a4076291ffa2a70b54f0c4b80c7834b1d", - "sha256:e47bf8776a7e15576887f04314f5228c6527b99946e6638cf2f16da56d260cab", - "sha256:f8be976cec59b11f011f790b88aca67b4ea2bd286578d0bd3e31bcd19afcd3e4", - "sha256:fc9bc7a9b79fe5c750fc81a307052f8daabb709bdaabb0fb18fb136b66b653b5" - ], - "index": "pypi", - "version": "==3.15.0" - }, - "pydantic": { - "hashes": [ - "sha256:02eefd7087268b711a3ff4db528e9916ac9aa18616da7bca69c1871d0b7a091f", - "sha256:059b6c1795170809103a1538255883e1983e5b831faea6558ef873d4955b4a74", - "sha256:0bf07cab5b279859c253d26a9194a8906e6f4a210063b84b433cf90a569de0c1", - "sha256:1542636a39c4892c4f4fa6270696902acb186a9aaeac6f6cf92ce6ae2e88564b", - "sha256:177071dfc0df6248fd22b43036f936cfe2508077a72af0933d0c1fa269b18537", - "sha256:18f3e912f9ad1bdec27fb06b8198a2ccc32f201e24174cec1b3424dda605a310", - "sha256:1dd8fecbad028cd89d04a46688d2fcc14423e8a196d5b0a5c65105664901f810", - "sha256:1ed987c3ff29fff7fd8c3ea3a3ea877ad310aae2ef9889a119e22d3f2db0691a", - "sha256:447d5521575f18e18240906beadc58551e97ec98142266e521c34968c76c8761", - "sha256:494f7c8537f0c02b740c229af4cb47c0d39840b829ecdcfc93d91dcbb0779892", - "sha256:4988c0f13c42bfa9ddd2fe2f569c9d54646ce84adc5de84228cfe83396f3bd58", - "sha256:4ce9ae9e91f46c344bec3b03d6ee9612802682c1551aaf627ad24045ce090761", - "sha256:5d93d4e95eacd313d2c765ebe40d49ca9dd2ed90e5b37d0d421c597af830c195", - "sha256:61b6760b08b7c395975d893e0b814a11cf011ebb24f7d869e7118f5a339a82e1", - "sha256:72ccb318bf0c9ab97fc04c10c37683d9eea952ed526707fabf9ac5ae59b701fd", - "sha256:79b485767c13788ee314669008d01f9ef3bc05db9ea3298f6a50d3ef596a154b", - "sha256:7eb57ba90929bac0b6cc2af2373893d80ac559adda6933e562dcfb375029acee", - "sha256:8bc541a405423ce0e51c19f637050acdbdf8feca34150e0d17f675e72d119580", - "sha256:969dd06110cb780da01336b281f53e2e7eb3a482831df441fb65dd30403f4608", - "sha256:985ceb5d0a86fcaa61e45781e567a59baa0da292d5ed2e490d612d0de5796918", - "sha256:9bcf8b6e011be08fb729d110f3e22e654a50f8a826b0575c7196616780683380", - "sha256:9ce157d979f742a915b75f792dbd6aa63b8eccaf46a1005ba03aa8a986bde34a", - "sha256:9f659a5ee95c8baa2436d392267988fd0f43eb774e5eb8739252e5a7e9cf07e0", - "sha256:a4a88dcd6ff8fd47c18b3a3709a89adb39a6373f4482e04c1b765045c7e282fd", - "sha256:a955260d47f03df08acf45689bd163ed9df82c0e0124beb4251b1290fa7ae728", - "sha256:a9af62e9b5b9bc67b2a195ebc2c2662fdf498a822d62f902bf27cccb52dbbf49", - "sha256:ae72f8098acb368d877b210ebe02ba12585e77bd0db78ac04a1ee9b9f5dd2166", - "sha256:b83ba3825bc91dfa989d4eed76865e71aea3a6ca1388b59fc801ee04c4d8d0d6", - "sha256:c11951b404e08b01b151222a1cb1a9f0a860a8153ce8334149ab9199cd198131", - "sha256:c320c64dd876e45254bdd350f0179da737463eea41c43bacbee9d8c9d1021f11", - "sha256:c8098a724c2784bf03e8070993f6d46aa2eeca031f8d8a048dff277703e6e193", - "sha256:d12f96b5b64bec3f43c8e82b4aab7599d0157f11c798c9f9c528a72b9e0b339a", - "sha256:e565a785233c2d03724c4dc55464559639b1ba9ecf091288dd47ad9c629433bd", - "sha256:f0f047e11febe5c3198ed346b507e1d010330d56ad615a7e0a89fae604065a0e", - "sha256:fe4670cb32ea98ffbf5a1262f14c3e102cccd92b1869df3bb09538158ba90fe6" - ], - "version": "==1.9.1" - }, - "pyngrok": { - "hashes": [ - "sha256:4d03f44a69c3cbc168b17377956a9edcf723e77dbc864eba34c272db15da443c" - ], - "index": "pypi", - "version": "==5.1.0" - }, - "pyparsing": { - "hashes": [ - "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb", - "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc" - ], - "version": "==3.0.9" - }, - "pypng": { - "hashes": [ - "sha256:76f8a1539ec56451da7ab7121f12a361969fe0f2d48d703d198ce2a99d6c5afd" - ], - "index": "pypi", - "version": "==0.0.21" - }, - "pyqrcode": { - "hashes": [ - "sha256:1b2812775fa6ff5c527977c4cd2ccb07051ca7d0bc0aecf937a43864abe5eff6", - "sha256:fdbf7634733e56b72e27f9bce46e4550b75a3a2c420414035cae9d9d26b234d5" - ], - "index": "pypi", - "version": "==1.2.1" - }, - "pyscss": { - "hashes": [ - "sha256:8f35521ffe36afa8b34c7d6f3195088a7057c185c2b8f15ee459ab19748669ff" - ], - "index": "pypi", - "version": "==1.4.0" - }, - "python-dotenv": { - "hashes": [ - "sha256:b7e3b04a59693c42c36f9ab1cc2acc46fa5df8c78e178fc33a8d4cd05c8d498f", - "sha256:d92a187be61fe482e4fd675b6d52200e7be63a12b724abbf931a40ce4fa92938" - ], - "version": "==0.20.0" - }, - "pyyaml": { - "hashes": [ - "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293", - "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b", - "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57", - "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b", - "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4", - "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07", - "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba", - "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9", - "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287", - "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513", - "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0", - "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0", - "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92", - "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f", - "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2", - "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc", - "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c", - "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86", - "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4", - "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c", - "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34", - "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b", - "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c", - "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb", - "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737", - "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3", - "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d", - "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53", - "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78", - "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803", - "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a", - "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174", - "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5" - ], - "version": "==6.0" - }, - "represent": { - "hashes": [ - "sha256:026c0de2ee8385d1255b9c2426cd4f03fe9177ac94c09979bc601946c8493aa0", - "sha256:99142650756ef1998ce0661568f54a47dac8c638fb27e3816c02536575dbba8c" - ], - "version": "==1.6.0.post0" - }, - "rfc3986": { - "hashes": [ - "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835", - "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97" - ], - "version": "==1.5.0" - }, - "secp256k1": { - "hashes": [ - "sha256:130f119b06142e597c10eb4470b5a38eae865362d01aaef06b113478d77f728d", - "sha256:373dc8bca735f3c2d73259aa2711a9ecea2f3c7edbb663555fe3422e3dd76102", - "sha256:3aedcfe6eb1c5fa7c6be25b7cc91c76d8eb984271920ba0f7a934ae41ed56f51", - "sha256:4b1bf09953cde181132cf5e9033065615e5c2694e803165e2db763efa47695e5", - "sha256:63eb148196b8f646922d4be6739b17fbbf50ebb3a020078c823e2445d88b7a81", - "sha256:6af07be5f8612628c3638dc7b208f6cc78d0abae3e25797eadb13890c7d5da81", - "sha256:72735da6cb28273e924431cd40aa607e7f80ef09608c8c9300be2e0e1d2417b4", - "sha256:7a27c479ab60571502516a1506a562d0a9df062de8ad645313fabfcc97252816", - "sha256:82c06712d69ef945220c8b53c1a0d424c2ff6a1f64aee609030df79ad8383397", - "sha256:87f4ad42a370f768910585989a301d1d65de17dcd86f6e8def9b021364b34d5c", - "sha256:97a30c8dae633cb18135c76b6517ae99dc59106818e8985be70dbc05dcc06c0d", - "sha256:a8dbd75a9fb6f42de307f3c5e24573fe59c3374637cbf39136edc66c200a4029", - "sha256:adc23a4c5d24c95191638eb2ca313097827f07db102e77b59faed15d50c98cae", - "sha256:bc761894b3634021686714278fc62b73395fa3eded33453eadfd8a00a6c44ef3", - "sha256:c91dd3154f6c46ac798d9a41166120e1751222587f54516cc3f378f56ce4ac82", - "sha256:c9e7c024ff17e9b9d7c392bb2a917da231d6cb40ab119389ff1f51dca10339a4", - "sha256:ce0314788d3248b275426501228969fd32f6501c9d1837902ee0e7bd8264a36f", - "sha256:f4062d8c101aa63b9ecb3709f1f075ad9c01b6672869bbaa1bd77271816936a7", - "sha256:f4b9306bff6dde020444dfee9ca9b9f5b20ca53a2c0b04898361a3f43d5daf2e", - "sha256:f666c67dcf1dc69e1448b2ede5e12aaf382b600204a61dbc65e4f82cea444405", - "sha256:fcabb3c3497a902fb61eec72d1b69bf72747d7bcc2a732d56d9319a1e8322262", - "sha256:fe3f503c9dfdf663b500d3e0688ad842e116c2907ad3f1e1d685812df3f56290", - "sha256:fec790cb6d0d37129ca0ce5b3f8e85692d5fb618d1c440f189453d18694035df" - ], - "index": "pypi", - "version": "==0.14.0" - }, - "shortuuid": { - "hashes": [ - "sha256:459f12fa1acc34ff213b1371467c0325169645a31ed989e268872339af7563d5", - "sha256:b2bb9eb7773170e253bb7ba25971023acb473517a8b76803d9618668cb1dd46f" - ], - "index": "pypi", - "version": "==1.0.9" - }, - "six": { - "hashes": [ - "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", - "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" - ], - "version": "==1.16.0" - }, - "sniffio": { - "hashes": [ - "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663", - "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de" - ], - "version": "==1.2.0" - }, - "sqlalchemy": { - "hashes": [ - "sha256:040bdfc1d76a9074717a3f43455685f781c581f94472b010cd6c4754754e1862", - "sha256:1fe5d8d39118c2b018c215c37b73fd6893c3e1d4895be745ca8ff6eb83333ed3", - "sha256:23927c3981d1ec6b4ea71eb99d28424b874d9c696a21e5fbd9fa322718be3708", - "sha256:24f9569e82a009a09ce2d263559acb3466eba2617203170e4a0af91e75b4f075", - "sha256:2578dbdbe4dbb0e5126fb37ffcd9793a25dcad769a95f171a2161030bea850ff", - "sha256:269990b3ab53cb035d662dcde51df0943c1417bdab707dc4a7e4114a710504b4", - "sha256:29cccc9606750fe10c5d0e8bd847f17a97f3850b8682aef1f56f5d5e1a5a64b1", - "sha256:37b83bf81b4b85dda273aaaed5f35ea20ad80606f672d94d2218afc565fb0173", - "sha256:63677d0c08524af4c5893c18dbe42141de7178001360b3de0b86217502ed3601", - "sha256:639940bbe1108ac667dcffc79925db2966826c270112e9159439ab6bb14f8d80", - "sha256:6a939a868fdaa4b504e8b9d4a61f21aac11e3fecc8a8214455e144939e3d2aea", - "sha256:6b8b8c80c7f384f06825612dd078e4a31f0185e8f1f6b8c19e188ff246334205", - "sha256:6c9e6cc9237de5660bcddea63f332428bb83c8e2015c26777281f7ffbd2efb84", - "sha256:6ec1044908414013ebfe363450c22f14698803ce97fbb47e53284d55c5165848", - "sha256:6fca33672578666f657c131552c4ef8979c1606e494f78cd5199742dfb26918b", - "sha256:751934967f5336a3e26fc5993ccad1e4fee982029f9317eb6153bc0bc3d2d2da", - "sha256:8be835aac18ec85351385e17b8665bd4d63083a7160a017bef3d640e8e65cadb", - "sha256:927ce09e49bff3104459e1451ce82983b0a3062437a07d883a4c66f0b344c9b5", - "sha256:94208867f34e60f54a33a37f1c117251be91a47e3bfdb9ab8a7847f20886ad06", - "sha256:94f667d86be82dd4cb17d08de0c3622e77ca865320e0b95eae6153faa7b4ecaf", - "sha256:9e9c25522933e569e8b53ccc644dc993cab87e922fb7e142894653880fdd419d", - "sha256:a0e306e9bb76fd93b29ae3a5155298e4c1b504c7cbc620c09c20858d32d16234", - "sha256:a8bfc1e1afe523e94974132d7230b82ca7fa2511aedde1f537ec54db0399541a", - "sha256:ac2244e64485c3778f012951fdc869969a736cd61375fde6096d08850d8be729", - "sha256:b4b0e44d586cd64b65b507fa116a3814a1a53d55dce4836d7c1a6eb2823ff8d1", - "sha256:baeb451ee23e264de3f577fee5283c73d9bbaa8cb921d0305c0bbf700094b65b", - "sha256:c7dc052432cd5d060d7437e217dd33c97025287f99a69a50e2dc1478dd610d64", - "sha256:d1a85dfc5dee741bf49cb9b6b6b8d2725a268e4992507cf151cba26b17d97c37", - "sha256:d90010304abb4102123d10cbad2cdf2c25a9f2e66a50974199b24b468509bad5", - "sha256:ddfb511e76d016c3a160910642d57f4587dc542ce5ee823b0d415134790eeeb9", - "sha256:e273367f4076bd7b9a8dc2e771978ef2bfd6b82526e80775a7db52bff8ca01dd", - "sha256:e5bb3463df697279e5459a7316ad5a60b04b0107f9392e88674d0ece70e9cf70", - "sha256:e8a1750b44ad6422ace82bf3466638f1aa0862dbb9689690d5f2f48cce3476c8", - "sha256:eab063a70cca4a587c28824e18be41d8ecc4457f8f15b2933584c6c6cccd30f0", - "sha256:ecce8c021894a77d89808222b1ff9687ad84db54d18e4bd0500ca766737faaf6", - "sha256:f4d972139d5000105fcda9539a76452039434013570d6059993120dc2a65e447", - "sha256:fd3b96f8c705af8e938eaa99cbd8fd1450f632d38cad55e7367c33b263bf98ec", - "sha256:fdd2ed7395df8ac2dbb10cefc44737b66c6a5cd7755c92524733d7a443e5b7e2" - ], - "index": "pypi", - "version": "==1.3.23" - }, - "sqlalchemy-aio": { - "hashes": [ - "sha256:3f4aa392c38f032d6734826a4138a0f02ed3122d442ed142be1e5964f2a33b60", - "sha256:f531c7982662d71dfc0b117e77bb2ed544e25cd5361e76cf9f5208edcfb71f7b" - ], - "index": "pypi", - "version": "==0.17.0" - }, - "sse-starlette": { - "hashes": [ - "sha256:840607fed361360cc76f8408a25f0eca887e7cab3c3ee44f9762f179428e2bd4", - "sha256:ca2de945af80b83f1efda6144df9e13db83880b3b87c660044b64f199395e8b7" - ], - "index": "pypi", - "version": "==0.10.3" - }, - "starlette": { - "hashes": [ - "sha256:5a60c5c2d051f3a8eb546136aa0c9399773a689595e099e0877704d5888279bf", - "sha256:c6d21096774ecb9639acad41b86b7706e52ba3bf1dc13ea4ed9ad593d47e24c7" - ], - "version": "==0.19.1" - }, - "typing-extensions": { - "hashes": [ - "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02", - "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6" - ], - "index": "pypi", - "markers": "python_version < '3.10'", - "version": "==4.3.0" - }, - "uvicorn": { - "hashes": [ - "sha256:c19a057deb1c5bb060946e2e5c262fc01590c6529c0af2c3d9ce941e89bc30e0", - "sha256:cade07c403c397f9fe275492a48c1b869efd175d5d8a692df649e6e7e2ed8f4e" - ], - "index": "pypi", - "version": "==0.18.2" - }, - "uvloop": { - "hashes": [ - "sha256:04ff57aa137230d8cc968f03481176041ae789308b4d5079118331ab01112450", - "sha256:089b4834fd299d82d83a25e3335372f12117a7d38525217c2258e9b9f4578897", - "sha256:1e5f2e2ff51aefe6c19ee98af12b4ae61f5be456cd24396953244a30880ad861", - "sha256:30ba9dcbd0965f5c812b7c2112a1ddf60cf904c1c160f398e7eed3a6b82dcd9c", - "sha256:3a19828c4f15687675ea912cc28bbcb48e9bb907c801873bd1519b96b04fb805", - "sha256:6224f1401025b748ffecb7a6e2652b17768f30b1a6a3f7b44660e5b5b690b12d", - "sha256:647e481940379eebd314c00440314c81ea547aa636056f554d491e40503c8464", - "sha256:6ccd57ae8db17d677e9e06192e9c9ec4bd2066b77790f9aa7dede2cc4008ee8f", - "sha256:772206116b9b57cd625c8a88f2413df2fcfd0b496eb188b82a43bed7af2c2ec9", - "sha256:8e0d26fa5875d43ddbb0d9d79a447d2ace4180d9e3239788208527c4784f7cab", - "sha256:98d117332cc9e5ea8dfdc2b28b0a23f60370d02e1395f88f40d1effd2cb86c4f", - "sha256:b572256409f194521a9895aef274cea88731d14732343da3ecdb175228881638", - "sha256:bd53f7f5db562f37cd64a3af5012df8cac2c464c97e732ed556800129505bd64", - "sha256:bd8f42ea1ea8f4e84d265769089964ddda95eb2bb38b5cbe26712b0616c3edee", - "sha256:e814ac2c6f9daf4c36eb8e85266859f42174a4ff0d71b99405ed559257750382", - "sha256:f74bc20c7b67d1c27c72601c78cf95be99d5c2cdd4514502b4f3eb0933ff1228" - ], - "version": "==0.16.0" - }, - "watchfiles": { - "hashes": [ - "sha256:56abed43e645d1f2d6def83e35999cc5758b051aff54ca1065cbfcaea15b3389", - "sha256:65ca99a94fcab29d00aa406526eb29cf198c0661854d59a315596064fed02141", - "sha256:67d4c66e46a564059df4aeedab78f09cba0b697bf36cc77566b0a7015dfb7f5d", - "sha256:6e0e8829d32b05151e6009570449f44f891e05f518e495d25f960e0d0b2d0064", - "sha256:715733c2ac9da67b2790788657ff6f8b3797eb31565bfc592289b523ae907ca2", - "sha256:7b81c6e404b2aa62482a719eb778e4a16d01728302dce1f1512c1e5354a73fda", - "sha256:82238d08d8a49f1a1ba254278cd4329a154f6100b028393059722ebeddd2ff3d", - "sha256:955e8f840e1996a8a41be57de4c03af7b1515a685b7fb6abe222f859e413a907", - "sha256:cab62510f990d195986302aa6a48ed636d685b099927049120d520c96069fa49", - "sha256:d1f9de6b776b3aff17898a4cf5ac5a2d0a16212ea7aad2bbe0ef6aa3e79a96af", - "sha256:d4f45acd1143db6d3ee77a4ff12d3239bc8083108133e6174e9dcce59c1f9902", - "sha256:f7f71012e096e11256fae3b37617a9777980f281e18deb2e789e85cd5b113935" - ], - "version": "==0.15.0" - }, - "websockets": { - "hashes": [ - "sha256:07cdc0a5b2549bcfbadb585ad8471ebdc7bdf91e32e34ae3889001c1c106a6af", - "sha256:210aad7fdd381c52e58777560860c7e6110b6174488ef1d4b681c08b68bf7f8c", - "sha256:28dd20b938a57c3124028680dc1600c197294da5db4292c76a0b48efb3ed7f76", - "sha256:2f94fa3ae454a63ea3a19f73b95deeebc9f02ba2d5617ca16f0bbdae375cda47", - "sha256:31564a67c3e4005f27815634343df688b25705cccb22bc1db621c781ddc64c69", - "sha256:347974105bbd4ea068106ec65e8e8ebd86f28c19e529d115d89bd8cc5cda3079", - "sha256:379e03422178436af4f3abe0aa8f401aa77ae2487843738542a75faf44a31f0c", - "sha256:3eda1cb7e9da1b22588cefff09f0951771d6ee9fa8dbe66f5ae04cc5f26b2b55", - "sha256:51695d3b199cd03098ae5b42833006a0f43dc5418d3102972addc593a783bc02", - "sha256:54c000abeaff6d8771a4e2cef40900919908ea7b6b6a30eae72752607c6db559", - "sha256:5b936bf552e4f6357f5727579072ff1e1324717902127ffe60c92d29b67b7be3", - "sha256:6075fd24df23133c1b078e08a9b04a3bc40b31a8def4ee0b9f2c8865acce913e", - "sha256:661f641b44ed315556a2fa630239adfd77bd1b11cb0b9d96ed8ad90b0b1e4978", - "sha256:6ea6b300a6bdd782e49922d690e11c3669828fe36fc2471408c58b93b5535a98", - "sha256:6ed1d6f791eabfd9808afea1e068f5e59418e55721db8b7f3bfc39dc831c42ae", - "sha256:7934e055fd5cd9dee60f11d16c8d79c4567315824bacb1246d0208a47eca9755", - "sha256:7ab36e17af592eec5747c68ef2722a74c1a4a70f3772bc661079baf4ae30e40d", - "sha256:7f6d96fdb0975044fdd7953b35d003b03f9e2bcf85f2d2cf86285ece53e9f991", - "sha256:83e5ca0d5b743cde3d29fda74ccab37bdd0911f25bd4cdf09ff8b51b7b4f2fa1", - "sha256:85506b3328a9e083cc0a0fb3ba27e33c8db78341b3eb12eb72e8afd166c36680", - "sha256:8af75085b4bc0b5c40c4a3c0e113fa95e84c60f4ed6786cbb675aeb1ee128247", - "sha256:8b1359aba0ff810d5830d5ab8e2c4a02bebf98a60aa0124fb29aa78cfdb8031f", - "sha256:8fbd7d77f8aba46d43245e86dd91a8970eac4fb74c473f8e30e9c07581f852b2", - "sha256:907e8247480f287aa9bbc9391bd6de23c906d48af54c8c421df84655eef66af7", - "sha256:93d5ea0b5da8d66d868b32c614d2b52d14304444e39e13a59566d4acb8d6e2e4", - "sha256:97bc9d41e69a7521a358f9b8e44871f6cdeb42af31815c17aed36372d4eec667", - "sha256:994cdb1942a7a4c2e10098d9162948c9e7b235df755de91ca33f6e0481366fdb", - "sha256:a141de3d5a92188234afa61653ed0bbd2dde46ad47b15c3042ffb89548e77094", - "sha256:a1e15b230c3613e8ea82c9fc6941b2093e8eb939dd794c02754d33980ba81e36", - "sha256:aad5e300ab32036eb3fdc350ad30877210e2f51bceaca83fb7fef4d2b6c72b79", - "sha256:b529fdfa881b69fe563dbd98acce84f3e5a67df13de415e143ef053ff006d500", - "sha256:b9c77f0d1436ea4b4dc089ed8335fa141e6a251a92f75f675056dac4ab47a71e", - "sha256:bb621ec2dbbbe8df78a27dbd9dd7919f9b7d32a73fafcb4d9252fc4637343582", - "sha256:c7250848ce69559756ad0086a37b82c986cd33c2d344ab87fea596c5ac6d9442", - "sha256:c8d1d14aa0f600b5be363077b621b1b4d1eb3fbf90af83f9281cda668e6ff7fd", - "sha256:d1655a6fc7aecd333b079d00fb3c8132d18988e47f19740c69303bf02e9883c6", - "sha256:d6353ba89cfc657a3f5beabb3b69be226adbb5c6c7a66398e17809b0ce3c4731", - "sha256:da4377904a3379f0c1b75a965fff23b28315bcd516d27f99a803720dfebd94d4", - "sha256:e49ea4c1a9543d2bd8a747ff24411509c29e4bdcde05b5b0895e2120cb1a761d", - "sha256:e4e08305bfd76ba8edab08dcc6496f40674f44eb9d5e23153efa0a35750337e8", - "sha256:e6fa05a680e35d0fcc1470cb070b10e6fe247af54768f488ed93542e71339d6f", - "sha256:e7e6f2d6fd48422071cc8a6f8542016f350b79cc782752de531577d35e9bd677", - "sha256:e904c0381c014b914136c492c8fa711ca4cced4e9b3d110e5e7d436d0fc289e8", - "sha256:ec2b0ab7edc8cd4b0eb428b38ed89079bdc20c6bdb5f889d353011038caac2f9", - "sha256:ef5ce841e102278c1c2e98f043db99d6755b1c58bde475516aef3a008ed7f28e", - "sha256:f351c7d7d92f67c0609329ab2735eee0426a03022771b00102816a72715bb00b", - "sha256:fab7c640815812ed5f10fbee7abbf58788d602046b7bb3af9b1ac753a6d5e916", - "sha256:fc06cc8073c8e87072138ba1e431300e2d408f054b27047d047b549455066ff4" - ], - "version": "==10.3" - } - }, - "develop": { - "appdirs": { - "hashes": [ - "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", - "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" - ], - "version": "==1.4.4" - }, - "attrs": { - "hashes": [ - "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4", - "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd" - ], - "version": "==21.4.0" - }, - "black": { - "hashes": [ - "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea" - ], - "index": "pypi", - "version": "==20.8b1" - }, - "certifi": { - "hashes": [ - "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d", - "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412" - ], - "version": "==2022.6.15" - }, - "charset-normalizer": { - "hashes": [ - "sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5", - "sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413" - ], - "version": "==2.1.0" - }, - "click": { - "hashes": [ - "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", - "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48" - ], - "version": "==8.1.3" - }, - "coverage": { - "hashes": [ - "sha256:0895ea6e6f7f9939166cc835df8fa4599e2d9b759b02d1521b574e13b859ac32", - "sha256:0f211df2cba951ffcae210ee00e54921ab42e2b64e0bf2c0befc977377fb09b7", - "sha256:147605e1702d996279bb3cc3b164f408698850011210d133a2cb96a73a2f7996", - "sha256:24b04d305ea172ccb21bee5bacd559383cba2c6fcdef85b7701cf2de4188aa55", - "sha256:25b7ec944f114f70803d6529394b64f8749e93cbfac0fe6c5ea1b7e6c14e8a46", - "sha256:2b20286c2b726f94e766e86a3fddb7b7e37af5d0c635bdfa7e4399bc523563de", - "sha256:2dff52b3e7f76ada36f82124703f4953186d9029d00d6287f17c68a75e2e6039", - "sha256:2f8553878a24b00d5ab04b7a92a2af50409247ca5c4b7a2bf4eabe94ed20d3ee", - "sha256:3def6791adf580d66f025223078dc84c64696a26f174131059ce8e91452584e1", - "sha256:422fa44070b42fef9fb8dabd5af03861708cdd6deb69463adc2130b7bf81332f", - "sha256:4f89d8e03c8a3757aae65570d14033e8edf192ee9298303db15955cadcff0c63", - "sha256:5336e0352c0b12c7e72727d50ff02557005f79a0b8dcad9219c7c4940a930083", - "sha256:54d8d0e073a7f238f0666d3c7c0d37469b2aa43311e4024c925ee14f5d5a1cbe", - "sha256:5ef42e1db047ca42827a85e34abe973971c635f83aed49611b7f3ab49d0130f0", - "sha256:5f65e5d3ff2d895dab76b1faca4586b970a99b5d4b24e9aafffc0ce94a6022d6", - "sha256:6c3ccfe89c36f3e5b9837b9ee507472310164f352c9fe332120b764c9d60adbe", - "sha256:6d0b48aff8e9720bdec315d67723f0babd936a7211dc5df453ddf76f89c59933", - "sha256:6fe75dcfcb889b6800f072f2af5a331342d63d0c1b3d2bf0f7b4f6c353e8c9c0", - "sha256:79419370d6a637cb18553ecb25228893966bd7935a9120fa454e7076f13b627c", - "sha256:7bb00521ab4f99fdce2d5c05a91bddc0280f0afaee0e0a00425e28e209d4af07", - "sha256:80db4a47a199c4563d4a25919ff29c97c87569130375beca3483b41ad5f698e8", - "sha256:866ebf42b4c5dbafd64455b0a1cd5aa7b4837a894809413b930026c91e18090b", - "sha256:8af6c26ba8df6338e57bedbf916d76bdae6308e57fc8f14397f03b5da8622b4e", - "sha256:a13772c19619118903d65a91f1d5fea84be494d12fd406d06c849b00d31bf120", - "sha256:a697977157adc052284a7160569b36a8bbec09db3c3220642e6323b47cec090f", - "sha256:a9032f9b7d38bdf882ac9f66ebde3afb8145f0d4c24b2e600bc4c6304aafb87e", - "sha256:b5e28db9199dd3833cc8a07fa6cf429a01227b5d429facb56eccd765050c26cd", - "sha256:c77943ef768276b61c96a3eb854eba55633c7a3fddf0a79f82805f232326d33f", - "sha256:d230d333b0be8042ac34808ad722eabba30036232e7a6fb3e317c49f61c93386", - "sha256:d4548be38a1c810d79e097a38107b6bf2ff42151900e47d49635be69943763d8", - "sha256:d4e7ced84a11c10160c0697a6cc0b214a5d7ab21dfec1cd46e89fbf77cc66fae", - "sha256:d56f105592188ce7a797b2bd94b4a8cb2e36d5d9b0d8a1d2060ff2a71e6b9bbc", - "sha256:d714af0bdba67739598849c9f18efdcc5a0412f4993914a0ec5ce0f1e864d783", - "sha256:d774d9e97007b018a651eadc1b3970ed20237395527e22cbeb743d8e73e0563d", - "sha256:e0524adb49c716ca763dbc1d27bedce36b14f33e6b8af6dba56886476b42957c", - "sha256:e2618cb2cf5a7cc8d698306e42ebcacd02fb7ef8cfc18485c59394152c70be97", - "sha256:e36750fbbc422c1c46c9d13b937ab437138b998fe74a635ec88989afb57a3978", - "sha256:edfdabe7aa4f97ed2b9dd5dde52d2bb29cb466993bb9d612ddd10d0085a683cf", - "sha256:f22325010d8824594820d6ce84fa830838f581a7fd86a9235f0d2ed6deb61e29", - "sha256:f23876b018dfa5d3e98e96f5644b109090f16a4acb22064e0f06933663005d39", - "sha256:f7bd0ffbcd03dc39490a1f40b2669cc414fae0c4e16b77bb26806a4d0b7d1452" - ], - "version": "==6.4.2" - }, - "idna": { - "hashes": [ - "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", - "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" - ], - "version": "==3.3" - }, - "iniconfig": { - "hashes": [ - "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", - "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" - ], - "version": "==1.1.1" - }, - "mock": { - "hashes": [ - "sha256:122fcb64ee37cfad5b3f48d7a7d51875d7031aaf3d8be7c42e2bee25044eee62", - "sha256:7d3fbbde18228f4ff2f1f119a45cdffa458b4c0dee32eb4d2bb2f82554bac7bc" - ], - "index": "pypi", - "version": "==4.0.3" - }, - "mypy": { - "hashes": [ - "sha256:006be38474216b833eca29ff6b73e143386f352e10e9c2fbe76aa8549e5554f5", - "sha256:03c6cc893e7563e7b2949b969e63f02c000b32502a1b4d1314cabe391aa87d66", - "sha256:0e9f70df36405c25cc530a86eeda1e0867863d9471fe76d1273c783df3d35c2e", - "sha256:1ece702f29270ec6af25db8cf6185c04c02311c6bb21a69f423d40e527b75c56", - "sha256:3e09f1f983a71d0672bbc97ae33ee3709d10c779beb613febc36805a6e28bb4e", - "sha256:439c726a3b3da7ca84a0199a8ab444cd8896d95012c4a6c4a0d808e3147abf5d", - "sha256:5a0b53747f713f490affdceef835d8f0cb7285187a6a44c33821b6d1f46ed813", - "sha256:5f1332964963d4832a94bebc10f13d3279be3ce8f6c64da563d6ee6e2eeda932", - "sha256:63e85a03770ebf403291ec50097954cc5caf2a9205c888ce3a61bd3f82e17569", - "sha256:64759a273d590040a592e0f4186539858c948302c653c2eac840c7a3cd29e51b", - "sha256:697540876638ce349b01b6786bc6094ccdaba88af446a9abb967293ce6eaa2b0", - "sha256:9940e6916ed9371809b35b2154baf1f684acba935cd09928952310fbddaba648", - "sha256:9f5f5a74085d9a81a1f9c78081d60a0040c3efb3f28e5c9912b900adf59a16e6", - "sha256:a5ea0875a049de1b63b972456542f04643daf320d27dc592d7c3d9cd5d9bf950", - "sha256:b117650592e1782819829605a193360a08aa99f1fc23d1d71e1a75a142dc7e15", - "sha256:b24be97351084b11582fef18d79004b3e4db572219deee0212078f7cf6352723", - "sha256:b88f784e9e35dcaa075519096dc947a388319cb86811b6af621e3523980f1c8a", - "sha256:bdd5ca340beffb8c44cb9dc26697628d1b88c6bddf5c2f6eb308c46f269bb6f3", - "sha256:d5aaf1edaa7692490f72bdb9fbd941fbf2e201713523bdb3f4038be0af8846c6", - "sha256:e999229b9f3198c0c880d5e269f9f8129c8862451ce53a011326cad38b9ccd24", - "sha256:f4a21d01fc0ba4e31d82f0fff195682e29f9401a8bdb7173891070eb260aeb3b", - "sha256:f4b794db44168a4fc886e3450201365c9526a522c46ba089b55e1f11c163750d", - "sha256:f730d56cb924d371c26b8eaddeea3cc07d78ff51c521c6d04899ac6904b75492" - ], - "index": "pypi", - "version": "==0.961" - }, - "mypy-extensions": { - "hashes": [ - "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", - "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" - ], - "version": "==0.4.3" - }, - "packaging": { - "hashes": [ - "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", - "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" - ], - "version": "==21.3" - }, - "pathspec": { - "hashes": [ - "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a", - "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1" - ], - "version": "==0.9.0" - }, - "pluggy": { - "hashes": [ - "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", - "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" - ], - "version": "==1.0.0" - }, - "py": { - "hashes": [ - "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", - "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378" - ], - "version": "==1.11.0" - }, - "pyparsing": { - "hashes": [ - "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb", - "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc" - ], - "version": "==3.0.9" - }, - "pytest": { - "hashes": [ - "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c", - "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45" - ], - "index": "pypi", - "version": "==7.1.2" - }, - "pytest-asyncio": { - "hashes": [ - "sha256:16cf40bdf2b4fb7fc8e4b82bd05ce3fbcd454cbf7b92afc445fe299dabb88213", - "sha256:7659bdb0a9eb9c6e3ef992eef11a2b3e69697800ad02fb06374a210d85b29f91", - "sha256:8fafa6c52161addfd41ee7ab35f11836c5a16ec208f93ee388f752bea3493a84" - ], - "index": "pypi", - "version": "==0.18.3" - }, - "pytest-cov": { - "hashes": [ - "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6", - "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470" - ], - "index": "pypi", - "version": "==3.0.0" - }, - "regex": { - "hashes": [ - "sha256:00d2e907d3c5e4f85197c8d2263a9cc2d34bf234a9c6236ae42a3fb0bc09b759", - "sha256:0186edcda692c38381db8ac257c2d023fd2e08818d45dc5bee4ed84212045f51", - "sha256:06c509bd7dcb7966bdb03974457d548e54d8327bad5b0c917e87248edc43e2eb", - "sha256:0a3f3f45c5902eb4d90266002ccb035531ae9b9278f6d5e8028247c34d192099", - "sha256:0c1821146b429e6fdbd13ea10f26765e48d5284bc79749468cfbfe3ceb929f0d", - "sha256:0d93167b7d7731fa9ff9fdc1bae84ec9c7133b01a35f8cc04e926d48da6ce1f7", - "sha256:0fd8c3635fa03ef79d07c7b3ed693b3f3930ccb52c0c51761c3296a7525b135c", - "sha256:119091c675e6ad19da8770f89aa1d52f4ad2a2018d631956f3e90c45882df880", - "sha256:121981ba84309dabefd5e1debd49be6d51624e54b4d44bfc184cd8d555ff1df1", - "sha256:1244e9b9b4b81c9c34e8a84273ffaeebdc78abc98a5b02dcdd49845eb3c63bd7", - "sha256:12e1404dfb4e928d3273a10e3468877fe84bdcd3c50b655a2c9613cfc5d9fe63", - "sha256:13d74951c14708f00700bb29475129ecbc40e01b4029c62ee7bfe9d1f59f31ce", - "sha256:162a5939a6fdf48658d3565eeff35acdd207e07367bf5caaff3d9ea7cb77d7a9", - "sha256:1703490c5b850fa9cef1af00c58966756042e6ca22f4fb5bb857345cd535834f", - "sha256:18e6203cfd81df42a987175aaeed7ba46bcb42130cd81763e2d5edcff0006d5d", - "sha256:192c2784833aea6fc7b004730bf1b91b8b8c6b998b30271aaf3bd8adfef20a96", - "sha256:1948d3ceac5b2d55bc93159c1e0679a256a87a54c735be5cef4543a9e692dbb9", - "sha256:206a327e628bc529d64b21ff79a5e2564f5aec7dc7abcd4b2e8a4b271ec10550", - "sha256:2e5db20412f0db8798ff72473d16da5f13ec808e975b49188badb2462f529fa9", - "sha256:2f94b0befc811fe74a972b1739fffbf74c0dc1a91102aca8e324aa4f2c6991bd", - "sha256:303676797c4c7978726e74eb8255d68f7125a3a29da71ff453448f2117290e9a", - "sha256:34ae4f35db30caa4caf85c55069fcb7a05966a3a5ba6e9e1dab5477d84fbb08a", - "sha256:3c6df8be7d1dd35a0d9a200fbc29f888c4452c8882d284f87608046152e049e6", - "sha256:402fa998c5988d11ed34585eb65740dcebd0fd11844d12eb0a6b4be178eb9c64", - "sha256:40a28759d345c0bb1f5b0ac74ac04f5d48136019522c95c0ec4b07786f67ce20", - "sha256:414ae507ba88264444baf771fec43ce0adcd4c5dbb304d3e0716f3f4d4499d2e", - "sha256:42da079e31ae9818ffa7a35cdd16ab7104e3f7eca9c0958040aede827b2e55c6", - "sha256:473a7d21932ce7c314953b33c32e63df690181860edcdf14bba1278cdf71b07f", - "sha256:49fcb45931a693b0e901972c5e077ea2cf30ec39da699645c43cb8b1542c6e14", - "sha256:4c5913cb9769038bd03e42318955c2f15a688384a6a0b807bcfc8271603d9277", - "sha256:4cfeb71095c8d8380a5df5a38ff94d27a3f483717e509130a822b4d6400b7991", - "sha256:4dc74f0171eede67d79a79c06eca0fe5b7b280dbb8c27ad1fae4ced2ad66268f", - "sha256:5b1cffff2d9f832288fe516021cb81c95c57c0067b13a82f1d2daabdbc2f4270", - "sha256:601c99ac775b6c89699a48976f3dbb000b47d3ca59362c8abc9582e6d0780d91", - "sha256:667a06bb8d72b6da3d9cf38dac4ba969688868ed2279a692e993d2c0e1c30aba", - "sha256:673549a0136c7893f567ed71ab5225ed3701c79b17c0a7faee846c645fc24010", - "sha256:67bd3bdd27db7a6460384869dd4b9c54267d805b67d70b20495bb5767f8e051c", - "sha256:727edff0a4eaff3b6d26cbb50216feac9055aba7e6290eec23c061c2fe2fab55", - "sha256:782627a1cb8fbb1c78d8e841f5b71c2c683086c038f975bebdac7cce7678a96f", - "sha256:7d462ba84655abeddae4dfc517fe1afefb5430b3b5acb0a954de12a47aea7183", - "sha256:8ab39aa445d00902c43a1e951871bedc7f18d095a21eccba153d594faac34aea", - "sha256:8e2075ed4ea2e231e2e98b16cfa5dae87e9a6045a71104525e1efc29aa8faa8e", - "sha256:9daeccb2764bf4cc280c40c6411ae176bb0876948e536590a052b3d647254c95", - "sha256:9e4006942334fa954ebd32fa0728718ec870f95f4ba7cda9edc46dd49c294f22", - "sha256:9f1c8fffd4def0b76c0947b8cb261b266e31041785dc2dc2db7569407a2f54fe", - "sha256:a00cd58a30a1041c193777cb1bc090200b05ff4b073d5935738afd1023e63069", - "sha256:a0220a7a16fd4bfc700661f920510defd31ef7830ce992d5cc51777aa8ccd724", - "sha256:a048f91823862270905cb22ef88038b08aac852ce48e0ecc4b4bf1b895ec37d9", - "sha256:a3c47c71fde0c5d584402e67546c81af9951540f1f622d821e9c20761556473a", - "sha256:a6d9ea727fd1233ee746bf44dd37e7d4320b3ed8ff09e73d7638c969b28d280f", - "sha256:ab0709daedc1099bbd4371ae17eeedd4efc1cf70fcdcfe5de1374a0944b61f80", - "sha256:ab1cb36b411f16da6e057ef8e6657dd0af36f59a667f07e0b4b617e44e53d7b2", - "sha256:ae1c5b435d44aa91d48cc710f20c3485e0584a3ad3565d5ae031d61a35f674f4", - "sha256:b279b9bb401af41130fd2a09427105100bc8c624ed45da1c81c1c0d0aa639734", - "sha256:b72a4ec79a15f6066d14ae1c472b743af4b4ecee14420e8d6e4a336b49b8f21c", - "sha256:c2cd93725911c0159d597b90c96151070ef7e0e67604637e2f2abe06c34bf079", - "sha256:c7c5f914b0eb5242c09f91058b80295525897e873b522575ab235b48db125597", - "sha256:d07d849c9e2eca80adb85d3567302a47195a603ad7b1f0a07508e253c041f954", - "sha256:d2672d68cf6c8452b6758fc3cd2d8feac966d511eed79a68182a5297b473af9c", - "sha256:d35bbcbf70d14f724e7489746cf68efe122796578addd98f91428e144d0ad266", - "sha256:d40b4447784dbe0896a6d10a178f6724598161f942c56f5a60dc0ef7fe63f7a1", - "sha256:d561dcb0fb0ab858291837d51330696a45fd3ba6912a332a4ee130e5484b9e47", - "sha256:d7f5ccfff648093152cadf6d886c7bd922047532f72024c953a79c7553aac2fe", - "sha256:dce6b2ad817e3eb107f8704782b091b0631dd3adf47f14bdc086165d05b528b0", - "sha256:e1fdda3ec7e9785065b67941693995cab95b54023a21db9bf39e54cc7b2c3526", - "sha256:e2a262ec85c595fc8e1f3162cafc654d2219125c00ea3a190c173cea70d2cc7a", - "sha256:e2fc1e3928c1189c0382c547c17717c6d9f425fffe619ef94270fe4c6c8be0a6", - "sha256:ea27acd97a752cfefa9907da935e583efecb302e6e9866f37565968c8407ad58", - "sha256:ee769a438827e443ed428e66d0aa7131c653ecd86ddc5d4644a81ed1d93af0e7", - "sha256:f32e0d1c7e7b0b9c3cac76f3d278e7ee6b99c95672d2c1c6ea625033431837c0", - "sha256:f355caec5bbce20421dc26e53787b10e32fd0df68db2b795435217210c08d69c", - "sha256:f87e9108bb532f8a1fc6bf7e69b930a35c7b0267b8fef0a3ede0bcb4c5aaa531", - "sha256:f8a2fd2f62a77536e4e3193303bec380df40d99e253b1c8f9b6eafa07eaeff67", - "sha256:fbdf4fc6adf38fab1091c579ece3fe9f493bd0f1cfc3d2c76d2e52461ca4f8a9" - ], - "version": "==2022.7.9" - }, - "requests": { - "hashes": [ - "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983", - "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349" - ], - "index": "pypi", - "version": "==2.28.1" - }, - "toml": { - "hashes": [ - "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", - "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" - ], - "version": "==0.10.2" - }, - "tomli": { - "hashes": [ - "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", - "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" - ], - "version": "==2.0.1" - }, - "typed-ast": { - "hashes": [ - "sha256:0261195c2062caf107831e92a76764c81227dae162c4f75192c0d489faf751a2", - "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1", - "sha256:183afdf0ec5b1b211724dfef3d2cad2d767cbefac291f24d69b00546c1837fb6", - "sha256:211260621ab1cd7324e0798d6be953d00b74e0428382991adfddb352252f1d62", - "sha256:267e3f78697a6c00c689c03db4876dd1efdfea2f251a5ad6555e82a26847b4ac", - "sha256:2efae9db7a8c05ad5547d522e7dbe62c83d838d3906a3716d1478b6c1d61388d", - "sha256:370788a63915e82fd6f212865a596a0fefcbb7d408bbbb13dea723d971ed8bdc", - "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2", - "sha256:3e123d878ba170397916557d31c8f589951e353cc95fb7f24f6bb69adc1a8a97", - "sha256:4879da6c9b73443f97e731b617184a596ac1235fe91f98d279a7af36c796da35", - "sha256:4e964b4ff86550a7a7d56345c7864b18f403f5bd7380edf44a3c1fb4ee7ac6c6", - "sha256:639c5f0b21776605dd6c9dbe592d5228f021404dafd377e2b7ac046b0349b1a1", - "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4", - "sha256:6778e1b2f81dfc7bc58e4b259363b83d2e509a65198e85d5700dfae4c6c8ff1c", - "sha256:683407d92dc953c8a7347119596f0b0e6c55eb98ebebd9b23437501b28dcbb8e", - "sha256:79b1e0869db7c830ba6a981d58711c88b6677506e648496b1f64ac7d15633aec", - "sha256:7d5d014b7daa8b0bf2eaef684295acae12b036d79f54178b92a2b6a56f92278f", - "sha256:98f80dee3c03455e92796b58b98ff6ca0b2a6f652120c263efdba4d6c5e58f72", - "sha256:a94d55d142c9265f4ea46fab70977a1944ecae359ae867397757d836ea5a3f47", - "sha256:a9916d2bb8865f973824fb47436fa45e1ebf2efd920f2b9f99342cb7fab93f72", - "sha256:c542eeda69212fa10a7ada75e668876fdec5f856cd3d06829e6aa64ad17c8dfe", - "sha256:cf4afcfac006ece570e32d6fa90ab74a17245b83dfd6655a6f68568098345ff6", - "sha256:ebd9d7f80ccf7a82ac5f88c521115cc55d84e35bf8b446fcd7836eb6b98929a3", - "sha256:ed855bbe3eb3715fca349c80174cfcfd699c2f9de574d40527b8429acae23a66" - ], - "version": "==1.5.4" - }, - "typing-extensions": { - "hashes": [ - "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02", - "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6" - ], - "index": "pypi", - "markers": "python_version < '3.10'", - "version": "==4.3.0" - }, - "urllib3": { - "hashes": [ - "sha256:8298d6d56d39be0e3bc13c1c97d133f9b45d797169a0e11cdd0e0489d786f7ec", - "sha256:879ba4d1e89654d9769ce13121e0f94310ea32e8d2f8cf587b77c08bbcdb30d6" - ], - "version": "==1.26.10" - } - } -} diff --git a/README.md b/README.md index 375da5cf..a22c857c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ LNbits ![Lightning network wallet](https://i.imgur.com/EHvK6Lq.png) -# LNbits v0.3 BETA, free and open-source lightning-network wallet/accounts system +# LNbits v0.9 BETA, free and open-source lightning-network wallet/accounts system (Join us on [https://t.me/lnbits](https://t.me/lnbits)) @@ -25,7 +25,7 @@ LNbits is a very simple Python server that sits on top of any funding source, an LNbits can run on top of any lightning-network funding source, currently there is support for LND, c-lightning, Spark, LNpay, OpenNode, lntxbot, with more being added regularly. -See [lnbits.org](https://lnbits.org) for more detailed documentation. +See [legend.lnbits.org](https://legend.lnbits.org) for more detailed documentation. Checkout the LNbits [YouTube](https://www.youtube.com/playlist?list=PLPj3KCksGbSYG0ciIQUWJru1dWstPHshe) video series. @@ -54,7 +54,7 @@ LNURL has a fallback scheme, so if scanned by a regular QR code reader it can de ![lnurl fallback](https://i.imgur.com/CPBKHIv.png) Using **lnbits.com/?lightning="LNURL-withdraw"** will trigger a withdraw that builds an LNbits wallet. -Example use would be an ATM, which utilises LNURL, if the user scans the QR with a regular QR code scanner app, they will stilll be able to access the funds. +Example use would be an ATM, which utilises LNURL, if the user scans the QR with a regular QR code scanner app, they will still be able to access the funds. ![lnurl ATM](https://i.imgur.com/Gi6bn3L.jpg) @@ -67,10 +67,10 @@ Wallets can be easily generated and given out to people at events (one click mul ## Tip us -If you like this project and might even use or extend it, why not [send some tip love](https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)! +If you like this project and might even use or extend it, why not [send some tip love](https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)! -[docs]: https://lnbits.org/ +[docs]: https://legend.lnbits.org/ [docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg [github-mypy]: https://github.com/lnbits/lnbits/actions?query=workflow%3Amypy [github-mypy-badge]: https://github.com/lnbits/lnbits/workflows/mypy/badge.svg diff --git a/build.py b/build.py new file mode 100644 index 00000000..2bc3e50c --- /dev/null +++ b/build.py @@ -0,0 +1,105 @@ +import glob +import os +import subprocess +import warnings +from os import path +from pathlib import Path +from typing import Any, List, NamedTuple, Optional + +LNBITS_PATH = path.dirname(path.realpath(__file__)) + "/lnbits" + + +def get_js_vendored(prefer_minified: bool = False) -> List[str]: + paths = get_vendored(".js", prefer_minified) + + def sorter(key: str): + if "moment@" in key: + return 1 + if "vue@" in key: + return 2 + if "vue-router@" in key: + return 3 + if "polyfills" in key: + return 4 + return 9 + + return sorted(paths, key=sorter) + + +def get_css_vendored(prefer_minified: bool = False) -> List[str]: + paths = get_vendored(".css", prefer_minified) + + def sorter(key: str): + if "quasar@" in key: + return 1 + if "vue@" in key: + return 2 + if "chart.js@" in key: + return 100 + return 9 + + return sorted(paths, key=sorter) + + +def get_vendored(ext: str, prefer_minified: bool = False) -> List[str]: + paths: List[str] = [] + for path in glob.glob( + os.path.join(LNBITS_PATH, "static/vendor/**"), recursive=True + ): + if path.endswith(".min" + ext): + # path is minified + unminified = path.replace(".min" + ext, ext) + if prefer_minified: + paths.append(path) + if unminified in paths: + paths.remove(unminified) + elif unminified not in paths: + paths.append(path) + + elif path.endswith(ext): + # path is not minified + minified = path.replace(ext, ".min" + ext) + if not prefer_minified: + paths.append(path) + if minified in paths: + paths.remove(minified) + elif minified not in paths: + paths.append(path) + + return sorted(paths) + + +def url_for_vendored(abspath: str) -> str: + return "/" + os.path.relpath(abspath, LNBITS_PATH) + + +def transpile_scss(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + from scss.compiler import compile_string # type: ignore + + with open(os.path.join(LNBITS_PATH, "static/scss/base.scss")) as scss: + with open(os.path.join(LNBITS_PATH, "static/css/base.css"), "w") as css: + css.write(compile_string(scss.read())) + + +def bundle_vendored(): + for getfiles, outputpath in [ + (get_js_vendored, os.path.join(LNBITS_PATH, "static/bundle.js")), + (get_css_vendored, os.path.join(LNBITS_PATH, "static/bundle.css")), + ]: + output = "" + for path in getfiles(): + with open(path) as f: + output += "/* " + url_for_vendored(path) + " */\n" + f.read() + ";\n" + with open(outputpath, "w") as f: + f.write(output) + + +def build(): + transpile_scss() + bundle_vendored() + + +if __name__ == "__main__": + build() diff --git a/docs/CNAME b/docs/CNAME index 1b08f754..9981b110 100644 --- a/docs/CNAME +++ b/docs/CNAME @@ -1 +1 @@ -lnbits.org \ No newline at end of file +legend.lnbits.org \ No newline at end of file diff --git a/docs/_config.yml b/docs/_config.yml index b4e4adbe..74e65187 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -3,7 +3,7 @@ title: "LNbits docs" remote_theme: pmarsceill/just-the-docs logo: "/logos/lnbits-full.png" search_enabled: true -url: https://lnbits.org +url: https://legend.lnbits.org aux_links: "LNbits on GitHub": - "//github.com/lnbits/lnbits" diff --git a/docs/devs/api.md b/docs/devs/api.md index a0938d62..a8217b9c 100644 --- a/docs/devs/api.md +++ b/docs/devs/api.md @@ -9,4 +9,4 @@ nav_order: 3 API reference ============= -Coming soon... +[Swagger Docs](https://legend.lnbits.org/devs/swagger.html) diff --git a/docs/devs/development.md b/docs/devs/development.md index f53b94bc..74a66405 100644 --- a/docs/devs/development.md +++ b/docs/devs/development.md @@ -17,10 +17,26 @@ Tests This project has unit tests that help prevent regressions. Before you can run the tests, you must install a few dependencies: ```bash -./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock +poetry install +npm i ``` Then to run the tests: ```bash make test ``` + +Run formatting: +```bash +make format +``` + +Run mypy checks: +```bash +poetry run mypy +``` + +Run everything: +```bash +make all +``` diff --git a/docs/devs/extensions.md b/docs/devs/extensions.md index 8c9a30a4..0ceb9cb3 100644 --- a/docs/devs/extensions.md +++ b/docs/devs/extensions.md @@ -28,17 +28,24 @@ Going over the example extension's structure: Adding new dependencies ----------------------- -If for some reason your extensions needs a new python package to work, you can add a new package using Pipenv: +If for some reason your extensions needs a new python package to work, you can add a new package using `venv`, or `poerty`: ```sh -$ pipenv install new_package_name +$ poetry add +# or +$ ./venv/bin/pip install ``` -This will create a new entry in the `Pipenv` file. **But we need an extra step to make sure LNbits doesn't break in production.** -All tests and deployments should run against the `requirements.txt` file so every time a new package is added -it is necessary to run the Pipenv `lock` command and manually update the requirements file: +Dependencies need to be added to `pyproject.toml` and `requirements.txt`, then tested by running on `venv` and `poetry`. +`nix` compatability can be tested with `nix build .#checks.x86_64-linux.vmTest`. -```sh -$ pipenv lock -r -``` + +SQLite to PostgreSQL migration +----------------------- + +LNbits currently supports SQLite and PostgreSQL databases. There is a migration script `tools/conv.py` that helps users migrate from SQLite to PostgreSQL. This script also copies all extension databases to the new backend. + +### Adding mock data to `mock_data.zip` + +`mock_data.zip` contains a few lines of sample SQLite data and is used in automated GitHub test to see whether your migration in `conv.py` works. Run your extension and save a few lines of data into a SQLite `your_extension.sqlite3` file. Unzip `tests/data/mock_data.zip`, add `your_extension.sqlite3` and zip it again. Add the updated `mock_data.zip` to your PR. \ No newline at end of file diff --git a/docs/devs/installation.md b/docs/devs/installation.md deleted file mode 100644 index f4d6b145..00000000 --- a/docs/devs/installation.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -layout: default -parent: For developers -title: Installation -nav_order: 1 ---- - -# Installation - -This guide has been moved to the [installation guide](../guide/installation.md). -To install the developer packages, use `pipenv install --dev`. - -## Notes: - -* We recommend using Caddy for a reverse-proxy if you want to serve your install through a domain, alternatively you can use [ngrok](https://ngrok.com/). -* Screen works well if you want LNbits to continue running when you close your terminal session. diff --git a/docs/devs/swagger.html b/docs/devs/swagger.html new file mode 100644 index 00000000..c787eb2b --- /dev/null +++ b/docs/devs/swagger.html @@ -0,0 +1,29 @@ + + + + + + My New API + + +
+ + + diff --git a/docs/guide/installation.md b/docs/guide/installation.md index daa9ae13..7c473eff 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -4,49 +4,64 @@ title: Basic installation nav_order: 2 --- - - # Basic installation -You can choose between two python package managers, `venv` and `pipenv`. Both are fine but if you don't know what you're doing, just go for the first option. +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). -## Option 1: pipenv - -You can also use Pipenv to manage your python packages. +## Option 1: poetry ```sh git clone https://github.com/lnbits/lnbits-legend.git cd lnbits-legend/ -sudo apt update && sudo apt install -y pipenv -pipenv install --dev -# pipenv --python 3.9 install --dev (if you wish to use a version of Python higher than 3.7) -pipenv shell -# pipenv --python 3.9 shell (if you wish to use a version of Python higher than 3.7) +# for making sure python 3.9 is installed, skip if installed +sudo apt update +sudo apt install software-properties-common +sudo add-apt-repository ppa:deadsnakes/ppa +sudo apt install python3.9 python3.9-distutils -# If any of the modules fails to install, try checking and upgrading your setupTool module -# pip install -U setuptools wheel +curl -sSL https://install.python-poetry.org | python3 - +export PATH="/home/ubuntu/.local/bin:$PATH" # or whatever is suggested in the poetry install notes printed to terminal +poetry env use python3.9 +poetry install --no-dev -# install libffi/libpq in case "pipenv install" fails -# sudo apt-get install -y libffi-dev libpq-dev +mkdir data +cp .env.example .env +sudo nano .env # set funding source - 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). +#### Running the server +```sh +poetry run lnbits +# To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0' +``` -## Option 2: venv +## Option 2: Nix -Download this repo and install the dependencies: +```sh +git clone https://github.com/lnbits/lnbits-legend.git +cd lnbits-legend/ +# Modern debian distros usually include Nix, however you can install with: +# 'sh <(curl -L https://nixos.org/nix/install) --daemon', or use setup here https://nixos.org/download.html#nix-verify-installation + +nix build .#lnbits +mkdir data + +``` + +#### Running the server + +```sh +# .env variables are currently passed when running +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 @@ -57,6 +72,8 @@ python3 -m venv venv ./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 @@ -65,20 +82,31 @@ mkdir data && cp .env.example .env ./venv/bin/uvicorn lnbits.__main__:app --port 5000 ``` -If you want to host LNbits on the internet, run with the option `--host 0.0.0.0`. +If you want to host LNbits on the internet, run with the option `--host 0.0.0.0`. + +## Option 4: Docker + +```sh +git clone https://github.com/lnbits/lnbits-legend.git +cd lnbits-legend +docker build -t lnbits-legend . +cp .env.example .env +mkdir data +docker run --detach --publish 5000:5000 --name lnbits-legend --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits-legend +``` ### Troubleshooting -Problems installing? These commands have helped us install LNbits. +Problems installing? These commands have helped us install LNbits. ```sh -sudo apt install pkg-config libffi-dev libpq-dev setuptools +sudo apt install pkg-config libffi-dev libpq-dev # if the secp256k1 build fails: -# if you used venv (option 1) -./venv/bin/pip install setuptools wheel -# if you used pipenv (option 2) -pipenv install setuptools wheel +# if you used venv +./venv/bin/pip install setuptools wheel +# if you used poetry +poetry add setuptools wheel # build essentials for debian/ubuntu sudo apt install python3-dev gcc build-essential ``` @@ -113,13 +141,13 @@ LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits" # Using LNbits -Now you can visit your LNbits at http://localhost:5000/. +Now you can visit your LNbits at http://localhost:5000/. -Now modify the `.env` file with any settings you prefer and add a proper [funding source](./wallets.md) by modifying the value of `LNBITS_BACKEND_WALLET_CLASS` and providing the extra information and credentials related to the chosen funding source. +Now modify the `.env` file with any settings you prefer and add a proper [funding source](./wallets.md) by modifying the value of `LNBITS_BACKEND_WALLET_CLASS` and providing the extra information and credentials related to the chosen funding source. Then you can restart it and it will be using the new settings. -You might also need to install additional packages or perform additional setup steps, depending on the chosen backend. See [the short guide](./wallets.md) on each different funding source. +You might also need to install additional packages or perform additional setup steps, depending on the chosen backend. See [the short guide](./wallets.md) on each different funding source. Take a look at [Polar](https://lightningpolar.com/) for an excellent way of spinning up a Lightning Network dev environment. @@ -127,7 +155,7 @@ Take a look at [Polar](https://lightningpolar.com/) for an excellent way of spin # Additional guides -### SQLite to PostgreSQL migration +## SQLite to PostgreSQL migration If you already have LNbits installed and running, on an SQLite database, we **highly** recommend you migrate to postgres if you are planning to run LNbits on scale. There's a script included that can do the migration easy. You should have Postgres already installed and there should be a password for the user (see Postgres install guide above). Additionally, your LNbits instance should run once on postgres to implement the database schema before the migration works: @@ -149,7 +177,7 @@ python3 tools/conv.py Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly. -### LNbits as a systemd service +## LNbits as a systemd service Systemd is great for taking care of your LNbits instance. It will start it on boot and restart it in case it crashes. If you want to run LNbits as a systemd service on your Debian/Ubuntu/Raspbian server, create a file at `/etc/systemd/system/lnbits.service` with the following content: @@ -161,21 +189,21 @@ Systemd is great for taking care of your LNbits instance. It will start it on bo Description=LNbits # you can uncomment these lines if you know what you're doing # it will make sure that lnbits starts after lnd (replace with your own backend service) -#Wants=lnd.service -#After=lnd.service +#Wants=lnd.service +#After=lnd.service [Service] # replace with the absolute path of your lnbits installation -WorkingDirectory=/home/bitcoin/lnbits +WorkingDirectory=/home/bitcoin/lnbits # same here -ExecStart=/home/bitcoin/lnbits/venv/bin/uvicorn lnbits.__main__:app --port 5000 +ExecStart=/home/bitcoin/lnbits/venv/bin/uvicorn lnbits.__main__:app --port 5000 # replace with the user that you're running lnbits on -User=bitcoin +User=bitcoin Restart=always TimeoutSec=120 RestartSec=30 # this makes sure that you receive logs in real time -Environment=PYTHONUNBUFFERED=1 +Environment=PYTHONUNBUFFERED=1 [Install] WantedBy=multi-user.target @@ -188,18 +216,94 @@ sudo systemctl enable lnbits.service sudo systemctl start lnbits.service ``` -### LNbits running on Umbrel behind Tor +## Running behind an apache2 reverse proxy over https +Install apache2 and enable apache2 mods +```sh +apt-get install apache2 certbot +a2enmod headers ssl proxy proxy-http +``` +create a ssl certificate with letsencrypt +```sh +certbot certonly --webroot --agree-tos --text --non-interactive --webroot-path /var/www/html -d lnbits.org +``` +create a apache2 vhost at: /etc/apache2/sites-enabled/lnbits.conf +```sh +cat < /etc/apache2/sites-enabled/lnbits.conf + + ServerName lnbits.org + SSLEngine On + SSLProxyEngine On + SSLCertificateFile /etc/letsencrypt/live/lnbits.org/fullchain.pem + SSLCertificateKeyFile /etc/letsencrypt/live/lnbits.org/privkey.pem + Include /etc/letsencrypt/options-ssl-apache.conf + LogLevel info + ErrorLog /var/log/apache2/lnbits.log + CustomLog /var/log/apache2/lnbits-access.log combined + RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME} + RequestHeader set "X-Forwarded-SSL" expr=%{HTTPS} + ProxyPreserveHost On + ProxyPass / http://localhost:5000/ + ProxyPassReverse / http://localhost:5000/ + + Order deny,allow + Allow from all + + +EOF +``` +restart apache2 +```sh +service restart apache2 +``` + + +## Using https without reverse proxy +The most common way of using LNbits via https is to use a reverse proxy such as Caddy, nginx, or ngriok. However, you can also run LNbits via https without additional software. This is useful for development purposes or if you want to use LNbits in your local network. + +We have to create a self-signed certificate using `mkcert`. Note that this certiciate is not "trusted" by most browsers but that's fine (since you know that you have created it) and encryption is always better than clear text. + +#### Install mkcert +You can find the install instructions for `mkcert` [here](https://github.com/FiloSottile/mkcert). + +Install mkcert on Ubuntu: +```sh +sudo apt install libnss3-tools +curl -JLO "https://dl.filippo.io/mkcert/latest?for=linux/amd64" +chmod +x mkcert-v*-linux-amd64 +sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert +``` +#### Create certificate +To create a certificate, first `cd` into your LNbits folder and execute the following command on Linux: +```sh +openssl req -new -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out cert.pem -keyout key.pem +``` +This will create two new files (`key.pem` and `cert.pem `). + +Alternatively, you can use mkcert ([more info](https://kifarunix.com/how-to-create-self-signed-ssl-certificate-with-mkcert-on-ubuntu-18-04/)): +```sh +# add your local IP (192.x.x.x) as well if you want to use it in your local network +mkcert localhost 127.0.0.1 ::1 +``` + +You can then pass the certificate files to uvicorn when you start LNbits: + +```sh +./venv/bin/uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./key.pem --ssl-certfile ./cert.pem +``` + + +## LNbits running on Umbrel behind Tor If you want to run LNbits on your Umbrel but want it to be reached through clearnet, _Uxellodunum_ made an extensive [guide](https://community.getumbrel.com/t/guide-lnbits-without-tor/604) on how to do it. -### Docker installation +## Docker installation To install using docker you first need to build the docker image as: ``` -git clone https://github.com/lnbits/lnbits.git -cd lnbits/ # ${PWD} referred as -docker build -t lnbits . +git clone https://github.com/lnbits/lnbits-legend.git +cd lnbits-legend +docker build -t lnbits-legend . ``` You can launch the docker in a different directory, but make sure to copy `.env.example` from lnbits there @@ -210,23 +314,15 @@ cp /.env.example .env and change the configuration in `.env` as required. -Then create the data directory for the user ID 1000, which is the user that runs the lnbits within the docker container. - +Then create the data directory ``` mkdir data -sudo chown 1000:1000 ./data/ ``` Then the image can be run as: ``` -docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits +docker run --detach --publish 5000:5000 --name lnbits-legend -e "LNBITS_BACKEND_WALLET_CLASS='FakeWallet'" --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits-legend ``` Finally you can access your lnbits on your machine at port 5000. - -# Additional guides - -## LNbits running on Umbrel behind Tor - -If you want to run LNbits on your Umbrel but want it to be reached through clearnet, _Uxellodunum_ made an extensive [guide](https://community.getumbrel.com/t/guide-lnbits-without-tor/604) on how to do it. diff --git a/docs/guide/wallets.md b/docs/guide/wallets.md index 7a3b6a27..592c29ef 100644 --- a/docs/guide/wallets.md +++ b/docs/guide/wallets.md @@ -8,19 +8,17 @@ nav_order: 3 Backend wallets =============== -LNbits can run on top of many lightning-network funding sources. Currently there is support for -CLightning, LND, LNbits, LNPay, lntxbot and OpenNode, with more being added regularily. +LNbits can run on top of many lightning-network funding sources. Currently there is support for CoreLightning, LND, LNbits, LNPay, lntxbot and OpenNode, with more being added regularly. A backend wallet can be configured using the following LNbits environment variables: -### CLightning +### CoreLightning Using this wallet requires the installation of the `pylightning` Python package. -If you want to use LNURLp you should use SparkWallet because of an issue with description_hash and CLightning. -- `LNBITS_BACKEND_WALLET_CLASS`: **CLightningWallet** -- `CLIGHTNING_RPC`: /file/path/lightning-rpc +- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet** +- `CORELIGHTNING_RPC`: /file/path/lightning-rpc ### Spark (c-lightning) @@ -28,6 +26,17 @@ If you want to use LNURLp you should use SparkWallet because of an issue with de - `SPARK_URL`: http://10.147.17.230:9737/rpc - `SPARK_TOKEN`: secret_access_key +### LND (REST) + +- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet** +- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/ +- `LND_REST_CERT`: /file/path/tls.cert +- `LND_REST_MACAROON`: /file/path/admin.macaroon or Bech64/Hex + +or + +- `LND_REST_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn + ### LND (gRPC) Using this wallet requires the installation of the `grpcio` and `protobuf` Python packages. @@ -44,17 +53,6 @@ You can also use an AES-encrypted macaroon (more info) instead by using To encrypt your macaroon, run `./venv/bin/python lnbits/wallets/macaroon/macaroon.py`. -### LND (REST) - -- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet** -- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/ -- `LND_REST_CERT`: /file/path/tls.cert -- `LND_REST_MACAROON`: /file/path/admin.macaroon or Bech64/Hex - -or - -- `LND_REST_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn - ### LNbits - `LNBITS_BACKEND_WALLET_CLASS`: **LNbitsWallet** diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..0ca2db01 --- /dev/null +++ b/flake.lock @@ -0,0 +1,77 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1656928814, + "narHash": "sha256-RIFfgBuKz6Hp89yRr7+NR5tzIAbn52h8vT6vXkYjZoM=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "7e2a3b3dfd9af950a856d66b0a7d01e3c18aa249", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1657114324, + "narHash": "sha256-fWuaUNXrHcz/ciHRHlcSO92dvV3EVS0GJQUSBO5JIB4=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "a5c867d9fe9e4380452628e8f171c26b69fa9d3d", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1657261001, + "narHash": "sha256-sUZeuRYfhG59uD6xafM07bc7bAIkpcGq84Vj4B+cyms=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0be91cefefde5701f8fa957904618a13e3bb51d8", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "type": "github" + } + }, + "poetry2nix": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1657149754, + "narHash": "sha256-iSnZoqwNDDVoO175whSuvl4sS9lAb/2zZ3Sa4ywo970=", + "owner": "nix-community", + "repo": "poetry2nix", + "rev": "fc1930e011dea149db81863aac22fe701f36f1b5", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "poetry2nix", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "poetry2nix": "poetry2nix" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..af25ba5c --- /dev/null +++ b/flake.nix @@ -0,0 +1,55 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + poetry2nix.url = "github:nix-community/poetry2nix"; + }; + outputs = { self, nixpkgs, poetry2nix }@inputs: + let + supportedSystems = [ "x86_64-linux" "aarch64-linux" ]; + forSystems = systems: f: + nixpkgs.lib.genAttrs systems + (system: f system (import nixpkgs { inherit system; overlays = [ poetry2nix.overlay self.overlays.default ]; })); + forAllSystems = forSystems supportedSystems; + projectName = "lnbits"; + in + { + devShells = forAllSystems (system: pkgs: { + default = pkgs.mkShell { + buildInputs = with pkgs; [ + nodePackages.prettier + ]; + }; + }); + overlays = { + default = final: prev: { + ${projectName} = self.packages.${final.hostPlatform.system}.${projectName}; + }; + }; + packages = forAllSystems (system: pkgs: { + default = self.packages.${system}.${projectName}; + ${projectName} = pkgs.poetry2nix.mkPoetryApplication { + projectDir = ./.; + python = pkgs.python39; + }; + }); + nixosModules = { + default = { pkgs, lib, config, ... }: { + imports = [ "${./nix/modules/${projectName}-service.nix}" ]; + nixpkgs.overlays = [ self.overlays.default ]; + }; + }; + checks = forAllSystems (system: pkgs: + let + vmTests = import ./nix/tests { + makeTest = (import (nixpkgs + "/nixos/lib/testing-python.nix") { inherit system; }).makeTest; + inherit inputs pkgs; + }; + in + pkgs.lib.optionalAttrs pkgs.stdenv.isLinux vmTests # vmTests can only be ran on Linux, so append them only if on Linux. + // + { + # Other checks here... + } + ); + }; +} diff --git a/lnbits/__main__.py b/lnbits/__main__.py index 90464a64..90cb1997 100644 --- a/lnbits/__main__.py +++ b/lnbits/__main__.py @@ -4,7 +4,7 @@ import uvloop from loguru import logger from starlette.requests import Request -from .commands import bundle_vendored, migrate_databases, transpile_scss +from .commands import migrate_databases from .settings import ( DEBUG, HOST, @@ -19,8 +19,6 @@ from .settings import ( uvloop.install() asyncio.create_task(migrate_databases()) -transpile_scss() -bundle_vendored() from .app import create_app diff --git a/lnbits/app.py b/lnbits/app.py index a7c8fdaf..fb750eb3 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -1,6 +1,7 @@ import asyncio import importlib import logging +import signal import sys import traceback import warnings @@ -17,7 +18,6 @@ from loguru import logger import lnbits.settings from lnbits.core.tasks import register_task_listeners -from .commands import db_migrate, handle_assets from .core import core_app from .core.views.generic import core_html_routes from .helpers import ( @@ -45,10 +45,19 @@ def create_app(config_object="lnbits.settings") -> FastAPI: """ configure_logger() - app = FastAPI() - app.mount("/static", StaticFiles(directory="lnbits/static"), name="static") + app = FastAPI( + title="LNbits API", + description="API for LNbits, the free and open source bitcoin wallet and accounts system with plugins.", + license_info={ + "name": "MIT License", + "url": "https://raw.githubusercontent.com/lnbits/lnbits-legend/main/LICENSE", + }, + ) + app.mount("/static", StaticFiles(packages=[("lnbits", "static")]), name="static") app.mount( - "/core/static", StaticFiles(directory="lnbits/core/static"), name="core_static" + "/core/static", + StaticFiles(packages=[("lnbits.core", "static")]), + name="core_static", ) origins = ["*"] @@ -67,7 +76,11 @@ def create_app(config_object="lnbits.settings") -> FastAPI: # Only the browser sends "text/html" request # not fail proof, but everything else get's a JSON response - if "text/html" in request.headers["accept"]: + if ( + request.headers + and "accept" in request.headers + and "text/html" in request.headers["accept"] + ): return template_renderer().TemplateResponse( "error.html", {"request": request, "err": f"{exc.errors()} is not a valid UUID."}, @@ -84,7 +97,6 @@ def create_app(config_object="lnbits.settings") -> FastAPI: check_funding_source(app) register_assets(app) register_routes(app) - # register_commands(app) register_async_tasks(app) register_exception_handlers(app) @@ -94,16 +106,27 @@ def create_app(config_object="lnbits.settings") -> FastAPI: def check_funding_source(app: FastAPI) -> None: @app.on_event("startup") async def check_wallet_status(): + original_sigint_handler = signal.getsignal(signal.SIGINT) + + def signal_handler(signal, frame): + logger.debug(f"SIGINT received, terminating LNbits.") + sys.exit(1) + + signal.signal(signal.SIGINT, signal_handler) while True: - error_message, balance = await WALLET.status() - if not error_message: - break - logger.error( - f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'", - RuntimeWarning, - ) - logger.info("Retrying connection to backend in 5 seconds...") - await asyncio.sleep(5) + try: + error_message, balance = await WALLET.status() + if not error_message: + break + logger.error( + f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'", + RuntimeWarning, + ) + logger.info("Retrying connection to backend in 5 seconds...") + await asyncio.sleep(5) + except: + pass + signal.signal(signal.SIGINT, original_sigint_handler) logger.info( f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat." ) @@ -137,12 +160,6 @@ def register_routes(app: FastAPI) -> None: ) -def register_commands(app: FastAPI): - """Register Click commands.""" - app.cli.add_command(db_migrate) - app.cli.add_command(handle_assets) - - def register_assets(app: FastAPI): """Serve each vendored asset separately or a bundle.""" @@ -184,7 +201,11 @@ def register_exception_handlers(app: FastAPI): traceback.print_exception(etype, err, tb) exc = traceback.format_exc() - if "text/html" in request.headers["accept"]: + if ( + request.headers + and "accept" in request.headers + and "text/html" in request.headers["accept"] + ): return template_renderer().TemplateResponse( "error.html", {"request": request, "err": err} ) diff --git a/lnbits/bolt11.py b/lnbits/bolt11.py index cc841585..32b43feb 100644 --- a/lnbits/bolt11.py +++ b/lnbits/bolt11.py @@ -61,7 +61,7 @@ def decode(pr: str) -> Invoice: invoice = Invoice() # decode the amount from the hrp - m = re.search("[^\d]+", hrp[2:]) + m = re.search(r"[^\d]+", hrp[2:]) if m: amountstr = hrp[2 + m.end() :] if amountstr != "": @@ -216,7 +216,7 @@ def lnencode(addr, privkey): expirybits = expirybits[5:] data += tagged("x", expirybits) elif k == "h": - data += tagged_bytes("h", hashlib.sha256(v.encode("utf-8")).digest()) + data += tagged_bytes("h", v) elif k == "n": data += tagged_bytes("n", v) else: @@ -296,7 +296,7 @@ def _unshorten_amount(amount: str) -> int: # BOLT #11: # A reader SHOULD fail if `amount` contains a non-digit, or is followed by # anything except a `multiplier` in the table above. - if not re.fullmatch("\d+[pnum]?", str(amount)): + if not re.fullmatch(r"\d+[pnum]?", str(amount)): raise ValueError("Invalid amount '{}'".format(amount)) if unit in units: diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index 770e2906..f150270a 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -4,6 +4,8 @@ from typing import Any, Dict, List, Optional from urllib.parse import urlparse from uuid import uuid4 +from loguru import logger + from lnbits import bolt11 from lnbits.db import COCKROACH, POSTGRES, Connection from lnbits.settings import DEFAULT_WALLET_NAME, LNBITS_ADMIN_USERS @@ -113,7 +115,7 @@ async def create_wallet( async def update_wallet( wallet_id: str, new_name: str, conn: Optional[Connection] = None ) -> Optional[Wallet]: - await (conn or db).execute( + return await (conn or db).execute( """ UPDATE wallets SET name = ? @@ -334,7 +336,7 @@ async def delete_expired_invoices( expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry) if expiration_date > datetime.datetime.utcnow(): continue - + logger.debug(f"Deleting expired invoice: {invoice.payment_hash}") await (conn or db).execute( """ DELETE FROM apipayments diff --git a/lnbits/core/models.py b/lnbits/core/models.py index ab73b702..c019d941 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -106,6 +106,8 @@ class Payment(BaseModel): @property def tag(self) -> Optional[str]: + if self.extra is None: + return "" return self.extra.get("tag") @property @@ -139,19 +141,25 @@ class Payment(BaseModel): if self.is_uncheckable: return + logger.debug( + f"Checking {'outgoing' if self.is_out else 'incoming'} pending payment {self.checking_id}" + ) + if self.is_out: status = await WALLET.get_payment_status(self.checking_id) else: status = await WALLET.get_invoice_status(self.checking_id) + logger.debug(f"Status: {status}") + if self.is_out and status.failed: logger.info( - f" - deleting outgoing failed payment {self.checking_id}: {status}" + f"Deleting outgoing failed payment {self.checking_id}: {status}" ) await self.delete() elif not status.pending: logger.info( - f" - marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}" + f"Marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}" ) await self.set_pending(status.pending) diff --git a/lnbits/core/services.py b/lnbits/core/services.py index d802bc4e..90f62186 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -6,15 +6,22 @@ from typing import Dict, Optional, Tuple from urllib.parse import parse_qs, urlparse import httpx +from fastapi import Depends from lnurl import LnurlErrorResponse from lnurl import decode as decode_lnurl # type: ignore from loguru import logger from lnbits import bolt11 from lnbits.db import Connection +from lnbits.decorators import ( + WalletTypeInfo, + get_key_type, + require_admin_key, + require_invoice_key, +) from lnbits.helpers import url_for, urlsafe_short_hash from lnbits.requestvars import g -from lnbits.settings import FAKE_WALLET, WALLET +from lnbits.settings import FAKE_WALLET, RESERVE_FEE_MIN, RESERVE_FEE_PERCENT, WALLET from lnbits.wallets.base import PaymentResponse, PaymentStatus from . import db @@ -47,6 +54,7 @@ async def create_invoice( amount: int, # in satoshis memo: str, description_hash: Optional[bytes] = None, + unhashed_description: Optional[bytes] = None, extra: Optional[Dict] = None, webhook: Optional[str] = None, internal: Optional[bool] = False, @@ -58,7 +66,10 @@ async def create_invoice( wallet = FAKE_WALLET if internal else WALLET ok, checking_id, payment_request, error_message = await wallet.create_invoice( - amount=amount, memo=invoice_memo, description_hash=description_hash + amount=amount, + memo=invoice_memo, + description_hash=description_hash, + unhashed_description=unhashed_description, ) if not ok: raise InvoiceFailure(error_message or "unexpected backend error.") @@ -102,18 +113,15 @@ async def pay_invoice( raise ValueError("Amount in invoice is too high.") # put all parameters that don't change here - PaymentKwargs = TypedDict( - "PaymentKwargs", - { - "wallet_id": str, - "payment_request": str, - "payment_hash": str, - "amount": int, - "memo": str, - "extra": Optional[Dict], - }, - ) - payment_kwargs: PaymentKwargs = dict( + class PaymentKwargs(TypedDict): + wallet_id: str + payment_request: str + payment_hash: str + amount: int + memo: str + extra: Optional[Dict] + + payment_kwargs: PaymentKwargs = PaymentKwargs( wallet_id=wallet_id, payment_request=payment_request, payment_hash=invoice.payment_hash, @@ -152,7 +160,7 @@ async def pay_invoice( logger.debug("balance is too low, deleting temporary payment") if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat: raise PaymentFailure( - f"You must reserve at least 1% ({round(fee_reserve_msat/1000)} sat) to cover potential routing fees." + f"You must reserve at least ({round(fee_reserve_msat/1000)} sat) to cover potential routing fees." ) raise PermissionError("Insufficient balance.") @@ -178,7 +186,7 @@ async def pay_invoice( payment_request, fee_reserve_msat ) logger.debug(f"backend: pay_invoice finished {temp_id}") - if payment.checking_id: + if payment.ok and payment.checking_id: logger.debug(f"creating final payment {payment.checking_id}") async with db.connect() as conn: await create_payment( @@ -192,7 +200,7 @@ async def pay_invoice( logger.debug(f"deleting temporary payment {temp_id}") await delete_payment(temp_id, conn=conn) else: - logger.debug(f"backend payment failed, no checking_id {temp_id}") + logger.debug(f"backend payment failed") async with db.connect() as conn: logger.debug(f"deleting temporary payment {temp_id}") await delete_payment(temp_id, conn=conn) @@ -258,12 +266,15 @@ async def redeem_lnurl_withdraw( async def perform_lnurlauth( - callback: str, conn: Optional[Connection] = None + callback: str, + wallet: WalletTypeInfo = Depends(require_admin_key), + conn: Optional[Connection] = None, ) -> Optional[LnurlErrorResponse]: cb = urlparse(callback) k1 = unhexlify(parse_qs(cb.query)["k1"][0]) - key = g().wallet.lnurlauth_key(cb.netloc) + + key = wallet.wallet.lnurlauth_key(cb.netloc) def int_to_bytes_suitable_der(x: int) -> bytes: """for strict DER we need to encode the integer with some quirks""" @@ -330,13 +341,16 @@ async def perform_lnurlauth( ) -async def check_invoice_status( +async def check_transaction_status( wallet_id: str, payment_hash: str, conn: Optional[Connection] = None ) -> PaymentStatus: payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn) if not payment: return PaymentStatus(None) - status = await WALLET.get_invoice_status(payment.checking_id) + if payment.is_out: + status = await WALLET.get_payment_status(payment.checking_id) + else: + status = await WALLET.get_invoice_status(payment.checking_id) if not payment.pending: return status if payment.is_out and status.failed: @@ -352,4 +366,4 @@ async def check_invoice_status( # WARN: this same value must be used for balance check and passed to WALLET.pay_invoice(), it may cause a vulnerability if the values differ def fee_reserve(amount_msat: int) -> int: - return max(2000, int(amount_msat * 0.01)) + return max(int(RESERVE_FEE_MIN), int(amount_msat * RESERVE_FEE_PERCENT / 100.0)) diff --git a/lnbits/core/static/js/extensions.js b/lnbits/core/static/js/extensions.js index 85ace775..ec8f811c 100644 --- a/lnbits/core/static/js/extensions.js +++ b/lnbits/core/static/js/extensions.js @@ -1,4 +1,36 @@ new Vue({ el: '#vue', + data: function () { + return { + searchTerm: '', + filteredExtensions: null + } + }, + mounted() { + this.filteredExtensions = this.g.extensions + }, + watch: { + searchTerm(term) { + // Reset the filter + this.filteredExtensions = this.g.extensions + if (term !== '') { + // Filter the extensions list + function extensionNameContains(searchTerm) { + return function (extension) { + return ( + extension.name.toLowerCase().includes(searchTerm.toLowerCase()) || + extension.shortDescription + .toLowerCase() + .includes(searchTerm.toLowerCase()) + ) + } + } + + this.filteredExtensions = this.filteredExtensions.filter( + extensionNameContains(term) + ) + } + } + }, mixins: [windowMixin] }) diff --git a/lnbits/core/tasks.py b/lnbits/core/tasks.py index 5fea769d..07b8a893 100644 --- a/lnbits/core/tasks.py +++ b/lnbits/core/tasks.py @@ -55,7 +55,7 @@ async def dispatch_webhook(payment: Payment): data = payment.dict() try: logger.debug("sending webhook", payment.webhook) - r = await client.post(payment.webhook, json=data, timeout=40) + r = await client.post(payment.webhook, json=data, timeout=40) # type: ignore await mark_webhook_sent(payment, r.status_code) except (httpx.ConnectError, httpx.RequestError): await mark_webhook_sent(payment, -1) diff --git a/lnbits/core/templates/core/extensions.html b/lnbits/core/templates/core/extensions.html index daeb660f..1b527903 100644 --- a/lnbits/core/templates/core/extensions.html +++ b/lnbits/core/templates/core/extensions.html @@ -2,10 +2,23 @@ %} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %} +
+
+ + + +
+
+
diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html index acfcf700..78f33f3a 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -689,7 +689,7 @@ diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 290fd402..5a6d1140 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -3,32 +3,30 @@ import hashlib import json from binascii import unhexlify from http import HTTPStatus -from typing import Dict, List, Optional, Union +from io import BytesIO +from typing import Dict, List, Optional, Tuple, Union from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse import httpx -from fastapi import Header, Query, Request +import pyqrcode +from fastapi import Depends, Header, Query, Request from fastapi.exceptions import HTTPException -from fastapi.param_functions import Depends from fastapi.params import Body from loguru import logger from pydantic import BaseModel from pydantic.fields import Field from sse_starlette.sse import EventSourceResponse +from starlette.responses import HTMLResponse, StreamingResponse from lnbits import bolt11, lnurl -from lnbits.bolt11 import Invoice from lnbits.core.models import Payment, Wallet from lnbits.decorators import ( - WalletAdminKeyChecker, - WalletInvoiceKeyChecker, WalletTypeInfo, get_key_type, require_admin_key, require_invoice_key, ) from lnbits.helpers import url_for, urlsafe_short_hash -from lnbits.requestvars import g from lnbits.settings import LNBITS_ADMIN_USERS, LNBITS_SITE_TITLE from lnbits.utils.exchange_rates import ( currencies, @@ -50,7 +48,7 @@ from ..crud import ( from ..services import ( InvoiceFailure, PaymentFailure, - check_invoice_status, + check_transaction_status, create_invoice, pay_invoice, perform_lnurlauth, @@ -125,7 +123,7 @@ async def api_payments( offset=offset, ) for payment in pendingPayments: - await check_invoice_status( + await check_transaction_status( wallet_id=payment.wallet_id, payment_hash=payment.payment_hash ) return await get_payments( @@ -143,6 +141,7 @@ class CreateInvoiceData(BaseModel): memo: Optional[str] = None unit: Optional[str] = "sat" description_hash: Optional[str] = None + unhashed_description: Optional[str] = None lnurl_callback: Optional[str] = None lnurl_balance_check: Optional[str] = None extra: Optional[dict] = None @@ -154,9 +153,15 @@ class CreateInvoiceData(BaseModel): async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet): if data.description_hash: description_hash = unhexlify(data.description_hash) + unhashed_description = b"" + memo = "" + elif data.unhashed_description: + unhashed_description = unhexlify(data.unhashed_description) + description_hash = b"" memo = "" else: description_hash = b"" + unhashed_description = b"" memo = data.memo or LNBITS_SITE_TITLE if data.unit == "sat": amount = int(data.amount) @@ -172,6 +177,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet): amount=amount, memo=memo, description_hash=description_hash, + unhashed_description=unhashed_description, extra=data.extra, webhook=data.webhook, internal=data.internal, @@ -186,11 +192,8 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet): lnurl_response: Union[None, bool, str] = None if data.lnurl_callback: - if "lnurl_balance_check" in data: - assert ( - data.lnurl_balance_check is not None - ), "lnurl_balance_check is required" - save_balance_check(wallet.id, data.lnurl_balance_check) + if data.lnurl_balance_check is not None: + await save_balance_check(wallet.id, data.lnurl_balance_check) async with httpx.AsyncClient() as client: try: @@ -247,13 +250,11 @@ async def api_payments_pay_invoice(bolt11: str, wallet: Wallet): @core_app.post( "/api/v1/payments", - # deprecated=True, - # description="DEPRECATED. Use /api/v2/TBD and /api/v2/TBD instead", status_code=HTTPStatus.CREATED, ) async def api_payments_create( wallet: WalletTypeInfo = Depends(require_invoice_key), - invoiceData: CreateInvoiceData = Body(...), + invoiceData: CreateInvoiceData = Body(...), # type: ignore ): if invoiceData.out is True and wallet.wallet_type == 0: if not invoiceData.bolt11: @@ -269,7 +270,7 @@ async def api_payments_create( return await api_payments_create_invoice(invoiceData, wallet.wallet) else: raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, + status_code=HTTPStatus.UNAUTHORIZED, detail="Invoice (or Admin) key required.", ) @@ -284,7 +285,7 @@ class CreateLNURLData(BaseModel): @core_app.post("/api/v1/payments/lnurl") async def api_payments_pay_lnurl( - data: CreateLNURLData, wallet: WalletTypeInfo = Depends(get_key_type) + data: CreateLNURLData, wallet: WalletTypeInfo = Depends(require_admin_key) ): domain = urlparse(data.callback).netloc @@ -296,7 +297,7 @@ async def api_payments_pay_lnurl( timeout=40, ) if r.is_error: - raise httpx.ConnectError + raise httpx.ConnectError("LNURL callback connection error") except (httpx.ConnectError, httpx.RequestError): raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, @@ -310,6 +311,12 @@ async def api_payments_pay_lnurl( detail=f"{domain} said: '{params.get('reason', '')}'", ) + if not params.get("pr"): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"{domain} did not return a payment request.", + ) + invoice = bolt11.decode(params["pr"]) if invoice.amount_msat != data.amount: raise HTTPException( @@ -317,11 +324,11 @@ async def api_payments_pay_lnurl( detail=f"{domain} returned an invalid invoice. Expected {data.amount} msat, got {invoice.amount_msat}.", ) - # if invoice.description_hash != data.description_hash: - # raise HTTPException( - # status_code=HTTPStatus.BAD_REQUEST, - # detail=f"{domain} returned an invalid invoice. Expected description_hash == {data.description_hash}, got {invoice.description_hash}.", - # ) + if invoice.description_hash != data.description_hash: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"{domain} returned an invalid invoice. Expected description_hash == {data.description_hash}, got {invoice.description_hash}.", + ) extra = {} @@ -353,7 +360,7 @@ async def subscribe(request: Request, wallet: Wallet): logger.debug("adding sse listener", payment_queue) api_invoice_listeners.append(payment_queue) - send_queue: asyncio.Queue[tuple[str, Payment]] = asyncio.Queue(0) + send_queue: asyncio.Queue[Tuple[str, Payment]] = asyncio.Queue(0) async def payment_received() -> None: while True: @@ -392,21 +399,18 @@ async def api_payments_sse( async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)): # We use X_Api_Key here because we want this call to work with and without keys # If a valid key is given, we also return the field "details", otherwise not - wallet = None - try: - if X_Api_Key.extra: - logger.warning("No key") - except: - wallet = await get_wallet_for_key(X_Api_Key) + wallet = await get_wallet_for_key(X_Api_Key) if type(X_Api_Key) == str else None + + # we have to specify the wallet id here, because postgres and sqlite return internal payments in different order + # and get_standalone_payment otherwise just fetches the first one, causing unpredictable results payment = await get_standalone_payment( payment_hash, wallet_id=wallet.id if wallet else None - ) # we have to specify the wallet id here, because postgres and sqlite return internal payments in different order - # and get_standalone_payment otherwise just fetches the first one, causing unpredictable results + ) if payment is None: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." ) - await check_invoice_status(payment.wallet_id, payment_hash) + await check_transaction_status(payment.wallet_id, payment_hash) payment = await get_standalone_payment( payment_hash, wallet_id=wallet.id if wallet else None ) @@ -435,10 +439,8 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)): return {"paid": not payment.pending, "preimage": payment.preimage} -@core_app.get( - "/api/v1/lnurlscan/{code}", dependencies=[Depends(WalletInvoiceKeyChecker())] -) -async def api_lnurlscan(code: str): +@core_app.get("/api/v1/lnurlscan/{code}") +async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type)): try: url = lnurl.decode(code) domain = urlparse(url).netloc @@ -466,7 +468,7 @@ async def api_lnurlscan(code: str): params.update(kind="auth") params.update(callback=url) # with k1 already in it - lnurlauth_key = g().wallet.lnurlauth_key(domain) + lnurlauth_key = wallet.wallet.lnurlauth_key(domain) params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex()) else: async with httpx.AsyncClient() as client: @@ -489,7 +491,8 @@ async def api_lnurlscan(code: str): ) try: - tag = data["tag"] + tag: str = data.get("tag") + params.update(**data) if tag == "channelRequest": raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, @@ -499,10 +502,7 @@ async def api_lnurlscan(code: str): "message": "unsupported", }, ) - - params.update(**data) - - if tag == "withdrawRequest": + elif tag == "withdrawRequest": params.update(kind="withdraw") params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"]) @@ -520,8 +520,7 @@ async def api_lnurlscan(code: str): query=urlencode(qs, doseq=True) ) params.update(callback=urlunparse(parsed_callback)) - - if tag == "payRequest": + elif tag == "payRequest": params.update(kind="pay") params.update(fixed=data["minSendable"] == data["maxSendable"]) @@ -539,8 +538,8 @@ async def api_lnurlscan(code: str): params.update(image=data_uri) if k == "text/email" or k == "text/identifier": params.update(targetUser=v) - params.update(commentAllowed=data.get("commentAllowed", 0)) + except KeyError as exc: raise HTTPException( status_code=HTTPStatus.SERVICE_UNAVAILABLE, @@ -582,14 +581,19 @@ async def api_payments_decode(data: DecodePayment): return {"message": "Failed to decode"} -@core_app.post("/api/v1/lnurlauth", dependencies=[Depends(WalletAdminKeyChecker())]) -async def api_perform_lnurlauth(callback: str): - err = await perform_lnurlauth(callback) +class Callback(BaseModel): + callback: str = Query(...) + + +@core_app.post("/api/v1/lnurlauth") +async def api_perform_lnurlauth( + callback: Callback, wallet: WalletTypeInfo = Depends(require_admin_key) +): + err = await perform_lnurlauth(callback.callback, wallet=wallet) if err: raise HTTPException( status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason ) - return "" @@ -608,8 +612,8 @@ class ConversionData(BaseModel): async def api_fiat_as_sats(data: ConversionData): output = {} if data.from_ == "sat": - output["sats"] = int(data.amount) output["BTC"] = data.amount / 100000000 + output["sats"] = int(data.amount) for currency in data.to.split(","): output[currency.strip().upper()] = await satoshis_amount_as_fiat( data.amount, currency.strip() @@ -620,3 +624,24 @@ async def api_fiat_as_sats(data: ConversionData): output["sats"] = await fiat_amount_as_satoshis(data.amount, data.from_) output["BTC"] = output["sats"] / 100000000 return output + + +@core_app.get("/api/v1/qrcode/{data}", response_class=StreamingResponse) +async def img(request: Request, data): + qr = pyqrcode.create(data) + stream = BytesIO() + qr.svg(stream, scale=3) + stream.seek(0) + + async def _generator(stream: BytesIO): + yield stream.getvalue() + + return StreamingResponse( + _generator(stream), + headers={ + "Content-Type": "image/svg+xml", + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + }, + ) diff --git a/lnbits/core/views/generic.py b/lnbits/core/views/generic.py index 4366028d..31a7b030 100644 --- a/lnbits/core/views/generic.py +++ b/lnbits/core/views/generic.py @@ -23,6 +23,7 @@ from lnbits.settings import ( SERVICE_FEE, ) +from ...helpers import get_valid_extensions from ..crud import ( create_account, create_wallet, @@ -54,9 +55,9 @@ async def home(request: Request, lightning: str = None): ) async def extensions( request: Request, - user: User = Depends(check_user_exists), - enable: str = Query(None), - disable: str = Query(None), + user: User = Depends(check_user_exists), # type: ignore + enable: str = Query(None), # type: ignore + disable: str = Query(None), # type: ignore ): extension_to_enable = enable extension_to_disable = disable @@ -66,6 +67,14 @@ async def extensions( HTTPStatus.BAD_REQUEST, "You can either `enable` or `disable` an extension." ) + # check if extension exists + if extension_to_enable or extension_to_disable: + ext = extension_to_enable or extension_to_disable + if ext not in [e.code for e in get_valid_extensions()]: + raise HTTPException( + HTTPStatus.BAD_REQUEST, f"Extension '{ext}' doesn't exist." + ) + if extension_to_enable: logger.info(f"Enabling extension: {extension_to_enable} for user {user.id}") await update_user_extension( @@ -79,7 +88,7 @@ async def extensions( # Update user as his extensions have been updated if extension_to_enable or extension_to_disable: - user = await get_user(user.id) + user = await get_user(user.id) # type: ignore return template_renderer().TemplateResponse( "core/extensions.html", {"request": request, "user": user.dict()} @@ -100,10 +109,10 @@ nothing: create everything
""", ) async def wallet( - request: Request = Query(None), - nme: Optional[str] = Query(None), - usr: Optional[UUID4] = Query(None), - wal: Optional[UUID4] = Query(None), + request: Request = Query(None), # type: ignore + nme: Optional[str] = Query(None), # type: ignore + usr: Optional[UUID4] = Query(None), # type: ignore + wal: Optional[UUID4] = Query(None), # type: ignore ): user_id = usr.hex if usr else None wallet_id = wal.hex if wal else None @@ -112,7 +121,7 @@ async def wallet( if not user_id: user = await get_user((await create_account()).id) - logger.info(f"Created new account for user {user.id}") + logger.info(f"Create user {user.id}") # type: ignore else: user = await get_user(user_id) if not user: @@ -126,22 +135,24 @@ async def wallet( if LNBITS_ADMIN_USERS and user_id in LNBITS_ADMIN_USERS: user.admin = True if not wallet_id: - if user.wallets and not wallet_name: - wallet = user.wallets[0] + if user.wallets and not wallet_name: # type: ignore + wallet = user.wallets[0] # type: ignore else: - wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name) + wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name) # type: ignore logger.info( - f"Created new wallet {wallet_name if wallet_name else '(no name)'} for user {user.id}" + f"Created new wallet {wallet_name if wallet_name else '(no name)'} for user {user.id}" # type: ignore ) return RedirectResponse( - f"/wallet?usr={user.id}&wal={wallet.id}", + f"/wallet?usr={user.id}&wal={wallet.id}", # type: ignore status_code=status.HTTP_307_TEMPORARY_REDIRECT, ) - logger.info(f"Access wallet {wallet_name} of user {user.id}") - wallet = user.get_wallet(wallet_id) - if not wallet: + logger.debug( + f"Access {'user '+ user.id + ' ' if user else ''} {'wallet ' + wallet_name if wallet_name else ''}" + ) + userwallet = user.get_wallet(wallet_id) # type: ignore + if not userwallet: return template_renderer().TemplateResponse( "error.html", {"request": request, "err": "Wallet not found"} ) @@ -150,10 +161,10 @@ async def wallet( "core/wallet.html", { "request": request, - "user": user.dict(), - "wallet": wallet.dict(), + "user": user.dict(), # type: ignore + "wallet": userwallet.dict(), "service_fee": service_fee, - "web_manifest": f"/manifest/{user.id}.webmanifest", + "web_manifest": f"/manifest/{user.id}.webmanifest", # type: ignore }, ) @@ -207,20 +218,20 @@ async def lnurl_full_withdraw_callback(request: Request): @core_html_routes.get("/deletewallet", response_class=RedirectResponse) -async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query(...)): +async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query(...)): # type: ignore user = await get_user(usr) - user_wallet_ids = [u.id for u in user.wallets] + user_wallet_ids = [u.id for u in user.wallets] # type: ignore if wal not in user_wallet_ids: raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.") else: - await delete_wallet(user_id=user.id, wallet_id=wal) + await delete_wallet(user_id=user.id, wallet_id=wal) # type: ignore user_wallet_ids.remove(wal) logger.debug("Deleted wallet {wal} of user {user.id}") if user_wallet_ids: return RedirectResponse( - url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]), + url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]), # type: ignore status_code=status.HTTP_307_TEMPORARY_REDIRECT, ) @@ -233,7 +244,7 @@ async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query async def lnurl_balance_notify(request: Request, service: str): bc = await get_balance_check(request.query_params.get("wal"), service) if bc: - redeem_lnurl_withdraw(bc.wallet, bc.url) + await redeem_lnurl_withdraw(bc.wallet, bc.url) @core_html_routes.get( @@ -243,7 +254,7 @@ async def lnurlwallet(request: Request): async with db.connect() as conn: account = await create_account(conn=conn) user = await get_user(account.id, conn=conn) - wallet = await create_wallet(user_id=user.id, conn=conn) + wallet = await create_wallet(user_id=user.id, conn=conn) # type: ignore asyncio.create_task( redeem_lnurl_withdraw( @@ -256,7 +267,7 @@ async def lnurlwallet(request: Request): ) return RedirectResponse( - f"/wallet?usr={user.id}&wal={wallet.id}", + f"/wallet?usr={user.id}&wal={wallet.id}", # type: ignore status_code=status.HTTP_307_TEMPORARY_REDIRECT, ) diff --git a/lnbits/decorators.py b/lnbits/decorators.py index e65b9041..090c11c5 100644 --- a/lnbits/decorators.py +++ b/lnbits/decorators.py @@ -1,4 +1,5 @@ from http import HTTPStatus +from typing import Union from cerberus import Validator # type: ignore from fastapi import status @@ -29,20 +30,21 @@ class KeyChecker(SecurityBase): self._key_type = "invoice" self._api_key = api_key if api_key: - self.model: APIKey = APIKey( + key = APIKey( **{"in": APIKeyIn.query}, name="X-API-KEY", description="Wallet API Key - QUERY", ) else: - self.model: APIKey = APIKey( + key = APIKey( **{"in": APIKeyIn.header}, name="X-API-KEY", description="Wallet API Key - HEADER", ) - self.wallet = None + self.wallet = None # type: ignore + self.model: APIKey = key - async def __call__(self, request: Request) -> Wallet: + async def __call__(self, request: Request): try: key_value = ( self._api_key @@ -52,7 +54,7 @@ class KeyChecker(SecurityBase): # FIXME: Find another way to validate the key. A fetch from DB should be avoided here. # Also, we should not return the wallet here - thats silly. # Possibly store it in a Redis DB - self.wallet = await get_wallet_for_key(key_value, self._key_type) + self.wallet = await get_wallet_for_key(key_value, self._key_type) # type: ignore if not self.wallet: raise HTTPException( status_code=HTTPStatus.UNAUTHORIZED, @@ -120,8 +122,8 @@ api_key_query = APIKeyQuery( async def get_key_type( r: Request, - api_key_header: str = Security(api_key_header), - api_key_query: str = Security(api_key_query), + api_key_header: str = Security(api_key_header), # type: ignore + api_key_query: str = Security(api_key_query), # type: ignore ) -> WalletTypeInfo: # 0: admin # 1: invoice @@ -134,9 +136,9 @@ async def get_key_type( token = api_key_header if api_key_header else api_key_query try: - checker = WalletAdminKeyChecker(api_key=token) - await checker.__call__(r) - wallet = WalletTypeInfo(0, checker.wallet) + admin_checker = WalletAdminKeyChecker(api_key=token) + await admin_checker.__call__(r) + wallet = WalletTypeInfo(0, admin_checker.wallet) # type: ignore if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and ( LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS ): @@ -153,9 +155,9 @@ async def get_key_type( raise try: - checker = WalletInvoiceKeyChecker(api_key=token) - await checker.__call__(r) - wallet = WalletTypeInfo(1, checker.wallet) + invoice_checker = WalletInvoiceKeyChecker(api_key=token) + await invoice_checker.__call__(r) + wallet = WalletTypeInfo(1, invoice_checker.wallet) # type: ignore if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and ( LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS ): @@ -167,15 +169,16 @@ async def get_key_type( if e.status_code == HTTPStatus.BAD_REQUEST: raise if e.status_code == HTTPStatus.UNAUTHORIZED: - return WalletTypeInfo(2, None) + return WalletTypeInfo(2, None) # type: ignore except: raise + return wallet async def require_admin_key( r: Request, - api_key_header: str = Security(api_key_header), - api_key_query: str = Security(api_key_query), + api_key_header: str = Security(api_key_header), # type: ignore + api_key_query: str = Security(api_key_query), # type: ignore ): token = api_key_header if api_key_header else api_key_query @@ -193,10 +196,16 @@ async def require_admin_key( async def require_invoice_key( r: Request, - api_key_header: str = Security(api_key_header), - api_key_query: str = Security(api_key_query), + api_key_header: str = Security(api_key_header), # type: ignore + api_key_query: str = Security(api_key_query), # type: ignore ): - token = api_key_header if api_key_header else api_key_query + token = api_key_header or api_key_query + + if token is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invoice (or Admin) key required.", + ) wallet = await get_key_type(r, token) diff --git a/lnbits/extensions/bleskomat/__init__.py b/lnbits/extensions/bleskomat/__init__.py index cc89760e..bef362dc 100644 --- a/lnbits/extensions/bleskomat/__init__.py +++ b/lnbits/extensions/bleskomat/__init__.py @@ -9,7 +9,7 @@ db = Database("ext_bleskomat") bleskomat_static_files = [ { "path": "/bleskomat/static", - "app": StaticFiles(directory="lnbits/extensions/bleskomat/static"), + "app": StaticFiles(packages=[("lnbits", "extensions/bleskomat/static")]), "name": "bleskomat_static", } ] diff --git a/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html b/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html index 210d534c..2a7160bd 100644 --- a/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html +++ b/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html @@ -62,4 +62,5 @@

+ diff --git a/lnbits/extensions/copilot/__init__.py b/lnbits/extensions/copilot/__init__.py index 8a634267..806801ce 100644 --- a/lnbits/extensions/copilot/__init__.py +++ b/lnbits/extensions/copilot/__init__.py @@ -12,7 +12,7 @@ db = Database("ext_copilot") copilot_static_files = [ { "path": "/copilot/static", - "app": StaticFiles(directory="lnbits/extensions/copilot/static"), + "app": StaticFiles(packages=[("lnbits", "extensions/copilot/static")]), "name": "copilot_static", } ] diff --git a/lnbits/extensions/copilot/lnurl.py b/lnbits/extensions/copilot/lnurl.py index 69777c9f..fa199691 100644 --- a/lnbits/extensions/copilot/lnurl.py +++ b/lnbits/extensions/copilot/lnurl.py @@ -73,11 +73,9 @@ async def lnurl_callback( wallet_id=cp.wallet, amount=int(amount_received / 1000), memo=cp.lnurl_title, - description_hash=hashlib.sha256( - ( - LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]])) - ).encode("utf-8") - ).digest(), + unhashed_description=( + LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]])) + ).encode("utf-8"), extra={"tag": "copilot", "copilotid": cp.id, "comment": comment}, ) payResponse = {"pr": payment_request, "routes": []} diff --git a/lnbits/extensions/copilot/templates/copilot/_api_docs.html b/lnbits/extensions/copilot/templates/copilot/_api_docs.html index eed25169..6105d169 100644 --- a/lnbits/extensions/copilot/templates/copilot/_api_docs.html +++ b/lnbits/extensions/copilot/templates/copilot/_api_docs.html @@ -14,6 +14,7 @@ label="API info" :content-inset-level="0.5" > + diff --git a/lnbits/extensions/discordbot/__init__.py b/lnbits/extensions/discordbot/__init__.py index ff60dd62..21989b24 100644 --- a/lnbits/extensions/discordbot/__init__.py +++ b/lnbits/extensions/discordbot/__init__.py @@ -9,7 +9,7 @@ db = Database("ext_discordbot") discordbot_static_files = [ { "path": "/discordbot/static", - "app": StaticFiles(directory="lnbits/extensions/discordbot/static"), + "app": StaticFiles(packages=[("lnbits", "extensions/discordbot/static")]), "name": "discordbot_static", } ] diff --git a/lnbits/extensions/discordbot/templates/discordbot/_api_docs.html b/lnbits/extensions/discordbot/templates/discordbot/_api_docs.html index fcda086a..b57e4ab1 100644 --- a/lnbits/extensions/discordbot/templates/discordbot/_api_docs.html +++ b/lnbits/extensions/discordbot/templates/discordbot/_api_docs.html @@ -34,6 +34,7 @@ label="API info" :content-inset-level="0.5" > + diff --git a/lnbits/extensions/events/crud.py b/lnbits/extensions/events/crud.py index 9e04476d..4cc86ac4 100644 --- a/lnbits/extensions/events/crud.py +++ b/lnbits/extensions/events/crud.py @@ -16,7 +16,7 @@ async def create_ticket( INSERT INTO events.ticket (id, wallet, event, name, email, registered, paid) VALUES (?, ?, ?, ?, ?, ?, ?) """, - (payment_hash, wallet, event, name, email, False, False), + (payment_hash, wallet, event, name, email, False, True), ) ticket = await get_ticket(payment_hash) diff --git a/lnbits/extensions/events/templates/events/_api_docs.html b/lnbits/extensions/events/templates/events/_api_docs.html index a5c82174..d2fa890e 100644 --- a/lnbits/extensions/events/templates/events/_api_docs.html +++ b/lnbits/extensions/events/templates/events/_api_docs.html @@ -20,4 +20,5 @@

+
diff --git a/lnbits/extensions/events/templates/events/display.html b/lnbits/extensions/events/templates/events/display.html index 4c1f557f..4589c578 100644 --- a/lnbits/extensions/events/templates/events/display.html +++ b/lnbits/extensions/events/templates/events/display.html @@ -135,15 +135,7 @@ var self = this axios - .post( - '/events/api/v1/tickets/' + '{{ event_id }}/{{ event_price }}', - { - event: '{{ event_id }}', - event_name: '{{ event_name }}', - name: self.formDialog.data.name, - email: self.formDialog.data.email - } - ) + .get('/events/api/v1/tickets/' + '{{ event_id }}') .then(function (response) { self.paymentReq = response.data.payment_request self.paymentCheck = response.data.payment_hash @@ -161,7 +153,17 @@ paymentChecker = setInterval(function () { axios - .get('/events/api/v1/tickets/' + self.paymentCheck) + .post( + '/events/api/v1/tickets/' + + '{{ event_id }}/' + + self.paymentCheck, + { + event: '{{ event_id }}', + event_name: '{{ event_name }}', + name: self.formDialog.data.name, + email: self.formDialog.data.email + } + ) .then(function (res) { if (res.data.paid) { clearInterval(paymentChecker) diff --git a/lnbits/extensions/events/templates/events/register.html b/lnbits/extensions/events/templates/events/register.html index 4dff9afb..43d43070 100644 --- a/lnbits/extensions/events/templates/events/register.html +++ b/lnbits/extensions/events/templates/events/register.html @@ -133,7 +133,10 @@ var self = this LNbits.api - .request('GET', '/events/api/v1/register/ticket/' + res) + .request( + 'GET', + '/events/api/v1/register/ticket/' + res.split('//')[1] + ) .then(function (response) { self.$q.notify({ type: 'positive', diff --git a/lnbits/extensions/events/templates/events/ticket.html b/lnbits/extensions/events/templates/events/ticket.html index a53f834f..21b7cfa8 100644 --- a/lnbits/extensions/events/templates/events/ticket.html +++ b/lnbits/extensions/events/templates/events/ticket.html @@ -13,9 +13,8 @@

diff --git a/lnbits/extensions/events/views_api.py b/lnbits/extensions/events/views_api.py index 87edb07d..56e6b06c 100644 --- a/lnbits/extensions/events/views_api.py +++ b/lnbits/extensions/events/views_api.py @@ -97,8 +97,8 @@ async def api_tickets( return [ticket.dict() for ticket in await get_tickets(wallet_ids)] -@events_ext.post("/api/v1/tickets/{event_id}/{sats}") -async def api_ticket_make_ticket(event_id, sats, data: CreateTicket): +@events_ext.get("/api/v1/tickets/{event_id}") +async def api_ticket_make_ticket(event_id): event = await get_event(event_id) if not event: raise HTTPException( @@ -107,37 +107,36 @@ async def api_ticket_make_ticket(event_id, sats, data: CreateTicket): try: payment_hash, payment_request = await create_invoice( wallet_id=event.wallet, - amount=int(sats), + amount=event.price_per_ticket, memo=f"{event_id}", extra={"tag": "events"}, ) except Exception as e: raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) - ticket = await create_ticket( - payment_hash=payment_hash, - wallet=event.wallet, - event=event_id, - name=data.name, - email=data.email, - ) - - if not ticket: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail=f"Event could not be fetched." - ) - return {"payment_hash": payment_hash, "payment_request": payment_request} -@events_ext.get("/api/v1/tickets/{payment_hash}") -async def api_ticket_send_ticket(payment_hash): - ticket = await get_ticket(payment_hash) - +@events_ext.post("/api/v1/tickets/{event_id}/{payment_hash}") +async def api_ticket_send_ticket(event_id, payment_hash, data: CreateTicket): + event = await get_event(event_id) try: status = await api_payment(payment_hash) if status["paid"]: - await set_ticket_paid(payment_hash=payment_hash) + ticket = await create_ticket( + payment_hash=payment_hash, + wallet=event.wallet, + event=event_id, + name=data.name, + email=data.email, + ) + + if not ticket: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Event could not be fetched.", + ) + return {"paid": True, "ticket_id": ticket.id} except Exception: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Not paid") diff --git a/lnbits/extensions/invoices/README.md b/lnbits/extensions/invoices/README.md new file mode 100644 index 00000000..2b5bd538 --- /dev/null +++ b/lnbits/extensions/invoices/README.md @@ -0,0 +1,19 @@ +# Invoices + +## Create invoices that you can send to your client to pay online over Lightning. + +This extension allows users to create "traditional" invoices (not in the lightning sense) that contain one or more line items. Line items are denominated in a user-configurable fiat currency. Each invoice contains one or more payments up to the total of the invoice. Each invoice creates a public link that can be shared with a customer that they can use to (partially or in full) pay the invoice. + +## Usage + +1. Create an invoice by clicking "NEW INVOICE"\ + ![create new invoice](https://imgur.com/a/Dce3wrr.png) +2. Fill the options for your INVOICE + - select the wallet + - select the fiat currency the invoice will be denominated in + - select a status for the invoice (default is draft) + - enter a company name, first name, last name, email, phone & address (optional) + - add one or more line items + - enter a name & price for each line item +3. You can then use share your invoice link with your customer to receive payment\ + ![invoice link](https://imgur.com/a/L0JOj4T.png) \ No newline at end of file diff --git a/lnbits/extensions/invoices/__init__.py b/lnbits/extensions/invoices/__init__.py new file mode 100644 index 00000000..0b60837b --- /dev/null +++ b/lnbits/extensions/invoices/__init__.py @@ -0,0 +1,36 @@ +import asyncio + +from fastapi import APIRouter +from starlette.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_invoices") + +invoices_static_files = [ + { + "path": "/invoices/static", + "app": StaticFiles(directory="lnbits/extensions/invoices/static"), + "name": "invoices_static", + } +] + +invoices_ext: APIRouter = APIRouter(prefix="/invoices", tags=["invoices"]) + + +def invoices_renderer(): + return template_renderer(["lnbits/extensions/invoices/templates"]) + + +from .tasks import wait_for_paid_invoices + + +def invoices_start(): + loop = asyncio.get_event_loop() + loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) + + +from .views import * # noqa +from .views_api import * # noqa diff --git a/lnbits/extensions/invoices/config.json b/lnbits/extensions/invoices/config.json new file mode 100644 index 00000000..0811e0ef --- /dev/null +++ b/lnbits/extensions/invoices/config.json @@ -0,0 +1,6 @@ +{ + "name": "Invoices", + "short_description": "Create invoices for your clients.", + "icon": "request_quote", + "contributors": ["leesalminen"] +} diff --git a/lnbits/extensions/invoices/crud.py b/lnbits/extensions/invoices/crud.py new file mode 100644 index 00000000..4fd055e9 --- /dev/null +++ b/lnbits/extensions/invoices/crud.py @@ -0,0 +1,206 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import ( + CreateInvoiceData, + CreateInvoiceItemData, + CreatePaymentData, + Invoice, + InvoiceItem, + Payment, + UpdateInvoiceData, + UpdateInvoiceItemData, +) + + +async def get_invoice(invoice_id: str) -> Optional[Invoice]: + row = await db.fetchone( + "SELECT * FROM invoices.invoices WHERE id = ?", (invoice_id,) + ) + return Invoice.from_row(row) if row else None + + +async def get_invoice_items(invoice_id: str) -> List[InvoiceItem]: + rows = await db.fetchall( + f"SELECT * FROM invoices.invoice_items WHERE invoice_id = ?", (invoice_id,) + ) + + return [InvoiceItem.from_row(row) for row in rows] + + +async def get_invoice_item(item_id: str) -> InvoiceItem: + row = await db.fetchone( + "SELECT * FROM invoices.invoice_items WHERE id = ?", (item_id,) + ) + return InvoiceItem.from_row(row) if row else None + + +async def get_invoice_total(items: List[InvoiceItem]) -> int: + return sum(item.amount for item in items) + + +async def get_invoices(wallet_ids: Union[str, List[str]]) -> List[Invoice]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM invoices.invoices WHERE wallet IN ({q})", (*wallet_ids,) + ) + + return [Invoice.from_row(row) for row in rows] + + +async def get_invoice_payments(invoice_id: str) -> List[Payment]: + rows = await db.fetchall( + f"SELECT * FROM invoices.payments WHERE invoice_id = ?", (invoice_id,) + ) + + return [Payment.from_row(row) for row in rows] + + +async def get_invoice_payment(payment_id: str) -> Payment: + row = await db.fetchone( + "SELECT * FROM invoices.payments WHERE id = ?", (payment_id,) + ) + return Payment.from_row(row) if row else None + + +async def get_payments_total(payments: List[Payment]) -> int: + return sum(item.amount for item in payments) + + +async def create_invoice_internal(wallet_id: str, data: CreateInvoiceData) -> Invoice: + invoice_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO invoices.invoices (id, wallet, status, currency, company_name, first_name, last_name, email, phone, address) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + invoice_id, + wallet_id, + data.status, + data.currency, + data.company_name, + data.first_name, + data.last_name, + data.email, + data.phone, + data.address, + ), + ) + + invoice = await get_invoice(invoice_id) + assert invoice, "Newly created invoice couldn't be retrieved" + return invoice + + +async def create_invoice_items( + invoice_id: str, data: List[CreateInvoiceItemData] +) -> List[InvoiceItem]: + for item in data: + item_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO invoices.invoice_items (id, invoice_id, description, amount) + VALUES (?, ?, ?, ?) + """, + ( + item_id, + invoice_id, + item.description, + int(item.amount * 100), + ), + ) + + invoice_items = await get_invoice_items(invoice_id) + return invoice_items + + +async def update_invoice_internal(wallet_id: str, data: UpdateInvoiceData) -> Invoice: + await db.execute( + """ + UPDATE invoices.invoices + SET wallet = ?, currency = ?, status = ?, company_name = ?, first_name = ?, last_name = ?, email = ?, phone = ?, address = ? + WHERE id = ? + """, + ( + wallet_id, + data.currency, + data.status, + data.company_name, + data.first_name, + data.last_name, + data.email, + data.phone, + data.address, + data.id, + ), + ) + + invoice = await get_invoice(data.id) + assert invoice, "Newly updated invoice couldn't be retrieved" + return invoice + + +async def update_invoice_items( + invoice_id: str, data: List[UpdateInvoiceItemData] +) -> List[InvoiceItem]: + updated_items = [] + for item in data: + if item.id: + updated_items.append(item.id) + await db.execute( + """ + UPDATE invoices.invoice_items + SET description = ?, amount = ? + WHERE id = ? + """, + (item.description, int(item.amount * 100), item.id), + ) + + placeholders = ",".join("?" for i in range(len(updated_items))) + if not placeholders: + placeholders = "?" + updated_items = ("skip",) + + await db.execute( + f""" + DELETE FROM invoices.invoice_items + WHERE invoice_id = ? + AND id NOT IN ({placeholders}) + """, + ( + invoice_id, + *tuple(updated_items), + ), + ) + + for item in data: + if not item.id: + await create_invoice_items(invoice_id=invoice_id, data=[item]) + + invoice_items = await get_invoice_items(invoice_id) + return invoice_items + + +async def create_invoice_payment(invoice_id: str, amount: int) -> Payment: + payment_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO invoices.payments (id, invoice_id, amount) + VALUES (?, ?, ?) + """, + ( + payment_id, + invoice_id, + amount, + ), + ) + + payment = await get_invoice_payment(payment_id) + assert payment, "Newly created payment couldn't be retrieved" + return payment diff --git a/lnbits/extensions/invoices/migrations.py b/lnbits/extensions/invoices/migrations.py new file mode 100644 index 00000000..c47a954a --- /dev/null +++ b/lnbits/extensions/invoices/migrations.py @@ -0,0 +1,55 @@ +async def m001_initial_invoices(db): + + # STATUS COLUMN OPTIONS: 'draft', 'open', 'paid', 'canceled' + + await db.execute( + f""" + CREATE TABLE invoices.invoices ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + + status TEXT NOT NULL DEFAULT 'draft', + + currency TEXT NOT NULL, + + company_name TEXT DEFAULT NULL, + first_name TEXT DEFAULT NULL, + last_name TEXT DEFAULT NULL, + email TEXT DEFAULT NULL, + phone TEXT DEFAULT NULL, + address TEXT DEFAULT NULL, + + + time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + + await db.execute( + f""" + CREATE TABLE invoices.invoice_items ( + id TEXT PRIMARY KEY, + invoice_id TEXT NOT NULL, + + description TEXT NOT NULL, + amount INTEGER NOT NULL, + + FOREIGN KEY(invoice_id) REFERENCES {db.references_schema}invoices(id) + ); + """ + ) + + await db.execute( + f""" + CREATE TABLE invoices.payments ( + id TEXT PRIMARY KEY, + invoice_id TEXT NOT NULL, + + amount INT NOT NULL, + + time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + + FOREIGN KEY(invoice_id) REFERENCES {db.references_schema}invoices(id) + ); + """ + ) diff --git a/lnbits/extensions/invoices/models.py b/lnbits/extensions/invoices/models.py new file mode 100644 index 00000000..adf03e46 --- /dev/null +++ b/lnbits/extensions/invoices/models.py @@ -0,0 +1,104 @@ +from enum import Enum +from sqlite3 import Row +from typing import List, Optional + +from fastapi.param_functions import Query +from pydantic import BaseModel + + +class InvoiceStatusEnum(str, Enum): + draft = "draft" + open = "open" + paid = "paid" + canceled = "canceled" + + +class CreateInvoiceItemData(BaseModel): + description: str + amount: float = Query(..., ge=0.01) + + +class CreateInvoiceData(BaseModel): + status: InvoiceStatusEnum = InvoiceStatusEnum.draft + currency: str + company_name: Optional[str] + first_name: Optional[str] + last_name: Optional[str] + email: Optional[str] + phone: Optional[str] + address: Optional[str] + items: List[CreateInvoiceItemData] + + class Config: + use_enum_values = True + + +class UpdateInvoiceItemData(BaseModel): + id: Optional[str] + description: str + amount: float = Query(..., ge=0.01) + + +class UpdateInvoiceData(BaseModel): + id: str + wallet: str + status: InvoiceStatusEnum = InvoiceStatusEnum.draft + currency: str + company_name: Optional[str] + first_name: Optional[str] + last_name: Optional[str] + email: Optional[str] + phone: Optional[str] + address: Optional[str] + items: List[UpdateInvoiceItemData] + + +class Invoice(BaseModel): + id: str + wallet: str + status: InvoiceStatusEnum = InvoiceStatusEnum.draft + currency: str + company_name: Optional[str] + first_name: Optional[str] + last_name: Optional[str] + email: Optional[str] + phone: Optional[str] + address: Optional[str] + time: int + + class Config: + use_enum_values = True + + @classmethod + def from_row(cls, row: Row) -> "Invoice": + return cls(**dict(row)) + + +class InvoiceItem(BaseModel): + id: str + invoice_id: str + description: str + amount: int + + class Config: + orm_mode = True + + @classmethod + def from_row(cls, row: Row) -> "InvoiceItem": + return cls(**dict(row)) + + +class Payment(BaseModel): + id: str + invoice_id: str + amount: int + time: int + + @classmethod + def from_row(cls, row: Row) -> "Payment": + return cls(**dict(row)) + + +class CreatePaymentData(BaseModel): + invoice_id: str + amount: int diff --git a/lnbits/extensions/invoices/static/css/pay.css b/lnbits/extensions/invoices/static/css/pay.css new file mode 100644 index 00000000..ad7ce914 --- /dev/null +++ b/lnbits/extensions/invoices/static/css/pay.css @@ -0,0 +1,65 @@ +#invoicePage>.row:first-child>.col-md-6 { + display: flex; +} + +#invoicePage>.row:first-child>.col-md-6>.q-card { + flex: 1; +} + +#invoicePage .clear { + margin-bottom: 25px; +} + +#printQrCode { + display: none; +} + +@media (min-width: 1024px) { + #invoicePage>.row:first-child>.col-md-6:first-child>div { + margin-right: 5px; + } + + #invoicePage>.row:first-child>.col-md-6:nth-child(2)>div { + margin-left: 5px; + } +} + + +@media print { + * { + color: black !important; + } + + header, button, #payButtonContainer { + display: none !important; + } + + main, .q-page-container { + padding-top: 0px !important; + } + + .q-card { + box-shadow: none !important; + border: 1px solid black; + } + + .q-item { + padding: 5px; + } + + .q-card__section { + padding: 5px; + } + + #printQrCode { + display: block; + } + + p { + margin-bottom: 0px !important; + } + + #invoicePage .clear { + margin-bottom: 10px !important; + } +} \ No newline at end of file diff --git a/lnbits/extensions/invoices/tasks.py b/lnbits/extensions/invoices/tasks.py new file mode 100644 index 00000000..61bcb7b4 --- /dev/null +++ b/lnbits/extensions/invoices/tasks.py @@ -0,0 +1,51 @@ +import asyncio +import json + +from lnbits.core.models import Payment +from lnbits.helpers import urlsafe_short_hash +from lnbits.tasks import internal_invoice_queue, register_invoice_listener + +from .crud import ( + create_invoice_payment, + get_invoice, + get_invoice_items, + get_invoice_payments, + get_invoice_total, + get_payments_total, + update_invoice_internal, +) + + +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: + if payment.extra.get("tag") != "invoices": + # not relevant + return + + invoice_id = payment.extra.get("invoice_id") + + payment = await create_invoice_payment( + invoice_id=invoice_id, amount=payment.extra.get("famount") + ) + + invoice = await get_invoice(invoice_id) + + invoice_items = await get_invoice_items(invoice_id) + invoice_total = await get_invoice_total(invoice_items) + + invoice_payments = await get_invoice_payments(invoice_id) + payments_total = await get_payments_total(invoice_payments) + + if payments_total >= invoice_total: + invoice.status = "paid" + await update_invoice_internal(invoice.wallet, invoice) + + return diff --git a/lnbits/extensions/invoices/templates/invoices/_api_docs.html b/lnbits/extensions/invoices/templates/invoices/_api_docs.html new file mode 100644 index 00000000..6e2a6355 --- /dev/null +++ b/lnbits/extensions/invoices/templates/invoices/_api_docs.html @@ -0,0 +1,153 @@ + + + + + GET /invoices/api/v1/invoices +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<invoice_object>, ...] +
Curl example
+ curl -X GET {{ request.base_url }}invoices/api/v1/invoices -H + "X-Api-Key: <invoice_key>" + +
+
+
+ + + + + GET + /invoices/api/v1/invoice/{invoice_id} +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {invoice_object} +
Curl example
+ curl -X GET {{ request.base_url + }}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key: + <invoice_key>" + +
+
+
+ + + + + POST /invoices/api/v1/invoice +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {invoice_object} +
Curl example
+ curl -X POST {{ request.base_url }}invoices/api/v1/invoice -H + "X-Api-Key: <invoice_key>" + +
+
+
+ + + + + POST + /invoices/api/v1/invoice/{invoice_id} +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {invoice_object} +
Curl example
+ curl -X POST {{ request.base_url + }}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key: + <invoice_key>" + +
+
+
+ + + + + POST + /invoices/api/v1/invoice/{invoice_id}/payments +
Headers
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {payment_object} +
Curl example
+ curl -X POST {{ request.base_url + }}invoices/api/v1/invoice/{invoice_id}/payments -H "X-Api-Key: + <invoice_key>" + +
+
+
+ + + + + GET + /invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash} +
Headers
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+
Curl example
+ curl -X GET {{ request.base_url + }}invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash} -H + "X-Api-Key: <invoice_key>" + +
+
+
+
diff --git a/lnbits/extensions/invoices/templates/invoices/index.html b/lnbits/extensions/invoices/templates/invoices/index.html new file mode 100644 index 00000000..e3093e3c --- /dev/null +++ b/lnbits/extensions/invoices/templates/invoices/index.html @@ -0,0 +1,571 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New Invoice + + + + + +
+
+
Invoices
+
+
+ Export to CSV +
+
+ + {% raw %} + + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} Invoices extension +
+
+ + + {% include "invoices/_api_docs.html" %} + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Add Line Item + + + + +
+ Create Invoice + Save Invoice + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/invoices/templates/invoices/pay.html b/lnbits/extensions/invoices/templates/invoices/pay.html new file mode 100644 index 00000000..7b6452dc --- /dev/null +++ b/lnbits/extensions/invoices/templates/invoices/pay.html @@ -0,0 +1,430 @@ +{% extends "public.html" %} {% block toolbar_title %} Invoice + + +{% endblock %} {% from "macros.jinja" import window_vars with context %} {% +block page %} + +
+
+
+ + +

+ Invoice +

+ + + + ID + {{ invoice_id }} + + + + Created At + {{ datetime.utcfromtimestamp(invoice.time).strftime('%Y-%m-%d + %H:%M') }} + + + + Status + + + {{ invoice.status }} + + + + + + Total + + {{ "{:0,.2f}".format(invoice_total / 100) }} {{ invoice.currency + }} + + + + + Paid + +
+
+ {{ "{:0,.2f}".format(payments_total / 100) }} {{ + invoice.currency }} +
+
+ {% if payments_total < invoice_total %} + + Pay Invoice + + {% endif %} +
+
+
+
+
+
+
+
+ +
+ + +

+ Bill To +

+ + + + Company Name + {{ invoice.company_name }} + + + + Name + {{ invoice.first_name }} {{ invoice.last_name + }} + + + + Address + {{ invoice.address }} + + + + Email + {{ invoice.email }} + + + + Phone + {{ invoice.phone }} + + +
+
+
+
+ +
+ +
+
+ + +

+ Items +

+ + + {% if invoice_items %} + + Item + Amount + + {% endif %} {% for item in invoice_items %} + + {{item.description}} + + {{ "{:0,.2f}".format(item.amount / 100) }} {{ invoice.currency + }} + + + {% endfor %} {% if not invoice_items %} No Invoice Items {% endif %} + +
+
+
+
+ +
+ +
+
+ + +

+ Payments +

+ + + {% if invoice_payments %} + + Date + Amount + + {% endif %} {% for item in invoice_payments %} + + {{ datetime.utcfromtimestamp(item.time).strftime('%Y-%m-%d + %H:%M') }} + + {{ "{:0,.2f}".format(item.amount / 100) }} {{ invoice.currency + }} + + + {% endfor %} {% if not invoice_payments %} No Invoice Payments {% + endif %} + +
+
+
+
+ +
+ +
+
+
+

Scan to View & Pay Online!

+ +
+
+
+ + + + + + + + +
+ Create Payment + Cancel +
+
+
+
+ + + + + + + + +
+ Copy Invoice +
+
+ + + + + + +
+

{{ request.url }}

+
+
+ Copy URL + Close +
+
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/invoices/views.py b/lnbits/extensions/invoices/views.py new file mode 100644 index 00000000..08223df8 --- /dev/null +++ b/lnbits/extensions/invoices/views.py @@ -0,0 +1,59 @@ +from datetime import datetime +from http import HTTPStatus + +from fastapi import FastAPI, Request +from fastapi.params import Depends +from fastapi.templating import Jinja2Templates +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import invoices_ext, invoices_renderer +from .crud import ( + get_invoice, + get_invoice_items, + get_invoice_payments, + get_invoice_total, + get_payments_total, +) + +templates = Jinja2Templates(directory="templates") + + +@invoices_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return invoices_renderer().TemplateResponse( + "invoices/index.html", {"request": request, "user": user.dict()} + ) + + +@invoices_ext.get("/pay/{invoice_id}", response_class=HTMLResponse) +async def index(request: Request, invoice_id: str): + invoice = await get_invoice(invoice_id) + + if not invoice: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist." + ) + + invoice_items = await get_invoice_items(invoice_id) + invoice_total = await get_invoice_total(invoice_items) + + invoice_payments = await get_invoice_payments(invoice_id) + payments_total = await get_payments_total(invoice_payments) + + return invoices_renderer().TemplateResponse( + "invoices/pay.html", + { + "request": request, + "invoice_id": invoice_id, + "invoice": invoice.dict(), + "invoice_items": invoice_items, + "invoice_total": invoice_total, + "invoice_payments": invoice_payments, + "payments_total": payments_total, + "datetime": datetime, + }, + ) diff --git a/lnbits/extensions/invoices/views_api.py b/lnbits/extensions/invoices/views_api.py new file mode 100644 index 00000000..23a262e3 --- /dev/null +++ b/lnbits/extensions/invoices/views_api.py @@ -0,0 +1,136 @@ +from http import HTTPStatus + +from fastapi import Query +from fastapi.params import Depends +from loguru import logger +from starlette.exceptions import HTTPException + +from lnbits.core.crud import get_user +from lnbits.core.services import create_invoice +from lnbits.core.views.api import api_payment +from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key +from lnbits.utils.exchange_rates import fiat_amount_as_satoshis + +from . import invoices_ext +from .crud import ( + create_invoice_internal, + create_invoice_items, + get_invoice, + get_invoice_items, + get_invoice_payments, + get_invoice_total, + get_invoices, + get_payments_total, + update_invoice_internal, + update_invoice_items, +) +from .models import CreateInvoiceData, UpdateInvoiceData + + +@invoices_ext.get("/api/v1/invoices", status_code=HTTPStatus.OK) +async def api_invoices( + all_wallets: bool = Query(None), wallet: WalletTypeInfo = Depends(get_key_type) +): + wallet_ids = [wallet.wallet.id] + if all_wallets: + wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids + + return [invoice.dict() for invoice in await get_invoices(wallet_ids)] + + +@invoices_ext.get("/api/v1/invoice/{invoice_id}", status_code=HTTPStatus.OK) +async def api_invoice(invoice_id: str): + invoice = await get_invoice(invoice_id) + if not invoice: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist." + ) + invoice_items = await get_invoice_items(invoice_id) + + invoice_payments = await get_invoice_payments(invoice_id) + payments_total = await get_payments_total(invoice_payments) + + invoice_dict = invoice.dict() + invoice_dict["items"] = invoice_items + invoice_dict["payments"] = payments_total + return invoice_dict + + +@invoices_ext.post("/api/v1/invoice", status_code=HTTPStatus.CREATED) +async def api_invoice_create( + data: CreateInvoiceData, wallet: WalletTypeInfo = Depends(get_key_type) +): + invoice = await create_invoice_internal(wallet_id=wallet.wallet.id, data=data) + items = await create_invoice_items(invoice_id=invoice.id, data=data.items) + invoice_dict = invoice.dict() + invoice_dict["items"] = items + return invoice_dict + + +@invoices_ext.post("/api/v1/invoice/{invoice_id}", status_code=HTTPStatus.OK) +async def api_invoice_update( + data: UpdateInvoiceData, + invoice_id: str, + wallet: WalletTypeInfo = Depends(get_key_type), +): + invoice = await update_invoice_internal(wallet_id=wallet.wallet.id, data=data) + items = await update_invoice_items(invoice_id=invoice.id, data=data.items) + invoice_dict = invoice.dict() + invoice_dict["items"] = items + return invoice_dict + + +@invoices_ext.post( + "/api/v1/invoice/{invoice_id}/payments", status_code=HTTPStatus.CREATED +) +async def api_invoices_create_payment( + famount: int = Query(..., ge=1), invoice_id: str = None +): + invoice = await get_invoice(invoice_id) + invoice_items = await get_invoice_items(invoice_id) + invoice_total = await get_invoice_total(invoice_items) + + invoice_payments = await get_invoice_payments(invoice_id) + payments_total = await get_payments_total(invoice_payments) + + if payments_total + famount > invoice_total: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Amount exceeds invoice due." + ) + + if not invoice: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist." + ) + + price_in_sats = await fiat_amount_as_satoshis(famount / 100, invoice.currency) + + try: + payment_hash, payment_request = await create_invoice( + wallet_id=invoice.wallet, + amount=price_in_sats, + memo=f"Payment for invoice {invoice_id}", + extra={"tag": "invoices", "invoice_id": invoice_id, "famount": famount}, + ) + except Exception as e: + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) + + return {"payment_hash": payment_hash, "payment_request": payment_request} + + +@invoices_ext.get( + "/api/v1/invoice/{invoice_id}/payments/{payment_hash}", status_code=HTTPStatus.OK +) +async def api_invoices_check_payment(invoice_id: str, payment_hash: str): + invoice = await get_invoice(invoice_id) + if not invoice: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist." + ) + try: + status = await api_payment(payment_hash) + + except Exception as exc: + logger.error(exc) + return {"paid": False} + return status diff --git a/lnbits/extensions/jukebox/__init__.py b/lnbits/extensions/jukebox/__init__.py index 702a6c67..4559dccf 100644 --- a/lnbits/extensions/jukebox/__init__.py +++ b/lnbits/extensions/jukebox/__init__.py @@ -12,7 +12,7 @@ db = Database("ext_jukebox") jukebox_static_files = [ { "path": "/jukebox/static", - "app": StaticFiles(directory="lnbits/extensions/jukebox/static"), + "app": StaticFiles(packages=[("lnbits", "extensions/jukebox/static")]), "name": "jukebox_static", } ] diff --git a/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html b/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html index f4daaf39..4bae4965 100644 --- a/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html +++ b/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html @@ -24,6 +24,8 @@ label="API info" :content-inset-level="0.5" > + + diff --git a/lnbits/extensions/jukebox/templates/jukebox/index.html b/lnbits/extensions/jukebox/templates/jukebox/index.html index 9b4efbd5..a67767fb 100644 --- a/lnbits/extensions/jukebox/templates/jukebox/index.html +++ b/lnbits/extensions/jukebox/templates/jukebox/index.html @@ -117,7 +117,7 @@ > @@ -170,16 +170,25 @@
- + To use this extension you need a Spotify client ID and client secret. - You get these by creating an app in the Spotify developers dashboard - +
+ here
. + >Open the Spotify Developer Dashboard
+ - + - In the app go to edit-settings, set the redirect URI to this link +

+ In the app go to edit-settings, set the redirect URI to this link +

+ + + {% raw %}{{ locationcb }}{{ jukeboxDialog.data.sp_id }}{% endraw + %} + + Click to copy URL +
{% raw %}{{ locationcb }}{{ jukeboxDialog.data.sp_id }}{% endraw - %} Click to copy URL - -
- Settings can be found - here. + >Open the Spotify Application Settings +

+

+ After adding the redirect URI, click the "Authorise access" button + below. +

@@ -281,7 +301,7 @@ + + + diff --git a/lnbits/extensions/lnaddress/views_api.py b/lnbits/extensions/lnaddress/views_api.py index b1f1f004..8f403a38 100644 --- a/lnbits/extensions/lnaddress/views_api.py +++ b/lnbits/extensions/lnaddress/views_api.py @@ -6,7 +6,7 @@ from fastapi.params import Depends, Query from starlette.exceptions import HTTPException from lnbits.core.crud import get_user -from lnbits.core.services import check_invoice_status, create_invoice +from lnbits.core.services import check_transaction_status, create_invoice from lnbits.decorators import WalletTypeInfo, get_key_type from lnbits.extensions.lnaddress.models import CreateAddress, CreateDomain @@ -229,7 +229,7 @@ async def api_address_send_address(payment_hash): address = await get_address(payment_hash) domain = await get_domain(address.domain) try: - status = await check_invoice_status(domain.wallet, payment_hash) + status = await check_transaction_status(domain.wallet, payment_hash) is_paid = not status.pending except Exception as e: return {"paid": False, "error": str(e)} diff --git a/lnbits/extensions/lndhub/templates/lndhub/_instructions.html b/lnbits/extensions/lndhub/templates/lndhub/_instructions.html index 4db79aba..005bced5 100644 --- a/lnbits/extensions/lndhub/templates/lndhub/_instructions.html +++ b/lnbits/extensions/lndhub/templates/lndhub/_instructions.html @@ -31,5 +31,6 @@ + diff --git a/lnbits/extensions/lnticket/templates/lnticket/_api_docs.html b/lnbits/extensions/lnticket/templates/lnticket/_api_docs.html index 69328f38..e9340924 100644 --- a/lnbits/extensions/lnticket/templates/lnticket/_api_docs.html +++ b/lnbits/extensions/lnticket/templates/lnticket/_api_docs.html @@ -19,4 +19,5 @@

+
diff --git a/lnbits/extensions/lnticket/templates/lnticket/index.html b/lnbits/extensions/lnticket/templates/lnticket/index.html index 6dafb4c2..9329be7b 100644 --- a/lnbits/extensions/lnticket/templates/lnticket/index.html +++ b/lnbits/extensions/lnticket/templates/lnticket/index.html @@ -281,7 +281,13 @@ {% endraw %} - + @@ -371,6 +377,9 @@ } }, methods: { + resetForm() { + this.formDialog.data = {flatrate: false} + }, getTickets: function () { var self = this @@ -463,7 +472,7 @@ .then(function (response) { self.forms.push(mapLNTicket(response.data)) self.formDialog.show = false - self.formDialog.data = {} + self.resetForm() }) .catch(function (error) { LNbits.utils.notifyApiError(error) @@ -497,7 +506,7 @@ }) self.forms.push(mapLNTicket(response.data)) self.formDialog.show = false - self.formDialog.data = {} + self.resetForm() }) .catch(function (error) { LNbits.utils.notifyApiError(error) diff --git a/lnbits/extensions/lnurldevice/lnurl.py b/lnbits/extensions/lnurldevice/lnurl.py index 5e25dadb..df0cd4b8 100644 --- a/lnbits/extensions/lnurldevice/lnurl.py +++ b/lnbits/extensions/lnurldevice/lnurl.py @@ -205,9 +205,7 @@ async def lnurl_callback( wallet_id=device.wallet, amount=lnurldevicepayment.sats / 1000, memo=device.title, - description_hash=hashlib.sha256( - (await device.lnurlpay_metadata()).encode("utf-8") - ).digest(), + unhashed_description=(await device.lnurlpay_metadata()).encode("utf-8"), extra={"tag": "PoS"}, ) lnurldevicepayment = await update_lnurldevicepayment( diff --git a/lnbits/extensions/lnurldevice/templates/lnurldevice/_api_docs.html b/lnbits/extensions/lnurldevice/templates/lnurldevice/_api_docs.html index 940d4691..7f9afa27 100644 --- a/lnbits/extensions/lnurldevice/templates/lnurldevice/_api_docs.html +++ b/lnbits/extensions/lnurldevice/templates/lnurldevice/_api_docs.html @@ -17,6 +17,12 @@ label="API info" :content-inset-level="0.5" > + { LNbits.utils.notifyApiError(err) }) + }, + writeNfcTag: async function (lnurl) { + try { + if (typeof NDEFReader == 'undefined') { + throw { + toString: function () { + return 'NFC not supported on this device or browser.' + } + } + } + + const ndef = new NDEFReader() + + this.nfcTagWriting = true + this.$q.notify({ + message: 'Tap your NFC tag to write the LNURL-pay link to it.' + }) + + await ndef.write({ + records: [{recordType: 'url', data: 'lightning:' + lnurl, lang: 'en'}] + }) + + this.nfcTagWriting = false + this.$q.notify({ + type: 'positive', + message: 'NFC tag written successfully.' + }) + } catch (error) { + this.nfcTagWriting = false + this.$q.notify({ + type: 'negative', + message: error + ? error.toString() + : 'An unexpected error has occurred.' + }) + } } }, created() { diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html b/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html index 200865fb..abb37e90 100644 --- a/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html +++ b/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html @@ -4,6 +4,7 @@ label="API info" :content-inset-level="0.5" > + @@ -51,6 +52,7 @@ expand-separator label="Create a pay link" > + POST /lnurlp/api/v1/links diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/display.html b/lnbits/extensions/lnurlp/templates/lnurlp/display.html index 08e4de15..944e764b 100644 --- a/lnbits/extensions/lnurlp/templates/lnurlp/display.html +++ b/lnbits/extensions/lnurlp/templates/lnurlp/display.html @@ -14,10 +14,17 @@
-
+
Copy LNURL +
diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/index.html b/lnbits/extensions/lnurlp/templates/lnurlp/index.html index c535f2fb..9677a027 100644 --- a/lnbits/extensions/lnurlp/templates/lnurlp/index.html +++ b/lnbits/extensions/lnurlp/templates/lnurlp/index.html @@ -99,7 +99,8 @@ @click="openUpdateDialog(props.row.id)" icon="edit" color="light-blue" - > + > + + > +
+ > +
@@ -200,7 +203,8 @@ type="number" label="Comment maximum characters" hint="Tell wallets to prompt users for a comment that will be sent along with the payment. LNURLp will store the comment and send it in the webhook." - > + > + + > +
Shareable link + + + diff --git a/lnbits/extensions/offlineshop/__init__.py b/lnbits/extensions/offlineshop/__init__.py index a601c1b8..0b776a8c 100644 --- a/lnbits/extensions/offlineshop/__init__.py +++ b/lnbits/extensions/offlineshop/__init__.py @@ -9,7 +9,7 @@ db = Database("ext_offlineshop") offlineshop_static_files = [ { "path": "/offlineshop/static", - "app": StaticFiles(directory="lnbits/extensions/offlineshop/static"), + "app": StaticFiles(packages=[("lnbits", "extensions/offlineshop/static")]), "name": "offlineshop_static", } ] diff --git a/lnbits/extensions/offlineshop/crud.py b/lnbits/extensions/offlineshop/crud.py index bc4f3f14..896842d8 100644 --- a/lnbits/extensions/offlineshop/crud.py +++ b/lnbits/extensions/offlineshop/crud.py @@ -52,14 +52,20 @@ async def set_method(shop: int, method: str, wordlist: str = "") -> Optional[Sho async def add_item( - shop: int, name: str, description: str, image: Optional[str], price: int, unit: str + shop: int, + name: str, + description: str, + image: Optional[str], + price: int, + unit: str, + fiat_base_multiplier: int, ) -> int: result = await db.execute( """ - INSERT INTO offlineshop.items (shop, name, description, image, price, unit) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO offlineshop.items (shop, name, description, image, price, unit, fiat_base_multiplier) + VALUES (?, ?, ?, ?, ?, ?, ?) """, - (shop, name, description, image, price, unit), + (shop, name, description, image, price, unit, fiat_base_multiplier), ) return result._result_proxy.lastrowid @@ -72,6 +78,7 @@ async def update_item( image: Optional[str], price: int, unit: str, + fiat_base_multiplier: int, ) -> int: await db.execute( """ @@ -80,10 +87,11 @@ async def update_item( description = ?, image = ?, price = ?, - unit = ? + unit = ?, + fiat_base_multiplier = ? WHERE shop = ? AND id = ? """, - (name, description, image, price, unit, shop, item_id), + (name, description, image, price, unit, fiat_base_multiplier, shop, item_id), ) return item_id @@ -92,12 +100,12 @@ async def get_item(id: int) -> Optional[Item]: row = await db.fetchone( "SELECT * FROM offlineshop.items WHERE id = ? LIMIT 1", (id,) ) - return Item(**dict(row)) if row else None + return Item.from_row(row) if row else None async def get_items(shop: int) -> List[Item]: rows = await db.fetchall("SELECT * FROM offlineshop.items WHERE shop = ?", (shop,)) - return [Item(**dict(row)) for row in rows] + return [Item.from_row(row) for row in rows] async def delete_item_from_shop(shop: int, item_id: int): diff --git a/lnbits/extensions/offlineshop/lnurl.py b/lnbits/extensions/offlineshop/lnurl.py index 0bf779e4..f50df99a 100644 --- a/lnbits/extensions/offlineshop/lnurl.py +++ b/lnbits/extensions/offlineshop/lnurl.py @@ -73,9 +73,7 @@ async def lnurl_callback(request: Request, item_id: int): wallet_id=shop.wallet, amount=int(amount_received / 1000), memo=item.name, - description_hash=hashlib.sha256( - (await item.lnurlpay_metadata()).encode("utf-8") - ).digest(), + unhashed_description=(await item.lnurlpay_metadata()).encode("utf-8"), extra={"tag": "offlineshop", "item": item.id}, ) except Exception as exc: diff --git a/lnbits/extensions/offlineshop/migrations.py b/lnbits/extensions/offlineshop/migrations.py index f7c2dfec..84aea27e 100644 --- a/lnbits/extensions/offlineshop/migrations.py +++ b/lnbits/extensions/offlineshop/migrations.py @@ -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;" + ) diff --git a/lnbits/extensions/offlineshop/models.py b/lnbits/extensions/offlineshop/models.py index 0128fdb8..ca5c73a5 100644 --- a/lnbits/extensions/offlineshop/models.py +++ b/lnbits/extensions/offlineshop/models.py @@ -2,6 +2,7 @@ import base64 import hashlib import json from collections import OrderedDict +from sqlite3 import Row from typing import Dict, List, Optional from lnurl import encode as lnurl_encode # type: ignore @@ -87,8 +88,16 @@ class Item(BaseModel): description: str image: Optional[str] enabled: bool - price: int + price: float unit: str + fiat_base_multiplier: int + + @classmethod + def from_row(cls, row: Row) -> "Item": + data = dict(row) + if data["unit"] != "sat" and data["fiat_base_multiplier"]: + data["price"] /= data["fiat_base_multiplier"] + return cls(**data) def lnurl(self, req: Request) -> str: return lnurl_encode(req.url_for("offlineshop.lnurl_response", item_id=self.id)) diff --git a/lnbits/extensions/offlineshop/static/js/index.js b/lnbits/extensions/offlineshop/static/js/index.js index 00e93241..c0390609 100644 --- a/lnbits/extensions/offlineshop/static/js/index.js +++ b/lnbits/extensions/offlineshop/static/js/index.js @@ -124,7 +124,8 @@ new Vue({ description, image, price, - unit + unit, + fiat_base_multiplier: unit == 'sat' ? 1 : 100 } try { diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html index a472d549..0a4b9df8 100644 --- a/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html +++ b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html @@ -47,6 +47,7 @@ label="API info" :content-inset-level="0.5" > + + diff --git a/lnbits/extensions/paywall/views_api.py b/lnbits/extensions/paywall/views_api.py index 8052c63b..d2d49853 100644 --- a/lnbits/extensions/paywall/views_api.py +++ b/lnbits/extensions/paywall/views_api.py @@ -4,7 +4,7 @@ from fastapi import Depends, Query from starlette.exceptions import HTTPException from lnbits.core.crud import get_user, get_wallet -from lnbits.core.services import check_invoice_status, create_invoice +from lnbits.core.services import check_transaction_status, create_invoice from lnbits.decorators import WalletTypeInfo, get_key_type from . import paywall_ext @@ -87,7 +87,7 @@ async def api_paywal_check_invoice( status_code=HTTPStatus.NOT_FOUND, detail="Paywall does not exist." ) try: - status = await check_invoice_status(paywall.wallet, payment_hash) + status = await check_transaction_status(paywall.wallet, payment_hash) is_paid = not status.pending except Exception: return {"paid": False} diff --git a/lnbits/extensions/satsdice/lnurl.py b/lnbits/extensions/satsdice/lnurl.py index 03d20502..caafc3a4 100644 --- a/lnbits/extensions/satsdice/lnurl.py +++ b/lnbits/extensions/satsdice/lnurl.py @@ -77,9 +77,7 @@ async def api_lnurlp_callback( wallet_id=link.wallet, amount=int(amount_received / 1000), memo="Satsdice bet", - description_hash=hashlib.sha256( - link.lnurlpay_metadata.encode("utf-8") - ).digest(), + unhashed_description=link.lnurlpay_metadata.encode("utf-8"), extra={"tag": "satsdice", "link": link.id, "comment": "comment"}, ) diff --git a/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html b/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html index a80fd37a..e85e9586 100644 --- a/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html +++ b/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html @@ -4,6 +4,7 @@ label="API info" :content-inset-level="0.5" > + diff --git a/lnbits/extensions/satsdice/templates/satsdice/index.html b/lnbits/extensions/satsdice/templates/satsdice/index.html index eb52b6b2..004e9ccb 100644 --- a/lnbits/extensions/satsdice/templates/satsdice/index.html +++ b/lnbits/extensions/satsdice/templates/satsdice/index.html @@ -421,7 +421,13 @@ this.formDialog = { show: false, fixedAmount: true, - data: {} + data: { + haircut: 0, + min_bet: 10, + max_bet: 1000, + currency: 'satoshis', + comment_chars: 0 + } } }, updatePayLink(wallet, data) { diff --git a/lnbits/extensions/satspay/__init__.py b/lnbits/extensions/satspay/__init__.py index f33f3aa5..37245c21 100644 --- a/lnbits/extensions/satspay/__init__.py +++ b/lnbits/extensions/satspay/__init__.py @@ -1,6 +1,7 @@ import asyncio from fastapi import APIRouter +from fastapi.staticfiles import StaticFiles from lnbits.db import Database from lnbits.helpers import template_renderer @@ -11,6 +12,14 @@ db = Database("ext_satspay") satspay_ext: APIRouter = APIRouter(prefix="/satspay", tags=["satspay"]) +satspay_static_files = [ + { + "path": "/satspay/static", + "app": StaticFiles(directory="lnbits/extensions/satspay/static"), + "name": "satspay_static", + } +] + def satspay_renderer(): return template_renderer(["lnbits/extensions/satspay/templates"]) diff --git a/lnbits/extensions/satspay/crud.py b/lnbits/extensions/satspay/crud.py index 9deb3215..47d7a4a8 100644 --- a/lnbits/extensions/satspay/crud.py +++ b/lnbits/extensions/satspay/crud.py @@ -6,7 +6,7 @@ from lnbits.core.services import create_invoice from lnbits.core.views.api import api_payment from lnbits.helpers import urlsafe_short_hash -from ..watchonly.crud import get_fresh_address, get_mempool, get_watch_wallet +from ..watchonly.crud import get_config, get_fresh_address # from lnbits.db import open_ext_db from . import db @@ -18,7 +18,6 @@ from .models import Charges, CreateCharge async def create_charge(user: str, data: CreateCharge) -> Charges: charge_id = urlsafe_short_hash() if data.onchainwallet: - wallet = await get_watch_wallet(data.onchainwallet) onchain = await get_fresh_address(data.onchainwallet) onchainaddress = onchain.address else: @@ -89,7 +88,8 @@ async def get_charge(charge_id: str) -> Charges: async def get_charges(user: str) -> List[Charges]: rows = await db.fetchall( - """SELECT * FROM satspay.charges WHERE "user" = ?""", (user,) + """SELECT * FROM satspay.charges WHERE "user" = ? ORDER BY "timestamp" DESC """, + (user,), ) return [Charges.from_row(row) for row in rows] @@ -102,14 +102,16 @@ async def check_address_balance(charge_id: str) -> List[Charges]: charge = await get_charge(charge_id) if not charge.paid: if charge.onchainaddress: - mempool = await get_mempool(charge.user) + config = await get_config(charge.user) try: async with httpx.AsyncClient() as client: r = await client.get( - mempool.endpoint + "/api/address/" + charge.onchainaddress + config.mempool_endpoint + + "/api/address/" + + charge.onchainaddress ) respAmount = r.json()["chain_stats"]["funded_txo_sum"] - if respAmount >= charge.balance: + if respAmount > charge.balance: await update_charge(charge_id=charge_id, balance=respAmount) except Exception: pass diff --git a/lnbits/extensions/satspay/models.py b/lnbits/extensions/satspay/models.py index 7e8080dc..e8638d5e 100644 --- a/lnbits/extensions/satspay/models.py +++ b/lnbits/extensions/satspay/models.py @@ -1,4 +1,4 @@ -import time +from datetime import datetime, timedelta from sqlite3 import Row from typing import Optional @@ -38,12 +38,16 @@ class Charges(BaseModel): def from_row(cls, row: Row) -> "Charges": return cls(**dict(row)) + @property + def time_left(self): + now = datetime.utcnow().timestamp() + start = datetime.fromtimestamp(self.timestamp) + expiration = (start + timedelta(minutes=self.time)).timestamp() + return (expiration - now) / 60 + @property def time_elapsed(self): - if (self.timestamp + (self.time * 60)) >= time.time(): - return False - else: - return True + return self.time_left < 0 @property def paid(self): diff --git a/lnbits/extensions/satspay/static/js/utils.js b/lnbits/extensions/satspay/static/js/utils.js new file mode 100644 index 00000000..9b4abbfc --- /dev/null +++ b/lnbits/extensions/satspay/static/js/utils.js @@ -0,0 +1,31 @@ +const sleep = ms => new Promise(r => setTimeout(r, ms)) +const retryWithDelay = async function (fn, retryCount = 0) { + try { + await sleep(25) + // Do not return the call directly, use result. + // Otherwise the error will not be cought in this try-catch block. + const result = await fn() + return result + } catch (err) { + if (retryCount > 100) throw err + await sleep((retryCount + 1) * 1000) + return retryWithDelay(fn, retryCount + 1) + } +} + +const mapCharge = (obj, oldObj = {}) => { + const charge = _.clone(obj) + + charge.progress = obj.time_left < 0 ? 1 : 1 - obj.time_left / obj.time + charge.time = minutesToTime(obj.time) + charge.timeLeft = minutesToTime(obj.time_left) + + charge.expanded = false + charge.displayUrl = ['/satspay/', obj.id].join('') + charge.expanded = oldObj.expanded + charge.pendingBalance = oldObj.pendingBalance || 0 + return charge +} + +const minutesToTime = min => + min > 0 ? new Date(min * 1000).toISOString().substring(14, 19) : '' diff --git a/lnbits/extensions/satspay/templates/satspay/_api_docs.html b/lnbits/extensions/satspay/templates/satspay/_api_docs.html index 77451ae5..ed658735 100644 --- a/lnbits/extensions/satspay/templates/satspay/_api_docs.html +++ b/lnbits/extensions/satspay/templates/satspay/_api_docs.html @@ -8,171 +8,10 @@ Created by, Ben Arc

+
+
+ Swagger REST API Documentation
- - - - - POST /satspay/api/v1/charge -
Headers
- {"X-Api-Key": <admin_key>}
-
- Body (application/json) -
-
- Returns 200 OK (application/json) -
- [<charge_object>, ...] -
Curl example
- curl -X POST {{ request.base_url }}satspay/api/v1/charge -d - '{"onchainwallet": <string, watchonly_wallet_id>, - "description": <string>, "webhook":<string>, "time": - <integer>, "amount": <integer>, "lnbitswallet": - <string, lnbits_wallet_id>}' -H "Content-type: - application/json" -H "X-Api-Key: {{user.wallets[0].adminkey }}" - -
-
-
- - - - PUT - /satspay/api/v1/charge/<charge_id> -
Headers
- {"X-Api-Key": <admin_key>}
-
- Body (application/json) -
-
- Returns 200 OK (application/json) -
- [<charge_object>, ...] -
Curl example
- curl -X POST {{ request.base_url - }}satspay/api/v1/charge/<charge_id> -d '{"onchainwallet": - <string, watchonly_wallet_id>, "description": <string>, - "webhook":<string>, "time": <integer>, "amount": - <integer>, "lnbitswallet": <string, lnbits_wallet_id>}' - -H "Content-type: application/json" -H "X-Api-Key: - {{user.wallets[0].adminkey }}" - -
-
-
- - - - - GET - /satspay/api/v1/charge/<charge_id> -
Headers
- {"X-Api-Key": <invoice_key>}
-
- Body (application/json) -
-
- Returns 200 OK (application/json) -
- [<charge_object>, ...] -
Curl example
- curl -X GET {{ request.base_url - }}satspay/api/v1/charge/<charge_id> -H "X-Api-Key: {{ - user.wallets[0].inkey }}" - -
-
-
- - - - GET /satspay/api/v1/charges -
Headers
- {"X-Api-Key": <invoice_key>}
-
- Body (application/json) -
-
- Returns 200 OK (application/json) -
- [<charge_object>, ...] -
Curl example
- curl -X GET {{ request.base_url }}satspay/api/v1/charges -H - "X-Api-Key: {{ user.wallets[0].inkey }}" - -
-
-
- - - - DELETE - /satspay/api/v1/charge/<charge_id> -
Headers
- {"X-Api-Key": <admin_key>}
-
Returns 204 NO CONTENT
- -
Curl example
- curl -X DELETE {{ request.base_url - }}satspay/api/v1/charge/<charge_id> -H "X-Api-Key: {{ - user.wallets[0].adminkey }}" - -
-
-
- - - - GET - /satspay/api/v1/charges/balance/<charge_id> -
- Body (application/json) -
-
- Returns 200 OK (application/json) -
- [<charge_object>, ...] -
Curl example
- curl -X GET {{ request.base_url - }}satspay/api/v1/charges/balance/<charge_id> -H "X-Api-Key: {{ - user.wallets[0].inkey }}" - -
-
-
-
diff --git a/lnbits/extensions/satspay/templates/satspay/display.html b/lnbits/extensions/satspay/templates/satspay/display.html index 8c577fbe..f34ac509 100644 --- a/lnbits/extensions/satspay/templates/satspay/display.html +++ b/lnbits/extensions/satspay/templates/satspay/display.html @@ -1,223 +1,299 @@ {% extends "public.html" %} {% block page %} -
- -
-
-
{{ charge.description }}
-
-
-
-
Time elapsed
-
-
-
Charge paid
-
-
- - - - Awaiting payment... - - {% raw %} {{ newTimeLeft }} {% endraw %} - - - +
+
+
+ +
+
+
-
-
- Charge ID: {{ charge.id }} +
+
-
- {% raw %} Total to pay: {{ charge_amount }}sats
- Amount paid: {{ charge_balance }}

- Amount due: {{ charge_amount - charge_balance }}sats {% endraw %} -
-
- -
-
-
- - - bitcoin lightning payment method not available - - - - pay with lightning - + Time elapsed
-
- - - bitcoin onchain payment method not available - - - - pay onchain - -
-
- -
-
- - -
-
- -
-
- - +
+ Charge paid
-
- Pay this
- lightning-network invoice
+ + + Awaiting payment... + + {% raw %} {{ charge.timeLeft }} {% endraw %} + + + +
+
+
+
+
+
+
Charge Id:
+
+ +
+
+
+
Total to pay:
+
+ + sat + +
+
+
+
Amount paid:
+
+ + + sat - - - - - - -
- Copy invoice +
+
+
Amount pending:
+
+ + sat + +
+
+
+
Amount due:
+
+ + + sat + + + none
+
+ +
+
+
+
+ + + bitcoin lightning payment method not available + + + + pay with lightning + +
+
+ + + bitcoin onchain payment method not available + + + + pay onchain + +
+
+ +
+
+ + + +
+
+
+
+ +
+
+ + +
+
+
+
+ Pay this lightning-network invoice: +
+
+ + + + + + +
+
+ Copy invoice +
+
+
+
+
+
-
-
- -
-
- - -
-
-
- Send {{ charge.amount }}sats
- to this onchain address
-
- - - - +
+
+ -
+
+
+
+
+
+
+ +
+
+ Copy address + v-if="charge.webhook" + type="a" + :href="charge.completelink" + :label="charge.completelinktext" + > +
+
+
+
+ Send + + + sats to this onchain address +
+
+ + + + + + +
+
+ Copy address +
+
+
- +
+
{% endblock %} {% block scripts %} - + + diff --git a/lnbits/extensions/satspay/templates/satspay/index.html b/lnbits/extensions/satspay/templates/satspay/index.html index 5be38cf6..396200cf 100644 --- a/lnbits/extensions/satspay/templates/satspay/index.html +++ b/lnbits/extensions/satspay/templates/satspay/index.html @@ -18,46 +18,54 @@
Charges
-
+
- Export to CSV +
+
+ + + + + Export to CSV + + + +
@@ -66,73 +74,179 @@ + + + + + expired + + + + paid + + + waiting + + + + {{props.row.description}} - Payment link - - - +
{{props.row.timeLeft}}
+ - Time elapsed -
- - - PAID! - - - - Processing - - - Delete charge - +
- -
-
{{ col.value }}
+ +
{{props.row.time}}
+
+ +
{{props.row.amount}}
+
+ +
{{props.row.balance}}
+
+ +
+ {{props.row.pendingBalance ? props.row.pendingBalance : ''}} +
+
+ + {{props.row.onchainaddress}} + +
+ + +
+
Onchain Wallet:
+
+ {{getOnchainWalletName(props.row.onchainwallet)}} +
+
+
+
LNbits Wallet:
+
+ {{getLNbitsWalletName(props.row.lnbitswallet)}} +
+
+ + + +
+
ID:
+
{{props.row.id}}
+
+
+
+
+ Details + Refresh Balance +
+
+ Delete +
+
+
+
@@ -155,11 +269,7 @@
- + - Watch-Only extension MUST be activated and have a wallet + Onchain Wallet (watch-only) extension MUST be activated and + have a wallet
@@ -245,7 +356,7 @@ filled dense emit-value - v-model="formDialogCharge.data.onchainwallet" + v-model="onchainwallet" :options="walletLinks" label="Onchain Wallet" /> @@ -283,49 +394,28 @@ + + + diff --git a/lnbits/extensions/satspay/views.py b/lnbits/extensions/satspay/views.py index d33d5c17..69d81dad 100644 --- a/lnbits/extensions/satspay/views.py +++ b/lnbits/extensions/satspay/views.py @@ -9,6 +9,7 @@ from starlette.responses import HTMLResponse from lnbits.core.crud import get_wallet from lnbits.core.models import User from lnbits.decorators import check_user_exists +from lnbits.extensions.watchonly.crud import get_config from . import satspay_ext, satspay_renderer from .crud import get_charge @@ -24,14 +25,24 @@ async def index(request: Request, user: User = Depends(check_user_exists)): @satspay_ext.get("/{charge_id}", response_class=HTMLResponse) -async def display(request: Request, charge_id): +async def display(request: Request, charge_id: str): charge = await get_charge(charge_id) if not charge: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Charge link does not exist." ) wallet = await get_wallet(charge.lnbitswallet) + onchainwallet_config = await get_config(charge.user) + inkey = wallet.inkey if wallet else None + mempool_endpoint = ( + onchainwallet_config.mempool_endpoint if onchainwallet_config else None + ) return satspay_renderer().TemplateResponse( "satspay/display.html", - {"request": request, "charge": charge, "wallet_key": wallet.inkey}, + { + "request": request, + "charge_data": charge.dict(), + "wallet_inkey": inkey, + "mempool_endpoint": mempool_endpoint, + }, ) diff --git a/lnbits/extensions/satspay/views_api.py b/lnbits/extensions/satspay/views_api.py index c3e38f0c..f94b970a 100644 --- a/lnbits/extensions/satspay/views_api.py +++ b/lnbits/extensions/satspay/views_api.py @@ -1,7 +1,6 @@ from http import HTTPStatus import httpx -from fastapi import Query from fastapi.params import Depends from starlette.exceptions import HTTPException @@ -31,7 +30,12 @@ async def api_charge_create( data: CreateCharge, wallet: WalletTypeInfo = Depends(require_invoice_key) ): charge = await create_charge(user=wallet.wallet.user, data=data) - return charge.dict() + return { + **charge.dict(), + **{"time_elapsed": charge.time_elapsed}, + **{"time_left": charge.time_left}, + **{"paid": charge.paid}, + } @satspay_ext.put("/api/v1/charge/{charge_id}") @@ -51,6 +55,7 @@ async def api_charges_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)): { **charge.dict(), **{"time_elapsed": charge.time_elapsed}, + **{"time_left": charge.time_left}, **{"paid": charge.paid}, } for charge in await get_charges(wallet.wallet.user) @@ -73,6 +78,7 @@ async def api_charge_retrieve( return { **charge.dict(), **{"time_elapsed": charge.time_elapsed}, + **{"time_left": charge.time_left}, **{"paid": charge.paid}, } @@ -93,9 +99,18 @@ async def api_charge_delete(charge_id, wallet: WalletTypeInfo = Depends(get_key_ #############################BALANCE########################## -@satspay_ext.get("/api/v1/charges/balance/{charge_id}") -async def api_charges_balance(charge_id): +@satspay_ext.get("/api/v1/charges/balance/{charge_ids}") +async def api_charges_balance(charge_ids): + charge_id_list = charge_ids.split(",") + charges = [] + for charge_id in charge_id_list: + charge = await api_charge_balance(charge_id) + charges.append(charge) + return charges + +@satspay_ext.get("/api/v1/charge/balance/{charge_id}") +async def api_charge_balance(charge_id): charge = await check_address_balance(charge_id) if not charge: @@ -125,23 +140,9 @@ async def api_charges_balance(charge_id): ) except AssertionError: charge.webhook = None - return charge.dict() - - -#############################MEMPOOL########################## - - -@satspay_ext.put("/api/v1/mempool") -async def api_update_mempool( - endpoint: str = Query(...), wallet: WalletTypeInfo = Depends(get_key_type) -): - mempool = await update_mempool(endpoint, user=wallet.wallet.user) - return mempool.dict() - - -@satspay_ext.route("/api/v1/mempool") -async def api_get_mempool(wallet: WalletTypeInfo = Depends(get_key_type)): - mempool = await get_mempool(wallet.wallet.user) - if not mempool: - mempool = await create_mempool(user=wallet.wallet.user) - return mempool.dict() + return { + **charge.dict(), + **{"time_elapsed": charge.time_elapsed}, + **{"time_left": charge.time_left}, + **{"paid": charge.paid}, + } diff --git a/lnbits/extensions/scrub/README.md b/lnbits/extensions/scrub/README.md new file mode 100644 index 00000000..680c5e6d --- /dev/null +++ b/lnbits/extensions/scrub/README.md @@ -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)\ + ![create scrub](https://i.imgur.com/LUeNkzM.jpg) + + - 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\ + ![scrub](https://i.imgur.com/LNoFkeu.jpg) + + - only one scrub can be created for each wallet! + - You can _edit_ or _delete_ the Scrub at any time\ + ![edit scrub](https://i.imgur.com/Qu65lGG.jpg) + +3. On your wallet, you'll see a transaction of a payment received and another right after it as apayment sent, marked with **#scrubed**\ + ![wallet view](https://i.imgur.com/S6EWWCP.jpg) diff --git a/lnbits/extensions/scrub/__init__.py b/lnbits/extensions/scrub/__init__.py new file mode 100644 index 00000000..777a7c3f --- /dev/null +++ b/lnbits/extensions/scrub/__init__.py @@ -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)) diff --git a/lnbits/extensions/scrub/config.json b/lnbits/extensions/scrub/config.json new file mode 100644 index 00000000..df9e0038 --- /dev/null +++ b/lnbits/extensions/scrub/config.json @@ -0,0 +1,6 @@ +{ + "name": "Scrub", + "short_description": "Pass payments to LNURLp/LNaddress", + "icon": "send", + "contributors": ["arcbtc", "talvasconcelos"] +} diff --git a/lnbits/extensions/scrub/crud.py b/lnbits/extensions/scrub/crud.py new file mode 100644 index 00000000..1772a8c5 --- /dev/null +++ b/lnbits/extensions/scrub/crud.py @@ -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 diff --git a/lnbits/extensions/scrub/migrations.py b/lnbits/extensions/scrub/migrations.py new file mode 100644 index 00000000..f8f2ba43 --- /dev/null +++ b/lnbits/extensions/scrub/migrations.py @@ -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 + ); + """ + ) diff --git a/lnbits/extensions/scrub/models.py b/lnbits/extensions/scrub/models.py new file mode 100644 index 00000000..db05e4f1 --- /dev/null +++ b/lnbits/extensions/scrub/models.py @@ -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) diff --git a/lnbits/extensions/scrub/static/js/index.js b/lnbits/extensions/scrub/static/js/index.js new file mode 100644 index 00000000..43990792 --- /dev/null +++ b/lnbits/extensions/scrub/static/js/index.js @@ -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() + } + } +}) diff --git a/lnbits/extensions/scrub/tasks.py b/lnbits/extensions/scrub/tasks.py new file mode 100644 index 00000000..87e1364b --- /dev/null +++ b/lnbits/extensions/scrub/tasks.py @@ -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, + } diff --git a/lnbits/extensions/scrub/templates/scrub/_api_docs.html b/lnbits/extensions/scrub/templates/scrub/_api_docs.html new file mode 100644 index 00000000..ae3f44d8 --- /dev/null +++ b/lnbits/extensions/scrub/templates/scrub/_api_docs.html @@ -0,0 +1,136 @@ + + + + + GET /scrub/api/v1/links +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<pay_link_object>, ...] +
Curl example
+ curl -X GET {{ request.base_url }}scrub/api/v1/links?all_wallets=true + -H "X-Api-Key: {{ user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /scrub/api/v1/links/<scrub_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {"id": <string>, "wallet": <string>, "description": + <string>, "payoraddress": <string>} +
Curl example
+ curl -X GET {{ request.base_url }}scrub/api/v1/links/<pay_id> + -H "X-Api-Key: {{ user.wallets[0].inkey }}" + +
+
+
+ + + + POST /scrub/api/v1/links +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"wallet": <string>, "description": <string>, + "payoraddress": <string>} +
+ Returns 201 CREATED (application/json) +
+ {"id": <string>, "wallet": <string>, "description": + <string>, "payoraddress": <string>} +
Curl example
+ 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 }}" + +
+
+
+ + + + PUT + /scrub/api/v1/links/<pay_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"wallet": <string>, "description": <string>, + "payoraddress": <string>} +
+ Returns 200 OK (application/json) +
+ {"id": <string>, "wallet": <string>, "description": + <string>, "payoraddress": <string>} +
Curl example
+ 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 }}" + +
+
+
+ + + + DELETE + /scrub/api/v1/links/<pay_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.base_url + }}scrub/api/v1/links/<pay_id> -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" + +
+
+
+
diff --git a/lnbits/extensions/scrub/templates/scrub/_lnurl.html b/lnbits/extensions/scrub/templates/scrub/_lnurl.html new file mode 100644 index 00000000..da46d9c4 --- /dev/null +++ b/lnbits/extensions/scrub/templates/scrub/_lnurl.html @@ -0,0 +1,28 @@ + + + +

+ WARNING: LNURL must be used over https or TOR
+ 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. +

+

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

+ Check + Awesome LNURL + for further information. +
+
+
diff --git a/lnbits/extensions/scrub/templates/scrub/index.html b/lnbits/extensions/scrub/templates/scrub/index.html new file mode 100644 index 00000000..c063c858 --- /dev/null +++ b/lnbits/extensions/scrub/templates/scrub/index.html @@ -0,0 +1,140 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New scrub link + + + + + +
+
+
Scrub links
+
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
{{SITE_TITLE}} Scrub extension
+
+ + + + {% include "scrub/_api_docs.html" %} + + {% include "scrub/_lnurl.html" %} + + +
+
+ + + + + + + + + +
+ Update pay link + Create pay link + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/scrub/views.py b/lnbits/extensions/scrub/views.py new file mode 100644 index 00000000..73c7ffd9 --- /dev/null +++ b/lnbits/extensions/scrub/views.py @@ -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()} + ) diff --git a/lnbits/extensions/scrub/views_api.py b/lnbits/extensions/scrub/views_api.py new file mode 100644 index 00000000..3714a304 --- /dev/null +++ b/lnbits/extensions/scrub/views_api.py @@ -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) diff --git a/lnbits/extensions/splitpayments/__init__.py b/lnbits/extensions/splitpayments/__init__.py index df6feb94..9989728e 100644 --- a/lnbits/extensions/splitpayments/__init__.py +++ b/lnbits/extensions/splitpayments/__init__.py @@ -12,7 +12,7 @@ db = Database("ext_splitpayments") splitpayments_static_files = [ { "path": "/splitpayments/static", - "app": StaticFiles(directory="lnbits/extensions/splitpayments/static"), + "app": StaticFiles(packages=[("lnbits", "extensions/splitpayments/static")]), "name": "splitpayments_static", } ] diff --git a/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html b/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html index 4cf7190c..4b5ed979 100644 --- a/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html +++ b/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html @@ -28,6 +28,12 @@ label="API info" :content-inset-level="0.5" > +

+
diff --git a/lnbits/extensions/streamalerts/templates/streamalerts/index.html b/lnbits/extensions/streamalerts/templates/streamalerts/index.html index 46d1bb31..e86bc8b7 100644 --- a/lnbits/extensions/streamalerts/templates/streamalerts/index.html +++ b/lnbits/extensions/streamalerts/templates/streamalerts/index.html @@ -168,7 +168,8 @@
- Watch-Only extension MUST be activated and have a wallet + Onchain Wallet (watch-only) extension MUST be activated and + have a wallet
diff --git a/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html b/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html index b839c641..db3b2477 100644 --- a/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html +++ b/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html @@ -22,5 +22,6 @@ >

+ diff --git a/lnbits/extensions/subdomains/views_api.py b/lnbits/extensions/subdomains/views_api.py index ba87bc98..b01e6ffb 100644 --- a/lnbits/extensions/subdomains/views_api.py +++ b/lnbits/extensions/subdomains/views_api.py @@ -5,7 +5,7 @@ from fastapi.params import Depends from starlette.exceptions import HTTPException from lnbits.core.crud import get_user -from lnbits.core.services import check_invoice_status, create_invoice +from lnbits.core.services import check_transaction_status, create_invoice from lnbits.decorators import WalletTypeInfo, get_key_type from lnbits.extensions.subdomains.models import CreateDomain, CreateSubdomain @@ -161,7 +161,7 @@ async def api_subdomain_make_subdomain(domain_id, data: CreateSubdomain): async def api_subdomain_send_subdomain(payment_hash): subdomain = await get_subdomain(payment_hash) try: - status = await check_invoice_status(subdomain.wallet, payment_hash) + status = await check_transaction_status(subdomain.wallet, payment_hash) is_paid = not status.pending except Exception: return {"paid": False} diff --git a/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html b/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html index 95ba6e06..cfb8136b 100644 --- a/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html +++ b/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html @@ -12,4 +12,5 @@ >

+ diff --git a/lnbits/extensions/tipjar/views_api.py b/lnbits/extensions/tipjar/views_api.py index 5a55a3ca..50c5138b 100644 --- a/lnbits/extensions/tipjar/views_api.py +++ b/lnbits/extensions/tipjar/views_api.py @@ -57,7 +57,7 @@ async def api_create_tip(data: createTips): name = name.replace('"', "''") if not name: name = "Anonymous" - description = f'"{name}": {message}' + description = f"{name}: {message}" charge = await create_charge( user=charge_details["user"], data=CreateCharge( diff --git a/lnbits/extensions/tpos/crud.py b/lnbits/extensions/tpos/crud.py index 8f071d8c..94e2c006 100644 --- a/lnbits/extensions/tpos/crud.py +++ b/lnbits/extensions/tpos/crud.py @@ -30,7 +30,7 @@ async def create_tpos(wallet_id: str, data: CreateTposData) -> TPoS: async def get_tpos(tpos_id: str) -> Optional[TPoS]: row = await db.fetchone("SELECT * FROM tpos.tposs WHERE id = ?", (tpos_id,)) - return TPoS.from_row(row) if row else None + return TPoS(**row) if row else None async def get_tposs(wallet_ids: Union[str, List[str]]) -> List[TPoS]: @@ -42,7 +42,7 @@ async def get_tposs(wallet_ids: Union[str, List[str]]) -> List[TPoS]: f"SELECT * FROM tpos.tposs WHERE wallet IN ({q})", (*wallet_ids,) ) - return [TPoS.from_row(row) for row in rows] + return [TPoS(**row) for row in rows] async def delete_tpos(tpos_id: str) -> None: diff --git a/lnbits/extensions/tpos/models.py b/lnbits/extensions/tpos/models.py index 6a2ff1d2..f6522add 100644 --- a/lnbits/extensions/tpos/models.py +++ b/lnbits/extensions/tpos/models.py @@ -1,13 +1,15 @@ from sqlite3 import Row +from typing import Optional +from fastapi import Query from pydantic import BaseModel class CreateTposData(BaseModel): name: str currency: str - tip_options: str - tip_wallet: str + tip_options: str = Query(None) + tip_wallet: str = Query(None) class TPoS(BaseModel): @@ -15,9 +17,13 @@ class TPoS(BaseModel): wallet: str name: str currency: str - tip_options: str - tip_wallet: str + tip_options: Optional[str] + tip_wallet: Optional[str] @classmethod def from_row(cls, row: Row) -> "TPoS": return cls(**dict(row)) + + +class PayLnurlWData(BaseModel): + lnurl: str diff --git a/lnbits/extensions/tpos/tasks.py b/lnbits/extensions/tpos/tasks.py index 01c11428..af9663cc 100644 --- a/lnbits/extensions/tpos/tasks.py +++ b/lnbits/extensions/tpos/tasks.py @@ -26,7 +26,6 @@ async def on_invoice_paid(payment: Payment) -> None: # now we make some special internal transfers (from no one to the receiver) tpos = await get_tpos(payment.extra.get("tposId")) - tipAmount = payment.extra.get("tipAmount") if tipAmount is None: @@ -34,6 +33,7 @@ async def on_invoice_paid(payment: Payment) -> None: return tipAmount = tipAmount * 1000 + amount = payment.amount - tipAmount # mark the original payment with one extra key, "splitted" # (this prevents us from doing this process again and it's informative) @@ -41,13 +41,13 @@ async def on_invoice_paid(payment: Payment) -> None: await core_db.execute( """ UPDATE apipayments - SET extra = ?, amount = amount - ? + SET extra = ?, amount = ? WHERE hash = ? AND checking_id NOT LIKE 'internal_%' """, ( json.dumps(dict(**payment.extra, tipSplitted=True)), - tipAmount, + amount, payment.payment_hash, ), ) @@ -60,7 +60,7 @@ async def on_invoice_paid(payment: Payment) -> None: payment_request="", payment_hash=payment.payment_hash, amount=tipAmount, - memo=payment.memo, + memo=f"Tip for {payment.memo}", pending=False, extra={"tipSplitted": True}, ) diff --git a/lnbits/extensions/tpos/templates/tpos/_api_docs.html b/lnbits/extensions/tpos/templates/tpos/_api_docs.html index 8930d990..cbb21be1 100644 --- a/lnbits/extensions/tpos/templates/tpos/_api_docs.html +++ b/lnbits/extensions/tpos/templates/tpos/_api_docs.html @@ -4,6 +4,7 @@ label="API info" :content-inset-level="0.5" > + diff --git a/lnbits/extensions/tpos/templates/tpos/index.html b/lnbits/extensions/tpos/templates/tpos/index.html index 76f33000..edbb2aa8 100644 --- a/lnbits/extensions/tpos/templates/tpos/index.html +++ b/lnbits/extensions/tpos/templates/tpos/index.html @@ -54,8 +54,8 @@ > - {{ (col.name == 'tip_options' ? JSON.parse(col.value).join(", ") - : col.value) }} + {{ (col.name == 'tip_options' && col.value ? + JSON.parse(col.value).join(", ") : col.value) }}

{% raw %}{{ famount }}{% endraw %}

-
+
{% raw %}{{ fsat }}{% endraw %} sat
@@ -167,8 +167,20 @@

{% raw %}{{ famount }}{% endraw %}

- {% raw %}{{ fsat }}{% endraw %} sat + {% raw %}{{ fsat }} + sat + ( + {{ tipAmountSat }} tip) + {% endraw %}
+
Close @@ -272,10 +284,11 @@ return { tposId: '{{ tpos.id }}', currency: '{{ tpos.currency }}', - tip_options: JSON.parse('{{ tpos.tip_options }}'), + tip_options: null, exchangeRate: null, stack: [], tipAmount: 0.0, + nfcTagReading: false, invoiceDialog: { show: false, data: null, @@ -303,14 +316,15 @@ }, sat: function () { if (!this.exchangeRate) return 0 - return Math.ceil((this.amount / this.exchangeRate) * 100000000) + return Math.ceil( + ((this.amount - this.tipAmount) / this.exchangeRate) * 100000000 + ) }, tipAmountSat: function () { if (!this.exchangeRate) return 0 return Math.ceil((this.tipAmount / this.exchangeRate) * 100000000) }, fsat: function () { - console.log('sat', this.sat, LNbits.utils.formatSat(this.sat)) return LNbits.utils.formatSat(this.sat) } }, @@ -362,7 +376,6 @@ showInvoice: function () { var self = this var dialog = this.invoiceDialog - console.log(this.sat, this.tposId) axios .post('/tpos/api/v1/tposs/' + this.tposId + '/invoices', null, { params: { @@ -405,6 +418,98 @@ LNbits.utils.notifyApiError(error) }) }, + readNfcTag: function () { + try { + const self = this + + if (typeof NDEFReader == 'undefined') { + throw { + toString: function () { + return 'NFC not supported on this device or browser.' + } + } + } + + const ndef = new NDEFReader() + + const readerAbortController = new AbortController() + readerAbortController.signal.onabort = event => { + console.log('All NFC Read operations have been aborted.') + } + + this.nfcTagReading = true + this.$q.notify({ + message: 'Tap your NFC tag to pay this invoice with LNURLw.' + }) + + return ndef.scan({signal: readerAbortController.signal}).then(() => { + ndef.onreadingerror = () => { + self.nfcTagReading = false + + this.$q.notify({ + type: 'negative', + message: 'There was an error reading this NFC tag.' + }) + + readerAbortController.abort() + } + + ndef.onreading = ({message}) => { + //Decode NDEF data from tag + const textDecoder = new TextDecoder('utf-8') + + const record = message.records.find(el => { + const payload = textDecoder.decode(el.data) + return payload.toUpperCase().indexOf('LNURL') !== -1 + }) + + const lnurl = textDecoder.decode(record.data) + + //User feedback, show loader icon + self.nfcTagReading = false + self.payInvoice(lnurl, readerAbortController) + + this.$q.notify({ + type: 'positive', + message: 'NFC tag read successfully.' + }) + } + }) + } catch (error) { + this.nfcTagReading = false + this.$q.notify({ + type: 'negative', + message: error + ? error.toString() + : 'An unexpected error has occurred.' + }) + } + }, + payInvoice: function (lnurl, readerAbortController) { + const self = this + + return axios + .post( + '/tpos/api/v1/tposs/' + + self.tposId + + '/invoices/' + + self.invoiceDialog.data.payment_request + + '/pay', + { + lnurl: lnurl + } + ) + .then(response => { + if (!response.data.success) { + this.$q.notify({ + type: 'negative', + message: response.data.detail + }) + } + + readerAbortController.abort() + }) + }, getRates: function () { var self = this axios.get('https://api.opennode.co/v1/rates').then(function (response) { @@ -416,9 +521,13 @@ created: function () { var getRates = this.getRates getRates() + this.tip_options = + '{{ tpos.tip_options | tojson }}' == 'null' + ? null + : JSON.parse('{{ tpos.tip_options }}') setInterval(function () { getRates() - }, 20000) + }, 120000) } }) diff --git a/lnbits/extensions/tpos/views_api.py b/lnbits/extensions/tpos/views_api.py index 9567f98a..b7f14b98 100644 --- a/lnbits/extensions/tpos/views_api.py +++ b/lnbits/extensions/tpos/views_api.py @@ -1,7 +1,9 @@ from http import HTTPStatus +import httpx from fastapi import Query from fastapi.params import Depends +from lnurl import decode as decode_lnurl from loguru import logger from starlette.exceptions import HTTPException @@ -12,12 +14,12 @@ from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key from . import tpos_ext from .crud import create_tpos, delete_tpos, get_tpos, get_tposs -from .models import CreateTposData +from .models import CreateTposData, PayLnurlWData @tpos_ext.get("/api/v1/tposs", status_code=HTTPStatus.OK) async def api_tposs( - all_wallets: bool = Query(None), wallet: WalletTypeInfo = Depends(get_key_type) + all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type) ): wallet_ids = [wallet.wallet.id] if all_wallets: @@ -63,6 +65,9 @@ async def api_tpos_create_invoice( status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." ) + if tipAmount: + amount += tipAmount + try: payment_hash, payment_request = await create_invoice( wallet_id=tpos.wallet, @@ -76,6 +81,66 @@ async def api_tpos_create_invoice( return {"payment_hash": payment_hash, "payment_request": payment_request} +@tpos_ext.post( + "/api/v1/tposs/{tpos_id}/invoices/{payment_request}/pay", status_code=HTTPStatus.OK +) +async def api_tpos_pay_invoice( + lnurl_data: PayLnurlWData, payment_request: str = None, tpos_id: str = None +): + tpos = await get_tpos(tpos_id) + + if not tpos: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." + ) + + lnurl = ( + lnurl_data.lnurl.replace("lnurlw://", "") + .replace("lightning://", "") + .replace("LIGHTNING://", "") + .replace("lightning:", "") + .replace("LIGHTNING:", "") + ) + + if lnurl.lower().startswith("lnurl"): + lnurl = decode_lnurl(lnurl) + else: + lnurl = "https://" + lnurl + + async with httpx.AsyncClient() as client: + try: + r = await client.get(lnurl, follow_redirects=True) + if r.is_error: + lnurl_response = {"success": False, "detail": "Error loading"} + else: + resp = r.json() + if resp["tag"] != "withdrawRequest": + lnurl_response = {"success": False, "detail": "Wrong tag type"} + else: + r2 = await client.get( + resp["callback"], + follow_redirects=True, + params={ + "k1": resp["k1"], + "pr": payment_request, + }, + ) + resp2 = r2.json() + if r2.is_error: + lnurl_response = { + "success": False, + "detail": "Error loading callback", + } + elif resp2["status"] == "ERROR": + lnurl_response = {"success": False, "detail": resp2["reason"]} + else: + lnurl_response = {"success": True, "detail": resp2} + except (httpx.ConnectError, httpx.RequestError): + lnurl_response = {"success": False, "detail": "Unexpected error occurred"} + + return lnurl_response + + @tpos_ext.get( "/api/v1/tposs/{tpos_id}/invoices/{payment_hash}", status_code=HTTPStatus.OK ) diff --git a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html index 34b3c39b..886589e6 100644 --- a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html +++ b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html @@ -28,6 +28,7 @@ label="API info" :content-inset-level="0.5" > + diff --git a/lnbits/extensions/watchonly/README.md b/lnbits/extensions/watchonly/README.md index d93f7162..c07f75b1 100644 --- a/lnbits/extensions/watchonly/README.md +++ b/lnbits/extensions/watchonly/README.md @@ -1,19 +1,85 @@ -# Watch Only wallet +# Onchain Wallet (watch-only) ## Monitor an onchain wallet and generate addresses for onchain payments Monitor an extended public key and generate deterministic fresh public keys with this simple watch only wallet. Invoice payments can also be generated, both through a publically shareable page and API. -1. Start by clicking "NEW WALLET"\ - ![new wallet](https://i.imgur.com/vgbAB7c.png) -2. Fill the requested fields: - - give the wallet a name - - paste an Extended Public Key (xpub, ypub, zpub) - - click "CREATE WATCH-ONLY WALLET"\ - ![fill wallet form](https://i.imgur.com/UVoG7LD.png) -3. You can then access your onchain addresses\ - ![get address](https://i.imgur.com/zkxTQ6l.png) -4. You can then generate bitcoin onchain adresses from LNbits\ - ![onchain address](https://i.imgur.com/4KVSSJn.png) - 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` 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`) + - an account can be added `From Hardware Device` + +### Scan Blockchain + - when the user clicks `Scan Blockchain`, the wallet will loop over the all addresses (for each account) + - 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 + +### New Payment + - create a new `Partially Signed Bitcoin Transaction` + - multiple `Send Addresses` can be added + - the `Max` button next to an address is for sending the remaining funds to this address (no change) + - the user can select the inputs (UTXOs) manually, or it can use of the basic selection algorithms + - amounts have to be provided for the `Send Addresses` beforehand (so the algorithm knows the amount to be selected) + - `Show Change` allows to select from which account the change address will be selected (defaults to the first one) + - `Show Custom Fee` allows to manually select the fee + - it defaults to the `Medium` value at the moment the `New Payment` button was clicked + - it can be refreshed + - warnings are shown if the fee is too Low or to High + +### Check & Send + - creates the PSBT and sends it to the Hardware Wallet + - a confirmation will be shown for each Output and for the Fee + - after the user confirms the addresses and amounts, the transaction will be signed on the Hardware Device + +### Share PSBT + - Show the PSBT without sending it to the Hardware Wallet + +## Screensots +- screenshot 1: +![image](https://user-images.githubusercontent.com/2951406/177181611-eeeac70c-c245-4b45-b80b-8bbb511f6d1d.png) + +- screenshot 2: +![image](https://user-images.githubusercontent.com/2951406/183087898-b91f5243-8ed9-4a14-9e57-7bb4f1fd43ef.png) + +- screenshot 3: +![image](https://user-images.githubusercontent.com/2951406/177333755-4a9118fb-3eaf-43d6-bc7e-c3d8c80bc61e.png) + +- screenshot 4: +![image](https://user-images.githubusercontent.com/2951406/177337474-bfcf7a7c-501a-4ebb-916e-ca391e63f6a7.png) + + diff --git a/lnbits/extensions/watchonly/__init__.py b/lnbits/extensions/watchonly/__init__.py index 0c525980..a7fff888 100644 --- a/lnbits/extensions/watchonly/__init__.py +++ b/lnbits/extensions/watchonly/__init__.py @@ -1,10 +1,18 @@ from fastapi import APIRouter +from fastapi.staticfiles import StaticFiles from lnbits.db import Database from lnbits.helpers import template_renderer db = Database("ext_watchonly") +watchonly_static_files = [ + { + "path": "/watchonly/static", + "app": StaticFiles(directory="lnbits/extensions/watchonly/static"), + "name": "watchonly_static", + } +] watchonly_ext: APIRouter = APIRouter(prefix="/watchonly", tags=["watchonly"]) diff --git a/lnbits/extensions/watchonly/config.json b/lnbits/extensions/watchonly/config.json index 48c19ef0..6331418c 100644 --- a/lnbits/extensions/watchonly/config.json +++ b/lnbits/extensions/watchonly/config.json @@ -1,8 +1,9 @@ { - "name": "Watch Only", + "name": "Onchain Wallet", "short_description": "Onchain watch only wallets", "icon": "visibility", "contributors": [ - "arcbtc" + "arcbtc", + "motorina0" ] } diff --git a/lnbits/extensions/watchonly/crud.py b/lnbits/extensions/watchonly/crud.py index 0ce3ead9..21fea6f0 100644 --- a/lnbits/extensions/watchonly/crud.py +++ b/lnbits/extensions/watchonly/crud.py @@ -1,81 +1,16 @@ +import json from typing import List, Optional -from embit.descriptor import Descriptor, Key # type: ignore -from embit.descriptor.arguments import AllowedDerivation # type: ignore -from embit.networks import NETWORKS # type: ignore - from lnbits.helpers import urlsafe_short_hash from . import db -from .models import Addresses, Mempool, Wallets +from .helpers import derive_address +from .models import Address, Config, WalletAccount ##########################WALLETS#################### -def detect_network(k): - version = k.key.version - for network_name in NETWORKS: - net = NETWORKS[network_name] - # not found in this network - if version in [net["xpub"], net["ypub"], net["zpub"], net["Zpub"], net["Ypub"]]: - return net - - -def parse_key(masterpub: str): - """Parses masterpub or descriptor and returns a tuple: (Descriptor, network) - To create addresses use descriptor.derive(num).address(network=network) - """ - network = None - # probably a single key - if "(" not in masterpub: - k = Key.from_string(masterpub) - if not k.is_extended: - raise ValueError("The key is not a master public key") - if k.is_private: - raise ValueError("Private keys are not allowed") - # check depth - if k.key.depth != 3: - raise ValueError( - "Non-standard depth. Only bip44, bip49 and bip84 are supported with bare xpubs. For custom derivation paths use descriptors." - ) - # if allowed derivation is not provided use default /{0,1}/* - if k.allowed_derivation is None: - k.allowed_derivation = AllowedDerivation.default() - # get version bytes - version = k.key.version - for network_name in NETWORKS: - net = NETWORKS[network_name] - # not found in this network - if version in [net["xpub"], net["ypub"], net["zpub"]]: - network = net - if version == net["xpub"]: - desc = Descriptor.from_string("pkh(%s)" % str(k)) - elif version == net["ypub"]: - desc = Descriptor.from_string("sh(wpkh(%s))" % str(k)) - elif version == net["zpub"]: - desc = Descriptor.from_string("wpkh(%s)" % str(k)) - break - # we didn't find correct version - if network is None: - raise ValueError("Unknown master public key version") - else: - desc = Descriptor.from_string(masterpub) - if not desc.is_wildcard: - raise ValueError("Descriptor should have wildcards") - for k in desc.keys: - if k.is_extended: - net = detect_network(k) - if net is None: - raise ValueError(f"Unknown version: {k}") - if network is not None and network != net: - raise ValueError("Keys from different networks") - network = net - return desc, network - - -async def create_watch_wallet(user: str, masterpub: str, title: str) -> Wallets: - # check the masterpub is fine, it will raise an exception if not - parse_key(masterpub) +async def create_watch_wallet(w: WalletAccount) -> WalletAccount: wallet_id = urlsafe_short_hash() await db.execute( """ @@ -83,34 +18,47 @@ async def create_watch_wallet(user: str, masterpub: str, title: str) -> Wallets: id, "user", masterpub, + fingerprint, title, + type, address_no, - balance + balance, + network ) - 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, + w.network, + ), ) return await get_watch_wallet(wallet_id) -async def get_watch_wallet(wallet_id: str) -> Optional[Wallets]: +async def get_watch_wallet(wallet_id: str) -> Optional[WalletAccount]: row = await db.fetchone( "SELECT * FROM watchonly.wallets WHERE id = ?", (wallet_id,) ) - return Wallets.from_row(row) if row else None + return WalletAccount.from_row(row) if row else None -async def get_watch_wallets(user: str) -> List[Wallets]: +async def get_watch_wallets(user: str, network: str) -> List[WalletAccount]: rows = await db.fetchall( - """SELECT * FROM watchonly.wallets WHERE "user" = ?""", (user,) + """SELECT * FROM watchonly.wallets WHERE "user" = ? AND network = ?""", + (user, network), ) - return [Wallets(**row) for row in rows] + return [WalletAccount(**row) for row in rows] -async def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[Wallets]: +async def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[WalletAccount]: q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) await db.execute( @@ -119,94 +67,177 @@ async def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[Wallets]: row = await db.fetchone( "SELECT * FROM watchonly.wallets WHERE id = ?", (wallet_id,) ) - return Wallets.from_row(row) if row else None + return WalletAccount.from_row(row) if row else None async def delete_watch_wallet(wallet_id: str) -> None: await db.execute("DELETE FROM watchonly.wallets WHERE id = ?", (wallet_id,)) - ########################ADDRESSES####################### + +########################ADDRESSES####################### -async def get_derive_address(wallet_id: str, num: int): - wallet = await get_watch_wallet(wallet_id) - key = wallet.masterpub - desc, network = parse_key(key) - return desc.derive(num).address(network=network) - - -async def get_fresh_address(wallet_id: str) -> Optional[Addresses]: +async def get_fresh_address(wallet_id: str) -> Optional[Address]: + # todo: move logic to views_api after satspay refactoring wallet = await get_watch_wallet(wallet_id) if not wallet: return None - address = await get_derive_address(wallet_id, wallet.address_no + 1) + wallet_addresses = await get_addresses(wallet_id) + receive_addresses = list( + filter( + lambda addr: addr.branch_index == 0 and addr.has_activity, wallet_addresses + ) + ) + last_receive_index = ( + receive_addresses.pop().address_index if receive_addresses else -1 + ) + address_index = ( + last_receive_index + if last_receive_index > wallet.address_no + else wallet.address_no + ) - await update_watch_wallet(wallet_id=wallet_id, address_no=wallet.address_no + 1) - masterpub_id = urlsafe_short_hash() - await db.execute( - """ + address = await get_address_at_index(wallet_id, 0, address_index + 1) + + if not address: + addresses = await create_fresh_addresses( + wallet_id, address_index + 1, address_index + 2 + ) + address = addresses.pop() + + await update_watch_wallet(wallet_id, **{"address_no": address_index + 1}) + + return address + + +async def create_fresh_addresses( + wallet_id: str, + start_address_index: int, + end_address_index: int, + change_address=False, +) -> List[Address]: + if start_address_index > end_address_index: + return None + + wallet = await get_watch_wallet(wallet_id) + if not wallet: + return None + + branch_index = 1 if change_address else 0 + + for address_index in range(start_address_index, end_address_index): + address = await derive_address(wallet.masterpub, address_index, branch_index) + + await db.execute( + """ INSERT INTO watchonly.addresses ( id, address, wallet, - amount + amount, + branch_index, + address_index ) - VALUES (?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?) """, - (masterpub_id, address, wallet_id, 0), + (urlsafe_short_hash(), address, wallet_id, 0, branch_index, address_index), + ) + + # return fresh addresses + rows = await db.fetchall( + """ + SELECT * FROM watchonly.addresses + WHERE wallet = ? AND branch_index = ? AND address_index >= ? AND address_index < ? + ORDER BY branch_index, address_index + """, + (wallet_id, branch_index, start_address_index, end_address_index), ) - return await get_address(address) + return [Address(**row) for row in rows] -async def get_address(address: str) -> Optional[Addresses]: +async def get_address(address: str) -> Optional[Address]: row = await db.fetchone( "SELECT * FROM watchonly.addresses WHERE address = ?", (address,) ) - return Addresses.from_row(row) if row else None + return Address.from_row(row) if row else None -async def get_addresses(wallet_id: str) -> List[Addresses]: - rows = await db.fetchall( - "SELECT * FROM watchonly.addresses WHERE wallet = ?", (wallet_id,) - ) - return [Addresses(**row) for row in rows] - - -######################MEMPOOL####################### - - -async def create_mempool(user: str) -> Optional[Mempool]: - await db.execute( - """ - INSERT INTO watchonly.mempool ("user",endpoint) - VALUES (?, ?) - """, - (user, "https://mempool.space"), - ) +async def get_address_at_index( + wallet_id: str, branch_index: int, address_index: int +) -> Optional[Address]: row = await db.fetchone( - """SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,) + """ + SELECT * FROM watchonly.addresses + WHERE wallet = ? AND branch_index = ? AND address_index = ? + """, + ( + wallet_id, + branch_index, + address_index, + ), ) - return Mempool.from_row(row) if row else None + return Address.from_row(row) if row else None -async def update_mempool(user: str, **kwargs) -> Optional[Mempool]: +async def get_addresses(wallet_id: str) -> List[Address]: + rows = await db.fetchall( + """ + SELECT * FROM watchonly.addresses WHERE wallet = ? + ORDER BY branch_index, address_index + """, + (wallet_id,), + ) + + return [Address(**row) for row in rows] + + +async def update_address(id: str, **kwargs) -> Optional[Address]: q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) await db.execute( - f"""UPDATE watchonly.mempool SET {q} WHERE "user" = ?""", - (*kwargs.values(), user), + f"""UPDATE watchonly.addresses SET {q} WHERE id = ? """, + (*kwargs.values(), id), ) - row = await db.fetchone( - """SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,) - ) - return Mempool.from_row(row) if row else None + row = await db.fetchone("SELECT * FROM watchonly.addresses WHERE id = ?", (id)) + return Address.from_row(row) if row else None -async def get_mempool(user: str) -> Mempool: - row = await db.fetchone( - """SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,) +async def delete_addresses_for_wallet(wallet_id: str) -> None: + await db.execute("DELETE FROM watchonly.addresses WHERE wallet = ?", (wallet_id,)) + + +######################CONFIG####################### +async def create_config(user: str) -> Config: + config = Config() + await db.execute( + """ + INSERT INTO watchonly.config ("user", json_data) + VALUES (?, ?) + """, + (user, json.dumps(config.dict())), ) - return Mempool.from_row(row) if row else None + row = await db.fetchone( + """SELECT json_data FROM watchonly.config WHERE "user" = ?""", (user,) + ) + return json.loads(row[0], object_hook=lambda d: Config(**d)) + + +async def update_config(config: Config, user: str) -> Optional[Config]: + await db.execute( + f"""UPDATE watchonly.config SET json_data = ? WHERE "user" = ?""", + (json.dumps(config.dict()), user), + ) + row = await db.fetchone( + """SELECT json_data FROM watchonly.config WHERE "user" = ?""", (user,) + ) + return json.loads(row[0], object_hook=lambda d: Config(**d)) + + +async def get_config(user: str) -> Optional[Config]: + row = await db.fetchone( + """SELECT json_data FROM watchonly.config WHERE "user" = ?""", (user,) + ) + return json.loads(row[0], object_hook=lambda d: Config(**d)) if row else None diff --git a/lnbits/extensions/watchonly/helpers.py b/lnbits/extensions/watchonly/helpers.py new file mode 100644 index 00000000..74125dde --- /dev/null +++ b/lnbits/extensions/watchonly/helpers.py @@ -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) diff --git a/lnbits/extensions/watchonly/migrations.py b/lnbits/extensions/watchonly/migrations.py index 05c229b5..0c06b738 100644 --- a/lnbits/extensions/watchonly/migrations.py +++ b/lnbits/extensions/watchonly/migrations.py @@ -34,3 +34,62 @@ 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 + );""" + ) + + +async def m005_add_network_column_to_wallets(db): + """ + Add network' column to the 'wallets' table + """ + + await db.execute( + "ALTER TABLE watchonly.wallets ADD COLUMN network TEXT DEFAULT 'Mainnet';" + ) + + +async def m006_drop_mempool_table(db): + """ + Mempool data is now part of `config` + """ + await db.execute("DROP TABLE watchonly.mempool;") diff --git a/lnbits/extensions/watchonly/models.py b/lnbits/extensions/watchonly/models.py index d0894097..4bf0dfca 100644 --- a/lnbits/extensions/watchonly/models.py +++ b/lnbits/extensions/watchonly/models.py @@ -1,4 +1,5 @@ from sqlite3 import Row +from typing import List, Optional from fastapi.param_functions import Query from pydantic import BaseModel @@ -7,36 +8,90 @@ from pydantic import BaseModel class CreateWallet(BaseModel): masterpub: str = Query("") title: str = Query("") + network: str = "Mainnet" -class Wallets(BaseModel): +class WalletAccount(BaseModel): id: str user: str masterpub: str + fingerprint: str title: str address_no: int balance: int + type: str = "" + network: str = "Mainnet" @classmethod - def from_row(cls, row: Row) -> "Wallets": + def from_row(cls, row: Row) -> "WalletAccount": return cls(**dict(row)) -class Mempool(BaseModel): - user: str - endpoint: str - - @classmethod - def from_row(cls, row: Row) -> "Mempool": - return cls(**dict(row)) - - -class Addresses(BaseModel): +class Address(BaseModel): id: str address: str wallet: str - amount: int + amount: int = 0 + branch_index: int = 0 + address_index: int + note: str = None + has_activity: bool = False @classmethod - def from_row(cls, row: Row) -> "Addresses": + def from_row(cls, row: Row) -> "Address": return cls(**dict(row)) + + +class TransactionInput(BaseModel): + tx_id: str + vout: int + amount: int + address: str + branch_index: int + address_index: int + wallet: str + tx_hex: str + + +class TransactionOutput(BaseModel): + amount: int + address: str + branch_index: int = None + address_index: int = None + wallet: str = None + + +class MasterPublicKey(BaseModel): + id: str + public_key: str + fingerprint: str + + +class CreatePsbt(BaseModel): + masterpubs: List[MasterPublicKey] + inputs: List[TransactionInput] + outputs: List[TransactionOutput] + fee_rate: int + tx_size: int + + +class ExtractPsbt(BaseModel): + psbtBase64 = "" # // todo snake case + inputs: List[TransactionInput] + + +class SignedTransaction(BaseModel): + tx_hex: Optional[str] + tx_json: Optional[str] + + +class BroadcastTransaction(BaseModel): + tx_hex: str + + +class Config(BaseModel): + mempool_endpoint = "https://mempool.space" + receive_gap_limit = 20 + change_gap_limit = 5 + sats_denominated = True + network = "Mainnet" diff --git a/lnbits/extensions/watchonly/static/components/address-list/address-list.html b/lnbits/extensions/watchonly/static/components/address-list/address-list.html new file mode 100644 index 00000000..8db6e4d0 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/address-list/address-list.html @@ -0,0 +1,214 @@ +
+
+
+ +
+
+ +
+
+ + + +
+
+ + + +
diff --git a/lnbits/extensions/watchonly/static/components/address-list/address-list.js b/lnbits/extensions/watchonly/static/components/address-list/address-list.js new file mode 100644 index 00000000..61545df0 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/address-list/address-list.js @@ -0,0 +1,131 @@ +async function addressList(path) { + const template = await loadTemplateAsync(path) + Vue.component('address-list', { + name: 'address-list', + template, + + props: [ + 'addresses', + 'accounts', + 'mempool-endpoint', + 'inkey', + 'sats-denominated' + ], + data: function () { + return { + show: false, + history: [], + selectedWallet: null, + note: '', + filterOptions: [ + 'Show Change Addresses', + 'Show Gap Addresses', + 'Only With Amount' + ], + filterValues: [], + + addressesTable: { + columns: [ + { + name: 'expand', + align: 'left', + label: '' + }, + { + name: 'address', + align: 'left', + label: 'Address', + field: 'address', + sortable: true + }, + { + name: 'amount', + align: 'left', + label: 'Amount', + field: 'amount', + sortable: true + }, + { + name: 'note', + align: 'left', + label: 'Note', + field: 'note', + sortable: true + }, + { + name: 'wallet', + align: 'left', + label: 'Account', + field: 'wallet', + sortable: true + } + ], + pagination: { + rowsPerPage: 0, + sortBy: 'amount', + descending: true + }, + filter: '' + } + } + }, + + methods: { + satBtc(val, showUnit = true) { + return satOrBtc(val, showUnit, this.satsDenominated) + }, + // todo: bad. base.js not present in custom components + copyText: function (text, message, position) { + var notify = this.$q.notify + Quasar.utils.copyToClipboard(text).then(function () { + notify({ + message: message || 'Copied to clipboard!', + position: position || 'bottom' + }) + }) + }, + getWalletName: function (walletId) { + const wallet = (this.accounts || []).find(wl => wl.id === walletId) + return wallet ? wallet.title : 'unknown' + }, + getFilteredAddresses: function () { + const selectedWalletId = this.selectedWallet?.id + const filter = this.filterValues || [] + const includeChangeAddrs = filter.includes('Show Change Addresses') + const includeGapAddrs = filter.includes('Show Gap Addresses') + const excludeNoAmount = filter.includes('Only With Amount') + + const walletsLimit = (this.accounts || []).reduce((r, w) => { + r[`_${w.id}`] = w.address_no + return r + }, {}) + + const fAddresses = this.addresses.filter( + a => + (includeChangeAddrs || !a.isChange) && + (includeGapAddrs || + a.isChange || + a.addressIndex <= walletsLimit[`_${a.wallet}`]) && + !(excludeNoAmount && a.amount === 0) && + (!selectedWalletId || a.wallet === selectedWalletId) + ) + return fAddresses + }, + + scanAddress: async function (addressData) { + this.$emit('scan:address', addressData) + }, + showAddressDetails: function (addressData) { + this.$emit('show-address-details', addressData) + }, + searchInTab: function (tab, value) { + this.$emit('search:tab', {tab, value}) + }, + updateNoteForAddress: async function (addressData, note) { + this.$emit('update:note', {addressId: addressData.id, note}) + } + }, + + created: async function () {} + }) +} diff --git a/lnbits/extensions/watchonly/static/components/fee-rate/fee-rate.html b/lnbits/extensions/watchonly/static/components/fee-rate/fee-rate.html new file mode 100644 index 00000000..c65ad1c4 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/fee-rate/fee-rate.html @@ -0,0 +1,61 @@ +
+
+
Fee Rate:
+
+ +
+
+ +
+
+
+
+
+ + Warning! The fee is too low. The transaction might take a long time to + confirm. + + + Warning! The fee is too high. You might be overpaying for this + transaction. + +
+
+ +
+
Fee:
+
{{feeValue}} sats
+
+ Refresh Fee Rates +
+
+
diff --git a/lnbits/extensions/watchonly/static/components/fee-rate/fee-rate.js b/lnbits/extensions/watchonly/static/components/fee-rate/fee-rate.js new file mode 100644 index 00000000..7a920a9a --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/fee-rate/fee-rate.js @@ -0,0 +1,64 @@ +async function feeRate(path) { + const template = await loadTemplateAsync(path) + Vue.component('fee-rate', { + name: 'fee-rate', + template, + + props: ['rate', 'fee-value', 'sats-denominated', 'mempool-endpoint'], + + computed: { + feeRate: { + get: function () { + return this['rate'] + }, + set: function (value) { + this.$emit('update:rate', +value) + } + } + }, + + data: function () { + return { + recommededFees: { + fastestFee: 1, + halfHourFee: 1, + hourFee: 1, + economyFee: 1, + minimumFee: 1 + } + } + }, + + methods: { + satBtc(val, showUnit = true) { + return satOrBtc(val, showUnit, this.satsDenominated) + }, + + refreshRecommendedFees: async function () { + const fn = async () => { + const { + bitcoin: {fees: feesAPI} + } = mempoolJS({ + hostname: this.mempoolEndpoint + }) + return feesAPI.getFeesRecommended() + } + this.recommededFees = await retryWithDelay(fn) + }, + getFeeRateLabel: function (feeRate) { + const fees = this.recommededFees + if (feeRate >= fees.fastestFee) + return `High Priority (${feeRate} sat/vB)` + if (feeRate >= fees.halfHourFee) + return `Medium Priority (${feeRate} sat/vB)` + if (feeRate >= fees.hourFee) return `Low Priority (${feeRate} sat/vB)` + return `No Priority (${feeRate} sat/vB)` + } + }, + + created: async function () { + await this.refreshRecommendedFees() + this.feeRate = this.recommededFees.halfHourFee + } + }) +} diff --git a/lnbits/extensions/watchonly/static/components/history/history.html b/lnbits/extensions/watchonly/static/components/history/history.html new file mode 100644 index 00000000..ac805dfa --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/history/history.html @@ -0,0 +1,144 @@ +
+
+
+
+ + + +
+
+ + + + + Export to CSV + + + + +
+
+ + + +
diff --git a/lnbits/extensions/watchonly/static/components/history/history.js b/lnbits/extensions/watchonly/static/components/history/history.js new file mode 100644 index 00000000..574a1ef6 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/history/history.js @@ -0,0 +1,94 @@ +async function history(path) { + const template = await loadTemplateAsync(path) + Vue.component('history', { + name: 'history', + template, + + props: ['history', 'mempool-endpoint', 'sats-denominated', 'filter'], + data: function () { + return { + historyTable: { + columns: [ + { + name: 'expand', + align: 'left', + label: '' + }, + { + name: 'status', + align: 'left', + label: 'Status' + }, + { + name: 'amount', + align: 'left', + label: 'Amount', + field: 'amount', + sortable: true + }, + { + name: 'address', + align: 'left', + label: 'Address', + field: 'address', + sortable: true + }, + { + name: 'date', + align: 'left', + label: 'Date', + field: 'date', + sortable: true + } + ], + exportColums: [ + { + label: 'Action', + field: 'action' + }, + { + label: 'Date&Time', + field: 'date' + }, + { + label: 'Amount', + field: 'amount' + }, + { + label: 'Fee', + field: 'fee' + }, + { + label: 'Transaction Id', + field: 'txId' + } + ], + pagination: { + rowsPerPage: 0 + } + } + } + }, + + methods: { + satBtc(val, showUnit = true) { + return satOrBtc(val, showUnit, this.satsDenominated) + }, + getFilteredAddressesHistory: function () { + return this.history.filter(a => (!a.isChange || a.sent) && !a.isSubItem) + }, + exportHistoryToCSV: function () { + const history = this.getFilteredAddressesHistory().map(a => ({ + ...a, + action: a.sent ? 'Sent' : 'Received' + })) + LNbits.utils.exportCSV( + this.historyTable.exportColums, + history, + 'address-history' + ) + } + }, + created: async function () {} + }) +} diff --git a/lnbits/extensions/watchonly/static/components/my-checkbox/my-checkbox.html b/lnbits/extensions/watchonly/static/components/my-checkbox/my-checkbox.html new file mode 100644 index 00000000..83af1248 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/my-checkbox/my-checkbox.html @@ -0,0 +1,5 @@ +
+
+
{{ title }}
+ XXX +
diff --git a/lnbits/extensions/watchonly/static/components/my-checkbox/my-checkbox.js b/lnbits/extensions/watchonly/static/components/my-checkbox/my-checkbox.js new file mode 100644 index 00000000..3d22c3a0 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/my-checkbox/my-checkbox.js @@ -0,0 +1,16 @@ +async function initMyCheckbox(path) { + const t = await loadTemplateAsync(path) + Vue.component('my-checkbox', { + name: 'my-checkbox', + template: t, + data() { + return {checked: false, title: 'Check me'} + }, + methods: { + check() { + this.checked = !this.checked + console.log('### checked', this.checked) + } + } + }) +} diff --git a/lnbits/extensions/watchonly/static/components/payment/payment.html b/lnbits/extensions/watchonly/static/components/payment/payment.html new file mode 100644 index 00000000..cde65ca2 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/payment/payment.html @@ -0,0 +1,312 @@ +
+ + + + + + + + + +
+
+ +
+ +
+
+ Fee Rate: + + {{feeRate}} sats/vbyte + Fee: + {{satBtc(feeValue)}} +
+
+
+ +
+
+ + +
+
+
+
+ + + +
+
+ +
+ +
+
+ Balance: + {{satBtc(balance)}} + Selected: + + {{satBtc(selectedAmount)}} + +
+
+
+ +
+
+ + +
+
+
+
+ + + +
+
+ +
+ +
+ + Below dust limit. Will be used as fee. + +
+
+
+ Change: + + {{satBtc(0)}} + + + {{satBtc(changeAmount)}} + +
+
+
+ +
+
+ +
+
Change Account:
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+ + + + + Serial Port + + Sign using a Serial Port device + + + + + Share PSBT + Share the PSBT as text or Animated QR Code + + + + +
+ +
+ + + The payed amount is higher than the selected amount! + +
+
+
+ + + + +
+ Close +
+
+
+ + + +
+
+ Transaction Details +
+
+ +
+
+
+
Version
+
{{signedTx.version}}
+
+
+
Locktime
+
{{signedTx.locktime}}
+
+
+
Fee
+
+ {{satBtc(signedTx.fee)}} +
+
+ + Outputs + +
+
+ {{satBtc(out.amount)}} +
+ +
+ {{out.address}} +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+ Send + Close +
+
+
+
diff --git a/lnbits/extensions/watchonly/static/components/payment/payment.js b/lnbits/extensions/watchonly/static/components/payment/payment.js new file mode 100644 index 00000000..1459083c --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/payment/payment.js @@ -0,0 +1,336 @@ +async function payment(path) { + const t = await loadTemplateAsync(path) + Vue.component('payment', { + name: 'payment', + template: t, + + props: [ + 'accounts', + 'addresses', + 'utxos', + 'mempool-endpoint', + 'sats-denominated', + 'serial-signer-ref', + 'adminkey' + ], + watch: { + immediate: true, + accounts() { + this.updateChangeAddress() + }, + addresses() { + this.updateChangeAddress() + } + }, + + data: function () { + return { + DUST_LIMIT: 546, + tx: null, + psbtBase64: null, + psbtBase64Signed: null, + signedTx: null, + signedTxHex: null, + sentTxId: null, + signedTxId: null, + paymentTab: 'destination', + sendToList: [{address: '', amount: undefined}], + changeWallet: null, + changeAddress: {}, + showCustomFee: false, + showCoinSelect: false, + showChecking: false, + showChange: false, + showPsbt: false, + showFinalTx: false, + feeRate: 1 + } + }, + + computed: { + txSize: function () { + const tx = this.createTx() + return Math.round(txSize(tx)) + }, + txSizeNoChange: function () { + const tx = this.createTx(true) + return Math.round(txSize(tx)) + }, + feeValue: function () { + return this.feeRate * this.txSize + }, + selectedAmount: function () { + return this.utxos + .filter(utxo => utxo.selected) + .reduce((t, a) => t + (a.amount || 0), 0) + }, + changeAmount: function () { + return ( + this.selectedAmount - + this.totalPayedAmount - + this.feeRate * this.txSize + ) + }, + balance: function () { + return this.utxos.reduce((t, a) => t + (a.amount || 0), 0) + }, + totalPayedAmount: function () { + return this.sendToList.reduce((t, a) => t + (a.amount || 0), 0) + } + }, + + methods: { + satBtc(val, showUnit = true) { + return satOrBtc(val, showUnit, this.satsDenominated) + }, + checkAndSend: async function () { + this.showChecking = true + try { + if (!this.serialSignerRef.isConnected()) { + const portOpen = await this.serialSignerRef.openSerialPort() + if (!portOpen) return + } + if (!this.serialSignerRef.isAuthenticated()) { + await this.serialSignerRef.hwwShowPasswordDialog() + const authenticated = await this.serialSignerRef.isAuthenticating() + if (!authenticated) return + } + + await this.createPsbt() + + if (this.psbtBase64) { + const txData = { + outputs: this.tx.outputs, + feeRate: this.tx.fee_rate, + feeValue: this.feeValue + } + await this.serialSignerRef.hwwSendPsbt(this.psbtBase64, txData) + await this.serialSignerRef.isSendingPsbt() + } + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Cannot check and sign PSBT!', + caption: `${error}`, + timeout: 10000 + }) + } finally { + this.showChecking = false + this.psbtBase64 = null + } + }, + showPsbtDialog: async function () { + try { + const valid = await this.$refs.paymentFormRef.validate() + if (!valid) return + + const data = await this.createPsbt() + if (data) { + this.showPsbt = true + } + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to create PSBT!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + createPsbt: async function () { + try { + console.log('### this.createPsbt') + this.tx = this.createTx() + for (const input of this.tx.inputs) { + input.tx_hex = await this.fetchTxHex(input.tx_id) + } + + const changeOutput = this.tx.outputs.find(o => o.branch_index === 1) + if (changeOutput) changeOutput.amount = this.changeAmount + + const {data} = await LNbits.api.request( + 'POST', + '/watchonly/api/v1/psbt', + this.adminkey, + this.tx + ) + + this.psbtBase64 = data + return data + } catch (err) { + LNbits.utils.notifyApiError(err) + } + }, + createTx: function (excludeChange = false) { + const tx = { + fee_rate: this.feeRate, + masterpubs: this.accounts.map(w => ({ + id: w.id, + public_key: w.masterpub, + fingerprint: w.fingerprint + })) + } + tx.inputs = this.utxos + .filter(utxo => utxo.selected) + .map(mapUtxoToPsbtInput) + .sort((a, b) => + a.tx_id < b.tx_id ? -1 : a.tx_id > b.tx_id ? 1 : a.vout - b.vout + ) + + tx.outputs = this.sendToList.map(out => ({ + address: out.address, + amount: out.amount + })) + + if (!excludeChange) { + const change = this.createChangeOutput() + const diffAmount = this.selectedAmount - this.totalPayedAmount + if (diffAmount >= this.DUST_LIMIT) { + tx.outputs.push(change) + } + } + tx.tx_size = Math.round(txSize(tx)) + tx.inputs = _.shuffle(tx.inputs) + tx.outputs = _.shuffle(tx.outputs) + + return tx + }, + createChangeOutput: function () { + const change = this.changeAddress + const walletAcount = + this.accounts.find(w => w.id === change.wallet) || {} + + return { + address: change.address, + address_index: change.addressIndex, + branch_index: change.isChange ? 1 : 0, + wallet: walletAcount.id + } + }, + selectChangeAddress: function (account) { + if (!account) this.changeAddress = '' + this.changeAddress = + this.addresses.find( + a => a.wallet === account.id && a.isChange && !a.hasActivity + ) || {} + }, + updateChangeAddress: function () { + if (this.changeWallet) { + const changeAccount = (this.accounts || []).find( + w => w.id === this.changeWallet.id + ) + // change account deleted + if (!changeAccount) { + this.changeWallet = this.accounts[0] + } + } else { + this.changeWallet = this.accounts[0] + } + this.selectChangeAddress(this.changeWallet) + }, + updateSignedPsbt: async function (psbtBase64) { + try { + this.showChecking = true + this.psbtBase64Signed = psbtBase64 + + console.log('### payment updateSignedPsbt psbtBase64', psbtBase64) + + const data = await this.extractTxFromPsbt(psbtBase64) + this.showFinalTx = true + if (data) { + this.signedTx = JSON.parse(data.tx_json) + this.signedTxHex = data.tx_hex + } else { + this.signedTx = null + this.signedTxHex = null + } + } finally { + this.showChecking = false + } + }, + extractTxFromPsbt: async function (psbtBase64) { + console.log('### extractTxFromPsbt psbtBase64', psbtBase64) + try { + const {data} = await LNbits.api.request( + 'PUT', + '/watchonly/api/v1/psbt/extract', + this.adminkey, + { + psbtBase64, + inputs: this.tx.inputs + } + ) + console.log('### extractTxFromPsbt data', data) + return data + } catch (error) { + console.log('### error', error) + this.$q.notify({ + type: 'warning', + message: 'Cannot finalize PSBT!', + timeout: 10000 + }) + LNbits.utils.notifyApiError(error) + } + }, + broadcastTransaction: async function () { + try { + const {data} = await LNbits.api.request( + 'POST', + '/watchonly/api/v1/tx', + this.adminkey, + {tx_hex: this.signedTxHex} + ) + this.sentTxId = data + + this.$q.notify({ + type: 'positive', + message: 'Transaction broadcasted!', + caption: `${data}`, + timeout: 10000 + }) + + // todo: event rescan with amount + // todo: display tx id + } catch (error) { + this.sentTxId = null + this.$q.notify({ + type: 'warning', + message: 'Failed to broadcast!', + caption: `${error}`, + timeout: 10000 + }) + } finally { + this.showFinalTx = false + } + }, + fetchTxHex: async function (txId) { + const { + bitcoin: {transactions: transactionsAPI} + } = mempoolJS({ + hostname: this.mempoolEndpoint + }) + + try { + const response = await transactionsAPI.getTxHex({txid: txId}) + return response + } catch (error) { + this.$q.notify({ + type: 'warning', + message: `Failed to fetch transaction details for tx id: '${txId}'`, + timeout: 10000 + }) + LNbits.utils.notifyApiError(error) + throw error + } + }, + handleOutputsChange: function () { + this.$refs.utxoList.refreshUtxoSelection(this.totalPayedAmount) + }, + getTotalPaymentAmount: function () { + return this.sendToList.reduce((t, a) => t + (a.amount || 0), 0) + } + }, + + created: async function () {} + }) +} diff --git a/lnbits/extensions/watchonly/static/components/send-to/send-to.html b/lnbits/extensions/watchonly/static/components/send-to/send-to.html new file mode 100644 index 00000000..c16ebf95 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/send-to/send-to.html @@ -0,0 +1,77 @@ +
+
+ + + +
+
+ Add +
+
+
+ Payed Amount: + + {{satBtc(getTotalPaymentAmount())}} + +
+
+
+
+
diff --git a/lnbits/extensions/watchonly/static/components/send-to/send-to.js b/lnbits/extensions/watchonly/static/components/send-to/send-to.js new file mode 100644 index 00000000..2b93cea7 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/send-to/send-to.js @@ -0,0 +1,81 @@ +async function sendTo(path) { + const template = await loadTemplateAsync(path) + Vue.component('send-to', { + name: 'send-to', + template, + + props: [ + 'data', + 'tx-size', + 'selected-amount', + 'fee-rate', + 'sats-denominated' + ], + + computed: { + dataLocal: { + get: function () { + return this.data + }, + set: function (value) { + console.log('### computed update data', value) + this.$emit('update:data', value) + } + } + }, + + data: function () { + return { + DUST_LIMIT: 546, + paymentTable: { + columns: [ + { + name: 'data', + align: 'left' + } + ], + pagination: { + rowsPerPage: 10 + }, + filter: '' + } + } + }, + + methods: { + satBtc(val, showUnit = true) { + return satOrBtc(val, showUnit, this.satsDenominated) + }, + addPaymentAddress: function () { + this.dataLocal.push({address: '', amount: undefined}) + this.handleOutputsChange() + }, + deletePaymentAddress: function (v) { + const index = this.dataLocal.indexOf(v) + if (index !== -1) { + this.dataLocal.splice(index, 1) + } + this.handleOutputsChange() + }, + + sendMaxToAddress: function (paymentAddress = {}) { + const feeValue = this.feeRate * this.txSize + const inputAmount = this.selectedAmount + const currentAmount = Math.max(0, paymentAddress.amount || 0) + const payedAmount = this.getTotalPaymentAmount() - currentAmount + paymentAddress.amount = Math.max( + 0, + inputAmount - payedAmount - feeValue + ) + }, + handleOutputsChange: function () { + this.$emit('update:outputs') + }, + getTotalPaymentAmount: function () { + return this.dataLocal.reduce((t, a) => t + (a.amount || 0), 0) + } + }, + + created: async function () {} + }) +} diff --git a/lnbits/extensions/watchonly/static/components/serial-port-config/serial-port-config.html b/lnbits/extensions/watchonly/static/components/serial-port-config/serial-port-config.html new file mode 100644 index 00000000..392ace17 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/serial-port-config/serial-port-config.html @@ -0,0 +1,67 @@ +
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+
diff --git a/lnbits/extensions/watchonly/static/components/serial-port-config/serial-port-config.js b/lnbits/extensions/watchonly/static/components/serial-port-config/serial-port-config.js new file mode 100644 index 00000000..8c155435 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/serial-port-config/serial-port-config.js @@ -0,0 +1,24 @@ +async function serialPortConfig(path) { + const t = await loadTemplateAsync(path) + Vue.component('serial-port-config', { + name: 'serial-port-config', + template: t, + data() { + return { + config: { + baudRate: 9600, + bufferSize: 255, + dataBits: 8, + flowControl: 'none', + parity: 'none', + stopBits: 1 + } + } + }, + methods: { + getConfig: function () { + return this.config + } + } + }) +} diff --git a/lnbits/extensions/watchonly/static/components/serial-signer/serial-signer.html b/lnbits/extensions/watchonly/static/components/serial-signer/serial-signer.html new file mode 100644 index 00000000..eed1c560 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/serial-signer/serial-signer.html @@ -0,0 +1,451 @@ +
+ + + + + Login + Enter password for Hardware Wallet. + + + + + + Logout + Clear password for HWW. + + + + + Config & Connect + Set the Serial Port communication parameters. + + + + + Disconnect + Disconnect from Serial Port. + + + + + + Restore + Restore wallet from existing word list. + + + + + Show Seed + Show seed on the Hardware Wallet display. + + + + + Wipe + Clean-up the wallet. New random seed. + + + + + Help + View available comands. + + + + + Console + Show the serial port communication messages + + + + + + + + + Enter Config + + + +
+ Connect + Cancel +
+
+
+
+ + + + + Enter password for Hardware Wallet (8 numbers/letters) + + +
+ Login + Cancel +
+
+
+
+ + + + +
+
+
+ Output {{hww.confirm.outputIndex}} + + change + +
+
+
+
+ Address: +
+
+ {{tx.outputs[hww.confirm.outputIndex].address}} +
+
+
+
+ Amount: +
+
+ {{satBtc(tx.outputs[hww.confirm.outputIndex].amount)}} +
+
+
+
+ Fee: +
+
+ {{satBtc(tx.feeValue)}} +
+
+
+
+ Fee Rate: +
+
+ {{tx.feeRate}} sats/vbyte +
+
+
+
+
+ + Check data on the display of the hardware device. + +
+
+
+
+ + + +
+
+ + +
+
+ Cancel +
+
+
+
+
+ + + + + + This action will remove all data from the Hardware Wallet. Please + create a back-up for the seed! + + Enter new password for Hardware Wallet (8 numbers/letters) + + + + + This action cannot be reversed! + + +
+ Wipe + Cancel +
+
+
+
+ + + + + +
+ Close +
+
+
+ + + + Check word at position {{hww.seedWordPosition}} on display + +
+
+ Prev +
+
+ Next +
+
+ Close +
+
+
+
+ + + + + + For test purposes only. Do not enter word list with real funds!!! + +


+ Enter new word list separated by space + + + +
+ Enter new password (8 numbers/letters) + + + + + +

+ + For test purposes only. Do not enter word list with real funds!!! + + + + ALL existing data on the Hardware Device will be lost. + + +
+ Restore + Cancel +
+
+
+
+
diff --git a/lnbits/extensions/watchonly/static/components/serial-signer/serial-signer.js b/lnbits/extensions/watchonly/static/components/serial-signer/serial-signer.js new file mode 100644 index 00000000..2f864414 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/serial-signer/serial-signer.js @@ -0,0 +1,602 @@ +async function serialSigner(path) { + const t = await loadTemplateAsync(path) + Vue.component('serial-signer', { + name: 'serial-signer', + template: t, + + props: ['sats-denominated', 'network'], + data: function () { + return { + selectedPort: null, + writableStreamClosed: null, + writer: null, + readableStreamClosed: null, + reader: null, + receivedData: '', + config: {}, + + hww: { + password: null, + showPassword: false, + mnemonic: null, + showMnemonic: false, + authenticated: false, + showPasswordDialog: false, + showConfigDialog: false, + showWipeDialog: false, + showRestoreDialog: false, + showConfirmationDialog: false, + showSignedPsbt: false, + sendingPsbt: false, + signingPsbt: false, + loginResolve: null, + psbtSentResolve: null, + xpubResolve: null, + seedWordPosition: 1, + showSeedDialog: false, + confirm: { + outputIndex: 0, + showFee: false + } + }, + tx: null, // todo: move to hww + + showConsole: false + } + }, + + methods: { + satBtc(val, showUnit = true) { + return satOrBtc(val, showUnit, this.satsDenominated) + }, + openSerialPortDialog: async function () { + await this.openSerialPort() + }, + openSerialPort: async function (config = {baudRate: 9600}) { + if (!this.checkSerialPortSupported()) return false + if (this.selectedPort) { + this.$q.notify({ + type: 'warning', + message: 'Already connected. Disconnect first!', + timeout: 10000 + }) + return true + } + + try { + navigator.serial.addEventListener('connect', event => { + console.log('### navigator.serial event: connected!', event) + }) + + navigator.serial.addEventListener('disconnect', () => { + console.log('### navigator.serial event: disconnected!') + this.selectedPort = null + this.hww.authenticated = false + this.$q.notify({ + type: 'warning', + message: 'Disconnected from Serial Port!', + timeout: 10000 + }) + }) + this.selectedPort = await navigator.serial.requestPort() + // Wait for the serial port to open. + await this.selectedPort.open(config) + this.startSerialPortReading() + + const textEncoder = new TextEncoderStream() + this.writableStreamClosed = textEncoder.readable.pipeTo( + this.selectedPort.writable + ) + + this.writer = textEncoder.writable.getWriter() + return true + } catch (error) { + this.selectedPort = null + this.$q.notify({ + type: 'warning', + message: 'Cannot open serial port!', + caption: `${error}`, + timeout: 10000 + }) + return false + } + }, + openSerialPortConfig: async function () { + this.hww.showConfigDialog = true + }, + closeSerialPort: async function () { + try { + if (this.writer) this.writer.close() + if (this.writableStreamClosed) await this.writableStreamClosed + if (this.reader) this.reader.cancel() + if (this.readableStreamClosed) + await this.readableStreamClosed.catch(() => { + /* Ignore the error */ + }) + if (this.selectedPort) await this.selectedPort.close() + + this.$q.notify({ + type: 'positive', + message: 'Serial port disconnected!', + timeout: 5000 + }) + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Cannot close serial port!', + caption: `${error}`, + timeout: 10000 + }) + } finally { + this.selectedPort = null + this.hww.authenticated = false + } + }, + + isConnected: function () { + return !!this.selectedPort + }, + isAuthenticated: function () { + return this.hww.authenticated + }, + isAuthenticating: function () { + if (this.isAuthenticated()) return false + return new Promise(resolve => { + this.loginResolve = resolve + }) + }, + + isSendingPsbt: async function () { + if (!this.hww.sendingPsbt) return false + return new Promise(resolve => { + this.psbtSentResolve = resolve + }) + }, + + isFetchingXpub: async function () { + return new Promise(resolve => { + this.xpubResolve = resolve + }) + }, + + checkSerialPortSupported: function () { + if (!navigator.serial) { + this.$q.notify({ + type: 'warning', + message: 'Serial port communication not supported!', + caption: + 'Make sure your browser supports Serial Port and that you are using HTTPS.', + timeout: 10000 + }) + return false + } + return true + }, + startSerialPortReading: async function () { + const port = this.selectedPort + + while (port && port.readable) { + const textDecoder = new TextDecoderStream() + this.readableStreamClosed = port.readable.pipeTo(textDecoder.writable) + this.reader = textDecoder.readable.getReader() + const readStringUntil = readFromSerialPort(this.reader) + + try { + while (true) { + const {value, done} = await readStringUntil('\n') + if (value) { + this.handleSerialPortResponse(value) + this.updateSerialPortConsole(value) + } + if (done) return + } + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Serial port communication error!', + caption: `${error}`, + timeout: 10000 + }) + } + } + }, + handleSerialPortResponse: function (value) { + const command = value.split(' ')[0] + const commandData = value.substring(command.length).trim() + + switch (command) { + case COMMAND_SIGN_PSBT: + this.handleSignResponse(commandData) + break + case COMMAND_PASSWORD: + this.handleLoginResponse(commandData) + break + case COMMAND_PASSWORD_CLEAR: + this.handleLogoutResponse(commandData) + break + case COMMAND_SEND_PSBT: + this.handleSendPsbtResponse(commandData) + break + case COMMAND_WIPE: + this.handleWipeResponse(commandData) + break + case COMMAND_XPUB: + this.handleXpubResponse(commandData) + break + default: + console.log('### console', value) + } + }, + updateSerialPortConsole: function (value) { + this.receivedData += value + '\n' + const textArea = document.getElementById('serial-port-console') + if (textArea) textArea.scrollTop = textArea.scrollHeight + }, + hwwShowPasswordDialog: async function () { + try { + this.hww.showPasswordDialog = true + await this.writer.write(COMMAND_PASSWORD + '\n') + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to connect to Hardware Wallet!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + hwwShowWipeDialog: async function () { + try { + this.hww.showWipeDialog = true + await this.writer.write(COMMAND_WIPE + '\n') + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to connect to Hardware Wallet!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + hwwShowRestoreDialog: async function () { + try { + this.hww.showRestoreDialog = true + await this.writer.write(COMMAND_WIPE + '\n') + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to connect to Hardware Wallet!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + hwwConfirmNext: async function () { + this.hww.confirm.outputIndex += 1 + if (this.hww.confirm.outputIndex >= this.tx.outputs.length) { + this.hww.confirm.showFee = true + } + await this.writer.write(COMMAND_CONFIRM_NEXT + '\n') + }, + cancelOperation: async function () { + try { + await this.writer.write(COMMAND_CANCEL + '\n') + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to send cancel!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + hwwConfigAndConnect: async function () { + this.hww.showConfigDialog = false + const config = this.$refs.serialPortConfig.getConfig() + await this.openSerialPort(config) + return true + }, + hwwLogin: async function () { + try { + await this.writer.write( + COMMAND_PASSWORD + ' ' + this.hww.password + '\n' + ) + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to send password to Hardware Wallet!', + caption: `${error}`, + timeout: 10000 + }) + } finally { + this.hww.showPasswordDialog = false + this.hww.password = null + this.hww.showPassword = false + } + }, + handleLoginResponse: function (res = '') { + this.hww.authenticated = res.trim() === '1' + if (this.loginResolve) { + this.loginResolve(this.hww.authenticated) + } + + if (this.hww.authenticated) { + this.$q.notify({ + type: 'positive', + message: 'Login successfull!', + timeout: 10000 + }) + } else { + this.$q.notify({ + type: 'warning', + message: 'Wrong password, try again!', + timeout: 10000 + }) + } + }, + hwwLogout: async function () { + try { + await this.writer.write(COMMAND_PASSWORD_CLEAR + '\n') + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to logout from Hardware Wallet!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + handleLogoutResponse: function (res = '') { + this.hww.authenticated = !(res.trim() === '1') + if (this.hww.authenticated) { + this.$q.notify({ + type: 'warning', + message: 'Failed to logout from Hardware Wallet', + timeout: 10000 + }) + } + }, + hwwSendPsbt: async function (psbtBase64, tx) { + try { + this.tx = tx + this.hww.sendingPsbt = true + await this.writer.write( + COMMAND_SEND_PSBT + ' ' + this.network + ' ' + psbtBase64 + '\n' + ) + this.$q.notify({ + type: 'positive', + message: 'Data sent to serial port device!', + timeout: 5000 + }) + } catch (error) { + this.hww.sendingPsbt = false + this.$q.notify({ + type: 'warning', + message: 'Failed to send data to serial port!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + handleSendPsbtResponse: function (res = '') { + try { + const psbtOK = res.trim() === '1' + if (!psbtOK) { + this.$q.notify({ + type: 'warning', + message: 'Failed to send PSBT!', + caption: `${res}`, + timeout: 10000 + }) + return + } + this.hww.confirm.outputIndex = 0 + this.hww.showConfirmationDialog = true + this.hww.confirm = { + outputIndex: 0, + showFee: false + } + this.hww.sendingPsbt = false + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to send PSBT!', + caption: `${error}`, + timeout: 10000 + }) + } finally { + this.psbtSentResolve() + } + }, + hwwSignPsbt: async function () { + try { + this.hww.showConfirmationDialog = false + this.hww.signingPsbt = true + await this.writer.write(COMMAND_SIGN_PSBT + '\n') + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to sign PSBT!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + handleSignResponse: function (res = '') { + this.hww.signingPsbt = false + const [count, psbt] = res.trim().split(' ') + if (!psbt || !count || count.trim() === '0') { + this.$q.notify({ + type: 'warning', + message: 'No input signed!', + caption: 'Are you using the right seed?', + timeout: 10000 + }) + return + } + this.updateSignedPsbt(psbt) + this.$q.notify({ + type: 'positive', + message: 'Transaction Signed', + message: `Inputs signed: ${count}`, + timeout: 10000 + }) + }, + hwwHelp: async function () { + try { + await this.writer.write(COMMAND_HELP + '\n') + this.$q.notify({ + type: 'positive', + message: 'Check display or console for details!', + timeout: 5000 + }) + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to ask for help!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + hwwWipe: async function () { + try { + this.hww.showWipeDialog = false + await this.writer.write(COMMAND_WIPE + ' ' + this.hww.password + '\n') + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to ask for help!', + caption: `${error}`, + timeout: 10000 + }) + } finally { + this.hww.password = null + this.hww.confirmedPassword = null + this.hww.showPassword = false + } + }, + handleWipeResponse: function (res = '') { + const wiped = res.trim() === '1' + if (wiped) { + this.$q.notify({ + type: 'positive', + message: 'Wallet wiped!', + timeout: 10000 + }) + } else { + this.$q.notify({ + type: 'warning', + message: 'Failed to wipe wallet!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + hwwXpub: async function (path) { + try { + console.log( + '### hwwXpub', + COMMAND_XPUB + ' ' + this.network + ' ' + path + ) + await this.writer.write( + COMMAND_XPUB + ' ' + this.network + ' ' + path + '\n' + ) + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to fetch XPub!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + handleXpubResponse: function (res = '') { + const args = res.trim().split(' ') + if (args.length < 3 || args[0].trim() !== '1') { + this.$q.notify({ + type: 'warning', + message: 'Failed to fetch XPub!', + caption: `${res}`, + timeout: 10000 + }) + this.xpubResolve({}) + return + } + const xpub = args[1].trim() + const fingerprint = args[2].trim() + this.xpubResolve({xpub, fingerprint}) + }, + hwwShowSeed: async function () { + try { + this.hww.showSeedDialog = true + this.hww.seedWordPosition = 1 + await this.writer.write( + COMMAND_SEED + ' ' + this.hww.seedWordPosition + '\n' + ) + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to show seed!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + showNextSeedWord: async function () { + this.hww.seedWordPosition++ + await this.writer.write( + COMMAND_SEED + ' ' + this.hww.seedWordPosition + '\n' + ) + }, + showPrevSeedWord: async function () { + this.hww.seedWordPosition = Math.max(1, this.hww.seedWordPosition - 1) + console.log('### this.hww.seedWordPosition', this.hww.seedWordPosition) + await this.writer.write( + COMMAND_SEED + ' ' + this.hww.seedWordPosition + '\n' + ) + }, + handleShowSeedResponse: function (res = '') { + const args = res.trim().split(' ') + if (args.length < 2 || args[0].trim() !== '1') { + this.$q.notify({ + type: 'warning', + message: 'Failed to show seed!', + caption: `${res}`, + timeout: 10000 + }) + return + } + }, + hwwRestore: async function () { + try { + await this.writer.write( + COMMAND_RESTORE + ' ' + this.hww.mnemonic + '\n' + ) + await this.writer.write( + COMMAND_PASSWORD + ' ' + this.hww.password + '\n' + ) + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to restore from seed!', + caption: `${error}`, + timeout: 10000 + }) + } finally { + this.hww.showRestoreDialog = false + this.hww.mnemonic = null + this.hww.showMnemonic = false + this.hww.password = null + this.hww.confirmedPassword = null + this.hww.showPassword = false + } + }, + + updateSignedPsbt: async function (value) { + this.$emit('signed:psbt', value) + } + }, + created: async function () {} + }) +} diff --git a/lnbits/extensions/watchonly/static/components/utxo-list/utxo-list.html b/lnbits/extensions/watchonly/static/components/utxo-list/utxo-list.html new file mode 100644 index 00000000..a55b99e9 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/utxo-list/utxo-list.html @@ -0,0 +1,135 @@ + + +
+
+ +
+
+ +
+
+
+
+ + + +
+
+ + + + +
diff --git a/lnbits/extensions/watchonly/static/components/utxo-list/utxo-list.js b/lnbits/extensions/watchonly/static/components/utxo-list/utxo-list.js new file mode 100644 index 00000000..6741ed94 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/utxo-list/utxo-list.js @@ -0,0 +1,148 @@ +async function utxoList(path) { + const template = await loadTemplateAsync(path) + Vue.component('utxo-list', { + name: 'utxo-list', + template, + + props: [ + 'utxos', + 'accounts', + 'selectable', + 'payed-amount', + 'sats-denominated', + 'mempool-endpoint', + 'filter' + ], + + data: function () { + return { + utxosTable: { + columns: [ + { + name: 'expand', + align: 'left', + label: '' + }, + { + name: 'selected', + align: 'left', + label: '', + selectable: true + }, + { + name: 'status', + align: 'center', + label: 'Status', + sortable: true + }, + { + name: 'address', + align: 'left', + label: 'Address', + field: 'address', + sortable: true + }, + { + name: 'amount', + align: 'left', + label: 'Amount', + field: 'amount', + sortable: true + }, + { + name: 'date', + align: 'left', + label: 'Date', + field: 'date', + sortable: true + }, + { + name: 'wallet', + align: 'left', + label: 'Account', + field: 'wallet', + sortable: true + } + ], + pagination: { + rowsPerPage: 10 + } + }, + utxoSelectionModes: [ + 'Manual', + 'Random', + 'Select All', + 'Smaller Inputs First', + 'Larger Inputs First' + ], + utxoSelectionMode: 'Random', + utxoSelectAmount: 0 + } + }, + + computed: { + columns: function () { + return this.utxosTable.columns.filter(c => + c.selectable ? this.selectable : true + ) + } + }, + + methods: { + satBtc(val, showUnit = true) { + return satOrBtc(val, showUnit, this.satsDenominated) + }, + getWalletName: function (walletId) { + const wallet = (this.accounts || []).find(wl => wl.id === walletId) + return wallet ? wallet.title : 'unknown' + }, + getTotalSelectedUtxoAmount: function () { + const total = (this.utxos || []) + .filter(u => u.selected) + .reduce((t, a) => t + (a.amount || 0), 0) + return total + }, + refreshUtxoSelection: function (totalPayedAmount) { + this.utxoSelectAmount = totalPayedAmount + this.applyUtxoSelectionMode() + }, + updateUtxoSelection: function () { + this.utxoSelectAmount = this.payedAmount + this.applyUtxoSelectionMode() + }, + applyUtxoSelectionMode: function () { + const mode = this.utxoSelectionMode + const isSelectAll = mode === 'Select All' + if (isSelectAll) { + this.utxos.forEach(u => (u.selected = true)) + return + } + + const isManual = mode === 'Manual' + if (isManual || !this.utxoSelectAmount) return + + this.utxos.forEach(u => (u.selected = false)) + + const isSmallerFirst = mode === 'Smaller Inputs First' + const isLargerFirst = mode === 'Larger Inputs First' + let selectedUtxos = this.utxos.slice() + if (isSmallerFirst || isLargerFirst) { + const sortFn = isSmallerFirst + ? (a, b) => a.amount - b.amount + : (a, b) => b.amount - a.amount + selectedUtxos.sort(sortFn) + } else { + // default to random order + selectedUtxos = _.shuffle(selectedUtxos) + } + selectedUtxos.reduce((total, utxo) => { + utxo.selected = total < this.utxoSelectAmount + total += utxo.amount + return total + }, 0) + } + }, + + created: async function () {} + }) +} diff --git a/lnbits/extensions/watchonly/static/components/wallet-config/wallet-config.html b/lnbits/extensions/watchonly/static/components/wallet-config/wallet-config.html new file mode 100644 index 00000000..748d650d --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/wallet-config/wallet-config.html @@ -0,0 +1,80 @@ +
+ +
+
+ + +
+
+
+
{{satBtc(total)}}
+
+
+
+ +
+
+
+ + + + + + + + + + + + + + + +
+ Update + Cancel +
+
+
+
+
diff --git a/lnbits/extensions/watchonly/static/components/wallet-config/wallet-config.js b/lnbits/extensions/watchonly/static/components/wallet-config/wallet-config.js new file mode 100644 index 00000000..447dc65c --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/wallet-config/wallet-config.js @@ -0,0 +1,67 @@ +async function walletConfig(path) { + const t = await loadTemplateAsync(path) + Vue.component('wallet-config', { + name: 'wallet-config', + template: t, + + props: ['total', 'config-data', 'adminkey'], + data: function () { + return { + networOptions: ['Mainnet', 'Testnet'], + internalConfig: {}, + show: false + } + }, + + computed: { + config: { + get() { + return this.internalConfig + }, + set(value) { + value.isLoaded = true + this.internalConfig = JSON.parse(JSON.stringify(value)) + this.$emit( + 'update:config-data', + JSON.parse(JSON.stringify(this.internalConfig)) + ) + } + } + }, + + methods: { + satBtc(val, showUnit = true) { + return satOrBtc(val, showUnit, this.config.sats_denominated) + }, + updateConfig: async function () { + try { + const {data} = await LNbits.api.request( + 'PUT', + '/watchonly/api/v1/config', + this.adminkey, + this.config + ) + this.show = false + this.config = data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + getConfig: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/watchonly/api/v1/config', + this.adminkey + ) + this.config = data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + } + }, + created: async function () { + await this.getConfig() + } + }) +} diff --git a/lnbits/extensions/watchonly/static/components/wallet-list/wallet-list.html b/lnbits/extensions/watchonly/static/components/wallet-list/wallet-list.html new file mode 100644 index 00000000..72ff2156 --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/wallet-list/wallet-list.html @@ -0,0 +1,232 @@ +
+ + +
+
+ + + + + New Account + Enter account Xpub or Descriptor + + + + + From Hardware Device + + Get Xpub from a Hardware Device + + + + +
+
+
+ + + +
+
+ + + + +
+
+ + + + + + + + + + +
+ + + + Cancel +
+
+
+
+
diff --git a/lnbits/extensions/watchonly/static/components/wallet-list/wallet-list.js b/lnbits/extensions/watchonly/static/components/wallet-list/wallet-list.js new file mode 100644 index 00000000..adc82b3e --- /dev/null +++ b/lnbits/extensions/watchonly/static/components/wallet-list/wallet-list.js @@ -0,0 +1,290 @@ +async function walletList(path) { + const template = await loadTemplateAsync(path) + Vue.component('wallet-list', { + name: 'wallet-list', + template, + + props: [ + 'adminkey', + 'inkey', + 'sats-denominated', + 'addresses', + 'network', + 'serial-signer-ref' + ], + data: function () { + return { + walletAccounts: [], + address: {}, + formDialog: { + show: false, + + addressType: { + label: 'Segwit (P2WPKH)', + id: 'wpkh', + pathMainnet: "m/84'/0'/0'", + pathTestnet: "m/84'/1'/0'" + }, + useSerialPort: false, + data: { + title: '', + masterpub: '' + } + }, + accountPath: '', + filter: '', + showCreating: false, + addressTypeOptions: [ + { + label: 'Legacy (P2PKH)', + id: 'pkh', + pathMainnet: "m/44'/0'/0'", + pathTestnet: "m/44'/1'/0'" + }, + { + label: 'Segwit (P2WPKH)', + id: 'wpkh', + pathMainnet: "m/84'/0'/0'", + pathTestnet: "m/84'/1'/0'" + }, + { + label: 'Wrapped Segwit (P2SH-P2WPKH)', + id: 'sh', + pathMainnet: "m/49'/0'/0'", + pathTestnet: "m/49'/1'/0'" + }, + { + label: 'Taproot (P2TR)', + id: 'tr', + pathMainnet: "m/86'/0'/0'", + pathTestnet: "m/86'/1'/0'" + } + ], + + walletsTable: { + columns: [ + { + name: 'new', + align: 'left', + label: '' + }, + { + name: 'title', + align: 'left', + label: 'Title', + field: 'title' + }, + { + name: 'amount', + align: 'left', + label: 'Amount' + }, + { + name: 'type', + align: 'left', + label: 'Type', + field: 'type' + }, + {name: 'id', align: 'left', label: 'ID', field: 'id'} + ], + pagination: { + rowsPerPage: 10 + }, + filter: '' + } + } + }, + watch: { + immediate: true, + async network(newNet, oldNet) { + if (newNet !== oldNet) { + await this.refreshWalletAccounts() + } + } + }, + + methods: { + satBtc(val, showUnit = true) { + return satOrBtc(val, showUnit, this.satsDenominated) + }, + + addWalletAccount: async function () { + this.showCreating = true + const data = _.omit(this.formDialog.data, 'wallet') + data.network = this.network + await this.createWalletAccount(data) + this.showCreating = false + }, + createWalletAccount: async function (data) { + try { + if (this.formDialog.useSerialPort) { + const {xpub, fingerprint} = await this.fetchXpubFromHww() + if (!xpub) return + const path = this.accountPath.substring(2) + const outputType = this.formDialog.addressType.id + if (outputType === 'sh') { + data.masterpub = `${outputType}(wpkh([${fingerprint}/${path}]${xpub}/{0,1}/*))` + } else { + data.masterpub = `${outputType}([${fingerprint}/${path}]${xpub}/{0,1}/*)` + } + } + const response = await LNbits.api.request( + 'POST', + '/watchonly/api/v1/wallet', + this.adminkey, + data + ) + this.walletAccounts.push(mapWalletAccount(response.data)) + this.formDialog.show = false + + await this.refreshWalletAccounts() + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + fetchXpubFromHww: async function () { + const error = findAccountPathIssues(this.accountPath) + if (error) { + this.$q.notify({ + type: 'warning', + message: 'Invalid derivation path.', + caption: error, + timeout: 10000 + }) + return + } + await this.serialSignerRef.hwwXpub(this.accountPath) + return await this.serialSignerRef.isFetchingXpub() + }, + deleteWalletAccount: function (walletAccountId) { + LNbits.utils + .confirmDialog( + 'Are you sure you want to delete this watch only wallet?' + ) + .onOk(async () => { + try { + await LNbits.api.request( + 'DELETE', + '/watchonly/api/v1/wallet/' + walletAccountId, + this.adminkey + ) + this.walletAccounts = _.reject(this.walletAccounts, function ( + obj + ) { + return obj.id === walletAccountId + }) + await this.refreshWalletAccounts() + } catch (error) { + this.$q.notify({ + type: 'warning', + message: + 'Error while deleting wallet account. Please try again.', + timeout: 10000 + }) + } + }) + }, + + getWatchOnlyWallets: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + `/watchonly/api/v1/wallet?network=${this.network}`, + this.inkey + ) + return data + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Failed to fetch wallets.', + timeout: 10000 + }) + LNbits.utils.notifyApiError(error) + } + return [] + }, + refreshWalletAccounts: async function () { + this.walletAccounts = [] + const wallets = await this.getWatchOnlyWallets() + this.walletAccounts = wallets.map(w => mapWalletAccount(w)) + this.$emit('accounts-update', this.walletAccounts) + }, + getAmmountForWallet: function (walletId) { + const amount = this.addresses + .filter(a => a.wallet === walletId) + .reduce((t, a) => t + a.amount || 0, 0) + return this.satBtc(amount) + }, + closeFormDialog: function () { + this.formDialog.data = { + is_unique: false + } + }, + getAccountDescription: function (accountType) { + return getAccountDescription(accountType) + }, + openGetFreshAddressDialog: async function (walletId) { + const {data} = await LNbits.api.request( + 'GET', + `/watchonly/api/v1/address/${walletId}`, + this.inkey + ) + const addressData = mapAddressesData(data) + + addressData.note = `Shared on ${currentDateTime()}` + const lastAcctiveAddress = + this.addresses + .filter( + a => + a.wallet === addressData.wallet && !a.isChange && a.hasActivity + ) + .pop() || {} + addressData.gapLimitExceeded = + !addressData.isChange && + addressData.addressIndex > + lastAcctiveAddress.addressIndex + DEFAULT_RECEIVE_GAP_LIMIT + + const wallet = this.walletAccounts.find(w => w.id === walletId) || {} + wallet.address_no = addressData.addressIndex + this.$emit('new-receive-address', addressData) + }, + showAddAccountDialog: function () { + this.formDialog.show = true + this.formDialog.useSerialPort = false + }, + getXpubFromDevice: async function () { + try { + if (!this.serialSignerRef.isConnected()) { + const portOpen = await this.serialSignerRef.openSerialPort() + if (!portOpen) return + } + if (!this.serialSignerRef.isAuthenticated()) { + await this.serialSignerRef.hwwShowPasswordDialog() + const authenticated = await this.serialSignerRef.isAuthenticating() + if (!authenticated) return + } + this.formDialog.show = true + this.formDialog.useSerialPort = true + } catch (error) { + this.$q.notify({ + type: 'warning', + message: 'Cannot fetch Xpub!', + caption: `${error}`, + timeout: 10000 + }) + } + }, + handleAddressTypeChanged: function (value = {}) { + const addressType = + this.addressTypeOptions.find(t => t.id === value.id) || {} + this.accountPath = addressType[`path${this.network}`] + } + }, + created: async function () { + if (this.inkey) { + await this.refreshWalletAccounts() + this.handleAddressTypeChanged(this.addressTypeOptions[1]) + } + } + }) +} diff --git a/lnbits/extensions/watchonly/static/js/index.js b/lnbits/extensions/watchonly/static/js/index.js new file mode 100644 index 00000000..68204aca --- /dev/null +++ b/lnbits/extensions/watchonly/static/js/index.js @@ -0,0 +1,399 @@ +const watchOnly = async () => { + Vue.component(VueQrcode.name, VueQrcode) + + await walletConfig('static/components/wallet-config/wallet-config.html') + await walletList('static/components/wallet-list/wallet-list.html') + await addressList('static/components/address-list/address-list.html') + await history('static/components/history/history.html') + await utxoList('static/components/utxo-list/utxo-list.html') + await feeRate('static/components/fee-rate/fee-rate.html') + await sendTo('static/components/send-to/send-to.html') + await payment('static/components/payment/payment.html') + await serialSigner('static/components/serial-signer/serial-signer.html') + await serialPortConfig( + 'static/components/serial-port-config/serial-port-config.html' + ) + + Vue.filter('reverse', function (value) { + // slice to make a copy of array, then reverse the copy + return value.slice().reverse() + }) + + new Vue({ + el: '#vue', + mixins: [windowMixin], + data: function () { + return { + scan: { + scanning: false, + scanCount: 0, + scanIndex: 0 + }, + + currentAddress: null, + + tab: 'addresses', + + config: {sats_denominated: true}, + + qrCodeDialog: { + show: false, + data: null + }, + ...tables, + ...tableData, + + walletAccounts: [], + addresses: [], + history: [], + historyFilter: '', + + showAddress: false, + addressNote: '', + showPayment: false, + fetchedUtxos: false, + utxosFilter: '', + network: null + } + }, + computed: { + mempoolHostname: function () { + if (!this.config.isLoaded) return + let hostname = new URL(this.config.mempool_endpoint).hostname + if (this.config.network === 'Testnet') { + hostname += '/testnet' + } + return hostname + } + }, + + methods: { + 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 + } + } + + // todo: account deleted + 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 ({addressId, note}) { + try { + const wallet = this.g.user.wallets[0] + await LNbits.api.request( + 'PUT', + `/watchonly/api/v1/address/${addressId}`, + wallet.adminkey, + {note} + ) + const updatedAddress = + this.addresses.find(a => a.id === addressId) || {} + updatedAddress.note = note + } catch (err) { + LNbits.utils.notifyApiError(err) + } + }, + + //################### 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 + }, + + markSameTxAddressHistory: function () { + this.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 + }) + }, + + //################### PAYMENT ################### + + initPaymentData: async function () { + if (!this.payment.show) return + await this.refreshAddresses() + }, + + goToPaymentView: async function () { + this.showPayment = true + await this.initPaymentData() + }, + + //################### PSBT ################### + + updateSignedPsbt: async function (psbtBase64) { + this.$refs.paymentRef.updateSignedPsbt(psbtBase64) + }, + + //################### SERIAL PORT ################### + + //################### HARDWARE WALLET ################### + + //################### UTXOs ################### + scanAllAddresses: async function () { + await this.refreshAddresses() + this.history = [] + let addresses = this.addresses + this.utxos.data = [] + this.utxos.total = 0 + // Loop while new funds are found on the gap adresses. + // Use 1000 limit as a safety check (scan 20 000 addresses max) + for (let i = 0; i < 1000 && addresses.length; i++) { + await this.updateUtxosForAddresses(addresses) + const oldAddresses = this.addresses.slice() + await this.refreshAddresses() + const newAddresses = this.addresses.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.history = [] + const addresses = this.addresses.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 + }) + }, + refreshAddresses: async function () { + if (!this.walletAccounts) return + this.addresses = [] + for (const {id, type} of this.walletAccounts) { + const newAddresses = await this.getAddressesForWallet(id) + const uniqueAddresses = newAddresses.filter( + newAddr => !this.addresses.find(a => a.address === newAddr.address) + ) + + const lastAcctiveAddress = + uniqueAddresses.filter(a => !a.isChange && a.hasActivity).pop() || + {} + + uniqueAddresses.forEach(a => { + a.expanded = false + a.accountType = type + a.gapLimitExceeded = + !a.isChange && + a.addressIndex > + lastAcctiveAddress.addressIndex + DEFAULT_RECEIVE_GAP_LIMIT + }) + this.addresses.push(...uniqueAddresses) + } + this.$emit('update:addresses', this.addresses) + }, + getAddressesForWallet: async function (walletId) { + try { + const {data} = await LNbits.api.request( + 'GET', + '/watchonly/api/v1/addresses/' + walletId, + this.g.user.wallets[0].inkey + ) + return data.map(mapAddressesData) + } catch (error) { + this.$q.notify({ + type: 'warning', + message: `Failed to fetch addresses for wallet with id ${walletId}.`, + timeout: 10000 + }) + LNbits.utils.notifyApiError(error) + } + return [] + }, + updateUtxosForAddresses: async function (addresses = []) { + this.scan = {scanning: true, scanCount: addresses.length, scanIndex: 0} + + try { + for (addrData of addresses) { + const addressHistory = await this.getAddressTxsDelayed(addrData) + // remove old entries + this.history = this.history.filter( + h => h.address !== addrData.address + ) + + // add new entries + this.history.push(...addressHistory) + this.history.sort((a, b) => (!a.height ? -1 : b.height - a.height)) + this.markSameTxAddressHistory() + + if (addressHistory.length) { + // search only if it ever had any activity + const utxos = await this.getAddressTxsUtxoDelayed( + addrData.address + ) + 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) + }, + + //################### MEMPOOL API ################### + getAddressTxsDelayed: async function (addrData) { + const accounts = this.walletAccounts + const { + bitcoin: {addresses: addressesAPI} + } = mempoolJS({ + hostname: this.mempoolHostname + }) + const fn = async () => { + if (!accounts.find(w => w.id === addrData.wallet)) return [] + return addressesAPI.getAddressTxs({ + address: addrData.address + }) + } + const addressTxs = await retryWithDelay(fn) + return this.addressHistoryFromTxs(addrData, addressTxs) + }, + + getAddressTxsUtxoDelayed: async function (address) { + const endpoint = this.mempoolHostname + const { + bitcoin: {addresses: addressesAPI} + } = mempoolJS({ + hostname: endpoint + }) + + const fn = async () => { + if (endpoint !== this.mempoolHostname) return [] + return addressesAPI.getAddressTxsUtxo({ + address + }) + } + return retryWithDelay(fn) + }, + + //################### OTHER ################### + + openQrCodeDialog: function (addressData) { + this.currentAddress = addressData + this.addressNote = addressData.note || '' + this.showAddress = true + }, + searchInTab: function ({tab, value}) { + this.tab = tab + this[`${tab}Filter`] = value + }, + + updateAccounts: async function (accounts) { + this.walletAccounts = accounts + await this.refreshAddresses() + await this.scanAddressWithAmount() + }, + showAddressDetails: function (addressData) { + this.openQrCodeDialog(addressData) + }, + initUtxos: function (addresses) { + if (!this.fetchedUtxos && addresses.length) { + this.fetchedUtxos = true + this.addresses = addresses + this.scanAddressWithAmount() + } + } + }, + created: async function () { + if (this.g.user.wallets.length) { + await this.refreshAddresses() + await this.scanAddressWithAmount() + } + } + }) +} +watchOnly() diff --git a/lnbits/extensions/watchonly/static/js/map.js b/lnbits/extensions/watchonly/static/js/map.js new file mode 100644 index 00000000..ecc0b316 --- /dev/null +++ b/lnbits/extensions/watchonly/static/js/map.js @@ -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, + wallet: utxo.wallet, + 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 (o) { + return Object.assign({}, o, { + date: o.time + ? Quasar.utils.date.formatDate( + new Date(o.time * 1000), + 'YYYY-MM-DD HH:mm' + ) + : '', + label: o.title, + expanded: false + }) +} diff --git a/lnbits/extensions/watchonly/static/js/tables.js b/lnbits/extensions/watchonly/static/js/tables.js new file mode 100644 index 00000000..f437bcd5 --- /dev/null +++ b/lnbits/extensions/watchonly/static/js/tables.js @@ -0,0 +1,61 @@ +const tables = { + 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' + } + ] + } +} + +const tableData = { + utxos: { + data: [], + total: 0 + }, + payment: { + fee: 0, + txSize: 0, + tx: null, + psbtBase64: '', + psbtBase64Signed: '', + signedTx: null, + signedTxHex: null, + sentTxId: null, + + signModes: [ + { + label: 'Serial Port Device', + value: 'serial-port' + }, + { + label: 'Animated QR', + value: 'animated-qr', + disable: true + } + ], + signMode: '', + show: false, + showAdvanced: false + }, + summary: { + data: [{totalInputs: 0, totalOutputs: 0, fees: 0, change: 0}] + } +} diff --git a/lnbits/extensions/watchonly/static/js/utils.js b/lnbits/extensions/watchonly/static/js/utils.js new file mode 100644 index 00000000..6065d74c --- /dev/null +++ b/lnbits/extensions/watchonly/static/js/utils.js @@ -0,0 +1,183 @@ +const PSBT_BASE64_PREFIX = 'cHNidP8' +const COMMAND_PASSWORD = '/password' +const COMMAND_PASSWORD_CLEAR = '/password-clear' +const COMMAND_SEND_PSBT = '/psbt' +const COMMAND_SIGN_PSBT = '/sign' +const COMMAND_HELP = '/help' +const COMMAND_WIPE = '/wipe' +const COMMAND_SEED = '/seed' +const COMMAND_RESTORE = '/restore' +const COMMAND_CONFIRM_NEXT = '/confirm-next' +const COMMAND_CANCEL = '/cancel' +const COMMAND_XPUB = '/xpub' + +const DEFAULT_RECEIVE_GAP_LIMIT = 20 + +const blockTimeToDate = blockTime => + blockTime ? moment(blockTime * 1000).format('LLL') : '' + +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' + +const readFromSerialPort = reader => { + let partialChunk + let fulliness = [] + + const readStringUntil = async (separator = '\n') => { + if (fulliness.length) return fulliness.shift().trim() + const chunks = [] + if (partialChunk) { + // leftovers from previous read + chunks.push(partialChunk) + partialChunk = undefined + } + while (true) { + const {value, done} = await reader.read() + if (value) { + const values = value.split(separator) + // found one or more separators + if (values.length > 1) { + chunks.push(values.shift()) // first element + partialChunk = values.pop() // last element + fulliness = values // full lines + return {value: chunks.join('').trim(), done: false} + } + chunks.push(value) + } + if (done) return {value: chunks.join('').trim(), done: true} + } + } + return readStringUntil +} + +function satOrBtc(val, showUnit = true, showSats = false) { + const value = showSats + ? LNbits.utils.formatSat(val) + : val == 0 + ? 0.0 + : (val / 100000000).toFixed(8) + if (!showUnit) return value + return showSats ? value + ' sat' : value + ' BTC' +} + +function loadTemplateAsync(path) { + const result = new Promise(resolve => { + const xhttp = new XMLHttpRequest() + + xhttp.onreadystatechange = function () { + if (this.readyState == 4) { + if (this.status == 200) resolve(this.responseText) + + if (this.status == 404) resolve(`
Page not found: ${path}
`) + } + } + + xhttp.open('GET', path, true) + xhttp.send() + }) + + return result +} + +function findAccountPathIssues(path = '') { + const p = path.split('/') + if (p[0] !== 'm') return "Path must start with 'm/'" + for (let i = 1; i < p.length; i++) { + if (p[i].endsWith('')) p[i] = p[i].substring(0, p[i].length - 1) + if (isNaN(p[i])) return `${p[i]} is not a valid value` + } +} diff --git a/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html b/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html index b421186a..db0811f5 100644 --- a/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html +++ b/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html @@ -1,247 +1,27 @@

- Watch Only extension uses mempool.space
+ Onchain Wallet (watch-only) extension uses mempool.space
For use with "account Extended Public Key" https://iancoleman.io/bip39/
Created by, - Ben Arc (using, - Ben Arc + (using, + Embit
) +
+
+ Swagger REST API Documentation

- - - - - - GET /watchonly/api/v1/wallet -
Headers
- {"X-Api-Key": <invoice_key>}
-
- Body (application/json) -
-
- Returns 200 OK (application/json) -
- [<wallets_object>, ...] -
Curl example
- curl -X GET {{ request.base_url }}watchonly/api/v1/wallet -H - "X-Api-Key: {{ user.wallets[0].inkey }}" - -
-
-
- - - - GET - /watchonly/api/v1/wallet/<wallet_id> -
Headers
- {"X-Api-Key": <invoice_key>}
-
- Body (application/json) -
-
- Returns 201 CREATED (application/json) -
- [<wallet_object>, ...] -
Curl example
- curl -X GET {{ request.base_url - }}watchonly/api/v1/wallet/<wallet_id> -H "X-Api-Key: {{ - user.wallets[0].inkey }}" - -
-
-
- - - - POST /watchonly/api/v1/wallet -
Headers
- {"X-Api-Key": <admin_key>}
-
- Body (application/json) -
-
- Returns 201 CREATED (application/json) -
- [<wallet_object>, ...] -
Curl example
- 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 }}" - -
-
-
- - - - DELETE - /watchonly/api/v1/wallet/<wallet_id> -
Headers
- {"X-Api-Key": <admin_key>}
-
Returns 204 NO CONTENT
- -
Curl example
- curl -X DELETE {{ request.base_url - }}watchonly/api/v1/wallet/<wallet_id> -H "X-Api-Key: {{ - user.wallets[0].adminkey }}" - -
-
-
- - - - - GET - /watchonly/api/v1/addresses/<wallet_id> -
Headers
- {"X-Api-Key": <invoice_key>}
-
- Body (application/json) -
-
- Returns 200 OK (application/json) -
- [<address_object>, ...] -
Curl example
- curl -X GET {{ request.base_url - }}watchonly/api/v1/addresses/<wallet_id> -H "X-Api-Key: {{ - user.wallets[0].inkey }}" - -
-
-
- - - - - GET - /watchonly/api/v1/address/<wallet_id> -
Headers
- {"X-Api-Key": <invoice_key>}
-
- Body (application/json) -
-
- Returns 200 OK (application/json) -
- [<address_object>, ...] -
Curl example
- curl -X GET {{ request.base_url - }}watchonly/api/v1/address/<wallet_id> -H "X-Api-Key: {{ - user.wallets[0].inkey }}" - -
-
-
- - - - - GET /watchonly/api/v1/mempool -
Headers
- {"X-Api-Key": <admin_key>}
-
- Body (application/json) -
-
- Returns 200 OK (application/json) -
- [<mempool_object>, ...] -
Curl example
- curl -X GET {{ request.base_url }}watchonly/api/v1/mempool -H - "X-Api-Key: {{ user.wallets[0].adminkey }}" - -
-
-
- - - - - POST - /watchonly/api/v1/mempool -
Headers
- {"X-Api-Key": <admin_key>}
-
- Body (application/json) -
-
- Returns 201 CREATED (application/json) -
- [<mempool_object>, ...] -
Curl example
- 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 }}" - -
-
-
-
diff --git a/lnbits/extensions/watchonly/templates/watchonly/index.html b/lnbits/extensions/watchonly/templates/watchonly/index.html index e70f8a23..f6c853e3 100644 --- a/lnbits/extensions/watchonly/templates/watchonly/index.html +++ b/lnbits/extensions/watchonly/templates/watchonly/index.html @@ -2,176 +2,143 @@ %} {% block page %}
- - - {% raw %} - New wallet - - -
- Point to another Mempool - {{ this.mempool.endpoint }} - - -
- set - cancel -
-
-
-
-
-
+ + + + + + + {% raw %} - -
-
-
Wallets
-
-
- - - -
+
+
+ Scan Blockchain
- - - - - - +
+ +
+
+ New Payment + Back +
+
- -
-
{{satBtc(utxos.total)}}
- - {{utxos.sats ? ' sats' : ' BTC'}} +
+
- - -
-
-
Transactions
-
-
- + + + + + + + + + - - -
-
- - - - + + + + + + + + +
+
+ + +
{% endraw %} @@ -180,7 +147,7 @@
- {{SITE_TITLE}} Watch Only Extension + {{SITE_TITLE}} Onchain Wallet (watch-only) Extension
@@ -189,460 +156,97 @@
- - - - - - - - -
- Create Watch-only Wallet - Cancel -
-
-
-
- - - - {% raw %} -
Addresses
+ {% raw %} + + +
Address Details

-

- Current: - {{ currentaddress }} - -

+ -

- - - - {{ data.address }} - - - - -

+

+ + {{ currentAddress.address }} + + +

+

+ +

+
+ Gap limit of 20 addresses exceeded. Other wallets might not detect + funds at this address. +
Get fresh addressSave Note Close
+
+ {% endraw %}
+ {% endblock %} {% block scripts %} {{ window_vars(user) }} + - - + + + + + + + + + + + + + + + {% endblock %} diff --git a/lnbits/extensions/watchonly/views_api.py b/lnbits/extensions/watchonly/views_api.py index 0cca67bc..1a4b93ed 100644 --- a/lnbits/extensions/watchonly/views_api.py +++ b/lnbits/extensions/watchonly/views_api.py @@ -1,6 +1,12 @@ +import json from http import HTTPStatus -from fastapi import Query +import httpx +from embit import finalizer, script +from embit.ec import PublicKey +from embit.psbt import PSBT, DerivationPath +from embit.transaction import Transaction, TransactionInput, TransactionOutput +from fastapi import Query, Request from fastapi.params import Depends from starlette.exceptions import HTTPException @@ -8,28 +14,46 @@ from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key from lnbits.extensions.watchonly import watchonly_ext from .crud import ( - create_mempool, + create_config, + create_fresh_addresses, create_watch_wallet, + delete_addresses_for_wallet, delete_watch_wallet, get_addresses, + get_config, get_fresh_address, - get_mempool, get_watch_wallet, get_watch_wallets, - update_mempool, + update_address, + update_config, + update_watch_wallet, +) +from .helpers import parse_key +from .models import ( + BroadcastTransaction, + Config, + CreatePsbt, + CreateWallet, + ExtractPsbt, + SignedTransaction, + WalletAccount, ) -from .models import CreateWallet ###################WALLETS############################# @watchonly_ext.get("/api/v1/wallet") -async def api_wallets_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)): +async def api_wallets_retrieve( + network: str = Query("Mainnet"), wallet: WalletTypeInfo = Depends(get_key_type) +): try: - return [wallet.dict() for wallet in await get_watch_wallets(wallet.wallet.user)] + return [ + wallet.dict() + for wallet in await get_watch_wallets(wallet.wallet.user, network) + ] except: - return "" + return [] @watchonly_ext.get("/api/v1/wallet/{wallet_id}") @@ -48,18 +72,56 @@ async def api_wallet_retrieve( @watchonly_ext.post("/api/v1/wallet") async def api_wallet_create_or_update( - data: CreateWallet, wallet_id=None, w: WalletTypeInfo = Depends(require_admin_key) + data: CreateWallet, w: WalletTypeInfo = Depends(require_admin_key) ): try: - wallet = await create_watch_wallet( - user=w.wallet.user, masterpub=data.masterpub, title=data.title + (descriptor, network) = parse_key(data.masterpub) + if data.network != network["name"]: + raise ValueError( + "Account network error. This account is for '{}'".format( + network["name"] + ) + ) + + new_wallet = WalletAccount( + id="none", + 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, + network=network["name"], ) + + wallets = await get_watch_wallets(w.wallet.user, network["name"]) + existing_wallet = next( + ( + ew + for ew in wallets + if ew.fingerprint == new_wallet.fingerprint + and ew.network == new_wallet.network + and ew.masterpub == new_wallet.masterpub + ), + None, + ) + if existing_wallet: + raise ValueError( + "Account '{}' has the same master pulic key".format( + existing_wallet.title + ) + ) + + wallet = await create_watch_wallet(new_wallet) + + await api_get_addresses(wallet.id, w) except Exception as e: raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) - mempool = await get_mempool(w.wallet.user) - if not mempool: - create_mempool(user=w.wallet.user) + config = await get_config(w.wallet.user) + if not config: + await create_config(user=w.wallet.user) return wallet.dict() @@ -73,6 +135,7 @@ async def api_wallet_delete(wallet_id, w: WalletTypeInfo = Depends(require_admin ) await delete_watch_wallet(wallet_id) + await delete_addresses_for_wallet(wallet_id) raise HTTPException(status_code=HTTPStatus.NO_CONTENT) @@ -83,42 +146,225 @@ async def api_wallet_delete(wallet_id, w: WalletTypeInfo = Depends(require_admin @watchonly_ext.get("/api/v1/address/{wallet_id}") async def api_fresh_address(wallet_id, w: WalletTypeInfo = Depends(get_key_type)): address = await get_fresh_address(wallet_id) + return address.dict() - return [address.dict()] + +@watchonly_ext.put("/api/v1/address/{id}") +async def api_update_address( + id: str, req: Request, w: WalletTypeInfo = Depends(require_admin_key) +): + body = await req.json() + params = {} + # amout is only updated if the address has history + if "amount" in body: + params["amount"] = int(body["amount"]) + params["has_activity"] = True + + if "note" in body: + params["note"] = str(body["note"]) + + address = await update_address(**params, id=id) + + wallet = ( + await get_watch_wallet(address.wallet) + if address.branch_index == 0 and address.amount != 0 + else None + ) + + if wallet and wallet.address_no < address.address_index: + await update_watch_wallet( + address.wallet, **{"address_no": address.address_index} + ) + return address @watchonly_ext.get("/api/v1/addresses/{wallet_id}") async def api_get_addresses(wallet_id, w: WalletTypeInfo = Depends(get_key_type)): wallet = await get_watch_wallet(wallet_id) - if not wallet: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist." ) addresses = await get_addresses(wallet_id) + config = await get_config(w.wallet.user) if not addresses: - await get_fresh_address(wallet_id) + await create_fresh_addresses(wallet_id, 0, config.receive_gap_limit) + await create_fresh_addresses(wallet_id, 0, config.change_gap_limit, True) addresses = await get_addresses(wallet_id) + receive_addresses = list(filter(lambda addr: addr.branch_index == 0, addresses)) + change_addresses = list(filter(lambda addr: addr.branch_index == 1, addresses)) + + last_receive_address = list( + filter(lambda addr: addr.has_activity, receive_addresses) + )[-1:] + last_change_address = list( + filter(lambda addr: addr.has_activity, change_addresses) + )[-1:] + + if last_receive_address: + current_index = receive_addresses[-1].address_index + address_index = last_receive_address[0].address_index + await create_fresh_addresses( + wallet_id, current_index + 1, address_index + config.receive_gap_limit + 1 + ) + + if last_change_address: + current_index = change_addresses[-1].address_index + address_index = last_change_address[0].address_index + await create_fresh_addresses( + wallet_id, + current_index + 1, + address_index + config.change_gap_limit + 1, + True, + ) + + addresses = await get_addresses(wallet_id) return [address.dict() for address in addresses] -#############################MEMPOOL########################## +#############################PSBT########################## -@watchonly_ext.put("/api/v1/mempool") -async def api_update_mempool( - endpoint: str = Query(...), w: WalletTypeInfo = Depends(require_admin_key) +@watchonly_ext.post("/api/v1/psbt") +async def api_psbt_create( + data: CreatePsbt, w: WalletTypeInfo = Depends(require_admin_key) ): - mempool = await update_mempool(**{"endpoint": endpoint}, user=w.wallet.user) - return mempool.dict() + try: + vin = [ + TransactionInput(bytes.fromhex(inp.tx_id), inp.vout) for inp in data.inputs + ] + vout = [ + TransactionOutput(out.amount, script.address_to_scriptpubkey(out.address)) + for out in data.outputs + ] + + descriptors = {} + for _, masterpub in enumerate(data.masterpubs): + descriptors[masterpub.id] = parse_key(masterpub.public_key) + + inputs_extra = [] + + for i, inp in enumerate(data.inputs): + bip32_derivations = {} + descriptor = descriptors[inp.wallet][0] + d = descriptor.derive(inp.address_index, inp.branch_index) + for k in d.keys: + bip32_derivations[PublicKey.parse(k.sec())] = DerivationPath( + 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) + print("### ", inp.get("non_witness_utxo", None)) + + outputs_extra = [] + bip32_derivations = {} + for i, out in enumerate(data.outputs): + if out.branch_index == 1: + descriptor = descriptors[out.wallet][0] + d = descriptor.derive(out.address_index, out.branch_index) + for k in d.keys: + bip32_derivations[PublicKey.parse(k.sec())] = DerivationPath( + k.origin.fingerprint, k.origin.derivation + ) + outputs_extra.append({"bip32_derivations": bip32_derivations}) + + for i, out in enumerate(outputs_extra): + psbt.outputs[i].bip32_derivations = out["bip32_derivations"] + + return psbt.to_string() + + except Exception as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) -@watchonly_ext.get("/api/v1/mempool") -async def api_get_mempool(w: WalletTypeInfo = Depends(require_admin_key)): - mempool = await get_mempool(w.wallet.user) - if not mempool: - mempool = await create_mempool(user=w.wallet.user) - return mempool.dict() +@watchonly_ext.put("/api/v1/psbt/extract") +async def api_psbt_extract_tx( + data: ExtractPsbt, w: WalletTypeInfo = Depends(require_admin_key) +): + res = SignedTransaction() + try: + psbt = PSBT.from_base64(data.psbtBase64) + for i, inp in enumerate(data.inputs): + psbt.inputs[i].non_witness_utxo = Transaction.from_string(inp.tx_hex) + + final_psbt = finalizer.finalize_psbt(psbt) + if not final_psbt: + raise ValueError("PSBT cannot be finalized!") + res.tx_hex = final_psbt.to_string() + + transaction = Transaction.from_string(res.tx_hex) + tx = { + "locktime": transaction.locktime, + "version": transaction.version, + "outputs": [], + "fee": psbt.fee(), + } + + for out in transaction.vout: + tx["outputs"].append( + {"amount": out.value, "address": out.script_pubkey.address()} + ) + res.tx_json = json.dumps(tx) + except Exception as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + return res.dict() + + +@watchonly_ext.post("/api/v1/tx") +async def api_tx_broadcast( + data: BroadcastTransaction, w: WalletTypeInfo = Depends(require_admin_key) +): + try: + config = await get_config(w.wallet.user) + if not config: + raise ValueError( + "Cannot broadcast transaction. Mempool endpoint not defined!" + ) + + endpoint = ( + config.mempool_endpoint + if config.network == "Mainnet" + else config.mempool_endpoint + "/testnet" + ) + async with httpx.AsyncClient() as client: + r = await client.post(endpoint + "/api/tx", data=data.tx_hex) + tx_id = r.text + print("### broadcast tx_id: ", tx_id) + return tx_id + # return "0f0f0f0f0f0f0f0f0f0f0f00f0f0f0f0f0f0f0f0f0f00f0f0f0f0f0f0.mock.transaction.id" + except Exception as e: + print("### broadcast error: ", str(e)) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + + +#############################CONFIG########################## + + +@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() diff --git a/lnbits/extensions/withdraw/README.md b/lnbits/extensions/withdraw/README.md index 0e5939fd..7bf7c232 100644 --- a/lnbits/extensions/withdraw/README.md +++ b/lnbits/extensions/withdraw/README.md @@ -26,6 +26,8 @@ LNBits Quick Vouchers allows you to easily create a batch of LNURLw's QR codes t - on details you can print the vouchers\ ![printable vouchers](https://i.imgur.com/2xLHbob.jpg) - every printed LNURLw QR code is unique, it can only be used once +3. Bonus: you can use an LNbits themed voucher, or use a custom one. There's a _template.svg_ file in `static/images` folder if you want to create your own.\ + ![voucher](https://i.imgur.com/qyQoHi3.jpg) #### Advanced diff --git a/lnbits/extensions/withdraw/__init__.py b/lnbits/extensions/withdraw/__init__.py index 58ccfe7e..a0f4b606 100644 --- a/lnbits/extensions/withdraw/__init__.py +++ b/lnbits/extensions/withdraw/__init__.py @@ -9,7 +9,7 @@ db = Database("ext_withdraw") withdraw_static_files = [ { "path": "/withdraw/static", - "app": StaticFiles(directory="lnbits/extensions/withdraw/static"), + "app": StaticFiles(packages=[("lnbits", "extensions/withdraw/static")]), "name": "withdraw_static", } ] diff --git a/lnbits/extensions/withdraw/crud.py b/lnbits/extensions/withdraw/crud.py index ab35fafa..9868b057 100644 --- a/lnbits/extensions/withdraw/crud.py +++ b/lnbits/extensions/withdraw/crud.py @@ -26,9 +26,10 @@ async def create_withdraw_link( k1, open_time, usescsv, - webhook_url + webhook_url, + custom_url ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( link_id, @@ -44,6 +45,7 @@ async def create_withdraw_link( int(datetime.now().timestamp()) + data.wait_time, usescsv, data.webhook_url, + data.custom_url, ), ) link = await get_withdraw_link(link_id, 0) diff --git a/lnbits/extensions/withdraw/migrations.py b/lnbits/extensions/withdraw/migrations.py index 83f3fc24..5484277a 100644 --- a/lnbits/extensions/withdraw/migrations.py +++ b/lnbits/extensions/withdraw/migrations.py @@ -115,3 +115,10 @@ async def m004_webhook_url(db): Adds webhook_url """ await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN webhook_url TEXT;") + + +async def m005_add_custom_print_design(db): + """ + Adds custom print design + """ + await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN custom_url TEXT;") diff --git a/lnbits/extensions/withdraw/models.py b/lnbits/extensions/withdraw/models.py index c3ca7c45..2672537f 100644 --- a/lnbits/extensions/withdraw/models.py +++ b/lnbits/extensions/withdraw/models.py @@ -16,6 +16,7 @@ class CreateWithdrawData(BaseModel): wait_time: int = Query(..., ge=1) is_unique: bool webhook_url: str = Query(None) + custom_url: str = Query(None) class WithdrawLink(BaseModel): @@ -34,6 +35,7 @@ class WithdrawLink(BaseModel): usescsv: str = Query(None) number: int = Query(0) webhook_url: str = Query(None) + custom_url: str = Query(None) @property def is_spent(self) -> bool: diff --git a/lnbits/extensions/withdraw/static/js/index.js b/lnbits/extensions/withdraw/static/js/index.js index e54005c6..943e9024 100644 --- a/lnbits/extensions/withdraw/static/js/index.js +++ b/lnbits/extensions/withdraw/static/js/index.js @@ -20,9 +20,12 @@ var mapWithdrawLink = function (obj) { obj.uses_left = obj.uses - obj.used obj.print_url = [locationPath, 'print/', obj.id].join('') obj.withdraw_url = [locationPath, obj.id].join('') + obj._data.use_custom = Boolean(obj.custom_url) return obj } +const CUSTOM_URL = '/static/images/default_voucher.png' + new Vue({ el: '#vue', mixins: [windowMixin], @@ -53,18 +56,21 @@ new Vue({ rowsPerPage: 10 } }, + nfcTagWriting: false, formDialog: { show: false, secondMultiplier: 'seconds', secondMultiplierOptions: ['seconds', 'minutes', 'hours'], data: { - is_unique: false + is_unique: false, + use_custom: false } }, simpleformDialog: { show: false, data: { is_unique: true, + use_custom: false, title: 'Vouchers', min_withdrawable: 0, wait_time: 1 @@ -105,19 +111,20 @@ new Vue({ }, closeFormDialog: function () { this.formDialog.data = { - is_unique: false + is_unique: false, + use_custom: false } }, simplecloseFormDialog: function () { this.simpleformDialog.data = { - is_unique: false + is_unique: false, + use_custom: false } }, openQrCodeDialog: function (linkId) { var link = _.findWhere(this.withdrawLinks, {id: linkId}) this.qrCodeDialog.data = _.clone(link) - console.log(this.qrCodeDialog.data) this.qrCodeDialog.data.url = window.location.protocol + '//' + window.location.host this.qrCodeDialog.show = true @@ -133,6 +140,14 @@ new Vue({ }) var data = _.omit(this.formDialog.data, 'wallet') + if (!data.use_custom) { + data.custom_url = null + } + + if (data.use_custom && !data?.custom_url) { + data.custom_url = CUSTOM_URL + } + data.wait_time = data.wait_time * { @@ -140,7 +155,6 @@ new Vue({ minutes: 60, hours: 3600 }[this.formDialog.secondMultiplier] - if (data.id) { this.updateWithdrawLink(wallet, data) } else { @@ -158,6 +172,14 @@ new Vue({ data.title = 'vouchers' data.is_unique = true + if (!data.use_custom) { + data.custom_url = null + } + + if (data.use_custom && !data?.custom_url) { + data.custom_url = '/static/images/default_voucher.png' + } + if (data.id) { this.updateWithdrawLink(wallet, data) } else { @@ -180,7 +202,8 @@ new Vue({ 'uses', 'wait_time', 'is_unique', - 'webhook_url' + 'webhook_url', + 'custom_url' ) ) .then(function (response) { @@ -231,6 +254,42 @@ new Vue({ }) }) }, + writeNfcTag: async function (lnurl) { + try { + if (typeof NDEFReader == 'undefined') { + throw { + toString: function () { + return 'NFC not supported on this device or browser.' + } + } + } + + const ndef = new NDEFReader() + + this.nfcTagWriting = true + this.$q.notify({ + message: 'Tap your NFC tag to write the LNURL-withdraw link to it.' + }) + + await ndef.write({ + records: [{recordType: 'url', data: 'lightning:' + lnurl, lang: 'en'}] + }) + + this.nfcTagWriting = false + this.$q.notify({ + type: 'positive', + message: 'NFC tag written successfully.' + }) + } catch (error) { + this.nfcTagWriting = false + this.$q.notify({ + type: 'negative', + message: error + ? error.toString() + : 'An unexpected error has occurred.' + }) + } + }, exportCSV: function () { LNbits.utils.exportCSV(this.paywallsTable.columns, this.paywalls) } diff --git a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html index 095aad3a..ff88189d 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html +++ b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html @@ -4,6 +4,7 @@ label="API info" :content-inset-level="0.5" > + + > +
-
+
Copy LNURL +
@@ -51,7 +59,8 @@ mixins: [windowMixin], data: function () { return { - here: location.protocol + '//' + location.host + here: location.protocol + '//' + location.host, + nfcTagWriting: false } } }) diff --git a/lnbits/extensions/withdraw/templates/withdraw/index.html b/lnbits/extensions/withdraw/templates/withdraw/index.html index 6d3ab374..b1d927af 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/index.html +++ b/lnbits/extensions/withdraw/templates/withdraw/index.html @@ -217,6 +217,32 @@ label="Webhook URL (optional)" hint="A URL to be called whenever this link gets used." > + + + + + + + Use a custom voucher design + You can use an LNbits voucher design or a custom + one + + + + @@ -303,6 +329,32 @@ :default="1" label="Number of vouchers" > + + + + + + + Use a custom voucher design + You can use an LNbits voucher design or a custom + one + + + +
Shareable link + +
+ {% for page in link %} + + {% for one in page %} +
+ ... + {{ amt }} sats +
+ +
+
+ {% endfor %} +
+ {% endfor %} +
+
+{% endblock %} {% block styles %} + +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/withdraw/views.py b/lnbits/extensions/withdraw/views.py index 1f059a4b..97fb1271 100644 --- a/lnbits/extensions/withdraw/views.py +++ b/lnbits/extensions/withdraw/views.py @@ -99,6 +99,18 @@ async def print_qr(request: Request, link_id): page_link = list(chunks(links, 2)) linked = list(chunks(page_link, 5)) + if link.custom_url: + return withdraw_renderer().TemplateResponse( + "withdraw/print_qr_custom.html", + { + "request": request, + "link": page_link, + "unique": True, + "custom_url": link.custom_url, + "amt": link.max_withdrawable, + }, + ) + return withdraw_renderer().TemplateResponse( "withdraw/print_qr.html", {"request": request, "link": linked, "unique": True} ) diff --git a/lnbits/extensions/withdraw/views_api.py b/lnbits/extensions/withdraw/views_api.py index 800fecce..e0d3e56f 100644 --- a/lnbits/extensions/withdraw/views_api.py +++ b/lnbits/extensions/withdraw/views_api.py @@ -113,7 +113,7 @@ async def api_link_create_or_update( return {**link.dict(), **{"lnurl": link.lnurl(req)}} -@withdraw_ext.delete("/api/v1/links/{link_id}") +@withdraw_ext.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admin_key)): link = await get_withdraw_link(link_id) @@ -128,7 +128,7 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admi ) await delete_withdraw_link(link_id) - raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + return {"success": True} @withdraw_ext.get("/api/v1/links/{the_hash}/{lnurl_id}", status_code=HTTPStatus.OK) diff --git a/lnbits/helpers.py b/lnbits/helpers.py index 71b3dd69..e97fc7bb 100644 --- a/lnbits/helpers.py +++ b/lnbits/helpers.py @@ -34,7 +34,7 @@ class ExtensionManager: @property def extensions(self) -> List[Extension]: - output = [] + output: List[Extension] = [] if "all" in self._disabled: return output diff --git a/lnbits/jinja2_templating.py b/lnbits/jinja2_templating.py index 5abcd0bf..385703d1 100644 --- a/lnbits/jinja2_templating.py +++ b/lnbits/jinja2_templating.py @@ -21,7 +21,7 @@ class Jinja2Templates(templating.Jinja2Templates): self.env = self.get_environment(loader) def get_environment(self, loader: "jinja2.BaseLoader") -> "jinja2.Environment": - @jinja2.contextfunction + @jinja2.pass_context def url_for(context: dict, name: str, **path_params: typing.Any) -> str: request: Request = context["request"] return request.app.url_path_for(name, **path_params) diff --git a/lnbits/server.py b/lnbits/server.py new file mode 100644 index 00000000..e9849851 --- /dev/null +++ b/lnbits/server.py @@ -0,0 +1,49 @@ +import time + +import click +import uvicorn + +from lnbits.settings import HOST, PORT + + +@click.command( + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True, + ) +) +@click.option("--port", default=PORT, help="Port to listen on") +@click.option("--host", default=HOST, help="Host to run LNBits on") +@click.option("--ssl-keyfile", default=None, help="Path to SSL keyfile") +@click.option("--ssl-certfile", default=None, help="Path to SSL certificate") +@click.pass_context +def main(ctx, port: int, host: str, ssl_keyfile: str, ssl_certfile: str): + """Launched with `poetry run lnbits` at root level""" + # this beautiful beast parses all command line arguments and passes them to the uvicorn server + d = dict() + for a in ctx.args: + item = a.split("=") + if len(item) > 1: # argument like --key=value + print(a, item) + d[item[0].strip("--").replace("-", "_")] = ( + int(item[1]) # need to convert to int if it's a number + if item[1].isdigit() + else item[1] + ) + else: + d[a.strip("--")] = True # argument like --key + + config = uvicorn.Config( + "lnbits.__main__:app", + port=port, + host=host, + ssl_keyfile=ssl_keyfile, + ssl_certfile=ssl_certfile, + **d + ) + server = uvicorn.Server(config) + server.run() + + +if __name__ == "__main__": + main() diff --git a/lnbits/settings.py b/lnbits/settings.py index 5778b9e2..79c74fb4 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -55,6 +55,8 @@ FAKE_WALLET = getattr(wallets_module, "FakeWallet")() DEFAULT_WALLET_NAME = env.str("LNBITS_DEFAULT_WALLET_NAME", default="LNbits wallet") PREFER_SECURE_URLS = env.bool("LNBITS_FORCE_HTTPS", default=True) +RESERVE_FEE_MIN = env.int("LNBITS_RESERVE_FEE_MIN", default=2000) +RESERVE_FEE_PERCENT = env.float("LNBITS_RESERVE_FEE_PERCENT", default=1.0) SERVICE_FEE = env.float("LNBITS_SERVICE_FEE", default=0.0) try: diff --git a/lnbits/static/images/default_voucher.png b/lnbits/static/images/default_voucher.png new file mode 100644 index 00000000..8462b285 Binary files /dev/null and b/lnbits/static/images/default_voucher.png differ diff --git a/lnbits/static/images/voucher_template.svg b/lnbits/static/images/voucher_template.svg new file mode 100644 index 00000000..4347758f --- /dev/null +++ b/lnbits/static/images/voucher_template.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/lnbits/static/js/base.js b/lnbits/static/js/base.js index 7c0e9958..579db400 100644 --- a/lnbits/static/js/base.js +++ b/lnbits/static/js/base.js @@ -261,7 +261,7 @@ window.LNbits = { return data } }, - exportCSV: function (columns, data) { + exportCSV: function (columns, data, fileName) { var wrapCsvValue = function (val, formatFn) { var formatted = formatFn !== void 0 ? formatFn(val) : val @@ -295,7 +295,7 @@ window.LNbits = { .join('\r\n') var status = Quasar.utils.exportFile( - 'table-export.csv', + `${fileName || 'table-export'}.csv`, content, 'text/csv' ) @@ -392,7 +392,7 @@ window.windowMixin = { } if (window.extensions) { var user = this.g.user - this.g.extensions = Object.freeze( + const extensions = Object.freeze( window.extensions .map(function (data) { return window.LNbits.map.extension(data) @@ -413,9 +413,13 @@ window.windowMixin = { return obj }) .sort(function (a, b) { - return a.name > b.name + const nameA = a.name.toUpperCase() + const nameB = b.name.toUpperCase() + return nameA < nameB ? -1 : nameA > nameB ? 1 : 0 }) ) + + this.g.extensions = extensions } } } diff --git a/lnbits/tasks.py b/lnbits/tasks.py index 86863f98..f4d0a928 100644 --- a/lnbits/tasks.py +++ b/lnbits/tasks.py @@ -66,7 +66,7 @@ async def webhook_handler(): raise HTTPException(status_code=HTTPStatus.NO_CONTENT) -internal_invoice_queue = asyncio.Queue(0) +internal_invoice_queue: asyncio.Queue = asyncio.Queue(0) async def internal_invoice_listener(): diff --git a/lnbits/templates/base.html b/lnbits/templates/base.html index 6ab1ec84..acca92e7 100644 --- a/lnbits/templates/base.html +++ b/lnbits/templates/base.html @@ -228,7 +228,6 @@