Merge branch 'main' into diagon-alley

This commit is contained in:
ben 2022-06-21 10:14:17 +01:00
commit 7037793369
106 changed files with 2970 additions and 1136 deletions

View file

@ -11,6 +11,9 @@ LNBITS_ADMIN_USERS=""
LNBITS_ADMIN_EXTENSIONS="nostradmin" LNBITS_ADMIN_EXTENSIONS="nostradmin"
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet" LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
LNBITS_AD_SPACE="" # csv ad image filepaths or urls, extensions can choose to honor
LNBITS_HIDE_API=false # Hides wallet api, extensions can choose to honor
# Disable extensions for all users, use "all" to disable all extensions # Disable extensions for all users, use "all" to disable all extensions
LNBITS_DISABLED_EXTENSIONS="amilk" LNBITS_DISABLED_EXTENSIONS="amilk"
@ -29,11 +32,12 @@ LNBITS_SERVICE_FEE="0.0"
LNBITS_SITE_TITLE="LNbits" LNBITS_SITE_TITLE="LNbits"
LNBITS_SITE_TAGLINE="free and open-source lightning wallet" LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'" LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'"
# Choose from mint, flamingo, salvador, autumn, monochrome, classic # Choose from mint, flamingo, freedom, salvador, autumn, monochrome, classic
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, mint, autumn, monochrome, salvador" 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, LndWallet (gRPC), # Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet,
# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, FakeWallet # LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet
LNBITS_BACKEND_WALLET_CLASS=VoidWallet LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities, # VoidWallet is just a fallback that works without any actual Lightning capabilities,
# just so you can see the UI before dealing with this file. # just so you can see the UI before dealing with this file.
@ -50,14 +54,6 @@ CLIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
LNBITS_ENDPOINT=https://legend.lnbits.com LNBITS_ENDPOINT=https://legend.lnbits.com
LNBITS_KEY=LNBITS_ADMIN_KEY LNBITS_KEY=LNBITS_ADMIN_KEY
# LndWallet
LND_GRPC_ENDPOINT=127.0.0.1
LND_GRPC_PORT=11009
LND_GRPC_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
LND_GRPC_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon or HEXSTRING"
# To use an AES-encrypted macaroon, set
# LND_GRPC_MACAROON_ENCRYPTED="eNcRyPtEdMaCaRoOn"
# LndRestWallet # LndRestWallet
LND_REST_ENDPOINT=https://127.0.0.1:8080/ LND_REST_ENDPOINT=https://127.0.0.1:8080/
LND_REST_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert" LND_REST_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
@ -82,4 +78,8 @@ OPENNODE_KEY=OPENNODE_ADMIN_KEY
# FakeWallet # FakeWallet
FAKE_WALLET_SECRET="ToTheMoon1" FAKE_WALLET_SECRET="ToTheMoon1"
LNBITS_DENOMINATION=sats LNBITS_DENOMINATION=sats
# EclairWallet
ECLAIR_URL=http://127.0.0.1:8283
ECLAIR_PASS=eclairpw

View file

@ -9,4 +9,5 @@ jobs:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- uses: jpetrucciani/mypy-check@master - uses: jpetrucciani/mypy-check@master
with: with:
mypy_flags: '--install-types --non-interactive'
path: lnbits path: lnbits

View file

@ -1,58 +0,0 @@
name: Docker build on push
env:
DOCKER_CLI_EXPERIMENTAL: enabled
on:
push:
branches:
- master
jobs:
build:
runs-on: ubuntu-20.04
name: Build and push lnbits image
steps:
- name: Login to Docker Hub
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
- name: Checkout project
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
id: qemu
- name: Setup Docker buildx action
uses: docker/setup-buildx-action@v1
id: buildx
- name: Show available Docker buildx platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Cache Docker layers
uses: actions/cache@v2
id: cache
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Run Docker buildx against commit hash
run: |
docker buildx build \
--cache-from "type=local,src=/tmp/.buildx-cache" \
--cache-to "type=local,dest=/tmp/.buildx-cache" \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits:${GITHUB_SHA:0:7} \
--output "type=registry" ./
- name: Run Docker buildx against latest
run: |
docker buildx build \
--cache-from "type=local,src=/tmp/.buildx-cache" \
--cache-to "type=local,dest=/tmp/.buildx-cache" \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits:latest \
--output "type=registry" ./

68
.github/workflows/on-tag.yml vendored Normal file
View file

@ -0,0 +1,68 @@
name: Build and push Docker image on tag
env:
DOCKER_CLI_EXPERIMENTAL: enabled
on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+"
- "[0-9]+.[0-9]+.[0-9]+-*"
jobs:
build:
runs-on: ubuntu-20.04
name: Build and push lnbits image
steps:
- name: Login to Docker Hub
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
- name: Checkout project
uses: actions/checkout@v2
- name: Import environment variables
id: import-env
shell: bash
run: echo "TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
- name: Show set environment variables
run: |
printf " TAG: %s\n" "$TAG"
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
id: qemu
- name: Setup Docker buildx action
uses: docker/setup-buildx-action@v1
id: buildx
- name: Show available Docker buildx platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Cache Docker layers
uses: actions/cache@v2
id: cache
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Run Docker buildx against tag
run: |
docker buildx build \
--cache-from "type=local,src=/tmp/.buildx-cache" \
--cache-to "type=local,dest=/tmp/.buildx-cache" \
--platform linux/amd64,linux/arm64 \
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits-legend:${TAG} \
--output "type=registry" ./
- name: Run Docker buildx against latest
run: |
docker buildx build \
--cache-from "type=local,src=/tmp/.buildx-cache" \
--cache-to "type=local,dest=/tmp/.buildx-cache" \
--platform linux/amd64,linux/arm64 \
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits-legend:latest \
--output "type=registry" ./

View file

@ -8,8 +8,9 @@ ENV PATH="$VIRTUAL_ENV/bin:$PATH"
# Install build deps # Install build deps
RUN apt-get update RUN apt-get update
RUN apt-get install -y --no-install-recommends build-essential RUN apt-get install -y --no-install-recommends build-essential pkg-config
RUN python -m pip install --upgrade pip RUN python -m pip install --upgrade pip
RUN pip install wheel
# Install runtime deps # Install runtime deps
COPY requirements.txt /tmp/requirements.txt COPY requirements.txt /tmp/requirements.txt
@ -36,6 +37,9 @@ ENV PATH="$VIRTUAL_ENV/bin:$PATH"
WORKDIR /app WORKDIR /app
COPY --chown=1000:1000 lnbits /app/lnbits COPY --chown=1000:1000 lnbits /app/lnbits
ENV LNBITS_PORT="5000"
ENV LNBITS_HOST="0.0.0.0"
EXPOSE 5000 EXPOSE 5000
CMD ["uvicorn", "lnbits.__main__:app", "--port", "5000", "--host", "0.0.0.0"] CMD ["sh", "-c", "uvicorn lnbits.__main__:app --port $LNBITS_PORT --host $LNBITS_HOST"]

View file

@ -11,6 +11,8 @@ LNbits
(Join us on [https://t.me/lnbits](https://t.me/lnbits)) (Join us on [https://t.me/lnbits](https://t.me/lnbits))
(LNbits is beta, for responsible disclosure of any concerns please contact lnbits@pm.me)
Use [lnbits.com](https://lnbits.com), or run your own LNbits server! Use [lnbits.com](https://lnbits.com), or run your own LNbits server!
LNbits is a very simple Python server that sits on top of any funding source, and can be used as: LNbits is a very simple Python server that sits on top of any funding source, and can be used as:

View file

@ -1,6 +1,7 @@
import psycopg2 import psycopg2
import sqlite3 import sqlite3
import os import os
# Python script to migrate an LNbits SQLite DB to Postgres # Python script to migrate an LNbits SQLite DB to Postgres
# All credits to @Fritz446 for the awesome work # All credits to @Fritz446 for the awesome work

View file

@ -12,6 +12,8 @@ LNbits uses [Pipenv][pipenv] to manage Python packages.
```sh ```sh
git clone https://github.com/lnbits/lnbits-legend.git git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend/ cd lnbits-legend/
sudo apt-get install pipenv
pipenv shell pipenv shell
# pipenv --python 3.9 shell (if you wish to use a version of Python higher than 3.7) # pipenv --python 3.9 shell (if you wish to use a version of Python higher than 3.7)
pipenv install --dev pipenv install --dev
@ -19,6 +21,9 @@ pipenv install --dev
# If any of the modules fails to install, try checking and upgrading your setupTool module # If any of the modules fails to install, try checking and upgrading your setupTool module
# pip install -U setuptools # pip install -U setuptools
# install libffi/libpq in case "pipenv install" fails
# sudo apt-get install -y libffi-dev libpq-dev
``` ```
## Running the server ## Running the server
@ -41,4 +46,7 @@ E.g. when you want to use LND you have to `pipenv run pip install lndgrpc` and `
Take a look at [Polar][polar] for an excellent way of spinning up a Lightning Network dev environment. Take a look at [Polar][polar] for an excellent way of spinning up a Lightning Network dev environment.
**Note**: We reccomend using <a href="https://caddyserver.com/docs/install#debian-ubuntu-raspbian">Caddy</a> for a reverse-proxy, if you want to serve your install through a domain, alternatively you can use [ngrok](https://ngrok.com/). **Notes**:
* We reccomend using <a href="https://caddyserver.com/docs/install#debian-ubuntu-raspbian">Caddy</a> for a reverse-proxy if you want to serve your install through a domain, alternatively you can use [ngrok](https://ngrok.com/).
* <a href="https://linuxize.com/post/how-to-use-linux-screen/#starting-linux-screen">Screen</a> works well if you want LNbits to continue running when you close your terminal session.

View file

@ -49,17 +49,20 @@ You might also need to install additional packages or perform additional setup s
## Important note ## Important note
If you already have LNbits installed and running, on an SQLite database, we **HIGHLY** recommend you migrate to postgres! If you already have LNbits installed and running, on an SQLite database, we **HIGHLY** recommend you migrate to postgres!
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, check the guide above. 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, check the guide above. Additionally, your lnbits instance should run once on postgres to implement the database schema before the migration works:
```sh ```sh
# STOP LNbits # STOP LNbits
# on the LNBits folder, locate and edit 'conv.py' with the relevant credentials
python3 conv.py
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL= # add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
# postgres://<user>:<password>@<host>/<database> - alter line bellow with your user, password and db name # postgres://<user>:<password>@<host>/<database> - alter line bellow with your user, password and db name
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits" LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
# save and exit # save and exit
# START LNbits
# STOP LNbits
# on the LNBits folder, locate and edit 'conv.py' with the relevant credentials
python3 conv.py
``` ```
Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly. Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly.
@ -78,17 +81,23 @@ Systemd is great for taking care of your LNbits instance. It will start it on bo
[Unit] [Unit]
Description=LNbits Description=LNbits
#Wants=lnd.service # you can uncomment these lines if you know what you're doing # you can uncomment these lines if you know what you're doing
#After=lnd.service # it will make sure that lnbits starts after lnd (replace with your own backend service) # it will make sure that lnbits starts after lnd (replace with your own backend service)
#Wants=lnd.service
#After=lnd.service
[Service] [Service]
WorkingDirectory=/home/bitcoin/lnbits # replace with the absolute path of your lnbits installation # replace with the absolute path of your lnbits installation
ExecStart=/home/bitcoin/lnbits/venv/bin/uvicorn lnbits.__main__:app --port 5000 # same here WorkingDirectory=/home/bitcoin/lnbits
User=bitcoin # replace with the user that you're running lnbits on # same here
ExecStart=/home/bitcoin/lnbits/venv/bin/uvicorn lnbits.__main__:app --port 5000
# replace with the user that you're running lnbits on
User=bitcoin
Restart=always Restart=always
TimeoutSec=120 TimeoutSec=120
RestartSec=30 RestartSec=30
Environment=PYTHONUNBUFFERED=1 # this makes sure that you receive logs in real time # this makes sure that you receive logs in real time
Environment=PYTHONUNBUFFERED=1
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View file

@ -47,7 +47,7 @@ To encrypt your macaroon, run `./venv/bin/python lnbits/wallets/macaroon/macaroo
### LND (REST) ### LND (REST)
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet** - `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
- `LND_REST_ENDPOINT`: ip_address - `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
- `LND_REST_CERT`: /file/path/tls.cert - `LND_REST_CERT`: /file/path/tls.cert
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Bech64/Hex - `LND_REST_MACAROON`: /file/path/admin.macaroon or Bech64/Hex

View file

@ -3,11 +3,13 @@ import importlib
import sys import sys
import traceback import traceback
import warnings import warnings
from http import HTTPStatus
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
import lnbits.settings import lnbits.settings
@ -58,15 +60,19 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
async def validation_exception_handler( async def validation_exception_handler(
request: Request, exc: RequestValidationError request: Request, exc: RequestValidationError
): ):
return template_renderer().TemplateResponse( # Only the browser sends "text/html" request
"error.html", # not fail proof, but everything else get's a JSON response
{"request": request, "err": f"`{exc.errors()}` is not a valid UUID."},
) if "text/html" in request.headers["accept"]:
return template_renderer().TemplateResponse(
"error.html",
{"request": request, "err": f"{exc.errors()} is not a valid UUID."},
)
# return HTMLResponse( return JSONResponse(
# status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=HTTPStatus.NO_CONTENT,
# content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}), content={"detail": exc.errors()},
# ) )
app.add_middleware(GZipMiddleware, minimum_size=1000) app.add_middleware(GZipMiddleware, minimum_size=1000)
# app.add_middleware(ASGIProxyFix) # app.add_middleware(ASGIProxyFix)
@ -84,18 +90,19 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
def check_funding_source(app: FastAPI) -> None: def check_funding_source(app: FastAPI) -> None:
@app.on_event("startup") @app.on_event("startup")
async def check_wallet_status(): async def check_wallet_status():
error_message, balance = await WALLET.status() while True:
if error_message: error_message, balance = await WALLET.status()
if not error_message:
break
warnings.warn( warnings.warn(
f" × The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'", f" × The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
RuntimeWarning, RuntimeWarning,
) )
print("Retrying connection to backend in 5 seconds...")
sys.exit(4) await asyncio.sleep(5)
else: print(
print( f" ✔️ {WALLET.__class__.__name__} seems to be connected and with a balance of {balance} msat."
f" ✔️ {WALLET.__class__.__name__} seems to be connected and with a balance of {balance} msat." )
)
def register_routes(app: FastAPI) -> None: def register_routes(app: FastAPI) -> None:
@ -167,9 +174,17 @@ def register_exception_handlers(app: FastAPI):
@app.exception_handler(Exception) @app.exception_handler(Exception)
async def basic_error(request: Request, err): async def basic_error(request: Request, err):
print("handled error", traceback.format_exc()) print("handled error", traceback.format_exc())
print("ERROR:", err)
etype, _, tb = sys.exc_info() etype, _, tb = sys.exc_info()
traceback.print_exception(etype, err, tb) traceback.print_exception(etype, err, tb)
exc = traceback.format_exc() exc = traceback.format_exc()
return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": err} if "text/html" in request.headers["accept"]:
return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": err}
)
return JSONResponse(
status_code=HTTPStatus.NO_CONTENT,
content={"detail": err},
) )

View file

@ -165,7 +165,7 @@ def lnencode(addr, privkey):
if addr.amount: if addr.amount:
amount = Decimal(str(addr.amount)) amount = Decimal(str(addr.amount))
# We can only send down to millisatoshi. # We can only send down to millisatoshi.
if amount * 10 ** 12 % 10: if amount * 10**12 % 10:
raise ValueError( raise ValueError(
"Cannot encode {}: too many decimal places".format(addr.amount) "Cannot encode {}: too many decimal places".format(addr.amount)
) )
@ -270,7 +270,7 @@ class LnAddr(object):
def shorten_amount(amount): def shorten_amount(amount):
"""Given an amount in bitcoin, shorten it""" """Given an amount in bitcoin, shorten it"""
# Convert to pico initially # Convert to pico initially
amount = int(amount * 10 ** 12) amount = int(amount * 10**12)
units = ["p", "n", "u", "m", ""] units = ["p", "n", "u", "m", ""]
for unit in units: for unit in units:
if amount % 1000 == 0: if amount % 1000 == 0:
@ -289,7 +289,7 @@ def _unshorten_amount(amount: str) -> int:
# * `u` (micro): multiply by 0.000001 # * `u` (micro): multiply by 0.000001
# * `n` (nano): multiply by 0.000000001 # * `n` (nano): multiply by 0.000000001
# * `p` (pico): multiply by 0.000000000001 # * `p` (pico): multiply by 0.000000000001
units = {"p": 10 ** 12, "n": 10 ** 9, "u": 10 ** 6, "m": 10 ** 3} units = {"p": 10**12, "n": 10**9, "u": 10**6, "m": 10**3}
unit = str(amount)[-1] unit = str(amount)[-1]
# BOLT #11: # BOLT #11:
@ -348,9 +348,9 @@ def _trim_to_bytes(barr):
def _readable_scid(short_channel_id: int) -> str: def _readable_scid(short_channel_id: int) -> str:
return "{blockheight}x{transactionindex}x{outputindex}".format( return "{blockheight}x{transactionindex}x{outputindex}".format(
blockheight=((short_channel_id >> 40) & 0xffffff), blockheight=((short_channel_id >> 40) & 0xFFFFFF),
transactionindex=((short_channel_id >> 16) & 0xffffff), transactionindex=((short_channel_id >> 16) & 0xFFFFFF),
outputindex=(short_channel_id & 0xffff), outputindex=(short_channel_id & 0xFFFF),
) )

View file

@ -180,13 +180,18 @@ async def get_wallet_for_key(
async def get_standalone_payment( async def get_standalone_payment(
checking_id_or_hash: str, conn: Optional[Connection] = None checking_id_or_hash: str,
conn: Optional[Connection] = None,
incoming: Optional[bool] = False,
) -> Optional[Payment]: ) -> Optional[Payment]:
clause: str = "checking_id = ? OR hash = ?"
if incoming:
clause = f"({clause}) AND amount > 0"
row = await (conn or db).fetchone( row = await (conn or db).fetchone(
""" f"""
SELECT * SELECT *
FROM apipayments FROM apipayments
WHERE checking_id = ? OR hash = ? WHERE {clause}
LIMIT 1 LIMIT 1
""", """,
(checking_id_or_hash, checking_id_or_hash), (checking_id_or_hash, checking_id_or_hash),

View file

@ -85,18 +85,17 @@ async def pay_invoice(
description: str = "", description: str = "",
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> str: ) -> str:
invoice = bolt11.decode(payment_request)
fee_reserve_msat = fee_reserve(invoice.amount_msat)
async with (db.reuse_conn(conn) if conn else db.connect()) as conn: async with (db.reuse_conn(conn) if conn else db.connect()) as conn:
temp_id = f"temp_{urlsafe_short_hash()}" temp_id = f"temp_{urlsafe_short_hash()}"
internal_id = f"internal_{urlsafe_short_hash()}" internal_id = f"internal_{urlsafe_short_hash()}"
invoice = bolt11.decode(payment_request)
if invoice.amount_msat == 0: if invoice.amount_msat == 0:
raise ValueError("Amountless invoices not supported.") raise ValueError("Amountless invoices not supported.")
if max_sat and invoice.amount_msat > max_sat * 1000: if max_sat and invoice.amount_msat > max_sat * 1000:
raise ValueError("Amount in invoice is too high.") raise ValueError("Amount in invoice is too high.")
wallet = await get_wallet(wallet_id, conn=conn)
# put all parameters that don't change here # put all parameters that don't change here
PaymentKwargs = TypedDict( PaymentKwargs = TypedDict(
"PaymentKwargs", "PaymentKwargs",
@ -134,26 +133,20 @@ async def pay_invoice(
# the balance is enough in the next step # the balance is enough in the next step
await create_payment( await create_payment(
checking_id=temp_id, checking_id=temp_id,
fee=-fee_reserve(invoice.amount_msat), fee=-fee_reserve_msat,
conn=conn, conn=conn,
**payment_kwargs, **payment_kwargs,
) )
# do the balance check if internal payment # do the balance check
if internal_checking_id: wallet = await get_wallet(wallet_id, conn=conn)
wallet = await get_wallet(wallet_id, conn=conn) assert wallet
assert wallet if wallet.balance_msat < 0:
if wallet.balance_msat < 0: if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat:
raise PermissionError("Insufficient balance.") raise PaymentFailure(
f"You must reserve at least 1% ({round(fee_reserve_msat/1000)} sat) to cover potential routing fees."
# do the balance check if external payment
else:
if invoice.amount_msat > wallet.balance_msat - (
wallet.balance_msat / 100 * 2
):
raise PermissionError(
"LNbits requires you keep at least 2% reserve to cover potential routing fees."
) )
raise PermissionError("Insufficient balance.")
if internal_checking_id: if internal_checking_id:
# mark the invoice from the other side as not pending anymore # mark the invoice from the other side as not pending anymore
@ -171,7 +164,9 @@ async def pay_invoice(
await internal_invoice_queue.put(internal_checking_id) await internal_invoice_queue.put(internal_checking_id)
else: else:
# actually pay the external invoice # actually pay the external invoice
payment: PaymentResponse = await WALLET.pay_invoice(payment_request) payment: PaymentResponse = await WALLET.pay_invoice(
payment_request, fee_reserve_msat
)
if payment.checking_id: if payment.checking_id:
async with db.connect() as conn: async with db.connect() as conn:
await create_payment( await create_payment(
@ -286,12 +281,12 @@ async def perform_lnurlauth(
sign_len = 6 + r_len + s_len sign_len = 6 + r_len + s_len
signature = BytesIO() signature = BytesIO()
signature.write(0x30 .to_bytes(1, "big", signed=False)) signature.write(0x30.to_bytes(1, "big", signed=False))
signature.write((sign_len - 2).to_bytes(1, "big", signed=False)) signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
signature.write(0x02 .to_bytes(1, "big", signed=False)) signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(r_len.to_bytes(1, "big", signed=False)) signature.write(r_len.to_bytes(1, "big", signed=False))
signature.write(r) signature.write(r)
signature.write(0x02 .to_bytes(1, "big", signed=False)) signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(s_len.to_bytes(1, "big", signed=False)) signature.write(s_len.to_bytes(1, "big", signed=False))
signature.write(s) signature.write(s)
@ -340,5 +335,6 @@ async def check_invoice_status(
return status return 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: def fee_reserve(amount_msat: int) -> int:
return max(1000, int(amount_msat * 0.01)) return max(2000, int(amount_msat * 0.01))

View file

@ -364,12 +364,12 @@ new Vue({
}, },
decodeRequest: function () { decodeRequest: function () {
this.parse.show = true this.parse.show = true
let req = this.parse.data.request.toLowerCase()
if (this.parse.data.request.startsWith('lightning:')) { if (this.parse.data.request.startsWith('lightning:')) {
this.parse.data.request = this.parse.data.request.slice(10) this.parse.data.request = this.parse.data.request.slice(10)
} else if (this.parse.data.request.startsWith('lnurl:')) { } else if (this.parse.data.request.startsWith('lnurl:')) {
this.parse.data.request = this.parse.data.request.slice(6) this.parse.data.request = this.parse.data.request.slice(6)
} else if (this.parse.data.request.indexOf('lightning=lnurl1') !== -1) { } else if (req.indexOf('lightning=lnurl1') !== -1) {
this.parse.data.request = this.parse.data.request this.parse.data.request = this.parse.data.request
.split('lightning=')[1] .split('lightning=')[1]
.split('&')[0] .split('&')[0]
@ -618,10 +618,10 @@ new Vue({
}, },
updateWalletName: function () { updateWalletName: function () {
let newName = this.newName let newName = this.newName
let adminkey = this.g.wallet.adminkey
if (!newName || !newName.length) return if (!newName || !newName.length) return
// let data = {name: newName}
LNbits.api LNbits.api
.request('PUT', '/api/v1/wallet/' + newName, this.g.wallet.inkey, {}) .request('PUT', '/api/v1/wallet/' + newName, adminkey, {})
.then(res => { .then(res => {
this.newName = '' this.newName = ''
this.$q.notify({ this.$q.notify({
@ -691,10 +691,7 @@ new Vue({
}, },
mounted: function () { mounted: function () {
// show disclaimer // show disclaimer
if ( if (!this.$q.localStorage.getItem('lnbits.disclaimerShown')) {
this.$refs.disclaimer &&
!this.$q.localStorage.getItem('lnbits.disclaimerShown')
) {
this.disclaimerDialog.show = true this.disclaimerDialog.show = true
this.$q.localStorage.set('lnbits.disclaimerShown', true) this.$q.localStorage.set('lnbits.disclaimerShown', true)
} }

View file

@ -48,7 +48,7 @@
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br /> <code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5> <h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code <code
>{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;}</code >{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;, "unit": &lt;string&gt;, "webhook": &lt;url:string&gt;}</code
> >
<h5 class="text-caption q-mt-sm q-mb-none"> <h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json) Returns 201 CREATED (application/json)
@ -61,7 +61,7 @@
<code <code
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": false, >curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": false,
"amount": &lt;int&gt;, "memo": &lt;string&gt;, "webhook": "amount": &lt;int&gt;, "memo": &lt;string&gt;, "webhook":
&lt;url:string&gt;}' -H "X-Api-Key: <i>{{ wallet.inkey }}</i>" -H &lt;url:string&gt;, "unit": &lt;string&gt;}' -H "X-Api-Key: <i>{{ wallet.inkey }}</i>" -H
"Content-type: application/json"</code "Content-type: application/json"</code
> >
</q-card-section> </q-card-section>

View file

@ -11,7 +11,7 @@
color="primary" color="primary"
@click="processing" @click="processing"
type="a" type="a"
href="{{ url_for('core.lnurlwallet', lightning=lnurl) }}" href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}"
> >
Press to claim bitcoin Press to claim bitcoin
</q-btn> </q-btn>

View file

@ -273,442 +273,469 @@
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
{{ SITE_TITLE }} Wallet: <strong><em>{{ wallet.name }}</em></strong>
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% if HIDE_API %}
{% include "core/_api_docs.html" %} <div class="col-12 col-md-4 q-gutter-y-md">
{% else %}
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
{{ SITE_TITLE }} Wallet: <strong><em>{{ wallet.name }}</em></strong>
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator> <q-separator></q-separator>
{% if wallet.lnurlwithdraw_full %} <q-list>
<q-expansion-item group="extras" icon="crop_free" label="Drain Funds"> {% include "core/_api_docs.html" %}
<q-card> <q-separator></q-separator>
<q-card-section class="text-center">
<p> {% if wallet.lnurlwithdraw_full %}
This is an LNURL-withdraw QR code for slurping everything from <q-expansion-item
this wallet. Do not share with anyone. group="extras"
</p> icon="crop_free"
<a href="lightning:{{wallet.lnurlwithdraw_full}}"> label="Drain Funds"
>
<q-card>
<q-card-section class="text-center">
<p>
This is an LNURL-withdraw QR code for slurping everything
from this wallet. Do not share with anyone.
</p>
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
<qrcode
value="{{wallet.lnurlwithdraw_full}}"
:options="{width:240}"
></qrcode>
</a>
<p>
It is compatible with <code>balanceCheck</code> and
<code>balanceNotify</code> so your wallet may keep pulling
the funds continuously from here after the first withdraw.
</p>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
{% endif %}
<q-expansion-item
group="extras"
icon="settings_cell"
label="Export to Phone with QR Code"
>
<q-card>
<q-card-section class="text-center">
<p>
This QR code contains your wallet URL with full access. You
can scan it from your phone to open your wallet from there.
</p>
<qrcode <qrcode
value="{{wallet.lnurlwithdraw_full}}" :value="'{{request.base_url}}' +'wallet?usr={{user.id}}&wal={{wallet.id}}'"
:options="{width:240}" :options="{width:240}"
></qrcode> ></qrcode>
</a> </q-card-section>
<p> </q-card>
It is compatible with <code>balanceCheck</code> and </q-expansion-item>
<code>balanceNotify</code> so your wallet may keep pulling the <q-separator></q-separator>
funds continuously from here after the first withdraw. <q-expansion-item group="extras" icon="edit" label="Rename wallet">
</p> <q-card>
</q-card-section> <q-card-section>
</q-card> <div class="" style="max-width: 320px">
</q-expansion-item> <q-input
<q-separator></q-separator> filled
{% endif %} v-model.trim="newName"
label="Label"
<q-expansion-item dense="dense"
group="extras" @update:model-value="(e) => console.log(e)"
icon="settings_cell" />
label="Export to Phone with QR Code" </div>
> <q-btn
<q-card> :disable="!newName.length"
<q-card-section class="text-center"> unelevated
<p> class="q-mt-sm"
This QR code contains your wallet URL with full access. You color="primary"
can scan it from your phone to open your wallet from there. @click="updateWalletName()"
</p> >Update name</q-btn
<qrcode >
:value="'{{request.base_url}}' +'wallet?usr={{user.id}}&wal={{wallet.id}}'" </q-card-section>
:options="{width:240}" </q-card>
></qrcode> </q-expansion-item>
</q-card-section> <q-separator></q-separator>
</q-card> <q-expansion-item
</q-expansion-item> group="extras"
<q-separator></q-separator> icon="remove_circle"
<q-expansion-item group="extras" icon="edit" label="Rename wallet"> label="Delete wallet"
<q-card> >
<q-card-section> <q-card>
<div class="" style="max-width: 320px"> <q-card-section>
<q-input <p>
filled This whole wallet will be deleted, the funds will be
v-model.trim="newName" <strong>UNRECOVERABLE</strong>.
label="Label" </p>
dense="dense" <q-btn
@update:model-value="(e) => console.log(e)" unelevated
/> color="red-10"
</div> @click="deleteWallet('{{ wallet.id }}', '{{ user.id }}')"
<q-btn >Delete wallet</q-btn
:disable="!newName.length" >
unelevated </q-card-section>
class="q-mt-sm" </q-card>
color="primary" </q-expansion-item>
@click="updateWalletName()" </q-list>
>Update name</q-btn </q-card-section>
> </q-card>
</q-card-section> {% endif %} {% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD =
</q-card> ADS.split(';') %}
</q-expansion-item> <q-card>
<q-separator></q-separator> <a href="{{ AD[0] }}"
<q-expansion-item ><img width="100%" src="{{ AD[1] }}"
group="extras" /></a> </q-card
icon="remove_circle" >{% endfor %} {% endif %}
label="Delete wallet" </div>
>
<q-card>
<q-card-section>
<p>
This whole wallet will be deleted, the funds will be
<strong>UNRECOVERABLE</strong>.
</p>
<q-btn
unelevated
color="red-10"
@click="deleteWallet('{{ wallet.id }}', '{{ user.id }}')"
>Delete wallet</q-btn
>
</q-card-section>
</q-card>
</q-expansion-item>
</q-list>
</q-card-section>
</q-card>
</div> </div>
</div>
<q-dialog v-model="receive.show" @hide="closeReceiveDialog"> <q-dialog v-model="receive.show" @hide="closeReceiveDialog">
{% raw %} {% raw %}
<q-card <q-card
v-if="!receive.paymentReq" v-if="!receive.paymentReq"
class="q-pa-lg q-pt-xl lnbits__dialog-card" class="q-pa-lg q-pt-xl lnbits__dialog-card"
> >
<q-form @submit="createInvoice" class="q-gutter-md"> <q-form @submit="createInvoice" class="q-gutter-md">
<p v-if="receive.lnurl" class="text-h6 text-center q-my-none"> <p v-if="receive.lnurl" class="text-h6 text-center q-my-none">
<b>{{receive.lnurl.domain}}</b> is requesting an invoice: <b>{{receive.lnurl.domain}}</b> is requesting an invoice:
</p>
{% endraw %} {% if LNBITS_DENOMINATION != 'sats' %}
<q-input
filled
dense
v-model.number="receive.data.amount"
label="Amount ({{LNBITS_DENOMINATION}}) *"
mask="#.##"
fill-mask="0"
reverse-fill-mask
:min="receive.minMax[0]"
:max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed"
></q-input>
{% else %}
<q-select
filled
dense
v-model="receive.unit"
type="text"
label="Unit"
:options="receive.units"
></q-select>
<q-input
filled
dense
v-model.number="receive.data.amount"
type="number"
label="Amount ({{LNBITS_DENOMINATION}}) *"
:step="receive.unit != 'sat' ? '0.001' : '1'"
:min="receive.minMax[0]"
:max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed"
></q-input>
{% endif %}
<q-input
filled
dense
v-model.trim="receive.data.memo"
label="Memo"
></q-input>
{% raw %}
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="receive.data.memo == null || receive.data.amount == null || receive.data.amount <= 0"
type="submit"
>
<span v-if="receive.lnurl">
Withdraw from {{receive.lnurl.domain}}
</span>
<span v-else> Create invoice </span>
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
<q-spinner
v-if="receive.status == 'loading'"
color="primary"
size="2.55em"
></q-spinner>
</q-form>
</q-card>
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<a :href="'lightning:' + receive.paymentReq">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
:value="receive.paymentReq"
:options="{width: 340}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(receive.paymentReq)"
>Copy invoice</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
{% endraw %}
</q-dialog>
<q-dialog v-model="parse.show" @hide="closeParseDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div v-if="parse.invoice">
<h6 v-if="'{{LNBITS_DENOMINATION}}' != 'sats'" class="q-my-none">
{% raw %} {{ parseFloat(String(parse.invoice.fsat).replaceAll(",", ""))
/ 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
</h6>
<h6 v-else class="q-my-none">
{{ parse.invoice.fsat }}{% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
</h6>
<q-separator class="q-my-sm"></q-separator>
<p class="text-wrap">
<strong>Description:</strong> {{ parse.invoice.description }}<br />
<strong>Expire date:</strong> {{ parse.invoice.expireDate }}<br />
<strong>Hash:</strong> {{ parse.invoice.hash }}
</p>
{% endraw %}
<div v-if="canPay" class="row q-mt-lg">
<q-btn unelevated color="primary" @click="payInvoice">Pay</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
<div v-else class="row q-mt-lg">
<q-btn unelevated disabled color="yellow" text-color="black"
>Not enough funds!</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</div>
<div v-else-if="parse.lnurlauth">
{% raw %}
<q-form @submit="authLnurl" class="q-gutter-md">
<p class="q-my-none text-h6">
Authenticate with <b>{{ parse.lnurlauth.domain }}</b>?
</p> </p>
<q-separator class="q-my-sm"></q-separator> {% endraw %} {% if LNBITS_DENOMINATION != 'sats' %}
<p>
For every website and for every LNbits wallet, a new keypair will be
deterministically generated so your identity can't be tied to your
LNbits wallet or linked across websites. No other data will be shared
with {{ parse.lnurlauth.domain }}.
</p>
<p>Your public key for <b>{{ parse.lnurlauth.domain }}</b> is:</p>
<p class="q-mx-xl">
<code class="text-wrap"> {{ parse.lnurlauth.pubkey }} </code>
</p>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">Login</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
{% endraw %}
</div>
<div v-else-if="parse.lnurlpay">
{% raw %}
<q-form @submit="payLnurl" class="q-gutter-md">
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
<b>{{ parse.lnurlpay.domain }}</b> is requesting {{
parse.lnurlpay.maxSendable | msatoshiFormat }} {{LNBITS_DENOMINATION}}
<span v-if="parse.lnurlpay.commentAllowed > 0">
<br />
and a {{parse.lnurlpay.commentAllowed}}-char comment
</span>
</p>
<p v-else class="q-my-none text-h6 text-center">
<b>{{ parse.lnurlpay.targetUser || parse.lnurlpay.domain }}</b> is
requesting <br />
between <b>{{ parse.lnurlpay.minSendable | msatoshiFormat }}</b> and
<b>{{ parse.lnurlpay.maxSendable | msatoshiFormat }}</b>
{% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
<span v-if="parse.lnurlpay.commentAllowed > 0">
<br />
and a {{parse.lnurlpay.commentAllowed}}-char comment
</span>
</p>
<q-separator class="q-my-sm"></q-separator>
<div class="row">
<p class="col text-justify text-italic">
{{ parse.lnurlpay.description }}
</p>
<p class="col-4 q-pl-md" v-if="parse.lnurlpay.image">
<q-img :src="parse.lnurlpay.image" />
</p>
</div>
<div class="row">
<div class="col">
{% endraw %}
<q-input
filled
dense
v-model.number="parse.data.amount"
type="number"
label="Amount ({{LNBITS_DENOMINATION}}) *"
:min="parse.lnurlpay.minSendable / 1000"
:max="parse.lnurlpay.maxSendable / 1000"
:readonly="parse.lnurlpay.fixed"
></q-input>
{% raw %}
</div>
<div class="col-8 q-pl-md" v-if="parse.lnurlpay.commentAllowed > 0">
<q-input
filled
dense
v-model="parse.data.comment"
:type="parse.lnurlpay.commentAllowed > 64 ? 'textarea' : 'text'"
label="Comment (optional)"
:maxlength="parse.lnurlpay.commentAllowed"
></q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit"
>Send {{LNBITS_DENOMINATION}}</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
{% endraw %}
</div>
<div v-else>
<q-form
v-if="!parse.camera.show"
@submit="decodeRequest"
class="q-gutter-md"
>
<q-input <q-input
filled filled
dense dense
v-model.trim="parse.data.request" v-model.number="receive.data.amount"
type="textarea" label="Amount ({{LNBITS_DENOMINATION}}) *"
label="Paste an invoice, payment request or lnurl code *" mask="#.##"
> fill-mask="0"
</q-input> reverse-fill-mask
<div class="row q-mt-lg"> :min="receive.minMax[0]"
:max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed"
></q-input>
{% else %}
<q-select
filled
dense
v-model="receive.unit"
type="text"
label="Unit"
:options="receive.units"
></q-select>
<q-input
filled
dense
v-model.number="receive.data.amount"
:label="'Amount (' + receive.unit + ') *'"
:mask="receive.unit != 'sat' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
:step="receive.unit != 'sat' ? '0.01' : '1'"
:min="receive.minMax[0]"
:max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed"
></q-input>
{% endif %}
<q-input
filled
dense
v-model.trim="receive.data.memo"
label="Memo"
></q-input>
{% raw %}
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
<q-btn <q-btn
unelevated unelevated
color="primary" color="primary"
:disable="parse.data.request == ''" :disable="receive.data.amount == null || receive.data.amount <= 0"
type="submit" type="submit"
>Read</q-btn >
<span v-if="receive.lnurl">
Withdraw from {{receive.lnurl.domain}}
</span>
<span v-else> Create invoice </span>
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
<q-spinner
v-if="receive.status == 'loading'"
color="primary"
size="2.55em"
></q-spinner>
</q-form>
</q-card>
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<a :href="'lightning:' + receive.paymentReq">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
:value="receive.paymentReq"
:options="{width: 340}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(receive.paymentReq)"
>Copy invoice</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
{% endraw %}
</q-dialog>
<q-dialog v-model="parse.show" @hide="closeParseDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div v-if="parse.invoice">
<h6 v-if="'{{LNBITS_DENOMINATION}}' != 'sats'" class="q-my-none">
{% raw %} {{ parseFloat(String(parse.invoice.fsat).replaceAll(",",
"")) / 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
</h6>
<h6 v-else class="q-my-none">
{{ parse.invoice.fsat }}{% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
</h6>
<q-separator class="q-my-sm"></q-separator>
<p class="text-wrap">
<strong>Description:</strong> {{ parse.invoice.description }}<br />
<strong>Expire date:</strong> {{ parse.invoice.expireDate }}<br />
<strong>Hash:</strong> {{ parse.invoice.hash }}
</p>
{% endraw %}
<div v-if="canPay" class="row q-mt-lg">
<q-btn unelevated color="primary" @click="payInvoice">Pay</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
<div v-else class="row q-mt-lg">
<q-btn unelevated disabled color="yellow" text-color="black"
>Not enough funds!</q-btn
> >
<q-btn v-close-popup flat color="grey" class="q-ml-auto" <q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn >Cancel</q-btn
> >
</div> </div>
</q-form> </div>
<div v-else-if="parse.lnurlauth">
{% raw %}
<q-form @submit="authLnurl" class="q-gutter-md">
<p class="q-my-none text-h6">
Authenticate with <b>{{ parse.lnurlauth.domain }}</b>?
</p>
<q-separator class="q-my-sm"></q-separator>
<p>
For every website and for every LNbits wallet, a new keypair will be
deterministically generated so your identity can't be tied to your
LNbits wallet or linked across websites. No other data will be
shared with {{ parse.lnurlauth.domain }}.
</p>
<p>Your public key for <b>{{ parse.lnurlauth.domain }}</b> is:</p>
<p class="q-mx-xl">
<code class="text-wrap"> {{ parse.lnurlauth.pubkey }} </code>
</p>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">Login</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
{% endraw %}
</div>
<div v-else-if="parse.lnurlpay">
{% raw %}
<q-form @submit="payLnurl" class="q-gutter-md">
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
<b>{{ parse.lnurlpay.domain }}</b> is requesting {{
parse.lnurlpay.maxSendable | msatoshiFormat }}
{{LNBITS_DENOMINATION}}
<span v-if="parse.lnurlpay.commentAllowed > 0">
<br />
and a {{parse.lnurlpay.commentAllowed}}-char comment
</span>
</p>
<p v-else class="q-my-none text-h6 text-center">
<b>{{ parse.lnurlpay.targetUser || parse.lnurlpay.domain }}</b> is
requesting <br />
between <b>{{ parse.lnurlpay.minSendable | msatoshiFormat }}</b> and
<b>{{ parse.lnurlpay.maxSendable | msatoshiFormat }}</b>
{% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
<span v-if="parse.lnurlpay.commentAllowed > 0">
<br />
and a {{parse.lnurlpay.commentAllowed}}-char comment
</span>
</p>
<q-separator class="q-my-sm"></q-separator>
<div class="row">
<p class="col text-justify text-italic">
{{ parse.lnurlpay.description }}
</p>
<p class="col-4 q-pl-md" v-if="parse.lnurlpay.image">
<q-img :src="parse.lnurlpay.image" />
</p>
</div>
<div class="row">
<div class="col">
{% endraw %}
<q-input
filled
dense
v-model.number="parse.data.amount"
type="number"
label="Amount ({{LNBITS_DENOMINATION}}) *"
:min="parse.lnurlpay.minSendable / 1000"
:max="parse.lnurlpay.maxSendable / 1000"
:readonly="parse.lnurlpay.fixed"
></q-input>
{% raw %}
</div>
<div class="col-8 q-pl-md" v-if="parse.lnurlpay.commentAllowed > 0">
<q-input
filled
dense
v-model="parse.data.comment"
:type="parse.lnurlpay.commentAllowed > 64 ? 'textarea' : 'text'"
label="Comment (optional)"
:maxlength="parse.lnurlpay.commentAllowed"
></q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit"
>Send {{LNBITS_DENOMINATION}}</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
{% endraw %}
</div>
<div v-else> <div v-else>
<q-responsive :ratio="1"> <q-form
<qrcode-stream v-if="!parse.camera.show"
@decode="decodeQR" @submit="decodeRequest"
class="rounded-borders" class="q-gutter-md"
></qrcode-stream> >
</q-responsive> <q-input
<div class="row q-mt-lg"> filled
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"> dense
Cancel v-model.trim="parse.data.request"
</q-btn> type="textarea"
label="Paste an invoice, payment request or lnurl code *"
>
</q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="parse.data.request == ''"
type="submit"
>Read</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
<div v-else>
<q-responsive :ratio="1">
<qrcode-stream
@decode="decodeQR"
class="rounded-borders"
></qrcode-stream>
</q-responsive>
<div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto">
Cancel
</q-btn>
</div>
</div> </div>
</div> </div>
</div> </q-card>
</q-card> </q-dialog>
</q-dialog>
<q-dialog v-model="parse.camera.show"> <q-dialog v-model="parse.camera.show">
<q-card class="q-pa-lg q-pt-xl"> <q-card class="q-pa-lg q-pt-xl">
<div class="text-center q-mb-lg"> <div class="text-center q-mb-lg">
<qrcode-stream @decode="decodeQR" class="rounded-borders"></qrcode-stream> <qrcode-stream
</div> @decode="decodeQR"
<div class="row q-mt-lg"> class="rounded-borders"
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto" ></qrcode-stream>
>Cancel</q-btn </div>
> <div class="row q-mt-lg">
</div> <q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
</q-card> >Cancel</q-btn
</q-dialog> >
</div>
</q-card>
</q-dialog>
<q-dialog v-model="paymentsChart.show"> <q-dialog v-model="paymentsChart.show">
<q-card class="q-pa-sm" style="width: 800px; max-width: unset"> <q-card class="q-pa-sm" style="width: 800px; max-width: unset">
<q-card-section> <q-card-section>
<canvas ref="canvas" width="600" height="400"></canvas> <canvas ref="canvas" width="600" height="400"></canvas>
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-tabs <q-tabs
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-max" class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-max"
active-class="px-0" active-class="px-0"
indicator-color="transparent" indicator-color="transparent"
>
<q-tab
icon="account_balance_wallet"
label="Wallets"
@click="g.visibleDrawer = !g.visibleDrawer"
> >
</q-tab> <q-tab
<q-tab icon="content_paste" label="Paste" @click="showParseDialog"> </q-tab> icon="account_balance_wallet"
<q-tab icon="file_download" label="Receive" @click="showReceiveDialog"> label="Wallets"
</q-tab> @click="g.visibleDrawer = !g.visibleDrawer"
>
</q-tab>
<q-tab icon="content_paste" label="Paste" @click="showParseDialog"> </q-tab>
<q-tab icon="file_download" label="Receive" @click="showReceiveDialog">
</q-tab>
<q-tab icon="photo_camera" label="Scan" @click="showCamera"> </q-tab> <q-tab icon="photo_camera" label="Scan" @click="showCamera"> </q-tab>
</q-tabs> </q-tabs>
{% if service_fee > 0 %}
<div ref="disclaimer"></div> <q-dialog v-model="disclaimerDialog.show">
<q-dialog v-model="disclaimerDialog.show"> <q-card class="q-pa-lg">
<q-card class="q-pa-lg"> <h6 class="q-my-md text-primary">Warning</h6>
<h6 class="q-my-md text-deep-purple">Warning</h6> <p>
<p> Login functionality to be released in v0.2, for now,
Login functionality to be released in v0.2, for now, <strong
<strong >make sure you bookmark this page for future access to your
>make sure you bookmark this page for future access to your wallet</strong
wallet</strong >!
>! </p>
</p> <p>
<p> This service is in BETA, and we hold no responsibility for people losing
This service is in BETA, and we hold no responsibility for people losing access to funds. {% if service_fee > 0 %} To encourage you to run your
access to funds. To encourage you to run your own LNbits installation, any own LNbits installation, any balance on {% raw %}{{
balance on {% raw %}{{ disclaimerDialog.location.host }}{% endraw %} will disclaimerDialog.location.host }}{% endraw %} will incur a charge of
incur a charge of <strong>{{ service_fee }}% service fee</strong> per <strong>{{ service_fee }}% service fee</strong> per week. {% endif %}
week. </p>
</p> <div class="row q-mt-lg">
<div class="row q-mt-lg"> <q-btn
<q-btn outline
outline color="grey"
color="grey" @click="copyText(disclaimerDialog.location.href)"
@click="copyText(disclaimerDialog.location.href)" >Copy wallet URL</q-btn
>Copy wallet URL</q-btn >
> <q-btn v-close-popup flat color="grey" class="q-ml-auto"
<q-btn v-close-popup flat color="grey" class="q-ml-auto" >I understand</q-btn
>I understand</q-btn >
> </div>
</div> </q-card>
</q-card> </q-dialog>
</q-dialog> {% endblock %}
{% endif %} {% endblock %} </div>

View file

@ -7,7 +7,7 @@ from typing import Dict, List, Optional, Union
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
import httpx import httpx
from fastapi import Query, Request from fastapi import Header, Query, Request
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from fastapi.param_functions import Depends from fastapi.param_functions import Depends
from fastapi.params import Body from fastapi.params import Body
@ -23,9 +23,11 @@ from lnbits.decorators import (
WalletInvoiceKeyChecker, WalletInvoiceKeyChecker,
WalletTypeInfo, WalletTypeInfo,
get_key_type, get_key_type,
require_admin_key,
) )
from lnbits.helpers import url_for from lnbits.helpers import url_for, urlsafe_short_hash
from lnbits.requestvars import g from lnbits.requestvars import g
from lnbits.settings import LNBITS_ADMIN_USERS, LNBITS_SITE_TITLE
from lnbits.utils.exchange_rates import ( from lnbits.utils.exchange_rates import (
currencies, currencies,
fiat_amount_as_satoshis, fiat_amount_as_satoshis,
@ -34,13 +36,14 @@ from lnbits.utils.exchange_rates import (
from .. import core_app, db from .. import core_app, db
from ..crud import ( from ..crud import (
create_payment,
get_payments, get_payments,
get_standalone_payment, get_standalone_payment,
save_balance_check,
update_wallet,
create_payment,
get_wallet, get_wallet,
get_wallet_for_key,
save_balance_check,
update_payment_status, update_payment_status,
update_wallet,
) )
from ..services import ( from ..services import (
InvoiceFailure, InvoiceFailure,
@ -51,8 +54,6 @@ from ..services import (
perform_lnurlauth, perform_lnurlauth,
) )
from ..tasks import api_invoice_listeners from ..tasks import api_invoice_listeners
from lnbits.settings import LNBITS_ADMIN_USERS
from lnbits.helpers import urlsafe_short_hash
@core_app.get("/api/v1/wallet") @core_app.get("/api/v1/wallet")
@ -98,7 +99,7 @@ async def api_update_balance(
@core_app.put("/api/v1/wallet/{new_name}") @core_app.put("/api/v1/wallet/{new_name}")
async def api_update_wallet( async def api_update_wallet(
new_name: str, wallet: WalletTypeInfo = Depends(WalletAdminKeyChecker()) new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
): ):
await update_wallet(wallet.wallet.id, new_name) await update_wallet(wallet.wallet.id, new_name)
return { return {
@ -123,8 +124,8 @@ async def api_payments(wallet: WalletTypeInfo = Depends(get_key_type)):
class CreateInvoiceData(BaseModel): class CreateInvoiceData(BaseModel):
out: Optional[bool] = True out: Optional[bool] = True
amount: int = Query(None, ge=1) amount: float = Query(None, ge=0)
memo: str = None memo: Optional[str] = None
unit: Optional[str] = "sat" unit: Optional[str] = "sat"
description_hash: Optional[str] = None description_hash: Optional[str] = None
lnurl_callback: Optional[str] = None lnurl_callback: Optional[str] = None
@ -140,9 +141,9 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
memo = "" memo = ""
else: else:
description_hash = b"" description_hash = b""
memo = data.memo memo = data.memo or LNBITS_SITE_TITLE
if data.unit == "sat": if data.unit == "sat":
amount = data.amount amount = int(data.amount)
else: else:
price_in_sats = await fiat_amount_as_satoshis(data.amount, data.unit) price_in_sats = await fiat_amount_as_satoshis(data.amount, data.unit)
amount = price_in_sats amount = price_in_sats
@ -292,11 +293,11 @@ async def api_payments_pay_lnurl(
detail=f"{domain} returned an invalid invoice. Expected {data.amount} msat, got {invoice.amount_msat}.", detail=f"{domain} returned an invalid invoice. Expected {data.amount} msat, got {invoice.amount_msat}.",
) )
# if invoice.description_hash != data.description_hash: # if invoice.description_hash != data.description_hash:
# raise HTTPException( # raise HTTPException(
# status_code=HTTPStatus.BAD_REQUEST, # status_code=HTTPStatus.BAD_REQUEST,
# detail=f"{domain} returned an invalid invoice. Expected description_hash == {data.description_hash}, got {invoice.description_hash}.", # detail=f"{domain} returned an invalid invoice. Expected description_hash == {data.description_hash}, got {invoice.description_hash}.",
# ) # )
extra = {} extra = {}
@ -363,7 +364,13 @@ async def api_payments_sse(
@core_app.get("/api/v1/payments/{payment_hash}") @core_app.get("/api/v1/payments/{payment_hash}")
async def api_payment(payment_hash): async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
wallet = None
try:
if X_Api_Key.extra:
print("No key")
except:
wallet = await get_wallet_for_key(X_Api_Key)
payment = await get_standalone_payment(payment_hash) payment = await get_standalone_payment(payment_hash)
await check_invoice_status(payment.wallet_id, payment_hash) await check_invoice_status(payment.wallet_id, payment_hash)
payment = await get_standalone_payment(payment_hash) payment = await get_standalone_payment(payment_hash)
@ -372,13 +379,23 @@ async def api_payment(payment_hash):
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
) )
elif not payment.pending: elif not payment.pending:
if wallet and wallet.id == payment.wallet_id:
return {"paid": True, "preimage": payment.preimage, "details": payment}
return {"paid": True, "preimage": payment.preimage} return {"paid": True, "preimage": payment.preimage}
try: try:
await payment.check_pending() await payment.check_pending()
except Exception: except Exception:
if wallet and wallet.id == payment.wallet_id:
return {"paid": False, "details": payment}
return {"paid": False} return {"paid": False}
if wallet and wallet.id == payment.wallet_id:
return {
"paid": not payment.pending,
"preimage": payment.preimage,
"details": payment,
}
return {"paid": not payment.pending, "preimage": payment.preimage} return {"paid": not payment.pending, "preimage": payment.preimage}
@ -500,14 +517,19 @@ async def api_lnurlscan(code: str):
return params return params
class DecodePayment(BaseModel):
data: str
@core_app.post("/api/v1/payments/decode") @core_app.post("/api/v1/payments/decode")
async def api_payments_decode(data: str = Query(None)): async def api_payments_decode(data: DecodePayment):
payment_str = data.data
try: try:
if data["data"][:5] == "LNURL": if payment_str[:5] == "LNURL":
url = lnurl.decode(data["data"]) url = lnurl.decode(payment_str)
return {"domain": url} return {"domain": url}
else: else:
invoice = bolt11.decode(data["data"]) invoice = bolt11.decode(payment_str)
return { return {
"payment_hash": invoice.payment_hash, "payment_hash": invoice.payment_hash,
"amount_msat": invoice.amount_msat, "amount_msat": invoice.amount_msat,
@ -559,6 +581,6 @@ async def api_fiat_as_sats(data: ConversionData):
return output return output
else: else:
output[data.from_.upper()] = data.amount output[data.from_.upper()] = data.amount
output["sats"] = await fiat_amount_as_satoshis(data.amount, data.to) output["sats"] = await fiat_amount_as_satoshis(data.amount, data.from_)
output["BTC"] = output["sats"] / 100000000 output["BTC"] = output["sats"] / 100000000
return output return output

View file

@ -15,8 +15,8 @@ from lnbits.core.models import User
from lnbits.decorators import check_user_exists from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer, url_for from lnbits.helpers import template_renderer, url_for
from lnbits.settings import ( from lnbits.settings import (
LNBITS_ALLOWED_USERS,
LNBITS_ADMIN_USERS, LNBITS_ADMIN_USERS,
LNBITS_ALLOWED_USERS,
LNBITS_SITE_TITLE, LNBITS_SITE_TITLE,
SERVICE_FEE, SERVICE_FEE,
) )
@ -226,7 +226,9 @@ async def lnurl_balance_notify(request: Request, service: str):
redeem_lnurl_withdraw(bc.wallet, bc.url) redeem_lnurl_withdraw(bc.wallet, bc.url)
@core_html_routes.get("/lnurlwallet", response_class=RedirectResponse) @core_html_routes.get(
"/lnurlwallet", response_class=RedirectResponse, name="core.lnurlwallet"
)
async def lnurlwallet(request: Request): async def lnurlwallet(request: Request):
async with db.connect() as conn: async with db.connect() as conn:
account = await create_account(conn=conn) account = await create_account(conn=conn)

View file

@ -130,9 +130,15 @@ class Database(Compat):
) )
) )
else: else:
self.path = os.path.join(LNBITS_DATA_FOLDER, f"{self.name}.sqlite3") if os.path.isdir(LNBITS_DATA_FOLDER):
database_uri = f"sqlite:///{self.path}" self.path = os.path.join(LNBITS_DATA_FOLDER, f"{self.name}.sqlite3")
self.type = SQLITE database_uri = f"sqlite:///{self.path}"
self.type = SQLITE
else:
raise NotADirectoryError(
f"LNBITS_DATA_FOLDER named {LNBITS_DATA_FOLDER} was not created"
f" - please 'mkdir {LNBITS_DATA_FOLDER}' and try again"
)
self.schema = self.name self.schema = self.name
if self.name.startswith("ext_"): if self.name.startswith("ext_"):

View file

@ -13,7 +13,11 @@ from starlette.requests import Request
from lnbits.core.crud import get_user, get_wallet_for_key from lnbits.core.crud import get_user, get_wallet_for_key
from lnbits.core.models import User, Wallet from lnbits.core.models import User, Wallet
from lnbits.requestvars import g from lnbits.requestvars import g
from lnbits.settings import LNBITS_ALLOWED_USERS, LNBITS_ADMIN_USERS, LNBITS_ADMIN_EXTENSIONS from lnbits.settings import (
LNBITS_ALLOWED_USERS,
LNBITS_ADMIN_USERS,
LNBITS_ADMIN_EXTENSIONS,
)
class KeyChecker(SecurityBase): class KeyChecker(SecurityBase):
@ -122,7 +126,7 @@ async def get_key_type(
# 0: admin # 0: admin
# 1: invoice # 1: invoice
# 2: invalid # 2: invalid
pathname = r['path'].split('/')[1] pathname = r["path"].split("/")[1]
if not api_key_header and not api_key_query: if not api_key_header and not api_key_query:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
@ -133,8 +137,12 @@ async def get_key_type(
checker = WalletAdminKeyChecker(api_key=token) checker = WalletAdminKeyChecker(api_key=token)
await checker.__call__(r) await checker.__call__(r)
wallet = WalletTypeInfo(0, checker.wallet) wallet = WalletTypeInfo(0, checker.wallet)
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS): if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized.") LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS
):
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
)
return wallet return wallet
except HTTPException as e: except HTTPException as e:
if e.status_code == HTTPStatus.BAD_REQUEST: if e.status_code == HTTPStatus.BAD_REQUEST:
@ -147,9 +155,13 @@ async def get_key_type(
try: try:
checker = WalletInvoiceKeyChecker(api_key=token) checker = WalletInvoiceKeyChecker(api_key=token)
await checker.__call__(r) await checker.__call__(r)
wallet = WalletTypeInfo(0, checker.wallet) wallet = WalletTypeInfo(1, checker.wallet)
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS): if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized.") LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS
):
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
)
return wallet return wallet
except HTTPException as e: except HTTPException as e:
if e.status_code == HTTPStatus.BAD_REQUEST: if e.status_code == HTTPStatus.BAD_REQUEST:

View file

@ -88,7 +88,7 @@ async def get_copilot(copilot_id: str) -> Copilots:
async def get_copilots(user: str) -> List[Copilots]: async def get_copilots(user: str) -> List[Copilots]:
rows = await db.fetchall( rows = await db.fetchall(
"SELECT * FROM copilot.newer_copilots WHERE user = ?", (user,) 'SELECT * FROM copilot.newer_copilots WHERE "user" = ?', (user,)
) )
return [Copilots(**row) for row in rows] return [Copilots(**row) for row in rows]

View file

@ -0,0 +1,11 @@
[[source]]
url = "https://pypi.python.org/simple"
verify_ssl = true
name = "pypi"
[packages]
[dev-packages]
[requires]
python_version = "3.9"

View file

@ -0,0 +1,34 @@
# Discord Bot
## Provide LNbits wallets for all your Discord users
_This extension is a modifed version of LNbits [User Manager](../usermanager/README.md)_
The intended usage of this extension is to connect it to a specifically designed [Discord Bot](https://github.com/chrislennon/lnbits-discord-bot) leveraging LNbits as a community based lightning node.
## Setup
This bot can target [lnbits.com](https://lnbits.com) or a self hosted instance.
To setup and run the bot instructions are located [here](https://github.com/chrislennon/lnbits-discord-bot#installation)
## Usage
This bot will allow users to interact with it in the following ways [full command list](https://github.com/chrislennon/lnbits-discord-bot#commands):
`/create` Will create a wallet for the Discord user
- (currently limiting 1 Discord user == 1 LNbits user == 1 user wallet)
![create](https://imgur.com/CWdDusE.png)
`/balance` Will show the balance of the users wallet.
![balance](https://imgur.com/tKeReCp.png)
`/tip @user [amount]` Will sent money from one user to another
- If the recieving user does not have a wallet, one will be created for them
- The receiving user will receive a direct message from the bot with a link to their wallet
![tip](https://imgur.com/K3tnChK.png)
`/payme [amount] [description]` Will open an invoice that can be paid by any user
![payme](https://imgur.com/dFvAqL3.png)

View file

@ -0,0 +1,25 @@
from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
db = Database("ext_discordbot")
discordbot_static_files = [
{
"path": "/discordbot/static",
"app": StaticFiles(directory="lnbits/extensions/discordbot/static"),
"name": "discordbot_static",
}
]
discordbot_ext: APIRouter = APIRouter(prefix="/discordbot", tags=["discordbot"])
def discordbot_renderer():
return template_renderer(["lnbits/extensions/discordbot/templates"])
from .views import * # noqa
from .views_api import * # noqa

View file

@ -0,0 +1,6 @@
{
"name": "Discord Bot",
"short_description": "Generate users and wallets",
"icon": "person_add",
"contributors": ["bitcoingamer21"]
}

View file

@ -0,0 +1,123 @@
from typing import List, Optional
from lnbits.core.crud import (
create_account,
create_wallet,
delete_wallet,
get_payments,
get_user,
)
from lnbits.core.models import Payment
from . import db
from .models import CreateUserData, Users, Wallets
### Users
async def create_discordbot_user(data: CreateUserData) -> Users:
account = await create_account()
user = await get_user(account.id)
assert user, "Newly created user couldn't be retrieved"
wallet = await create_wallet(user_id=user.id, wallet_name=data.wallet_name)
await db.execute(
"""
INSERT INTO discordbot.users (id, name, admin, discord_id)
VALUES (?, ?, ?, ?)
""",
(user.id, data.user_name, data.admin_id, data.discord_id),
)
await db.execute(
"""
INSERT INTO discordbot.wallets (id, admin, name, "user", adminkey, inkey)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
wallet.id,
data.admin_id,
data.wallet_name,
user.id,
wallet.adminkey,
wallet.inkey,
),
)
user_created = await get_discordbot_user(user.id)
assert user_created, "Newly created user couldn't be retrieved"
return user_created
async def get_discordbot_user(user_id: str) -> Optional[Users]:
row = await db.fetchone("SELECT * FROM discordbot.users WHERE id = ?", (user_id,))
return Users(**row) if row else None
async def get_discordbot_users(user_id: str) -> List[Users]:
rows = await db.fetchall(
"SELECT * FROM discordbot.users WHERE admin = ?", (user_id,)
)
return [Users(**row) for row in rows]
async def delete_discordbot_user(user_id: str) -> None:
wallets = await get_discordbot_wallets(user_id)
for wallet in wallets:
await delete_wallet(user_id=user_id, wallet_id=wallet.id)
await db.execute("DELETE FROM discordbot.users WHERE id = ?", (user_id,))
await db.execute("""DELETE FROM discordbot.wallets WHERE "user" = ?""", (user_id,))
### Wallets
async def create_discordbot_wallet(
user_id: str, wallet_name: str, admin_id: str
) -> Wallets:
wallet = await create_wallet(user_id=user_id, wallet_name=wallet_name)
await db.execute(
"""
INSERT INTO discordbot.wallets (id, admin, name, "user", adminkey, inkey)
VALUES (?, ?, ?, ?, ?, ?)
""",
(wallet.id, admin_id, wallet_name, user_id, wallet.adminkey, wallet.inkey),
)
wallet_created = await get_discordbot_wallet(wallet.id)
assert wallet_created, "Newly created wallet couldn't be retrieved"
return wallet_created
async def get_discordbot_wallet(wallet_id: str) -> Optional[Wallets]:
row = await db.fetchone(
"SELECT * FROM discordbot.wallets WHERE id = ?", (wallet_id,)
)
return Wallets(**row) if row else None
async def get_discordbot_wallets(admin_id: str) -> Optional[Wallets]:
rows = await db.fetchall(
"SELECT * FROM discordbot.wallets WHERE admin = ?", (admin_id,)
)
return [Wallets(**row) for row in rows]
async def get_discordbot_users_wallets(user_id: str) -> Optional[Wallets]:
rows = await db.fetchall(
"""SELECT * FROM discordbot.wallets WHERE "user" = ?""", (user_id,)
)
return [Wallets(**row) for row in rows]
async def get_discordbot_wallet_transactions(wallet_id: str) -> Optional[Payment]:
return await get_payments(
wallet_id=wallet_id, complete=True, pending=False, outgoing=True, incoming=True
)
async def delete_discordbot_wallet(wallet_id: str, user_id: str) -> None:
await delete_wallet(user_id=user_id, wallet_id=wallet_id)
await db.execute("DELETE FROM discordbot.wallets WHERE id = ?", (wallet_id,))

View file

@ -0,0 +1,30 @@
async def m001_initial(db):
"""
Initial users table.
"""
await db.execute(
"""
CREATE TABLE discordbot.users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
admin TEXT NOT NULL,
discord_id TEXT
);
"""
)
"""
Initial wallets table.
"""
await db.execute(
"""
CREATE TABLE discordbot.wallets (
id TEXT PRIMARY KEY,
admin TEXT NOT NULL,
name TEXT NOT NULL,
"user" TEXT NOT NULL,
adminkey TEXT NOT NULL,
inkey TEXT NOT NULL
);
"""
)

View file

@ -0,0 +1,38 @@
from sqlite3 import Row
from fastapi.param_functions import Query
from pydantic import BaseModel
from typing import Optional
class CreateUserData(BaseModel):
user_name: str = Query(...)
wallet_name: str = Query(...)
admin_id: str = Query(...)
discord_id: str = Query("")
class CreateUserWallet(BaseModel):
user_id: str = Query(...)
wallet_name: str = Query(...)
admin_id: str = Query(...)
class Users(BaseModel):
id: str
name: str
admin: str
discord_id: str
class Wallets(BaseModel):
id: str
admin: str
name: str
user: str
adminkey: str
inkey: str
@classmethod
def from_row(cls, row: Row) -> "Wallets":
return cls(**dict(row))

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View file

@ -0,0 +1,260 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="Info"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<h5 class="text-subtitle1 q-my-none">
Discord Bot: Connect Discord users to LNbits.
</h5>
<p>
Connect your LNbits instance to a <a href="https://github.com/chrislennon/lnbits-discord-bot">Discord Bot</a> leveraging LNbits as a community based lightning node.<br />
<small>
Created by, <a href="https://github.com/chrislennon">Chris Lennon</a></small
> <br />
<small>
Based on User Manager, by <a href="https://github.com/benarc">Ben Arc</a></small
>
</p>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item group="api" dense expand-separator label="GET users">
<q-card>
<q-card-section>
<code
><span class="text-light-blue">GET</span>
/discordbot/api/v1/users</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>JSON list of users</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}discordbot/api/v1/users -H
"X-Api-Key: {{ user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="GET user">
<q-card>
<q-card-section>
<code
><span class="text-light-blue">GET</span>
/discordbot/api/v1/users/&lt;user_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>JSON list of users</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}discordbot/api/v1/users/&lt;user_id&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="GET wallets">
<q-card>
<q-card-section>
<code
><span class="text-light-blue">GET</span>
/discordbot/api/v1/wallets/&lt;user_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>JSON wallet data</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}discordbot/api/v1/wallets/&lt;user_id&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="GET transactions">
<q-card>
<q-card-section>
<code
><span class="text-light-blue">GET</span>
/discordbot/api/v1/wallets&lt;wallet_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>JSON a wallets transactions</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}discordbot/api/v1/wallets&lt;wallet_id&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="POST user + initial wallet"
>
<q-card>
<q-card-section>
<code
><span class="text-light-green">POST</span>
/discordbot/api/v1/users</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code
>{"X-Api-Key": &lt;string&gt;, "Content-type":
"application/json"}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json) - "admin_id" is a YOUR user ID
</h5>
<code
>{"admin_id": &lt;string&gt;, "user_name": &lt;string&gt;,
"wallet_name": &lt;string&gt;,"discord_id": &lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"id": &lt;string&gt;, "name": &lt;string&gt;, "admin":
&lt;string&gt;, "discord_id": &lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}discordbot/api/v1/users -d
'{"admin_id": "{{ user.id }}", "wallet_name": &lt;string&gt;,
"user_name": &lt;string&gt;, "discord_id": &lt;string&gt;}' -H "X-Api-Key: {{
user.wallets[0].inkey }}" -H "Content-type: application/json"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="POST wallet">
<q-card>
<q-card-section>
<code
><span class="text-light-green">POST</span>
/discordbot/api/v1/wallets</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code
>{"X-Api-Key": &lt;string&gt;, "Content-type":
"application/json"}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json) - "admin_id" is a YOUR user ID
</h5>
<code
>{"user_id": &lt;string&gt;, "wallet_name": &lt;string&gt;,
"admin_id": &lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"id": &lt;string&gt;, "admin": &lt;string&gt;, "name":
&lt;string&gt;, "user": &lt;string&gt;, "adminkey": &lt;string&gt;,
"inkey": &lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}discordbot/api/v1/wallets -d
'{"user_id": &lt;string&gt;, "wallet_name": &lt;string&gt;,
"admin_id": "{{ user.id }}"}' -H "X-Api-Key: {{ user.wallets[0].inkey
}}" -H "Content-type: application/json"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="DELETE user and their wallets"
>
<q-card>
<q-card-section>
<code
><span class="text-red">DELETE</span>
/discordbot/api/v1/users/&lt;user_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.base_url
}}discordbot/api/v1/users/&lt;user_id&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="DELETE wallet">
<q-card>
<q-card-section>
<code
><span class="text-red">DELETE</span>
/discordbot/api/v1/wallets/&lt;wallet_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.base_url
}}discordbot/api/v1/wallets/&lt;wallet_id&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="POST activate extension"
>
<q-card>
<q-card-section>
<code
><span class="text-green">POST</span>
/discordbot/api/v1/extensions</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}discordbot/api/v1/extensions -d
'{"userid": &lt;string&gt;, "extension": &lt;string&gt;, "active":
&lt;integer&gt;}' -H "X-Api-Key: {{ user.wallets[0].inkey }}" -H
"Content-type: application/json"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View file

@ -0,0 +1,464 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<center><img src="/discordbot/static/stack.png" height="200" /></center>
This extension is designed to be used through its API by a Discord Bot,
currently you have to install the bot
<a
href="https://github.com/chrislennon/lnbits-discord-bot/#installation"
>yourself</a
><br />
Soon™ there will be a much easier one-click install discord bot...
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Users</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportUsersCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:data="users"
row-key="id"
:columns="usersTable.columns"
:pagination.sync="usersTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteUser(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Wallets</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportWalletsCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:data="wallets"
row-key="id"
:columns="walletsTable.columns"
:pagination.sync="walletsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="account_balance_wallet"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="props.row.walllink"
target="_blank"
></q-btn>
<q-tooltip> Link to wallet </q-tooltip>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteWallet(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">LNbits Discord Bot Extension
<!--{{SITE_TITLE}} Discord Bot Extension-->
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "discordbot/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="userDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendUserFormData" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="userDialog.data.usrname"
label="Username"
></q-input>
<q-input
filled
dense
v-model.trim="userDialog.data.walname"
label="Initial wallet name"
></q-input>
<q-input
filled
dense
v-model.trim="userDialog.data.discord_id"
label="Discord ID"
></q-input>
<q-btn
unelevated
color="primary"
:disable="userDialog.data.walname == null"
type="submit"
>Create User</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="walletDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendWalletFormData" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="walletDialog.data.user"
:options="userOptions"
label="User *"
>
</q-select>
<q-input
filled
dense
v-model.trim="walletDialog.data.walname"
label="Wallet name"
></q-input>
<q-btn
unelevated
color="primary"
:disable="walletDialog.data.walname == null"
type="submit"
>Create Wallet</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
var mapUserManager = function (obj) {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.walllink = ['../wallet?usr=', obj.user, '&wal=', obj.id].join('')
obj._data = _.clone(obj)
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
wallets: [],
users: [],
usersTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'name', align: 'left', label: 'Username', field: 'name'},
{name: 'discord_id', align: 'left', label: 'discord_id', field: 'discord_id'}
],
pagination: {
rowsPerPage: 10
}
},
walletsTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'name', align: 'left', label: 'Name', field: 'name'},
{name: 'user', align: 'left', label: 'User', field: 'user'},
{
name: 'adminkey',
align: 'left',
label: 'Admin Key',
field: 'adminkey'
},
{name: 'inkey', align: 'left', label: 'Invoice Key', field: 'inkey'}
],
pagination: {
rowsPerPage: 10
}
},
walletDialog: {
show: false,
data: {}
},
userDialog: {
show: false,
data: {}
}
}
},
computed: {
userOptions: function () {
return this.users.map(function (obj) {
console.log(obj.id)
return {
value: String(obj.id),
label: String(obj.id)
}
})
}
},
methods: {
///////////////Users////////////////////////////
getUsers: function () {
var self = this
LNbits.api
.request(
'GET',
'/discordbot/api/v1/users',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.users = response.data.map(function (obj) {
return mapUserManager(obj)
})
})
},
openUserUpdateDialog: function (linkId) {
var link = _.findWhere(this.users, {id: linkId})
this.userDialog.data = _.clone(link._data)
this.userDialog.show = true
},
sendUserFormData: function () {
if (this.userDialog.data.id) {
} else {
var data = {
admin_id: this.g.user.id,
user_name: this.userDialog.data.usrname,
wallet_name: this.userDialog.data.walname,
discord_id: this.userDialog.data.discord_id
}
}
{
this.createUser(data)
}
},
createUser: function (data) {
var self = this
LNbits.api
.request(
'POST',
'/discordbot/api/v1/users',
this.g.user.wallets[0].inkey,
data
)
.then(function (response) {
self.users.push(mapUserManager(response.data))
self.userDialog.show = false
self.userDialog.data = {}
data = {}
self.getWallets()
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteUser: function (userId) {
var self = this
console.log(userId)
LNbits.utils
.confirmDialog('Are you sure you want to delete this User link?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/discordbot/api/v1/users/' + userId,
self.g.user.wallets[0].inkey
)
.then(function (response) {
self.users = _.reject(self.users, function (obj) {
return obj.id == userId
})
self.getWallets()
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportUsersCSV: function () {
LNbits.utils.exportCSV(this.usersTable.columns, this.users)
},
///////////////Wallets////////////////////////////
getWallets: function () {
var self = this
LNbits.api
.request(
'GET',
'/discordbot/api/v1/wallets',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.wallets = response.data.map(function (obj) {
return mapUserManager(obj)
})
})
},
openWalletUpdateDialog: function (linkId) {
var link = _.findWhere(this.users, {id: linkId})
this.walletDialog.data = _.clone(link._data)
this.walletDialog.show = true
},
sendWalletFormData: function () {
if (this.walletDialog.data.id) {
} else {
var data = {
user_id: this.walletDialog.data.user,
admin_id: this.g.user.id,
wallet_name: this.walletDialog.data.walname
}
}
{
this.createWallet(data)
}
},
createWallet: function (data) {
var self = this
LNbits.api
.request(
'POST',
'/discordbot/api/v1/wallets',
this.g.user.wallets[0].inkey,
data
)
.then(function (response) {
self.wallets.push(mapUserManager(response.data))
self.walletDialog.show = false
self.walletDialog.data = {}
data = {}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteWallet: function (userId) {
var self = this
LNbits.utils
.confirmDialog('Are you sure you want to delete this wallet link?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/discordbot/api/v1/wallets/' + userId,
self.g.user.wallets[0].inkey
)
.then(function (response) {
self.wallets = _.reject(self.wallets, function (obj) {
return obj.id == userId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportWalletsCSV: function () {
LNbits.utils.exportCSV(this.walletsTable.columns, this.wallets)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getUsers()
this.getWallets()
}
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,15 @@
from fastapi import Request
from fastapi.params import Depends
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import discordbot_ext, discordbot_renderer
@discordbot_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return discordbot_renderer().TemplateResponse(
"discordbot/index.html", {"request": request, "user": user.dict()}
)

View file

@ -0,0 +1,125 @@
from http import HTTPStatus
from fastapi import Query
from fastapi.params import Depends
from starlette.exceptions import HTTPException
from lnbits.core import update_user_extension
from lnbits.core.crud import get_user
from lnbits.decorators import WalletTypeInfo, get_key_type
from . import discordbot_ext
from .crud import (
create_discordbot_user,
create_discordbot_wallet,
delete_discordbot_user,
delete_discordbot_wallet,
get_discordbot_user,
get_discordbot_users,
get_discordbot_users_wallets,
get_discordbot_wallet,
get_discordbot_wallet_transactions,
get_discordbot_wallets,
)
from .models import CreateUserData, CreateUserWallet
# Users
@discordbot_ext.get("/api/v1/users", status_code=HTTPStatus.OK)
async def api_discordbot_users(wallet: WalletTypeInfo = Depends(get_key_type)):
user_id = wallet.wallet.user
return [user.dict() for user in await get_discordbot_users(user_id)]
@discordbot_ext.get("/api/v1/users/{user_id}", status_code=HTTPStatus.OK)
async def api_discordbot_user(user_id, wallet: WalletTypeInfo = Depends(get_key_type)):
user = await get_discordbot_user(user_id)
return user.dict()
@discordbot_ext.post("/api/v1/users", status_code=HTTPStatus.CREATED)
async def api_discordbot_users_create(
data: CreateUserData, wallet: WalletTypeInfo = Depends(get_key_type)
):
user = await create_discordbot_user(data)
full = user.dict()
full["wallets"] = [
wallet.dict() for wallet in await get_discordbot_users_wallets(user.id)
]
return full
@discordbot_ext.delete("/api/v1/users/{user_id}")
async def api_discordbot_users_delete(
user_id, wallet: WalletTypeInfo = Depends(get_key_type)
):
user = await get_discordbot_user(user_id)
if not user:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
)
await delete_discordbot_user(user_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
# Activate Extension
@discordbot_ext.post("/api/v1/extensions")
async def api_discordbot_activate_extension(
extension: str = Query(...), userid: str = Query(...), active: bool = Query(...)
):
user = await get_user(userid)
if not user:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
)
update_user_extension(user_id=userid, extension=extension, active=active)
return {"extension": "updated"}
# Wallets
@discordbot_ext.post("/api/v1/wallets")
async def api_discordbot_wallets_create(
data: CreateUserWallet, wallet: WalletTypeInfo = Depends(get_key_type)
):
user = await create_discordbot_wallet(
user_id=data.user_id, wallet_name=data.wallet_name, admin_id=data.admin_id
)
return user.dict()
@discordbot_ext.get("/api/v1/wallets")
async def api_discordbot_wallets(wallet: WalletTypeInfo = Depends(get_key_type)):
admin_id = wallet.wallet.user
return [wallet.dict() for wallet in await get_discordbot_wallets(admin_id)]
@discordbot_ext.get("/api/v1/transactions/{wallet_id}")
async def api_discordbot_wallet_transactions(
wallet_id, wallet: WalletTypeInfo = Depends(get_key_type)
):
return await get_discordbot_wallet_transactions(wallet_id)
@discordbot_ext.get("/api/v1/wallets/{user_id}")
async def api_discordbot_users_wallets(
user_id, wallet: WalletTypeInfo = Depends(get_key_type)
):
return [s_wallet.dict() for s_wallet in await get_discordbot_users_wallets(user_id)]
@discordbot_ext.delete("/api/v1/wallets/{wallet_id}")
async def api_discordbot_wallets_delete(
wallet_id, wallet: WalletTypeInfo = Depends(get_key_type)
):
get_wallet = await get_discordbot_wallet(wallet_id)
if not get_wallet:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
)
await delete_discordbot_wallet(wallet_id, get_wallet.user)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)

View file

@ -76,7 +76,7 @@ async def delete_ticket(payment_hash: str) -> None:
async def delete_event_tickets(event_id: str) -> None: async def delete_event_tickets(event_id: str) -> None:
await db.execute("DELETE FROM events.tickets WHERE event = ?", (event_id,)) await db.execute("DELETE FROM events.ticket WHERE event = ?", (event_id,))
# EVENTS # EVENTS

View file

@ -0,0 +1,11 @@
<h1>Example Extension</h1>
<h2>*tagline*</h2>
This is an example extension to help you organise and build you own.
Try to include an image
<img src="https://i.imgur.com/9i4xcQB.png">
<h2>If your extension has API endpoints, include useful ones here</h2>
<code>curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY"</code>

View file

@ -0,0 +1,16 @@
from fastapi import APIRouter
from lnbits.db import Database
from lnbits.helpers import template_renderer
db = Database("ext_example")
example_ext: APIRouter = APIRouter(prefix="/example", tags=["example"])
def example_renderer():
return template_renderer(["lnbits/extensions/example/templates"])
from .views import * # noqa
from .views_api import * # noqa

View file

@ -0,0 +1,6 @@
{
"name": "Build your own!!",
"short_description": "Join us, make an extension",
"icon": "info",
"contributors": ["github_username"]
}

View file

@ -0,0 +1,10 @@
# async def m001_initial(db):
# await db.execute(
# f"""
# CREATE TABLE example.example (
# id TEXT PRIMARY KEY,
# wallet TEXT NOT NULL,
# time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
# );
# """
# )

View file

@ -0,0 +1,5 @@
# from pydantic import BaseModel
# class Example(BaseModel):
# id: str
# wallet: str

View file

@ -0,0 +1,59 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<q-card>
<q-card-section>
<h5 class="text-subtitle1 q-mt-none q-mb-md">
Frameworks used by {{SITE_TITLE}}
</h5>
<q-list>
<q-item
v-for="tool in tools"
:key="tool.name"
tag="a"
:href="tool.url"
target="_blank"
>
{% raw %}
<!-- with raw Flask won't try to interpret the Vue moustaches -->
<q-item-section>
<q-item-label>{{ tool.name }}</q-item-label>
<q-item-label caption>{{ tool.language }}</q-item-label>
</q-item-section>
{% endraw %}
</q-item>
</q-list>
<q-separator class="q-my-lg"></q-separator>
<p>
A magical "g" is always available, with info about the user, wallets and
extensions:
</p>
<code class="text-caption">{% raw %}{{ g }}{% endraw %}</code>
</q-card-section>
</q-card>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
tools: []
}
},
created: function () {
var self = this
// axios is available for making requests
axios({
method: 'GET',
url: '/example/api/v1/tools',
headers: {
'X-example-header': 'not-used'
}
}).then(function (response) {
self.tools = response.data
})
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,18 @@
from fastapi import FastAPI, 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 example_ext, example_renderer
templates = Jinja2Templates(directory="templates")
@example_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return example_renderer().TemplateResponse(
"example/index.html", {"request": request, "user": user.dict()}
)

View file

@ -0,0 +1,35 @@
# views_api.py is for you API endpoints that could be hit by another service
# add your dependencies here
# import httpx
# (use httpx just like requests, except instead of response.ok there's only the
# response.is_error that is its inverse)
from . import example_ext
# add your endpoints here
@example_ext.get("/api/v1/tools")
async def api_example():
"""Try to add descriptions for others."""
tools = [
{
"name": "fastAPI",
"url": "https://fastapi.tiangolo.com/",
"language": "Python",
},
{
"name": "Vue.js",
"url": "https://vuejs.org/",
"language": "JavaScript",
},
{
"name": "Quasar Framework",
"url": "https://quasar.dev/",
"language": "JavaScript",
},
]
return tools

View file

@ -12,7 +12,7 @@ async def create_jukebox(
juke_id = urlsafe_short_hash() juke_id = urlsafe_short_hash()
result = await db.execute( result = await db.execute(
""" """
INSERT INTO jukebox.jukebox (id, user, title, wallet, sp_user, sp_secret, sp_access_token, sp_refresh_token, sp_device, sp_playlists, price, profit) INSERT INTO jukebox.jukebox (id, "user", title, wallet, sp_user, sp_secret, sp_access_token, sp_refresh_token, sp_device, sp_playlists, price, profit)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
@ -41,6 +41,7 @@ async def update_jukebox(
q = ", ".join([f"{field[0]} = ?" for field in data]) q = ", ".join([f"{field[0]} = ?" for field in data])
items = [f"{field[1]}" for field in data] items = [f"{field[1]}" for field in data]
items.append(juke_id) items.append(juke_id)
q = q.replace("user", '"user"', 1) # hack to make user be "user"!
await db.execute(f"UPDATE jukebox.jukebox SET {q} WHERE id = ?", (items)) await db.execute(f"UPDATE jukebox.jukebox SET {q} WHERE id = ?", (items))
row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,)) row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,))
return Jukebox(**row) if row else None return Jukebox(**row) if row else None
@ -57,11 +58,11 @@ async def get_jukebox_by_user(user: str) -> Optional[Jukebox]:
async def get_jukeboxs(user: str) -> List[Jukebox]: async def get_jukeboxs(user: str) -> List[Jukebox]:
rows = await db.fetchall("SELECT * FROM jukebox.jukebox WHERE user = ?", (user,)) rows = await db.fetchall('SELECT * FROM jukebox.jukebox WHERE "user" = ?', (user,))
for row in rows: for row in rows:
if row.sp_playlists == None: if row.sp_playlists == None:
await delete_jukebox(row.id) await delete_jukebox(row.id)
rows = await db.fetchall("SELECT * FROM jukebox.jukebox WHERE user = ?", (user,)) rows = await db.fetchall('SELECT * FROM jukebox.jukebox WHERE "user" = ?', (user,))
return [Jukebox(**row) for row in rows] return [Jukebox(**row) for row in rows]

View file

@ -75,7 +75,6 @@ async def api_check_credentials_check(
juke_id: str = Query(None), wallet: WalletTypeInfo = Depends(require_admin_key) juke_id: str = Query(None), wallet: WalletTypeInfo = Depends(require_admin_key)
): ):
jukebox = await get_jukebox(juke_id) jukebox = await get_jukebox(juke_id)
return jukebox return jukebox
@ -442,7 +441,7 @@ async def api_get_jukebox_currently(
token = await api_get_token(juke_id) token = await api_get_token(juke_id)
if token == False: if token == False:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="INvoice not paid" status_code=HTTPStatus.FORBIDDEN, detail="Invoice not paid"
) )
elif retry: elif retry:
raise HTTPException( raise HTTPException(
@ -456,5 +455,6 @@ async def api_get_jukebox_currently(
) )
except: except:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Something went wrong" status_code=HTTPStatus.NOT_FOUND,
detail="Something went wrong, or no song is playing yet",
) )

View file

@ -1,4 +1,5 @@
from http import HTTPStatus from http import HTTPStatus
# from mmap import MAP_DENYWRITE # from mmap import MAP_DENYWRITE
from fastapi.param_functions import Depends from fastapi.param_functions import Depends

View file

@ -117,9 +117,10 @@
{% raw %} {% raw %}
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props"> <q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }} {{ col.label }}
</q-th> </q-th>
</q-tr> </q-tr>
</template> </template>
@ -136,9 +137,19 @@
:href="'mailto:' + props.row.email" :href="'mailto:' + props.row.email"
></q-btn> ></q-btn>
</q-td> </q-td>
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="launch"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="ticketCard(props)"
><q-tooltip> Click to show ticket </q-tooltip></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props"> <q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }} {{ col.label == "Ticket" ? col.value.length > 20 ? `${col.value.substring(0, 20)}...` : col.value : col.value }}
</q-td> </q-td>
<q-td auto-width> <q-td auto-width>
@ -249,6 +260,29 @@
</q-form> </q-form>
</q-card> </q-card>
</q-dialog> </q-dialog>
<!-- Read Ticket Dialog -->
<q-dialog v-model="ticketDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
{% raw %}
<q-card-section>
<h4 class="text-subtitle1 q-my-none">
<i>{{this.ticketDialog.data.name}}</i> sent a ticket
</h4>
<div v-if="this.ticketDialog.data.email">
<small>{{this.ticketDialog.data.email}}</small>
</div>
<small>{{this.ticketDialog.data.date}}</small>
</q-card-section>
<q-separator></q-separator>
<q-card-section>
<p>{{this.ticketDialog.data.content}}</p>
</q-card-section>
{% endraw %}
<q-card-actions align="right">
<q-btn flat label="CLOSE" color="primary" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</div> </div>
{% endblock %} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block scripts %} {{ window_vars(user) }}
<script> <script>
@ -318,6 +352,10 @@
formDialog: { formDialog: {
show: false, show: false,
data: {flatrate: false} data: {flatrate: false}
},
ticketDialog: {
show: false,
data: {}
} }
} }
}, },
@ -372,6 +410,16 @@
}) })
}) })
}, },
ticketCard(ticket){
this.ticketDialog.show = true
let {date, email, ltext, name} = ticket.row
this.ticketDialog.data = {
date,
email,
content: ltext,
name
}
},
exportticketsCSV: function () { exportticketsCSV: function () {
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets) LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
}, },
@ -421,12 +469,13 @@
}, },
updateformDialog: function (formId) { updateformDialog: function (formId) {
var link = _.findWhere(this.forms, {id: formId}) var link = _.findWhere(this.forms, {id: formId})
console.log("LINK", link)
this.formDialog.data.id = link.id this.formDialog.data.id = link.id
this.formDialog.data.wallet = link.wallet this.formDialog.data.wallet = link.wallet
this.formDialog.data.name = link.name this.formDialog.data.name = link.name
this.formDialog.data.description = link.description this.formDialog.data.description = link.description
this.formDialog.data.flatrate = link.flatrate this.formDialog.data.flatrate = Boolean(link.flatrate)
this.formDialog.data.amount = link.amount this.formDialog.data.amount = link.amount
this.formDialog.show = true this.formDialog.show = true
}, },

View file

@ -103,6 +103,10 @@ async def api_ticket_make_ticket(data: CreateTicketData, form_id):
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail=f"LNTicket does not exist." status_code=HTTPStatus.NOT_FOUND, detail=f"LNTicket does not exist."
) )
if data.sats < 1:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail=f"0 invoices not allowed."
)
nwords = len(re.split(r"\s+", data.ltext)) nwords = len(re.split(r"\s+", data.ltext))

View file

@ -9,6 +9,12 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
returning = "" if db.type == SQLITE else "RETURNING ID" returning = "" if db.type == SQLITE else "RETURNING ID"
method = db.execute if db.type == SQLITE else db.fetchone method = db.execute if db.type == SQLITE else db.fetchone
# database only allows int4 entries for min and max. For fiat currencies,
# we multiply by data.fiat_base_multiplier (usually 100) to save the value in cents.
if data.currency and data.fiat_base_multiplier:
data.min *= data.fiat_base_multiplier
data.max *= data.fiat_base_multiplier
result = await (method)( result = await (method)(
f""" f"""
INSERT INTO lnurlp.pay_links ( INSERT INTO lnurlp.pay_links (
@ -22,9 +28,10 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
success_text, success_text,
success_url, success_url,
comment_chars, comment_chars,
currency currency,
fiat_base_multiplier
) )
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?)
{returning} {returning}
""", """,
( (
@ -37,6 +44,7 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
data.success_url, data.success_url,
data.comment_chars, data.comment_chars,
data.currency, data.currency,
data.fiat_base_multiplier,
), ),
) )
if db.type == SQLITE: if db.type == SQLITE:

View file

@ -33,7 +33,7 @@ async def api_lnurl_response(request: Request, link_id):
resp = LnurlPayResponse( resp = LnurlPayResponse(
callback=request.url_for("lnurlp.api_lnurl_callback", link_id=link.id), callback=request.url_for("lnurlp.api_lnurl_callback", link_id=link.id),
min_sendable=math.ceil(link.min * rate) * 1000, min_sendable=round(link.min * rate) * 1000,
max_sendable=round(link.max * rate) * 1000, max_sendable=round(link.max * rate) * 1000,
metadata=link.lnurlpay_metadata, metadata=link.lnurlpay_metadata,
) )

View file

@ -50,3 +50,13 @@ async def m003_min_max_comment_fiat(db):
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN max INTEGER;") await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN max INTEGER;")
await db.execute("UPDATE lnurlp.pay_links SET max = min;") await db.execute("UPDATE lnurlp.pay_links SET max = min;")
await db.execute("DROP TABLE lnurlp.invoices") await db.execute("DROP TABLE lnurlp.invoices")
async def m004_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 lnurlp.pay_links ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;"
)

View file

@ -11,20 +11,21 @@ from pydantic import BaseModel
class CreatePayLinkData(BaseModel): class CreatePayLinkData(BaseModel):
description: str description: str
min: int = Query(0.01, ge=0.01) min: float = Query(1, ge=0.01)
max: int = Query(0.01, ge=0.01) max: float = Query(1, ge=0.01)
currency: str = Query(None) currency: str = Query(None)
comment_chars: int = Query(0, ge=0, lt=800) comment_chars: int = Query(0, ge=0, lt=800)
webhook_url: str = Query(None) webhook_url: str = Query(None)
success_text: str = Query(None) success_text: str = Query(None)
success_url: str = Query(None) success_url: str = Query(None)
fiat_base_multiplier: int = Query(100, ge=1)
class PayLink(BaseModel): class PayLink(BaseModel):
id: int id: int
wallet: str wallet: str
description: str description: str
min: int min: float
served_meta: int served_meta: int
served_pr: int served_pr: int
webhook_url: Optional[str] webhook_url: Optional[str]
@ -32,11 +33,15 @@ class PayLink(BaseModel):
success_url: Optional[str] success_url: Optional[str]
currency: Optional[str] currency: Optional[str]
comment_chars: int comment_chars: int
max: int max: float
fiat_base_multiplier: int
@classmethod @classmethod
def from_row(cls, row: Row) -> "PayLink": def from_row(cls, row: Row) -> "PayLink":
data = dict(row) data = dict(row)
if data["currency"] and data["fiat_base_multiplier"]:
data["min"] /= data["fiat_base_multiplier"]
data["max"] /= data["fiat_base_multiplier"]
return cls(**data) return cls(**data)
def lnurl(self, req: Request) -> str: def lnurl(self, req: Request) -> str:

View file

@ -76,13 +76,14 @@ async def api_link_create_or_update(
link_id=None, link_id=None,
wallet: WalletTypeInfo = Depends(get_key_type), wallet: WalletTypeInfo = Depends(get_key_type),
): ):
if data.min > data.max: if data.min > data.max:
raise HTTPException( raise HTTPException(
detail="Min is greater than max.", status_code=HTTPStatus.BAD_REQUEST detail="Min is greater than max.", status_code=HTTPStatus.BAD_REQUEST
) )
if data.currency == None and ( if data.currency == None and (
round(data.min) != data.min or round(data.max) != data.max round(data.min) != data.min or round(data.max) != data.max or data.min < 1
): ):
raise HTTPException( raise HTTPException(
detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST

View file

@ -8,8 +8,8 @@ def hotp(key, counter, digits=6, digest="sha1"):
key = base64.b32decode(key.upper() + "=" * ((8 - len(key)) % 8)) key = base64.b32decode(key.upper() + "=" * ((8 - len(key)) % 8))
counter = struct.pack(">Q", counter) counter = struct.pack(">Q", counter)
mac = hmac.new(key, counter, digest).digest() mac = hmac.new(key, counter, digest).digest()
offset = mac[-1] & 0x0f offset = mac[-1] & 0x0F
binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7fffffff binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7FFFFFFF
return str(binary)[-digits:].zfill(digits) return str(binary)[-digits:].zfill(digits)

View file

@ -54,8 +54,7 @@ async def api_paywall_delete(
@paywall_ext.post("/api/v1/paywalls/invoice/{paywall_id}") @paywall_ext.post("/api/v1/paywalls/invoice/{paywall_id}")
async def api_paywall_create_invoice( async def api_paywall_create_invoice(
data: CreatePaywallInvoice, data: CreatePaywallInvoice, paywall_id: str = Query(None)
paywall_id: str = Query(None)
): ):
paywall = await get_paywall(paywall_id) paywall = await get_paywall(paywall_id)
if data.amount < paywall.amount: if data.amount < paywall.amount:
@ -78,7 +77,9 @@ async def api_paywall_create_invoice(
@paywall_ext.post("/api/v1/paywalls/check_invoice/{paywall_id}") @paywall_ext.post("/api/v1/paywalls/check_invoice/{paywall_id}")
async def api_paywal_check_invoice(data: CheckPaywallInvoice, paywall_id: str = Query(None)): async def api_paywal_check_invoice(
data: CheckPaywallInvoice, paywall_id: str = Query(None)
):
paywall = await get_paywall(paywall_id) paywall = await get_paywall(paywall_id)
payment_hash = data.payment_hash payment_hash = data.payment_hash
if not paywall: if not paywall:

View file

@ -64,7 +64,7 @@
label="lightning⚡" label="lightning⚡"
> >
<q-tooltip> <q-tooltip>
bitcoin onchain payment method not available bitcoin lightning payment method not available
</q-tooltip> </q-tooltip>
</q-btn> </q-btn>
<q-btn <q-btn
@ -86,7 +86,7 @@
label="onchain⛓" label="onchain⛓"
> >
<q-tooltip> <q-tooltip>
bitcoin lightning payment method not available bitcoin onchain payment method not available
</q-tooltip> </q-tooltip>
</q-btn> </q-btn>
<q-btn <q-btn
@ -243,7 +243,7 @@
} }
}, },
methods: { methods: {
startPaymentNotifier(){ startPaymentNotifier() {
this.cancelListener() this.cancelListener()
this.cancelListener = LNbits.event.onInvoicePaid( this.cancelListener = LNbits.event.onInvoicePaid(

View file

@ -60,7 +60,7 @@ class Service(BaseModel):
onchain: Optional[str] onchain: Optional[str]
servicename: str # Currently, this will just always be "Streamlabs" servicename: str # Currently, this will just always be "Streamlabs"
authenticated: bool # Whether a token (see below) has been acquired yet authenticated: bool # Whether a token (see below) has been acquired yet
token: Optional[int] # The token with which to authenticate requests token: Optional[str] # The token with which to authenticate requests
@classmethod @classmethod
def from_row(cls, row: Row) -> "Service": def from_row(cls, row: Row) -> "Service":

View file

@ -62,7 +62,7 @@
donationDialog: { donationDialog: {
show: false, show: false,
data: { data: {
name: '', name: null,
sats: '', sats: '',
message: '' message: ''
} }

View file

@ -7,6 +7,7 @@ from starlette.responses import RedirectResponse
from lnbits.core.crud import get_user from lnbits.core.crud import get_user
from lnbits.decorators import WalletTypeInfo, get_key_type from lnbits.decorators import WalletTypeInfo, get_key_type
from lnbits.extensions.satspay.models import CreateCharge
from lnbits.extensions.streamalerts.models import ( from lnbits.extensions.streamalerts.models import (
CreateDonation, CreateDonation,
CreateService, CreateService,
@ -113,10 +114,10 @@ async def api_create_donation(data: CreateDonation, request: Request):
service_id = data.service service_id = data.service
service = await get_service(service_id) service = await get_service(service_id)
charge_details = await get_charge_details(service.id) charge_details = await get_charge_details(service.id)
name = data.name name = data.name if data.name else "Anonymous"
description = f"{sats} sats donation from {name} to {service.twitchuser}" description = f"{sats} sats donation from {name} to {service.twitchuser}"
charge = await create_charge( create_charge_data = CreateCharge(
amount=sats, amount=sats,
completelink=f"https://twitch.tv/{service.twitchuser}", completelink=f"https://twitch.tv/{service.twitchuser}",
completelinktext="Back to Stream!", completelinktext="Back to Stream!",
@ -124,6 +125,7 @@ async def api_create_donation(data: CreateDonation, request: Request):
description=description, description=description,
**charge_details, **charge_details,
) )
charge = await create_charge(user=charge_details["user"], data=create_charge_data)
await create_donation( await create_donation(
id=charge.id, id=charge.id,
wallet=service.wallet, wallet=service.wallet,

View file

@ -1,5 +1,5 @@
from sqlite3 import Row from sqlite3 import Row
from typing import NamedTuple, Optional from typing import Optional
from fastapi.param_functions import Query from fastapi.param_functions import Query
from pydantic import BaseModel from pydantic import BaseModel
@ -26,7 +26,7 @@ class createTip(BaseModel):
message: str = "" message: str = ""
class Tip(NamedTuple): class Tip(BaseModel):
"""A Tip represents a single donation""" """A Tip represents a single donation"""
id: str # This ID always corresponds to a satspay charge ID id: str # This ID always corresponds to a satspay charge ID
@ -55,7 +55,7 @@ class createTips(BaseModel):
message: str message: str
class TipJar(NamedTuple): class TipJar(BaseModel):
"""A TipJar represents a user's tip jar""" """A TipJar represents a user's tip jar"""
id: int id: int

View file

@ -222,6 +222,7 @@
'INR', 'INR',
'IQD', 'IQD',
'IRR', 'IRR',
'IRT',
'ISK', 'ISK',
'JEP', 'JEP',
'JMD', 'JMD',

View file

@ -16,39 +16,95 @@
<div class="row justify-center full-width"> <div class="row justify-center full-width">
<div class="col-12 col-sm-8 col-md-6 col-lg-4"> <div class="col-12 col-sm-8 col-md-6 col-lg-4">
<div class="keypad q-pa-sm"> <div class="keypad q-pa-sm">
<q-btn unelevated @click="stack.push(1)" size="xl" color="grey-8" <q-btn
unelevated
@click="stack.push(1)"
size="xl"
:outline="!($q.dark.isActive)"
rounded
color="primary"
>1</q-btn >1</q-btn
> >
<q-btn unelevated @click="stack.push(2)" size="xl" color="grey-8" <q-btn
unelevated
@click="stack.push(2)"
size="xl"
:outline="!($q.dark.isActive)"
rounded
color="primary"
>2</q-btn >2</q-btn
> >
<q-btn unelevated @click="stack.push(3)" size="xl" color="grey-8" <q-btn
unelevated
@click="stack.push(3)"
size="xl"
:outline="!($q.dark.isActive)"
rounded
color="primary"
>3</q-btn >3</q-btn
> >
<q-btn <q-btn
unelevated unelevated
@click="stack = []" @click="stack = []"
size="xl" size="xl"
color="pink" :outline="!($q.dark.isActive)"
rounded
color="primary"
class="btn-cancel" class="btn-cancel"
>C</q-btn >C</q-btn
> >
<q-btn unelevated @click="stack.push(4)" size="xl" color="grey-8" <q-btn
unelevated
@click="stack.push(4)"
size="xl"
:outline="!($q.dark.isActive)"
rounded
color="primary"
>4</q-btn >4</q-btn
> >
<q-btn unelevated @click="stack.push(5)" size="xl" color="grey-8" <q-btn
unelevated
@click="stack.push(5)"
size="xl"
:outline="!($q.dark.isActive)"
rounded
color="primary"
>5</q-btn >5</q-btn
> >
<q-btn unelevated @click="stack.push(6)" size="xl" color="grey-8" <q-btn
unelevated
@click="stack.push(6)"
size="xl"
:outline="!($q.dark.isActive)"
rounded
color="primary"
>6</q-btn >6</q-btn
> >
<q-btn unelevated @click="stack.push(7)" size="xl" color="grey-8" <q-btn
unelevated
@click="stack.push(7)"
size="xl"
:outline="!($q.dark.isActive)"
rounded
color="primary"
>7</q-btn >7</q-btn
> >
<q-btn unelevated @click="stack.push(8)" size="xl" color="grey-8" <q-btn
unelevated
@click="stack.push(8)"
size="xl"
:outline="!($q.dark.isActive)"
rounded
color="primary"
>8</q-btn >8</q-btn
> >
<q-btn unelevated @click="stack.push(9)" size="xl" color="grey-8" <q-btn
unelevated
@click="stack.push(9)"
size="xl"
:outline="!($q.dark.isActive)"
rounded
color="primary"
>9</q-btn >9</q-btn
> >
<q-btn <q-btn
@ -56,7 +112,9 @@
:disabled="amount == 0" :disabled="amount == 0"
@click="showInvoice()" @click="showInvoice()"
size="xl" size="xl"
color="green" :outline="!($q.dark.isActive)"
rounded
color="primary"
class="btn-confirm" class="btn-confirm"
>OK</q-btn >OK</q-btn
> >
@ -64,17 +122,27 @@
unelevated unelevated
@click="stack.splice(-1, 1)" @click="stack.splice(-1, 1)"
size="xl" size="xl"
color="grey-7" :outline="!($q.dark.isActive)"
rounded
color="primary"
>DEL</q-btn >DEL</q-btn
> >
<q-btn unelevated @click="stack.push(0)" size="xl" color="grey-8" <q-btn
unelevated
@click="stack.push(0)"
size="xl"
:outline="!($q.dark.isActive)"
rounded
color="primary"
>0</q-btn >0</q-btn
> >
<q-btn <q-btn
unelevated unelevated
@click="urlDialog.show = true" @click="urlDialog.show = true"
size="xl" size="xl"
color="grey-7" :outline="!($q.dark.isActive)"
rounded
color="primary"
>#</q-btn >#</q-btn
> >
</div> </div>
@ -140,8 +208,8 @@
transition-show="fade" transition-show="fade"
class="text-light-green" class="text-light-green"
style="font-size: 40em" style="font-size: 40em"
></q-icon ></q-icon>
></q-dialog> </q-dialog>
</q-page> </q-page>
</q-page-container> </q-page-container>
{% endblock %} {% block styles %} {% endblock %} {% block styles %}
@ -152,9 +220,11 @@
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(4, 1fr); grid-template-rows: repeat(4, 1fr);
} }
.keypad .btn { .keypad .btn {
height: 100%; height: 100%;
} }
.btn-cancel, .btn-cancel,
.btn-confirm { .btn-confirm {
grid-row: auto/span 2; grid-row: auto/span 2;

View file

@ -14,7 +14,7 @@
extension allows the creation and management of users and wallets. extension allows the creation and management of users and wallets.
<br />For example, a games developer may be developing a game that needs <br />For example, a games developer may be developing a game that needs
each user to have their own wallet, LNbits can be included in the each user to have their own wallet, LNbits can be included in the
develpoers stack as the user and wallet manager.<br /> developers stack as the user and wallet manager.<br />
<small> <small>
Created by, <a href="https://github.com/benarc">Ben Arc</a></small Created by, <a href="https://github.com/benarc">Ben Arc</a></small
> >
@ -97,7 +97,7 @@
<q-card-section> <q-card-section>
<code <code
><span class="text-light-blue">GET</span> ><span class="text-light-blue">GET</span>
/usermanager/api/v1/wallets&lt;wallet_id&gt;</code /usermanager/api/v1/transactions/&lt;wallet_id&gt;</code
> >
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5> <h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;string&gt;}</code> <code>{"X-Api-Key": &lt;string&gt;}</code>
@ -109,7 +109,7 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> <h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code <code
>curl -X GET {{ request.base_url >curl -X GET {{ request.base_url
}}usermanager/api/v1/wallets&lt;wallet_id&gt; -H "X-Api-Key: {{ }}usermanager/api/v1/transactions/&lt;wallet_id&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}" user.wallets[0].inkey }}"
</code> </code>
</q-card-section> </q-card-section>

View file

@ -299,7 +299,7 @@
.request( .request(
'GET', 'GET',
'/usermanager/api/v1/users', '/usermanager/api/v1/users',
this.g.user.wallets[0].inkey this.g.user.wallets[0].adminkey
) )
.then(function (response) { .then(function (response) {
self.users = response.data.map(function (obj) { self.users = response.data.map(function (obj) {
@ -362,7 +362,7 @@
.request( .request(
'DELETE', 'DELETE',
'/usermanager/api/v1/users/' + userId, '/usermanager/api/v1/users/' + userId,
self.g.user.wallets[0].inkey self.g.user.wallets[0].adminkey
) )
.then(function (response) { .then(function (response) {
self.users = _.reject(self.users, function (obj) { self.users = _.reject(self.users, function (obj) {
@ -389,7 +389,7 @@
.request( .request(
'GET', 'GET',
'/usermanager/api/v1/wallets', '/usermanager/api/v1/wallets',
this.g.user.wallets[0].inkey this.g.user.wallets[0].adminkey
) )
.then(function (response) { .then(function (response) {
self.wallets = response.data.map(function (obj) { self.wallets = response.data.map(function (obj) {
@ -447,7 +447,7 @@
.request( .request(
'DELETE', 'DELETE',
'/usermanager/api/v1/wallets/' + userId, '/usermanager/api/v1/wallets/' + userId,
self.g.user.wallets[0].inkey self.g.user.wallets[0].adminkey
) )
.then(function (response) { .then(function (response) {
self.wallets = _.reject(self.wallets, function (obj) { self.wallets = _.reject(self.wallets, function (obj) {

View file

@ -6,7 +6,7 @@ from starlette.exceptions import HTTPException
from lnbits.core import update_user_extension from lnbits.core import update_user_extension
from lnbits.core.crud import get_user from lnbits.core.crud import get_user
from lnbits.decorators import WalletTypeInfo, get_key_type from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from . import usermanager_ext from . import usermanager_ext
from .crud import ( from .crud import (
@ -27,7 +27,7 @@ from .models import CreateUserData, CreateUserWallet
@usermanager_ext.get("/api/v1/users", status_code=HTTPStatus.OK) @usermanager_ext.get("/api/v1/users", status_code=HTTPStatus.OK)
async def api_usermanager_users(wallet: WalletTypeInfo = Depends(get_key_type)): async def api_usermanager_users(wallet: WalletTypeInfo = Depends(require_admin_key)):
user_id = wallet.wallet.user user_id = wallet.wallet.user
return [user.dict() for user in await get_usermanager_users(user_id)] return [user.dict() for user in await get_usermanager_users(user_id)]
@ -52,7 +52,7 @@ async def api_usermanager_users_create(
@usermanager_ext.delete("/api/v1/users/{user_id}") @usermanager_ext.delete("/api/v1/users/{user_id}")
async def api_usermanager_users_delete( async def api_usermanager_users_delete(
user_id, wallet: WalletTypeInfo = Depends(get_key_type) user_id, wallet: WalletTypeInfo = Depends(require_admin_key)
): ):
user = await get_usermanager_user(user_id) user = await get_usermanager_user(user_id)
if not user: if not user:
@ -93,7 +93,7 @@ async def api_usermanager_wallets_create(
@usermanager_ext.get("/api/v1/wallets") @usermanager_ext.get("/api/v1/wallets")
async def api_usermanager_wallets(wallet: WalletTypeInfo = Depends(get_key_type)): async def api_usermanager_wallets(wallet: WalletTypeInfo = Depends(require_admin_key)):
admin_id = wallet.wallet.user admin_id = wallet.wallet.user
return [wallet.dict() for wallet in await get_usermanager_wallets(admin_id)] return [wallet.dict() for wallet in await get_usermanager_wallets(admin_id)]
@ -107,7 +107,7 @@ async def api_usermanager_wallet_transactions(
@usermanager_ext.get("/api/v1/wallets/{user_id}") @usermanager_ext.get("/api/v1/wallets/{user_id}")
async def api_usermanager_users_wallets( async def api_usermanager_users_wallets(
user_id, wallet: WalletTypeInfo = Depends(get_key_type) user_id, wallet: WalletTypeInfo = Depends(require_admin_key)
): ):
return [ return [
s_wallet.dict() for s_wallet in await get_usermanager_users_wallets(user_id) s_wallet.dict() for s_wallet in await get_usermanager_users_wallets(user_id)
@ -116,7 +116,7 @@ async def api_usermanager_users_wallets(
@usermanager_ext.delete("/api/v1/wallets/{wallet_id}") @usermanager_ext.delete("/api/v1/wallets/{wallet_id}")
async def api_usermanager_wallets_delete( async def api_usermanager_wallets_delete(
wallet_id, wallet: WalletTypeInfo = Depends(get_key_type) wallet_id, wallet: WalletTypeInfo = Depends(require_admin_key)
): ):
get_wallet = await get_usermanager_wallet(wallet_id) get_wallet = await get_usermanager_wallet(wallet_id)
if not get_wallet: if not get_wallet:

View file

@ -112,7 +112,7 @@ async def api_get_addresses(wallet_id, w: WalletTypeInfo = Depends(get_key_type)
async def api_update_mempool( async def api_update_mempool(
endpoint: str = Query(...), w: WalletTypeInfo = Depends(require_admin_key) endpoint: str = Query(...), w: WalletTypeInfo = Depends(require_admin_key)
): ):
mempool = await update_mempool(endpoint, user=w.wallet.user) mempool = await update_mempool(**{"endpoint": endpoint}, user=w.wallet.user)
return mempool.dict() return mempool.dict()

View file

@ -25,9 +25,10 @@ async def create_withdraw_link(
unique_hash, unique_hash,
k1, k1,
open_time, open_time,
usescsv usescsv,
webhook_url
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
link_id, link_id,
@ -42,6 +43,7 @@ async def create_withdraw_link(
urlsafe_short_hash(), urlsafe_short_hash(),
int(datetime.now().timestamp()) + data.wait_time, int(datetime.now().timestamp()) + data.wait_time,
usescsv, usescsv,
data.webhook_url
), ),
) )
link = await get_withdraw_link(link_id, 0) link = await get_withdraw_link(link_id, 0)

View file

@ -1,4 +1,7 @@
import json import json
import traceback
import httpx
from datetime import datetime from datetime import datetime
from http import HTTPStatus from http import HTTPStatus
@ -30,7 +33,9 @@ async def api_lnurl_response(request: Request, unique_hash):
) )
if link.is_spent: if link.is_spent:
raise HTTPException(detail="Withdraw is spent.") raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent."
)
url = request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash) url = request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
withdrawResponse = { withdrawResponse = {
"tag": "withdrawRequest", "tag": "withdrawRequest",
@ -48,7 +53,11 @@ async def api_lnurl_response(request: Request, unique_hash):
@withdraw_ext.get("/api/v1/lnurl/cb/{unique_hash}", name="withdraw.api_lnurl_callback") @withdraw_ext.get("/api/v1/lnurl/cb/{unique_hash}", name="withdraw.api_lnurl_callback")
async def api_lnurl_callback( async def api_lnurl_callback(
unique_hash, request: Request, k1: str = Query(...), pr: str = Query(...) unique_hash,
request: Request,
k1: str = Query(...),
pr: str = Query(...),
id_unique_hash=None,
): ):
link = await get_withdraw_link_by_hash(unique_hash) link = await get_withdraw_link_by_hash(unique_hash)
now = int(datetime.now().timestamp()) now = int(datetime.now().timestamp())
@ -58,21 +67,38 @@ async def api_lnurl_callback(
) )
if link.is_spent: if link.is_spent:
raise HTTPException(status_code=HTTPStatus.OK, detail="Withdraw is spent.") raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent."
)
if link.k1 != k1: if link.k1 != k1:
raise HTTPException(status_code=HTTPStatus.OK, detail="Bad request.") raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Bad request.")
if now < link.open_time: if now < link.open_time:
return {"status": "ERROR", "reason": f"Wait {link.open_time - now} seconds."} return {"status": "ERROR", "reason": f"Wait {link.open_time - now} seconds."}
usescsv = ""
try: try:
usescsv = ""
for x in range(1, link.uses - link.used): for x in range(1, link.uses - link.used):
usecv = link.usescsv.split(",") usecv = link.usescsv.split(",")
usescsv += "," + str(usecv[x]) usescsv += "," + str(usecv[x])
usecsvback = usescsv usecsvback = usescsv
usescsv = usescsv[1:]
found = False
if id_unique_hash is not None:
useslist = link.usescsv.split(",")
for ind, x in enumerate(useslist):
tohash = link.id + link.unique_hash + str(x)
if id_unique_hash == shortuuid.uuid(name=tohash):
found = True
useslist.pop(ind)
usescsv = ",".join(useslist)
if not found:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
)
else:
usescsv = usescsv[1:]
changesback = { changesback = {
"open_time": link.wait_time, "open_time": link.wait_time,
@ -88,17 +114,35 @@ async def api_lnurl_callback(
await update_withdraw_link(link.id, **changes) await update_withdraw_link(link.id, **changes)
payment_request = pr payment_request = pr
await pay_invoice( payment_hash = await pay_invoice(
wallet_id=link.wallet, wallet_id=link.wallet,
payment_request=payment_request, payment_request=payment_request,
max_sat=link.max_withdrawable, max_sat=link.max_withdrawable,
extra={"tag": "withdraw"}, extra={"tag": "withdraw"},
) )
if link.webhook_url:
async with httpx.AsyncClient() as client:
try:
r = await client.post(
link.webhook_url,
json={
"payment_hash": payment_hash,
"payment_request": payment_request,
"lnurlw": link.id,
},
timeout=40,
)
except Exception as exc:
# webhook fails shouldn't cause the lnurlw to fail since invoice is already paid
print("Caught exception when dispatching webhook url:", exc)
return {"status": "OK"} return {"status": "OK"}
except Exception as e: except Exception as e:
await update_withdraw_link(link.id, **changesback) await update_withdraw_link(link.id, **changesback)
print(traceback.format_exc())
return {"status": "ERROR", "reason": "Link not working"} return {"status": "ERROR", "reason": "Link not working"}
@ -115,11 +159,13 @@ async def api_lnurl_multi_response(request: Request, unique_hash, id_unique_hash
if not link: if not link:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.OK, detail="LNURL-withdraw not found." status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
) )
if link.is_spent: if link.is_spent:
raise HTTPException(status_code=HTTPStatus.OK, detail="Withdraw is spent.") raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent."
)
useslist = link.usescsv.split(",") useslist = link.usescsv.split(",")
found = False found = False
@ -127,15 +173,16 @@ async def api_lnurl_multi_response(request: Request, unique_hash, id_unique_hash
tohash = link.id + link.unique_hash + str(x) tohash = link.id + link.unique_hash + str(x)
if id_unique_hash == shortuuid.uuid(name=tohash): if id_unique_hash == shortuuid.uuid(name=tohash):
found = True found = True
if not found: if not found:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.OK, detail="LNURL-withdraw not found." status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
) )
url = request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash) url = request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
withdrawResponse = { withdrawResponse = {
"tag": "withdrawRequest", "tag": "withdrawRequest",
"callback": url, "callback": url + "?id_unique_hash=" + id_unique_hash,
"k1": link.k1, "k1": link.k1,
"minWithdrawable": link.min_withdrawable * 1000, "minWithdrawable": link.min_withdrawable * 1000,
"maxWithdrawable": link.max_withdrawable * 1000, "maxWithdrawable": link.max_withdrawable * 1000,

View file

@ -108,3 +108,9 @@ async def m003_make_hash_check(db):
); );
""" """
) )
async def m004_webhook_url(db):
"""
Adds webhook_url
"""
await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN webhook_url TEXT;")

View file

@ -15,6 +15,7 @@ class CreateWithdrawData(BaseModel):
uses: int = Query(..., ge=1) uses: int = Query(..., ge=1)
wait_time: int = Query(..., ge=1) wait_time: int = Query(..., ge=1)
is_unique: bool is_unique: bool
webhook_url: str = Query(None)
class WithdrawLink(BaseModel): class WithdrawLink(BaseModel):
@ -32,6 +33,7 @@ class WithdrawLink(BaseModel):
used: int = Query(0) used: int = Query(0)
usescsv: str = Query(None) usescsv: str = Query(None)
number: int = Query(0) number: int = Query(0)
webhook_url: str = Query(None)
@property @property
def is_spent(self) -> bool: def is_spent(self) -> bool:

View file

@ -179,7 +179,8 @@ new Vue({
'max_withdrawable', 'max_withdrawable',
'uses', 'uses',
'wait_time', 'wait_time',
'is_unique' 'is_unique',
'webhook_url'
) )
) )
.then(function (response) { .then(function (response) {

View file

@ -70,7 +70,8 @@
<code <code
>{"title": &lt;string&gt;, "min_withdrawable": &lt;integer&gt;, >{"title": &lt;string&gt;, "min_withdrawable": &lt;integer&gt;,
"max_withdrawable": &lt;integer&gt;, "uses": &lt;integer&gt;, "max_withdrawable": &lt;integer&gt;, "uses": &lt;integer&gt;,
"wait_time": &lt;integer&gt;, "is_unique": &lt;boolean&gt;}</code "wait_time": &lt;integer&gt;, "is_unique": &lt;boolean&gt;,
"webhook_url": &lt;string&gt;}</code
> >
<h5 class="text-caption q-mt-sm q-mb-none"> <h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json) Returns 201 CREATED (application/json)
@ -81,7 +82,7 @@
>curl -X POST {{ request.base_url }}withdraw/api/v1/links -d '{"title": >curl -X POST {{ request.base_url }}withdraw/api/v1/links -d '{"title":
&lt;string&gt;, "min_withdrawable": &lt;integer&gt;, &lt;string&gt;, "min_withdrawable": &lt;integer&gt;,
"max_withdrawable": &lt;integer&gt;, "uses": &lt;integer&gt;, "max_withdrawable": &lt;integer&gt;, "uses": &lt;integer&gt;,
"wait_time": &lt;integer&gt;, "is_unique": &lt;boolean&gt;}' -H "wait_time": &lt;integer&gt;, "is_unique": &lt;boolean&gt;, "webhook_url": &lt;string&gt;}' -H
"Content-type: application/json" -H "X-Api-Key: {{ "Content-type: application/json" -H "X-Api-Key: {{
user.wallets[0].adminkey }}" user.wallets[0].adminkey }}"
</code> </code>

View file

@ -0,0 +1,10 @@
{% extends "print.html" %} {% block page %} {% for page in link %} {% for threes in page %} {% for one in threes %} {{one}}, {% endfor %} {% endfor %} {% endfor %} {% endblock %} {% block scripts %}
<script>
new Vue({
el: '#vue',
data: function() {
return {}
}
})
</script>
{% endblock %}

View file

@ -1,49 +1,38 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context {% extends "base.html" %} {% from "macros.jinja" import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
%} {% block scripts %} {{ window_vars(user) }}
<script src="/withdraw/static/js/index.js"></script> <script src="/withdraw/static/js/index.js"></script>
{% endblock %} {% block page %} {% endblock %} {% block page %}
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md"> <div class="col-12 col-md-7 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <q-card-section>
<q-btn unelevated color="primary" @click="simpleformDialog.show = true" <q-btn unelevated color="primary" @click="simpleformDialog.show = true">Quick vouchers</q-btn>
>Quick vouchers</q-btn <q-btn unelevated color="primary" @click="formDialog.show = true">Advanced withdraw link(s)</q-btn>
> </q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true" </q-card>
>Advanced withdraw link(s)</q-btn
>
</q-card-section>
</q-card>
<q-card> <q-card>
<q-card-section> <q-card-section>
<div class="row items-center no-wrap q-mb-md"> <div class="row items-center no-wrap q-mb-md">
<div class="col"> <div class="col">
<h5 class="text-subtitle1 q-my-none">Withdraw links</h5> <h5 class="text-subtitle1 q-my-none">Withdraw links</h5>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn> <q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
</div> </div>
</div> </div>
<q-table <q-table dense flat :data="sortedWithdrawLinks" row-key="id" :columns="withdrawLinksTable.columns" :pagination.sync="withdrawLinksTable.pagination">
dense {% raw %}
flat <template v-slot:header="props">
:data="sortedWithdrawLinks"
row-key="id"
:columns="withdrawLinksTable.columns"
:pagination.sync="withdrawLinksTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props"> <q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }} {{ col.label }}
</q-th> </q-th>
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th auto-width></q-th>
</q-tr> </q-tr>
</template> </template>
<template v-slot:body="props"> <template v-slot:body="props">
<q-tr :props="props"> <q-tr :props="props">
<q-td auto-width> <q-td auto-width>
<q-btn <q-btn
@ -69,6 +58,17 @@
target="_blank" target="_blank"
><q-tooltip> embeddable image </q-tooltip></q-btn ><q-tooltip> embeddable image </q-tooltip></q-btn
> >
<q-btn
unelevated
dense
size="xs"
icon="reorder"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'/withdraw/csv/' + props.row.id"
target="_blank"
><q-tooltip> csv list </q-tooltip></q-btn
>
<q-btn <q-btn
unelevated unelevated
dense dense
@ -82,6 +82,11 @@
<q-td v-for="col in props.cols" :key="col.name" :props="props"> <q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }} {{ col.value }}
</q-td> </q-td>
<q-td>
<q-icon v-if="props.row.webhook_url" size="14px" name="http">
<q-tooltip>Webhook to {{ props.row.webhook_url}}</q-tooltip>
</q-icon>
</q-td>
<q-td auto-width> <q-td auto-width>
<q-btn <q-btn
flat flat
@ -101,129 +106,72 @@
></q-btn> ></q-btn>
</q-td> </q-td>
</q-tr> </q-tr>
</template> </template> {% endraw %}
{% endraw %} </q-table>
</q-table> </q-card-section>
</q-card-section> </q-card>
</q-card> </div>
</div>
<div class="col-12 col-md-5 q-gutter-y-md"> <div class="col-12 col-md-5 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <q-card-section>
<h6 class="text-subtitle1 q-my-none"> <h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} LNURL-withdraw extension {{SITE_TITLE}} LNURL-withdraw extension
</h6> </h6>
</q-card-section> </q-card-section>
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<q-separator></q-separator> <q-separator></q-separator>
<q-list> <q-list>
{% include "withdraw/_api_docs.html" %} {% include "withdraw/_api_docs.html" %}
<q-separator></q-separator> <q-separator></q-separator>
{% include "withdraw/_lnurl.html" %} {% include "withdraw/_lnurl.html" %}
</q-list> </q-list>
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog"> <q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card"> <q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormData" class="q-gutter-md"> <q-form @submit="sendFormData" class="q-gutter-md">
<q-select <q-select filled dense emit-value v-model="formDialog.data.wallet" :options="g.user.walletOptions" label="Wallet *">
filled </q-select>
dense <q-input filled dense v-model.trim="formDialog.data.title" type="text" label="Link title *"></q-input>
emit-value <q-input filled dense v-model.number="formDialog.data.min_withdrawable" type="number" min="10" label="Min withdrawable (sat, at least 10) *"></q-input>
v-model="formDialog.data.wallet" <q-input filled dense v-model.number="formDialog.data.max_withdrawable" type="number" min="10" label="Max withdrawable (sat, at least 10) *"></q-input>
:options="g.user.walletOptions" <q-input filled dense v-model.number="formDialog.data.uses" type="number" max="250" :default="1" label="Amount of uses *"></q-input>
label="Wallet *" <div class="row q-col-gutter-none">
> <div class="col-8">
</q-select> <q-input filled dense v-model.number="formDialog.data.wait_time" type="number" :default="1" label="Time between withdrawals *">
<q-input </q-input>
filled </div>
dense <div class="col-4 q-pl-xs">
v-model.trim="formDialog.data.title" <q-select filled dense v-model="formDialog.secondMultiplier" :options="formDialog.secondMultiplierOptions">
type="text" </q-select>
label="Link title *" </div>
></q-input> </div>
<q-input <q-input
filled filled
dense dense
v-model.number="formDialog.data.min_withdrawable" v-model="formDialog.data.webhook_url"
type="number" type="text"
min="10" label="Webhook URL (optional)"
label="Min withdrawable (sat, at least 10) *" hint="A URL to be called whenever this link gets used."
></q-input> ></q-input>
<q-input <q-list>
filled <q-item tag="label" class="rounded-borders">
dense <q-item-section avatar>
v-model.number="formDialog.data.max_withdrawable" <q-checkbox v-model="formDialog.data.is_unique" color="primary"></q-checkbox>
type="number" </q-item-section>
min="10" <q-item-section>
label="Max withdrawable (sat, at least 10) *" <q-item-label>Use unique withdraw QR codes to reduce `assmilking`
></q-input> </q-item-label>
<q-input <q-item-label caption>This is recommended if you are sharing the links on social media or print QR codes.</q-item-label>
filled </q-item-section>
dense </q-item>
v-model.number="formDialog.data.uses" </q-list>
type="number" <div class="row q-mt-lg">
:default="1" <q-btn v-if="formDialog.data.id" unelevated color="primary" type="submit">Update withdraw link</q-btn>
label="Amount of uses *" <q-btn v-else unelevated color="primary" :disable="
></q-input>
<div class="row q-col-gutter-none">
<div class="col-8">
<q-input
filled
dense
v-model.number="formDialog.data.wait_time"
type="number"
:default="1"
label="Time between withdrawals *"
>
</q-input>
</div>
<div class="col-4 q-pl-xs">
<q-select
filled
dense
v-model="formDialog.secondMultiplier"
:options="formDialog.secondMultiplierOptions"
>
</q-select>
</div>
</div>
<q-list>
<q-item tag="label" class="rounded-borders">
<q-item-section avatar>
<q-checkbox
v-model="formDialog.data.is_unique"
color="primary"
></q-checkbox>
</q-item-section>
<q-item-section>
<q-item-label
>Use unique withdraw QR codes to reduce
`assmilking`</q-item-label
>
<q-item-label caption
>This is recommended if you are sharing the links on social
media or print QR codes.</q-item-label
>
</q-item-section>
</q-item>
</q-list>
<div class="row q-mt-lg">
<q-btn
v-if="formDialog.data.id"
unelevated
color="primary"
type="submit"
>Update withdraw link</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="
formDialog.data.wallet == null || formDialog.data.wallet == null ||
formDialog.data.title == null || formDialog.data.title == null ||
(formDialog.data.min_withdrawable == null || formDialog.data.min_withdrawable < 1) || (formDialog.data.min_withdrawable == null || formDialog.data.min_withdrawable < 1) ||
@ -233,88 +181,43 @@
formDialog.data.max_withdrawable < formDialog.data.min_withdrawable formDialog.data.max_withdrawable < formDialog.data.min_withdrawable
) || ) ||
formDialog.data.uses == null || formDialog.data.uses == null ||
formDialog.data.wait_time == null" formDialog.data.wait_time == null" type="submit">Create withdraw link</q-btn>
type="submit" <q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
>Create withdraw link</q-btn </div>
> </q-form>
<q-btn v-close-popup flat color="grey" class="q-ml-auto" </q-card>
>Cancel</q-btn </q-dialog>
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog <q-dialog v-model="simpleformDialog.show" position="top" @hide="simplecloseFormDialog">
v-model="simpleformDialog.show" <q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
position="top" <q-form @submit="simplesendFormData" class="q-gutter-md">
@hide="simplecloseFormDialog" <q-select filled dense emit-value v-model="simpleformDialog.data.wallet" :options="g.user.walletOptions" label="Wallet *">
> </q-select>
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card"> <q-input filled dense v-model.number="simpleformDialog.data.max_withdrawable" type="number" min="10" label="Withdraw amount per voucher (sat, at least 10)"></q-input>
<q-form @submit="simplesendFormData" class="q-gutter-md"> <q-input filled dense v-model.number="simpleformDialog.data.uses" type="number" max="250" :default="1" label="Number of vouchers"></q-input>
<q-select
filled
dense
emit-value
v-model="simpleformDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
<q-input
filled
dense
v-model.number="simpleformDialog.data.max_withdrawable"
type="number"
min="10"
label="Withdraw amount per voucher (sat, at least 10)"
></q-input>
<q-input
filled
dense
v-model.number="simpleformDialog.data.uses"
type="number"
:default="1"
label="Number of vouchers"
></q-input>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn unelevated color="primary" :disable="
unelevated
color="primary"
:disable="
simpleformDialog.data.wallet == null || simpleformDialog.data.wallet == null ||
simpleformDialog.data.max_withdrawable == null || simpleformDialog.data.max_withdrawable == null ||
simpleformDialog.data.max_withdrawable < 1 || simpleformDialog.data.max_withdrawable < 1 ||
simpleformDialog.data.uses == null" simpleformDialog.data.uses == null" type="submit">Create vouchers</q-btn>
type="submit" <q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
>Create vouchers</q-btn </div>
> </q-form>
<q-btn v-close-popup flat color="grey" class="q-ml-auto" </q-card>
>Cancel</q-btn </q-dialog>
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="qrCodeDialog.show" position="top"> <q-dialog v-model="qrCodeDialog.show" position="top">
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card"> <q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md"> <q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode <qrcode :value="qrCodeDialog.data.url + '/?lightning=' + qrCodeDialog.data.lnurl" :options="{width: 800}" class="rounded-borders"></qrcode>
:value="qrCodeDialog.data.url + '/?lightning=' + qrCodeDialog.data.lnurl" {% raw %}
:options="{width: 800}" </q-responsive>
class="rounded-borders" <p style="word-break: break-all">
></qrcode> <strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
{% raw %} <strong>Unique:</strong> {{ qrCodeDialog.data.is_unique }}<span v-if="qrCodeDialog.data.is_unique" class="text-deep-purple">
</q-responsive>
<p style="word-break: break-all">
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
<strong>Unique:</strong> {{ qrCodeDialog.data.is_unique }}<span
v-if="qrCodeDialog.data.is_unique"
class="text-deep-purple"
>
(QR code will change after each withdrawal)</span (QR code will change after each withdrawal)</span
><br /> ><br />
<strong>Max. withdrawable:</strong> {{ <strong>Max. withdrawable:</strong> {{
@ -356,4 +259,4 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -102,3 +102,38 @@ async def print_qr(request: Request, link_id):
return withdraw_renderer().TemplateResponse( return withdraw_renderer().TemplateResponse(
"withdraw/print_qr.html", {"request": request, "link": linked, "unique": True} "withdraw/print_qr.html", {"request": request, "link": linked, "unique": True}
) )
@withdraw_ext.get("/csv/{link_id}", response_class=HTMLResponse)
async def print_qr(request: Request, link_id):
link = await get_withdraw_link(link_id)
if not link:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
)
# response.status_code = HTTPStatus.NOT_FOUND
# return "Withdraw link does not exist."
if link.uses == 0:
return withdraw_renderer().TemplateResponse(
"withdraw/csv.html",
{"request": request, "link": link.dict(), "unique": False},
)
links = []
count = 0
for x in link.usescsv.split(","):
linkk = await get_withdraw_link(link_id, count)
if not linkk:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
)
links.append(str(linkk.lnurl(request)))
count = count + 1
page_link = list(chunks(links, 2))
linked = list(chunks(page_link, 5))
return withdraw_renderer().TemplateResponse(
"withdraw/csv.html", {"request": request, "link": linked, "unique": True}
)

View file

@ -71,6 +71,14 @@ async def api_link_create_or_update(
link_id: str = None, link_id: str = None,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
): ):
if data.uses > 250:
raise HTTPException(detail="250 uses max.", status_code=HTTPStatus.BAD_REQUEST)
if data.min_withdrawable < 1:
raise HTTPException(
detail="Min must be more than 1.", status_code=HTTPStatus.BAD_REQUEST
)
if data.max_withdrawable < data.min_withdrawable: if data.max_withdrawable < data.min_withdrawable:
raise HTTPException( raise HTTPException(
detail="`max_withdrawable` needs to be at least `min_withdrawable`.", detail="`max_withdrawable` needs to be at least `min_withdrawable`.",

View file

@ -6,11 +6,10 @@ from typing import Any, List, NamedTuple, Optional
import jinja2 import jinja2
import shortuuid # type: ignore import shortuuid # type: ignore
import lnbits.settings as settings
from lnbits.jinja2_templating import Jinja2Templates from lnbits.jinja2_templating import Jinja2Templates
from lnbits.requestvars import g from lnbits.requestvars import g
import lnbits.settings as settings
class Extension(NamedTuple): class Extension(NamedTuple):
code: str code: str
@ -26,7 +25,9 @@ class Extension(NamedTuple):
class ExtensionManager: class ExtensionManager:
def __init__(self): def __init__(self):
self._disabled: List[str] = settings.LNBITS_DISABLED_EXTENSIONS self._disabled: List[str] = settings.LNBITS_DISABLED_EXTENSIONS
self._admin_only: List[str] = [x.strip(' ') for x in settings.LNBITS_ADMIN_EXTENSIONS] self._admin_only: List[str] = [
x.strip(" ") for x in settings.LNBITS_ADMIN_EXTENSIONS
]
self._extension_folders: List[str] = [ self._extension_folders: List[str] = [
x[1] for x in os.walk(os.path.join(settings.LNBITS_PATH, "extensions")) x[1] for x in os.walk(os.path.join(settings.LNBITS_PATH, "extensions"))
][0] ][0]
@ -160,6 +161,10 @@ def template_renderer(additional_folders: List = []) -> Jinja2Templates:
["lnbits/templates", "lnbits/core/templates", *additional_folders] ["lnbits/templates", "lnbits/core/templates", *additional_folders]
) )
) )
if settings.LNBITS_AD_SPACE:
t.env.globals["AD_SPACE"] = settings.LNBITS_AD_SPACE
t.env.globals["HIDE_API"] = settings.LNBITS_HIDE_API
t.env.globals["SITE_TITLE"] = settings.LNBITS_SITE_TITLE t.env.globals["SITE_TITLE"] = settings.LNBITS_SITE_TITLE
t.env.globals["LNBITS_DENOMINATION"] = settings.LNBITS_DENOMINATION t.env.globals["LNBITS_DENOMINATION"] = settings.LNBITS_DENOMINATION
t.env.globals["SITE_TAGLINE"] = settings.LNBITS_SITE_TAGLINE t.env.globals["SITE_TAGLINE"] = settings.LNBITS_SITE_TAGLINE
@ -167,6 +172,8 @@ def template_renderer(additional_folders: List = []) -> Jinja2Templates:
t.env.globals["LNBITS_THEME_OPTIONS"] = settings.LNBITS_THEME_OPTIONS t.env.globals["LNBITS_THEME_OPTIONS"] = settings.LNBITS_THEME_OPTIONS
t.env.globals["LNBITS_VERSION"] = settings.LNBITS_COMMIT t.env.globals["LNBITS_VERSION"] = settings.LNBITS_COMMIT
t.env.globals["EXTENSIONS"] = get_valid_extensions() t.env.globals["EXTENSIONS"] = get_valid_extensions()
if settings.LNBITS_CUSTOM_LOGO:
t.env.globals["USE_CUSTOM_LOGO"] = settings.LNBITS_CUSTOM_LOGO
if settings.DEBUG: if settings.DEBUG:
t.env.globals["VENDORED_JS"] = map(url_for_vendored, get_js_vendored()) t.env.globals["VENDORED_JS"] = map(url_for_vendored, get_js_vendored())

View file

@ -1,10 +1,9 @@
import subprocess
import importlib import importlib
import subprocess
from environs import Env # type: ignore
from os import path from os import path
from typing import List from typing import List
from environs import Env # type: ignore
env = Env() env = Env()
env.read_env() env.read_env()
@ -29,11 +28,15 @@ LNBITS_ALLOWED_USERS: List[str] = env.list(
"LNBITS_ALLOWED_USERS", default=[], subcast=str "LNBITS_ALLOWED_USERS", default=[], subcast=str
) )
LNBITS_ADMIN_USERS: List[str] = env.list("LNBITS_ADMIN_USERS", default=[], subcast=str) LNBITS_ADMIN_USERS: List[str] = env.list("LNBITS_ADMIN_USERS", default=[], subcast=str)
LNBITS_ADMIN_EXTENSIONS: List[str] = env.list("LNBITS_ADMIN_EXTENSIONS", default=[], subcast=str) LNBITS_ADMIN_EXTENSIONS: List[str] = env.list(
"LNBITS_ADMIN_EXTENSIONS", default=[], subcast=str
)
LNBITS_DISABLED_EXTENSIONS: List[str] = env.list( LNBITS_DISABLED_EXTENSIONS: List[str] = env.list(
"LNBITS_DISABLED_EXTENSIONS", default=[], subcast=str "LNBITS_DISABLED_EXTENSIONS", default=[], subcast=str
) )
LNBITS_AD_SPACE = env.list("LNBITS_AD_SPACE", default=[])
LNBITS_HIDE_API = env.bool("LNBITS_HIDE_API", default=False)
LNBITS_SITE_TITLE = env.str("LNBITS_SITE_TITLE", default="LNbits") LNBITS_SITE_TITLE = env.str("LNBITS_SITE_TITLE", default="LNbits")
LNBITS_DENOMINATION = env.str("LNBITS_DENOMINATION", default="sats") LNBITS_DENOMINATION = env.str("LNBITS_DENOMINATION", default="sats")
LNBITS_SITE_TAGLINE = env.str( LNBITS_SITE_TAGLINE = env.str(
@ -45,6 +48,7 @@ LNBITS_THEME_OPTIONS: List[str] = env.list(
default="classic, flamingo, mint, salvador, monochrome, autumn", default="classic, flamingo, mint, salvador, monochrome, autumn",
subcast=str, subcast=str,
) )
LNBITS_CUSTOM_LOGO = env.str("LNBITS_CUSTOM_LOGO", default="")
WALLET = wallet_class() WALLET = wallet_class()
DEFAULT_WALLET_NAME = env.str("LNBITS_DEFAULT_WALLET_NAME", default="LNbits wallet") DEFAULT_WALLET_NAME = env.str("LNBITS_DEFAULT_WALLET_NAME", default="LNbits wallet")

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View file

@ -1,186 +1,143 @@
$themes: ( $themes: ( 'classic': ( primary: #673ab7, secondary: #9c27b0, dark: #1f2234, info: #333646, marginal-bg: #1f2234, marginal-text: #fff), 'bitcoin': ( primary: #ff9853, secondary: #ff7353, dark: #2d293b, info: #333646, marginal-bg: #2d293b, marginal-text: #fff), 'freedom': ( primary: #e22156, secondary: #b91a45, dark: #0a0a0a, info: #1b1b1b, marginal-bg: #2d293b, marginal-text: #fff), 'mint': ( primary: #3ab77d, secondary: #27b065, dark: #1f342b, info: #334642, marginal-bg: #1f342b, marginal-text: #fff), 'autumn': ( primary: #b7763a, secondary: #b07927, dark: #34291f, info: #463f33, marginal-bg: #342a1f, marginal-text: rgb(255, 255, 255)), 'flamingo': ( primary: #d11d53, secondary: #db3e6d, dark: #803a45, info: #ec7599, marginal-bg: #803a45, marginal-text: rgb(255, 255, 255)), 'monochrome': ( primary: #494949, secondary: #6b6b6b, dark: #000, info: rgb(39, 39, 39), marginal-bg: #000, marginal-text: rgb(255, 255, 255)));
'classic': ( @each $theme,
primary: #673ab7, $colors in $themes {
secondary: #9c27b0, @each $name,
dark: #1f2234, $color in $colors {
info: #333646, @if $name=='dark' {
marginal-bg: #1f2234, [data-theme='#{$theme}'] .q-drawer--dark,
marginal-text: #fff body[data-theme='#{$theme}'].body--dark,
), [data-theme='#{$theme}'] .q-menu--dark {
'bitcoin': ( background: $color !important;
primary: #ff9853, }
secondary: #ff7353, /* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
dark: #2d293b,
info: #333646,
marginal-bg: #2d293b,
marginal-text: #fff
),
'mint': (
primary: #3ab77d,
secondary: #27b065,
dark: #1f342b,
info: #334642,
marginal-bg: #1f342b,
marginal-text: #fff
),
'autumn': (
primary: #b7763a,
secondary: #b07927,
dark: #34291f,
info: #463f33,
marginal-bg: #342a1f,
marginal-text: rgb(255, 255, 255)
),
'flamingo': (
primary: #d11d53,
secondary: #db3e6d,
dark: #803a45,
info: #ec7599,
marginal-bg: #803a45,
marginal-text: rgb(255, 255, 255)
),
'monochrome': (
primary: #494949,
secondary: #6b6b6b,
dark: #000,
info: rgb(39, 39, 39),
marginal-bg: #000,
marginal-text: rgb(255, 255, 255)
)
);
@each $theme, $colors in $themes {
@each $name, $color in $colors {
@if $name == 'dark' {
[data-theme='#{$theme}'] .q-drawer--dark,
body[data-theme='#{$theme}'].body--dark,
[data-theme='#{$theme}'] .q-menu--dark {
background: $color !important;
}
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
// set a darker body bg for all themes, when in "dark mode" // set a darker body bg for all themes, when in "dark mode"
body[data-theme='#{$theme}'].body--dark { body[data-theme='#{$theme}'].body--dark {
background: scale-color($color, $lightness: -60%); background: scale-color($color, $lightness: -60%);
} }
*/ */
}
@if $name=='info' {
[data-theme='#{$theme}'] .q-card--dark,
[data-theme='#{$theme}'] .q-stepper--dark {
background: $color !important;
}
}
} }
@if $name == 'info' { [data-theme='#{$theme}'] {
[data-theme='#{$theme}'] .q-card--dark, @each $name,
[data-theme='#{$theme}'] .q-stepper--dark { $color in $colors {
background: $color !important; .bg-#{$name} {
} background: $color !important;
}
.text-#{$name} {
color: $color !important;
}
}
} }
}
[data-theme='#{$theme}'] {
@each $name, $color in $colors {
.bg-#{$name} {
background: $color !important;
}
.text-#{$name} {
color: $color !important;
}
}
}
} }
[data-theme='freedom'] .q-drawer--dark {
background: #0a0a0a !important;
}
[data-theme='freedom'] .q-header {
background: #0a0a0a !important;
}
[data-theme='salvador'] .q-drawer--dark { [data-theme='salvador'] .q-drawer--dark {
background: #242424 !important; background: #242424 !important;
} }
[data-theme='salvador'] .q-header { [data-theme='salvador'] .q-header {
background: #0f47af !important; background: #0f47af !important;
} }
[data-theme='flamingo'] .q-drawer--dark { [data-theme='flamingo'] .q-drawer--dark {
background: #e75480 !important; background: #e75480 !important;
} }
[data-theme='flamingo'] .q-header { [data-theme='flamingo'] .q-header {
background: #e75480 !important; background: #e75480 !important;
} }
[v-cloak] { [v-cloak] {
display: none; display: none;
} }
body.body--dark .q-table--dark { body.body--dark .q-table--dark {
background: transparent; background: transparent;
} }
body.body--dark .q-field--error { body.body--dark .q-field--error {
.text-negative, .text-negative,
.q-field__messages { .q-field__messages {
color: yellow !important; color: yellow !important;
} }
} }
.lnbits-drawer__q-list .q-item { .lnbits-drawer__q-list .q-item {
padding-top: 5px !important; padding-top: 5px !important;
padding-bottom: 5px !important; padding-bottom: 5px !important;
border-top-right-radius: 3px; border-top-right-radius: 3px;
border-bottom-right-radius: 3px; border-bottom-right-radius: 3px;
&.q-item--active {
&.q-item--active { color: inherit;
color: inherit; font-weight: bold;
font-weight: bold; }
}
} }
.lnbits__dialog-card { .lnbits__dialog-card {
width: 500px; width: 500px;
} }
.q-table--dense { .q-table--dense {
th:first-child, th:first-child,
td:first-child, td:first-child,
.q-table__bottom { .q-table__bottom {
padding-left: 6px !important; padding-left: 6px !important;
} }
th:last-child,
th:last-child, td:last-child,
td:last-child, .q-table__bottom {
.q-table__bottom { padding-right: 6px !important;
padding-right: 6px !important; }
}
} }
a.inherit { a.inherit {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
} }
// QR video // QR video
video { video {
border-radius: 3px; border-radius: 3px;
} }
// Material icons font // Material icons font
@font-face { @font-face {
font-family: 'Material Icons'; font-family: 'Material Icons';
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
src: url(../fonts/material-icons-v50.woff2) format('woff2'); src: url(../fonts/material-icons-v50.woff2) format('woff2');
} }
.material-icons { .material-icons {
font-family: 'Material Icons'; font-family: 'Material Icons';
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
font-size: 24px; font-size: 24px;
line-height: 1; line-height: 1;
letter-spacing: normal; letter-spacing: normal;
text-transform: none; text-transform: none;
display: inline-block; display: inline-block;
white-space: nowrap; white-space: nowrap;
word-wrap: normal; word-wrap: normal;
direction: ltr; direction: ltr;
-moz-font-feature-settings: 'liga'; -moz-font-feature-settings: 'liga';
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
// text-wrap // text-wrap
.text-wrap { .text-wrap {
word-break: break-word; word-break: break-word;
} }

View file

@ -114,7 +114,7 @@ async def perform_balance_checks():
async def invoice_callback_dispatcher(checking_id: str): async def invoice_callback_dispatcher(checking_id: str):
payment = await get_standalone_payment(checking_id) payment = await get_standalone_payment(checking_id, incoming=True)
if payment and payment.is_in: if payment and payment.is_in:
await payment.set_pending(False) await payment.set_pending(False)
for send_chan in invoice_listeners: for send_chan in invoice_listeners:

View file

@ -7,7 +7,6 @@
{% endfor %} {% endfor %}
<!----> <!---->
<link rel="stylesheet" type="text/css" href="/static/css/base.css" /> <link rel="stylesheet" type="text/css" href="/static/css/base.css" />
{% block styles %}{% endblock %} {% block styles %}{% endblock %}
<title>{% block title %}{{ SITE_TITLE }}{% endblock %}</title> <title>{% block title %}{{ SITE_TITLE }}{% endblock %}</title>
<meta charset="utf-8" /> <meta charset="utf-8" />
@ -35,10 +34,12 @@
{% endblock %} {% endblock %}
<q-toolbar-title> <q-toolbar-title>
<q-btn flat no-caps dense size="lg" type="a" href="/"> <q-btn flat no-caps dense size="lg" type="a" href="/">
{% block toolbar_title %} {% if SITE_TITLE != 'LNbits' %} {{ {% block toolbar_title %} {% if USE_CUSTOM_LOGO %}
SITE_TITLE }} {% else %} <strong>LN</strong>bits {% endif %} {% <img height="30px" alt="Logo" src="{{ USE_CUSTOM_LOGO }}" />
endblock %}</q-btn {%else%} {% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {% else
> %}
<strong>LN</strong>bits {% endif %} {%endif%} {% endblock %}
</q-btn>
</q-toolbar-title> </q-toolbar-title>
{% block beta %} {% block beta %}
<q-badge color="yellow" text-color="black" class="q-mr-md"> <q-badge color="yellow" text-color="black" class="q-mr-md">
@ -118,6 +119,16 @@
size="md" size="md"
><q-tooltip>elSalvador</q-tooltip> ><q-tooltip>elSalvador</q-tooltip>
</q-btn> </q-btn>
<q-btn
v-if="g.allowedThemes.includes('freedom')"
dense
flat
@click="changeColor('freedom')"
icon="format_color_fill"
color="pink-13"
size="md"
><q-tooltip>Freedom</q-tooltip>
</q-btn>
<q-btn <q-btn
v-if="g.allowedThemes.includes('flamingo')" v-if="g.allowedThemes.includes('flamingo')"
dense dense

View file

@ -1,7 +1,13 @@
{% extends "base.html" %} {% block beta %}{% endblock %} {% block drawer_toggle {% extends "base.html" %} {% block beta %}{% endblock %} {% block drawer_toggle
%}{% endblock %} {% block drawer %}{% endblock %} {% block toolbar_title %} %}{% endblock %} {% block drawer %}{% endblock %} {% block toolbar_title %}
<a href="/" class="inherit"> <a
{% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {% else %} href="/"
<strong>LN</strong>bits {% endif %} class="inherit q-btn q-btn-item non-selectable no-outline q-btn--flat q-btn--rectangle q-btn--actionable q-focusable q-hoverable q-btn--no-uppercase q-btn--wrap q-btn--dense q-btn--active"
style="font-size: 20px"
>
{% if USE_CUSTOM_LOGO %}
<img height="30px" alt="Logo" src="{{ USE_CUSTOM_LOGO }}" />
{%else%} {% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {% else %}
<strong>LN</strong>bits {% endif %} {% endif %}
</a> </a>
{% endblock %} {% endblock %}

View file

@ -71,6 +71,7 @@ currencies = {
"IMP": "Isle of Man Pound", "IMP": "Isle of Man Pound",
"INR": "Indian Rupee", "INR": "Indian Rupee",
"IQD": "Iraqi Dinar", "IQD": "Iraqi Dinar",
"IRT": "Iranian Toman",
"ISK": "Icelandic Króna", "ISK": "Icelandic Króna",
"JEP": "Jersey Pound", "JEP": "Jersey Pound",
"JMD": "Jamaican Dollar", "JMD": "Jamaican Dollar",
@ -179,6 +180,12 @@ class Provider(NamedTuple):
exchange_rate_providers = { exchange_rate_providers = {
"exir": Provider(
"Exir",
"exir.io",
"https://api.exir.io/v1/ticker?symbol={from}-{to}",
lambda data, replacements: data["last"],
),
"bitfinex": Provider( "bitfinex": Provider(
"Bitfinex", "Bitfinex",
"bitfinex.com", "bitfinex.com",

View file

@ -1,12 +1,12 @@
# flake8: noqa # flake8: noqa
from .void import VoidWallet
from .clightning import CLightningWallet from .clightning import CLightningWallet
from .lndgrpc import LndWallet from .eclair import EclairWallet
from .lntxbot import LntxbotWallet from .fake import FakeWallet
from .opennode import OpenNodeWallet
from .lnpay import LNPayWallet
from .lnbits import LNbitsWallet from .lnbits import LNbitsWallet
from .lndrest import LndRestWallet from .lndrest import LndRestWallet
from .lnpay import LNPayWallet
from .lntxbot import LntxbotWallet
from .opennode import OpenNodeWallet
from .spark import SparkWallet from .spark import SparkWallet
from .fake import FakeWallet from .void import VoidWallet

View file

@ -60,7 +60,9 @@ class Wallet(ABC):
pass pass
@abstractmethod @abstractmethod
def pay_invoice(self, bolt11: str) -> Coroutine[None, None, PaymentResponse]: def pay_invoice(
self, bolt11: str, fee_limit_msat: int
) -> Coroutine[None, None, PaymentResponse]:
pass pass
@abstractmethod @abstractmethod

View file

@ -18,6 +18,7 @@ from .base import (
Unsupported, Unsupported,
Wallet, Wallet,
) )
from lnbits import bolt11 as lnbits_bolt11
def async_wrap(func): def async_wrap(func):
@ -31,8 +32,8 @@ def async_wrap(func):
return run return run
def _pay_invoice(ln, bolt11): def _pay_invoice(ln, payload):
return ln.pay(bolt11) return ln.call("pay", payload)
def _paid_invoices_stream(ln, last_pay_index): def _paid_invoices_stream(ln, last_pay_index):
@ -102,10 +103,18 @@ class CLightningWallet(Wallet):
error_message = f"lightningd '{exc.method}' failed with '{exc.error}'." error_message = f"lightningd '{exc.method}' failed with '{exc.error}'."
return InvoiceResponse(False, label, None, error_message) return InvoiceResponse(False, label, None, error_message)
async def pay_invoice(self, bolt11: str) -> PaymentResponse: async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
invoice = lnbits_bolt11.decode(bolt11)
fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100
payload = {
"bolt11": bolt11,
"maxfeepercent": "{:.11}".format(fee_limit_percent),
"exemptfee": 0, # so fee_limit_percent is applied even on payments with fee under 5000 millisatoshi (which is default value of exemptfee)
}
try: try:
wrapped = async_wrap(_pay_invoice) wrapped = async_wrap(_pay_invoice)
r = await wrapped(self.ln, bolt11) r = await wrapped(self.ln, payload)
except RpcError as exc: except RpcError as exc:
return PaymentResponse(False, None, 0, None, str(exc)) return PaymentResponse(False, None, 0, None, str(exc))

200
lnbits/wallets/eclair.py Normal file
View file

@ -0,0 +1,200 @@
import asyncio
import base64
import json
import urllib.parse
from os import getenv
from typing import AsyncGenerator, Dict, Optional
import httpx
from websockets import connect
from websockets.exceptions import (
ConnectionClosed,
ConnectionClosedError,
ConnectionClosedOK,
)
from .base import (
InvoiceResponse,
PaymentResponse,
PaymentStatus,
StatusResponse,
Wallet,
)
class EclairError(Exception):
pass
class UnknownError(Exception):
pass
class EclairWallet(Wallet):
def __init__(self):
url = getenv("ECLAIR_URL")
self.url = url[:-1] if url.endswith("/") else url
self.ws_url = f"ws://{urllib.parse.urlsplit(self.url).netloc}/ws"
passw = getenv("ECLAIR_PASS")
encodedAuth = base64.b64encode(f":{passw}".encode("utf-8"))
auth = str(encodedAuth, "utf-8")
self.auth = {"Authorization": f"Basic {auth}"}
async def status(self) -> StatusResponse:
async with httpx.AsyncClient() as client:
r = await client.post(
f"{self.url}/usablebalances", headers=self.auth, timeout=40
)
try:
data = r.json()
except:
return StatusResponse(
f"Failed to connect to {self.url}, got: '{r.text[:200]}...'", 0
)
if r.is_error:
return StatusResponse(data["error"], 0)
return StatusResponse(None, data[0]["canSend"] * 1000)
async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
) -> InvoiceResponse:
data: Dict = {"amountMsat": amount * 1000}
if description_hash:
data["description_hash"] = description_hash.hex()
else:
data["description"] = memo or ""
async with httpx.AsyncClient() as client:
r = await client.post(
f"{self.url}/createinvoice", headers=self.auth, data=data, timeout=40
)
if r.is_error:
try:
data = r.json()
error_message = data["error"]
except:
error_message = r.text
pass
return InvoiceResponse(False, None, None, error_message)
data = r.json()
return InvoiceResponse(True, data["paymentHash"], data["serialized"], None)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
async with httpx.AsyncClient() as client:
r = await client.post(
f"{self.url}/payinvoice",
headers=self.auth,
data={"invoice": bolt11, "blocking": True},
timeout=40,
)
if "error" in r.json():
try:
data = r.json()
error_message = data["error"]
except:
error_message = r.text
pass
return PaymentResponse(False, None, 0, None, error_message)
data = r.json()
checking_id = data["paymentHash"]
preimage = data["paymentPreimage"]
async with httpx.AsyncClient() as client:
r = await client.post(
f"{self.url}/getsentinfo",
headers=self.auth,
data={"paymentHash": checking_id},
timeout=40,
)
if "error" in r.json():
try:
data = r.json()
error_message = data["error"]
except:
error_message = r.text
pass
return PaymentResponse(
True, checking_id, 0, preimage, error_message
) ## ?? is this ok ??
data = r.json()
fees = [i["status"] for i in data]
fee_msat = sum([i["feesPaid"] for i in fees])
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
async with httpx.AsyncClient() as client:
r = await client.post(
f"{self.url}/getreceivedinfo",
headers=self.auth,
data={"paymentHash": checking_id},
)
data = r.json()
if r.is_error or "error" in data:
return PaymentStatus(None)
if data["status"]["type"] != "received":
return PaymentStatus(False)
return PaymentStatus(True)
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
async with httpx.AsyncClient() as client:
r = await client.post(
url=f"{self.url}/getsentinfo",
headers=self.auth,
data={"paymentHash": checking_id},
)
data = r.json()[0]
if r.is_error:
return PaymentStatus(None)
if data["status"]["type"] != "sent":
return PaymentStatus(False)
return PaymentStatus(True)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
try:
async with connect(
self.ws_url,
extra_headers=[("Authorization", self.auth["Authorization"])],
) as ws:
while True:
message = await ws.recv()
message = json.loads(message)
if message and message["type"] == "payment-received":
yield message["paymentHash"]
except (
OSError,
ConnectionClosedOK,
ConnectionClosedError,
ConnectionClosed,
) as ose:
print("OSE", ose)
pass
print("lost connection to eclair's websocket, retrying in 5 seconds")
await asyncio.sleep(5)

View file

@ -36,7 +36,13 @@ class FakeWallet(Wallet):
"out": False, "out": False,
"amount": amount, "amount": amount,
"currency": "bc", "currency": "bc",
"privkey": hashlib.pbkdf2_hmac('sha256', secret.encode("utf-8"), ("FakeWallet").encode("utf-8"), 2048, 32).hex(), "privkey": hashlib.pbkdf2_hmac(
"sha256",
secret.encode("utf-8"),
("FakeWallet").encode("utf-8"),
2048,
32,
).hex(),
"memo": None, "memo": None,
"description_hash": None, "description_hash": None,
"description": "", "description": "",
@ -53,22 +59,29 @@ class FakeWallet(Wallet):
data["tags_set"] = ["d"] data["tags_set"] = ["d"]
data["memo"] = memo data["memo"] = memo
data["description"] = memo data["description"] = memo
randomHash = data["privkey"][:6] + hashlib.sha256( randomHash = (
str(random.getrandbits(256)).encode("utf-8") data["privkey"][:6]
).hexdigest()[6:] + hashlib.sha256(str(random.getrandbits(256)).encode("utf-8")).hexdigest()[
6:
]
)
data["paymenthash"] = randomHash data["paymenthash"] = randomHash
payment_request = encode(data) payment_request = encode(data)
checking_id = randomHash checking_id = randomHash
return InvoiceResponse(True, checking_id, payment_request) return InvoiceResponse(True, checking_id, payment_request)
async def pay_invoice(self, bolt11: str) -> PaymentResponse: async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
invoice = decode(bolt11) invoice = decode(bolt11)
if hasattr(invoice, 'checking_id') and invoice.checking_id[6:] == data["privkey"][:6]: if (
hasattr(invoice, "checking_id")
and invoice.checking_id[6:] == data["privkey"][:6]
):
return PaymentResponse(True, invoice.payment_hash, 0) return PaymentResponse(True, invoice.payment_hash, 0)
else: else:
return PaymentResponse(ok = False, error_message="Only internal invoices can be used!") return PaymentResponse(
ok=False, error_message="Only internal invoices can be used!"
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
return PaymentStatus(False) return PaymentStatus(False)

View file

@ -80,7 +80,7 @@ class LNbitsWallet(Wallet):
return InvoiceResponse(ok, checking_id, payment_request, error_message) return InvoiceResponse(ok, checking_id, payment_request, error_message)
async def pay_invoice(self, bolt11: str) -> PaymentResponse: async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
r = await client.post( r = await client.post(
url=f"{self.endpoint}/api/v1/payments", url=f"{self.endpoint}/api/v1/payments",

View file

@ -92,11 +92,12 @@ class LndWallet(Wallet):
or getenv("LND_GRPC_INVOICE_MACAROON") or getenv("LND_GRPC_INVOICE_MACAROON")
or getenv("LND_INVOICE_MACAROON") or getenv("LND_INVOICE_MACAROON")
) )
encrypted_macaroon = getenv("LND_GRPC_MACAROON_ENCRYPTED") encrypted_macaroon = getenv("LND_GRPC_MACAROON_ENCRYPTED")
if encrypted_macaroon: if encrypted_macaroon:
macaroon = AESCipher(description="macaroon decryption").decrypt(encrypted_macaroon) macaroon = AESCipher(description="macaroon decryption").decrypt(
encrypted_macaroon
)
self.macaroon = load_macaroon(macaroon) self.macaroon = load_macaroon(macaroon)
cert = open(self.cert_path, "rb").read() cert = open(self.cert_path, "rb").read()
@ -143,10 +144,10 @@ class LndWallet(Wallet):
payment_request = str(resp.payment_request) payment_request = str(resp.payment_request)
return InvoiceResponse(True, checking_id, payment_request, None) return InvoiceResponse(True, checking_id, payment_request, None)
async def pay_invoice(self, bolt11: str) -> PaymentResponse: async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
resp = await self.rpc.SendPayment( fee_limit_fixed = ln.FeeLimit(fixed=fee_limit_msat // 1000)
lnrpc.SendPaymentRequest(payment_request=bolt11) req = ln.SendRequest(payment_request=bolt11, fee_limit=fee_limit_fixed)
) resp = await self.rpc.SendPaymentSync(req)
if resp.payment_error: if resp.payment_error:
return PaymentResponse(False, "", 0, None, resp.payment_error) return PaymentResponse(False, "", 0, None, resp.payment_error)

View file

@ -39,11 +39,13 @@ class LndRestWallet(Wallet):
encrypted_macaroon = getenv("LND_REST_MACAROON_ENCRYPTED") encrypted_macaroon = getenv("LND_REST_MACAROON_ENCRYPTED")
if encrypted_macaroon: if encrypted_macaroon:
macaroon = AESCipher(description="macaroon decryption").decrypt(encrypted_macaroon) macaroon = AESCipher(description="macaroon decryption").decrypt(
encrypted_macaroon
)
self.macaroon = load_macaroon(macaroon) self.macaroon = load_macaroon(macaroon)
self.auth = {"Grpc-Metadata-macaroon": self.macaroon} self.auth = {"Grpc-Metadata-macaroon": self.macaroon}
self.cert = getenv("LND_REST_CERT") self.cert = getenv("LND_REST_CERT", True)
async def status(self) -> StatusResponse: async def status(self) -> StatusResponse:
try: try:
@ -97,15 +99,11 @@ class LndRestWallet(Wallet):
return InvoiceResponse(True, checking_id, payment_request, None) return InvoiceResponse(True, checking_id, payment_request, None)
async def pay_invoice(self, bolt11: str) -> PaymentResponse: async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
async with httpx.AsyncClient(verify=self.cert) as client: async with httpx.AsyncClient(verify=self.cert) as client:
# set the fee limit for the payment # set the fee limit for the payment
invoice = lnbits_bolt11.decode(bolt11)
lnrpcFeeLimit = dict() lnrpcFeeLimit = dict()
if invoice.amount_msat > 1000_000: lnrpcFeeLimit["fixed_msat"] = "{}".format(fee_limit_msat)
lnrpcFeeLimit["percent"] = "1" # in percent
else:
lnrpcFeeLimit["fixed"] = "10" # in sat
r = await client.post( r = await client.post(
url=f"{self.endpoint}/v1/channels/transactions", url=f"{self.endpoint}/v1/channels/transactions",
@ -162,6 +160,7 @@ class LndRestWallet(Wallet):
# for some reason our checking_ids are in base64 but the payment hashes # for some reason our checking_ids are in base64 but the payment hashes
# returned here are in hex, lnd is weird # returned here are in hex, lnd is weird
checking_id = checking_id.replace("_", "/")
checking_id = base64.b64decode(checking_id).hex() checking_id = base64.b64decode(checking_id).hex()
for p in r.json()["payments"]: for p in r.json()["payments"]:

View file

@ -76,7 +76,7 @@ class LNPayWallet(Wallet):
return InvoiceResponse(ok, checking_id, payment_request, error_message) return InvoiceResponse(ok, checking_id, payment_request, error_message)
async def pay_invoice(self, bolt11: str) -> PaymentResponse: async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
r = await client.post( r = await client.post(
f"{self.endpoint}/wallet/{self.wallet_key}/withdraw", f"{self.endpoint}/wallet/{self.wallet_key}/withdraw",

View file

@ -74,7 +74,7 @@ class LntxbotWallet(Wallet):
data = r.json() data = r.json()
return InvoiceResponse(True, data["payment_hash"], data["pay_req"], None) return InvoiceResponse(True, data["payment_hash"], data["pay_req"], None)
async def pay_invoice(self, bolt11: str) -> PaymentResponse: async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
r = await client.post( r = await client.post(
f"{self.endpoint}/payinvoice", f"{self.endpoint}/payinvoice",

View file

@ -1 +1 @@
from .macaroon import load_macaroon, AESCipher from .macaroon import load_macaroon, AESCipher

View file

@ -5,10 +5,11 @@ from hashlib import md5
import getpass import getpass
BLOCK_SIZE = 16 BLOCK_SIZE = 16
import getpass import getpass
def load_macaroon(macaroon: str) -> str: def load_macaroon(macaroon: str) -> str:
"""Returns hex version of a macaroon encoded in base64 or the file path. """Returns hex version of a macaroon encoded in base64 or the file path.
:param macaroon: Macaroon encoded in base64 or file path. :param macaroon: Macaroon encoded in base64 or file path.
:type macaroon: str :type macaroon: str
@ -29,6 +30,7 @@ def load_macaroon(macaroon: str) -> str:
pass pass
return macaroon return macaroon
class AESCipher(object): class AESCipher(object):
"""This class is compatible with crypto-js/aes.js """This class is compatible with crypto-js/aes.js
@ -39,6 +41,7 @@ class AESCipher(object):
AES.decrypt(encrypted, password).toString(Utf8); AES.decrypt(encrypted, password).toString(Utf8);
""" """
def __init__(self, key=None, description=""): def __init__(self, key=None, description=""):
self.key = key self.key = key
self.description = description + " " self.description = description + " "
@ -47,7 +50,6 @@ class AESCipher(object):
length = BLOCK_SIZE - (len(data) % BLOCK_SIZE) length = BLOCK_SIZE - (len(data) % BLOCK_SIZE)
return data + (chr(length) * length).encode() return data + (chr(length) * length).encode()
def unpad(self, data): def unpad(self, data):
return data[: -(data[-1] if type(data[-1]) == int else ord(data[-1]))] return data[: -(data[-1] if type(data[-1]) == int else ord(data[-1]))]
@ -70,8 +72,7 @@ class AESCipher(object):
return final_key[:output] return final_key[:output]
def decrypt(self, encrypted: str) -> str: def decrypt(self, encrypted: str) -> str:
"""Decrypts a string using AES-256-CBC. """Decrypts a string using AES-256-CBC."""
"""
passphrase = self.passphrase passphrase = self.passphrase
encrypted = base64.b64decode(encrypted) encrypted = base64.b64decode(encrypted)
assert encrypted[0:8] == b"Salted__" assert encrypted[0:8] == b"Salted__"
@ -92,7 +93,10 @@ class AESCipher(object):
key = key_iv[:32] key = key_iv[:32]
iv = key_iv[32:] iv = key_iv[32:]
aes = AES.new(key, AES.MODE_CBC, iv) aes = AES.new(key, AES.MODE_CBC, iv)
return base64.b64encode(b"Salted__" + salt + aes.encrypt(self.pad(message))).decode() return base64.b64encode(
b"Salted__" + salt + aes.encrypt(self.pad(message))
).decode()
# if this file is executed directly, ask for a macaroon and encrypt it # if this file is executed directly, ask for a macaroon and encrypt it
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -77,7 +77,7 @@ class OpenNodeWallet(Wallet):
payment_request = data["lightning_invoice"]["payreq"] payment_request = data["lightning_invoice"]["payreq"]
return InvoiceResponse(True, checking_id, payment_request, None) return InvoiceResponse(True, checking_id, payment_request, None)
async def pay_invoice(self, bolt11: str) -> PaymentResponse: async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
r = await client.post( r = await client.post(
f"{self.endpoint}/v2/withdrawals", f"{self.endpoint}/v2/withdrawals",

View file

@ -107,9 +107,12 @@ class SparkWallet(Wallet):
return InvoiceResponse(ok, checking_id, payment_request, error_message) return InvoiceResponse(ok, checking_id, payment_request, error_message)
async def pay_invoice(self, bolt11: str) -> PaymentResponse: async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
try: try:
r = await self.pay(bolt11) r = await self.pay(
bolt11=bolt11,
maxfee=fee_limit_msat,
)
except (SparkError, UnknownError) as exc: except (SparkError, UnknownError) as exc:
listpays = await self.listpays(bolt11) listpays = await self.listpays(bolt11)
if listpays: if listpays:
@ -129,7 +132,9 @@ class SparkWallet(Wallet):
if pay["status"] == "failed": if pay["status"] == "failed":
return PaymentResponse(False, None, 0, None, str(exc)) return PaymentResponse(False, None, 0, None, str(exc))
elif pay["status"] == "pending": elif pay["status"] == "pending":
return PaymentResponse(None, payment_hash, 0, None, None) return PaymentResponse(
None, payment_hash, fee_limit_msat, None, None
)
elif pay["status"] == "complete": elif pay["status"] == "complete":
r = pay r = pay
r["payment_preimage"] = pay["preimage"] r["payment_preimage"] = pay["preimage"]
@ -152,9 +157,11 @@ class SparkWallet(Wallet):
if not r or not r.get("invoices"): if not r or not r.get("invoices"):
return PaymentStatus(None) return PaymentStatus(None)
if r["invoices"][0]["status"] == "unpaid":
if r["invoices"][0]["status"] == "paid":
return PaymentStatus(True)
else:
return PaymentStatus(False) return PaymentStatus(False)
return PaymentStatus(True)
async def get_payment_status(self, checking_id: str) -> PaymentStatus: async def get_payment_status(self, checking_id: str) -> PaymentStatus:
# check if it's 32 bytes hex # check if it's 32 bytes hex

View file

@ -25,7 +25,7 @@ class VoidWallet(Wallet):
) )
return StatusResponse(None, 0) return StatusResponse(None, 0)
async def pay_invoice(self, bolt11: str) -> PaymentResponse: async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
raise Unsupported("") raise Unsupported("")
async def get_invoice_status(self, checking_id: str) -> PaymentStatus: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:

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