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_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
LNBITS_DISABLED_EXTENSIONS="amilk"
@ -29,11 +32,12 @@ LNBITS_SERVICE_FEE="0.0"
LNBITS_SITE_TITLE="LNbits"
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'"
# Choose from mint, flamingo, salvador, autumn, monochrome, classic
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, mint, autumn, monochrome, salvador"
# Choose from mint, flamingo, freedom, salvador, autumn, monochrome, classic
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),
# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, FakeWallet
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet,
# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
# just so you can see the UI before dealing with this file.
@ -50,14 +54,6 @@ CLIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
LNBITS_ENDPOINT=https://legend.lnbits.com
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
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"
@ -83,3 +79,7 @@ OPENNODE_KEY=OPENNODE_ADMIN_KEY
# FakeWallet
FAKE_WALLET_SECRET="ToTheMoon1"
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: jpetrucciani/mypy-check@master
with:
mypy_flags: '--install-types --non-interactive'
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
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 pip install wheel
# Install runtime deps
COPY requirements.txt /tmp/requirements.txt
@ -36,6 +37,9 @@ ENV PATH="$VIRTUAL_ENV/bin:$PATH"
WORKDIR /app
COPY --chown=1000:1000 lnbits /app/lnbits
ENV LNBITS_PORT="5000"
ENV LNBITS_HOST="0.0.0.0"
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))
(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!
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 sqlite3
import os
# Python script to migrate an LNbits SQLite DB to Postgres
# All credits to @Fritz446 for the awesome work

View file

@ -12,6 +12,8 @@ LNbits uses [Pipenv][pipenv] to manage Python packages.
```sh
git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend/
sudo apt-get install pipenv
pipenv shell
# pipenv --python 3.9 shell (if you wish to use a version of Python higher than 3.7)
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
# pip install -U setuptools
# install libffi/libpq in case "pipenv install" fails
# sudo apt-get install -y libffi-dev libpq-dev
```
## 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.
**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
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
# 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=
# postgres://<user>:<password>@<host>/<database> - alter line bellow with your user, password and db name
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
# 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.
@ -78,17 +81,23 @@ Systemd is great for taking care of your LNbits instance. It will start it on bo
[Unit]
Description=LNbits
#Wants=lnd.service # 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)
# you can uncomment these lines if you know what you're doing
# it will make sure that lnbits starts after lnd (replace with your own backend service)
#Wants=lnd.service
#After=lnd.service
[Service]
WorkingDirectory=/home/bitcoin/lnbits # replace with the absolute path of your lnbits installation
ExecStart=/home/bitcoin/lnbits/venv/bin/uvicorn lnbits.__main__:app --port 5000 # same here
User=bitcoin # replace with the user that you're running lnbits on
# replace with the absolute path of your lnbits installation
WorkingDirectory=/home/bitcoin/lnbits
# 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
TimeoutSec=120
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]
WantedBy=multi-user.target

View file

@ -47,7 +47,7 @@ To encrypt your macaroon, run `./venv/bin/python lnbits/wallets/macaroon/macaroo
### LND (REST)
- `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_MACAROON`: /file/path/admin.macaroon or Bech64/Hex

View file

@ -3,11 +3,13 @@ import importlib
import sys
import traceback
import warnings
from http import HTTPStatus
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
import lnbits.settings
@ -58,15 +60,19 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
async def validation_exception_handler(
request: Request, exc: RequestValidationError
):
# Only the browser sends "text/html" request
# not fail proof, but everything else get's a JSON response
if "text/html" in request.headers["accept"]:
return template_renderer().TemplateResponse(
"error.html",
{"request": request, "err": f"`{exc.errors()}` is not a valid UUID."},
{"request": request, "err": f"{exc.errors()} is not a valid UUID."},
)
# return HTMLResponse(
# status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
# content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}),
# )
return JSONResponse(
status_code=HTTPStatus.NO_CONTENT,
content={"detail": exc.errors()},
)
app.add_middleware(GZipMiddleware, minimum_size=1000)
# app.add_middleware(ASGIProxyFix)
@ -84,15 +90,16 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
def check_funding_source(app: FastAPI) -> None:
@app.on_event("startup")
async def check_wallet_status():
while True:
error_message, balance = await WALLET.status()
if error_message:
if not error_message:
break
warnings.warn(
f" × The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
RuntimeWarning,
)
sys.exit(4)
else:
print("Retrying connection to backend in 5 seconds...")
await asyncio.sleep(5)
print(
f" ✔️ {WALLET.__class__.__name__} seems to be connected and with a balance of {balance} msat."
)
@ -167,9 +174,17 @@ def register_exception_handlers(app: FastAPI):
@app.exception_handler(Exception)
async def basic_error(request: Request, err):
print("handled error", traceback.format_exc())
print("ERROR:", err)
etype, _, tb = sys.exc_info()
traceback.print_exception(etype, err, tb)
exc = traceback.format_exc()
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:
amount = Decimal(str(addr.amount))
# We can only send down to millisatoshi.
if amount * 10 ** 12 % 10:
if amount * 10**12 % 10:
raise ValueError(
"Cannot encode {}: too many decimal places".format(addr.amount)
)
@ -270,7 +270,7 @@ class LnAddr(object):
def shorten_amount(amount):
"""Given an amount in bitcoin, shorten it"""
# Convert to pico initially
amount = int(amount * 10 ** 12)
amount = int(amount * 10**12)
units = ["p", "n", "u", "m", ""]
for unit in units:
if amount % 1000 == 0:
@ -289,7 +289,7 @@ def _unshorten_amount(amount: str) -> int:
# * `u` (micro): multiply by 0.000001
# * `n` (nano): multiply by 0.000000001
# * `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]
# BOLT #11:
@ -348,9 +348,9 @@ def _trim_to_bytes(barr):
def _readable_scid(short_channel_id: int) -> str:
return "{blockheight}x{transactionindex}x{outputindex}".format(
blockheight=((short_channel_id >> 40) & 0xffffff),
transactionindex=((short_channel_id >> 16) & 0xffffff),
outputindex=(short_channel_id & 0xffff),
blockheight=((short_channel_id >> 40) & 0xFFFFFF),
transactionindex=((short_channel_id >> 16) & 0xFFFFFF),
outputindex=(short_channel_id & 0xFFFF),
)

View file

@ -180,13 +180,18 @@ async def get_wallet_for_key(
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]:
clause: str = "checking_id = ? OR hash = ?"
if incoming:
clause = f"({clause}) AND amount > 0"
row = await (conn or db).fetchone(
"""
f"""
SELECT *
FROM apipayments
WHERE checking_id = ? OR hash = ?
WHERE {clause}
LIMIT 1
""",
(checking_id_or_hash, checking_id_or_hash),

View file

@ -85,18 +85,17 @@ async def pay_invoice(
description: str = "",
conn: Optional[Connection] = None,
) -> 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:
temp_id = f"temp_{urlsafe_short_hash()}"
internal_id = f"internal_{urlsafe_short_hash()}"
invoice = bolt11.decode(payment_request)
if invoice.amount_msat == 0:
raise ValueError("Amountless invoices not supported.")
if max_sat and invoice.amount_msat > max_sat * 1000:
raise ValueError("Amount in invoice is too high.")
wallet = await get_wallet(wallet_id, conn=conn)
# put all parameters that don't change here
PaymentKwargs = TypedDict(
"PaymentKwargs",
@ -134,26 +133,20 @@ async def pay_invoice(
# the balance is enough in the next step
await create_payment(
checking_id=temp_id,
fee=-fee_reserve(invoice.amount_msat),
fee=-fee_reserve_msat,
conn=conn,
**payment_kwargs,
)
# do the balance check if internal payment
if internal_checking_id:
# do the balance check
wallet = await get_wallet(wallet_id, conn=conn)
assert wallet
if wallet.balance_msat < 0:
raise PermissionError("Insufficient balance.")
# 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."
if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat:
raise PaymentFailure(
f"You must reserve at least 1% ({round(fee_reserve_msat/1000)} sat) to cover potential routing fees."
)
raise PermissionError("Insufficient balance.")
if internal_checking_id:
# 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)
else:
# 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:
async with db.connect() as conn:
await create_payment(
@ -286,12 +281,12 @@ async def perform_lnurlauth(
sign_len = 6 + r_len + s_len
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(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)
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)
@ -340,5 +335,6 @@ async def check_invoice_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:
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 () {
this.parse.show = true
let req = this.parse.data.request.toLowerCase()
if (this.parse.data.request.startsWith('lightning:')) {
this.parse.data.request = this.parse.data.request.slice(10)
} else if (this.parse.data.request.startsWith('lnurl:')) {
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
.split('lightning=')[1]
.split('&')[0]
@ -618,10 +618,10 @@ new Vue({
},
updateWalletName: function () {
let newName = this.newName
let adminkey = this.g.wallet.adminkey
if (!newName || !newName.length) return
// let data = {name: newName}
LNbits.api
.request('PUT', '/api/v1/wallet/' + newName, this.g.wallet.inkey, {})
.request('PUT', '/api/v1/wallet/' + newName, adminkey, {})
.then(res => {
this.newName = ''
this.$q.notify({
@ -691,10 +691,7 @@ new Vue({
},
mounted: function () {
// show disclaimer
if (
this.$refs.disclaimer &&
!this.$q.localStorage.getItem('lnbits.disclaimerShown')
) {
if (!this.$q.localStorage.getItem('lnbits.disclaimerShown')) {
this.disclaimerDialog.show = true
this.$q.localStorage.set('lnbits.disclaimerShown', true)
}

View file

@ -48,7 +48,7 @@
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<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">
Returns 201 CREATED (application/json)
@ -61,7 +61,7 @@
<code
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": false,
"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
>
</q-card-section>

View file

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

View file

@ -273,6 +273,10 @@
</q-card-section>
</q-card>
</div>
{% if HIDE_API %}
<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>
@ -288,12 +292,16 @@
<q-separator></q-separator>
{% if wallet.lnurlwithdraw_full %}
<q-expansion-item group="extras" icon="crop_free" label="Drain Funds">
<q-expansion-item
group="extras"
icon="crop_free"
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.
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
@ -303,8 +311,8 @@
</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.
<code>balanceNotify</code> so your wallet may keep pulling
the funds continuously from here after the first withdraw.
</p>
</q-card-section>
</q-card>
@ -378,10 +386,17 @@
</q-list>
</q-card-section>
</q-card>
{% endif %} {% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD =
ADS.split(';') %}
<q-card>
<a href="{{ AD[0] }}"
><img width="100%" src="{{ AD[1] }}"
/></a> </q-card
>{% endfor %} {% endif %}
</div>
</div>
</div>
<q-dialog v-model="receive.show" @hide="closeReceiveDialog">
<q-dialog v-model="receive.show" @hide="closeReceiveDialog">
{% raw %}
<q-card
v-if="!receive.paymentReq"
@ -417,9 +432,11 @@
filled
dense
v-model.number="receive.data.amount"
type="number"
label="Amount ({{LNBITS_DENOMINATION}}) *"
:step="receive.unit != 'sat' ? '0.001' : '1'"
: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"
@ -437,7 +454,7 @@
<q-btn
unelevated
color="primary"
:disable="receive.data.memo == null || receive.data.amount == null || receive.data.amount <= 0"
:disable="receive.data.amount == null || receive.data.amount <= 0"
type="submit"
>
<span v-if="receive.lnurl">
@ -445,7 +462,9 @@
</span>
<span v-else> Create invoice </span>
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</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'"
@ -474,14 +493,14 @@
</div>
</q-card>
{% endraw %}
</q-dialog>
</q-dialog>
<q-dialog v-model="parse.show" @hide="closeParseDialog">
<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 %}
{% 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 %}
@ -495,13 +514,17 @@
{% 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>
<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>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</div>
<div v-else-if="parse.lnurlauth">
@ -514,8 +537,8 @@
<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 }}.
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">
@ -535,7 +558,8 @@
<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}}
parse.lnurlpay.maxSendable | msatoshiFormat }}
{{LNBITS_DENOMINATION}}
<span v-if="parse.lnurlpay.commentAllowed > 0">
<br />
and a {{parse.lnurlpay.commentAllowed}}-char comment
@ -640,12 +664,15 @@
</div>
</div>
</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">
<div class="text-center q-mb-lg">
<qrcode-stream @decode="decodeQR" class="rounded-borders"></qrcode-stream>
<qrcode-stream
@decode="decodeQR"
class="rounded-borders"
></qrcode-stream>
</div>
<div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
@ -653,20 +680,20 @@
>
</div>
</q-card>
</q-dialog>
</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-section>
<canvas ref="canvas" width="600" height="400"></canvas>
</q-card-section>
</q-card>
</q-dialog>
<q-tabs
</q-dialog>
<q-tabs
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-max"
active-class="px-0"
indicator-color="transparent"
>
>
<q-tab
icon="account_balance_wallet"
label="Wallets"
@ -678,12 +705,11 @@
</q-tab>
<q-tab icon="photo_camera" label="Scan" @click="showCamera"> </q-tab>
</q-tabs>
{% if service_fee > 0 %}
<div ref="disclaimer"></div>
<q-dialog v-model="disclaimerDialog.show">
</q-tabs>
<q-dialog v-model="disclaimerDialog.show">
<q-card class="q-pa-lg">
<h6 class="q-my-md text-deep-purple">Warning</h6>
<h6 class="q-my-md text-primary">Warning</h6>
<p>
Login functionality to be released in v0.2, for now,
<strong
@ -693,10 +719,10 @@
</p>
<p>
This service is in BETA, and we hold no responsibility for people losing
access to funds. To encourage you to run your own LNbits installation, any
balance on {% raw %}{{ disclaimerDialog.location.host }}{% endraw %} will
incur a charge of <strong>{{ service_fee }}% service fee</strong> per
week.
access to funds. {% if service_fee > 0 %} To encourage you to run your
own LNbits installation, any balance on {% raw %}{{
disclaimerDialog.location.host }}{% endraw %} will incur a charge of
<strong>{{ service_fee }}% service fee</strong> per week. {% endif %}
</p>
<div class="row q-mt-lg">
<q-btn
@ -710,5 +736,6 @@
>
</div>
</q-card>
</q-dialog>
{% endif %} {% endblock %}
</q-dialog>
{% 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
import httpx
from fastapi import Query, Request
from fastapi import Header, Query, Request
from fastapi.exceptions import HTTPException
from fastapi.param_functions import Depends
from fastapi.params import Body
@ -23,9 +23,11 @@ from lnbits.decorators import (
WalletInvoiceKeyChecker,
WalletTypeInfo,
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.settings import LNBITS_ADMIN_USERS, LNBITS_SITE_TITLE
from lnbits.utils.exchange_rates import (
currencies,
fiat_amount_as_satoshis,
@ -34,13 +36,14 @@ from lnbits.utils.exchange_rates import (
from .. import core_app, db
from ..crud import (
create_payment,
get_payments,
get_standalone_payment,
save_balance_check,
update_wallet,
create_payment,
get_wallet,
get_wallet_for_key,
save_balance_check,
update_payment_status,
update_wallet,
)
from ..services import (
InvoiceFailure,
@ -51,8 +54,6 @@ from ..services import (
perform_lnurlauth,
)
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")
@ -98,7 +99,7 @@ async def api_update_balance(
@core_app.put("/api/v1/wallet/{new_name}")
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)
return {
@ -123,8 +124,8 @@ async def api_payments(wallet: WalletTypeInfo = Depends(get_key_type)):
class CreateInvoiceData(BaseModel):
out: Optional[bool] = True
amount: int = Query(None, ge=1)
memo: str = None
amount: float = Query(None, ge=0)
memo: Optional[str] = None
unit: Optional[str] = "sat"
description_hash: Optional[str] = None
lnurl_callback: Optional[str] = None
@ -140,9 +141,9 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
memo = ""
else:
description_hash = b""
memo = data.memo
memo = data.memo or LNBITS_SITE_TITLE
if data.unit == "sat":
amount = data.amount
amount = int(data.amount)
else:
price_in_sats = await fiat_amount_as_satoshis(data.amount, data.unit)
amount = price_in_sats
@ -363,7 +364,13 @@ async def api_payments_sse(
@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)
await check_invoice_status(payment.wallet_id, 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."
)
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}
try:
await payment.check_pending()
except Exception:
if wallet and wallet.id == payment.wallet_id:
return {"paid": False, "details": payment}
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}
@ -500,14 +517,19 @@ async def api_lnurlscan(code: str):
return params
class DecodePayment(BaseModel):
data: str
@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:
if data["data"][:5] == "LNURL":
url = lnurl.decode(data["data"])
if payment_str[:5] == "LNURL":
url = lnurl.decode(payment_str)
return {"domain": url}
else:
invoice = bolt11.decode(data["data"])
invoice = bolt11.decode(payment_str)
return {
"payment_hash": invoice.payment_hash,
"amount_msat": invoice.amount_msat,
@ -559,6 +581,6 @@ async def api_fiat_as_sats(data: ConversionData):
return output
else:
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
return output

View file

@ -15,8 +15,8 @@ from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer, url_for
from lnbits.settings import (
LNBITS_ALLOWED_USERS,
LNBITS_ADMIN_USERS,
LNBITS_ALLOWED_USERS,
LNBITS_SITE_TITLE,
SERVICE_FEE,
)
@ -226,7 +226,9 @@ async def lnurl_balance_notify(request: Request, service: str):
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 with db.connect() as conn:
account = await create_account(conn=conn)

View file

@ -130,9 +130,15 @@ class Database(Compat):
)
)
else:
if os.path.isdir(LNBITS_DATA_FOLDER):
self.path = os.path.join(LNBITS_DATA_FOLDER, f"{self.name}.sqlite3")
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
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.models import User, Wallet
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):
@ -122,7 +126,7 @@ async def get_key_type(
# 0: admin
# 1: invoice
# 2: invalid
pathname = r['path'].split('/')[1]
pathname = r["path"].split("/")[1]
if not api_key_header and not api_key_query:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
@ -133,8 +137,12 @@ async def get_key_type(
checker = WalletAdminKeyChecker(api_key=token)
await checker.__call__(r)
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):
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized.")
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (
LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS
):
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
)
return wallet
except HTTPException as e:
if e.status_code == HTTPStatus.BAD_REQUEST:
@ -147,9 +155,13 @@ async def get_key_type(
try:
checker = WalletInvoiceKeyChecker(api_key=token)
await checker.__call__(r)
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):
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized.")
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
):
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
)
return wallet
except HTTPException as e:
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]:
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]

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:
await db.execute("DELETE FROM events.tickets WHERE event = ?", (event_id,))
await db.execute("DELETE FROM events.ticket WHERE event = ?", (event_id,))
# 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()
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
@ -41,6 +41,7 @@ async def update_jukebox(
q = ", ".join([f"{field[0]} = ?" for field in data])
items = [f"{field[1]}" for field in data]
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))
row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,))
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]:
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:
if row.sp_playlists == None:
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]

View file

@ -75,7 +75,6 @@ async def api_check_credentials_check(
juke_id: str = Query(None), wallet: WalletTypeInfo = Depends(require_admin_key)
):
jukebox = await get_jukebox(juke_id)
return jukebox
@ -442,7 +441,7 @@ async def api_get_jukebox_currently(
token = await api_get_token(juke_id)
if token == False:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="INvoice not paid"
status_code=HTTPStatus.FORBIDDEN, detail="Invoice not paid"
)
elif retry:
raise HTTPException(
@ -456,5 +455,6 @@ async def api_get_jukebox_currently(
)
except:
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 mmap import MAP_DENYWRITE
from fastapi.param_functions import Depends

View file

@ -117,6 +117,7 @@
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
@ -136,9 +137,19 @@
:href="'mailto:' + props.row.email"
></q-btn>
</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">
{{ col.value }}
{{ col.label == "Ticket" ? col.value.length > 20 ? `${col.value.substring(0, 20)}...` : col.value : col.value }}
</q-td>
<q-td auto-width>
@ -249,6 +260,29 @@
</q-form>
</q-card>
</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>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
@ -318,6 +352,10 @@
formDialog: {
show: 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 () {
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
},
@ -421,12 +469,13 @@
},
updateformDialog: function (formId) {
var link = _.findWhere(this.forms, {id: formId})
console.log("LINK", link)
this.formDialog.data.id = link.id
this.formDialog.data.wallet = link.wallet
this.formDialog.data.name = link.name
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.show = true
},

View file

@ -103,6 +103,10 @@ async def api_ticket_make_ticket(data: CreateTicketData, form_id):
raise HTTPException(
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))

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"
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)(
f"""
INSERT INTO lnurlp.pay_links (
@ -22,9 +28,10 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
success_text,
success_url,
comment_chars,
currency
currency,
fiat_base_multiplier
)
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?)
{returning}
""",
(
@ -37,6 +44,7 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
data.success_url,
data.comment_chars,
data.currency,
data.fiat_base_multiplier,
),
)
if db.type == SQLITE:

View file

@ -33,7 +33,7 @@ async def api_lnurl_response(request: Request, link_id):
resp = LnurlPayResponse(
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,
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("UPDATE lnurlp.pay_links SET max = min;")
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):
description: str
min: int = Query(0.01, ge=0.01)
max: int = Query(0.01, ge=0.01)
min: float = Query(1, ge=0.01)
max: float = Query(1, ge=0.01)
currency: str = Query(None)
comment_chars: int = Query(0, ge=0, lt=800)
webhook_url: str = Query(None)
success_text: str = Query(None)
success_url: str = Query(None)
fiat_base_multiplier: int = Query(100, ge=1)
class PayLink(BaseModel):
id: int
wallet: str
description: str
min: int
min: float
served_meta: int
served_pr: int
webhook_url: Optional[str]
@ -32,11 +33,15 @@ class PayLink(BaseModel):
success_url: Optional[str]
currency: Optional[str]
comment_chars: int
max: int
max: float
fiat_base_multiplier: int
@classmethod
def from_row(cls, row: Row) -> "PayLink":
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)
def lnurl(self, req: Request) -> str:

View file

@ -76,13 +76,14 @@ async def api_link_create_or_update(
link_id=None,
wallet: WalletTypeInfo = Depends(get_key_type),
):
if data.min > data.max:
raise HTTPException(
detail="Min is greater than max.", status_code=HTTPStatus.BAD_REQUEST
)
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(
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))
counter = struct.pack(">Q", counter)
mac = hmac.new(key, counter, digest).digest()
offset = mac[-1] & 0x0f
binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7fffffff
offset = mac[-1] & 0x0F
binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7FFFFFFF
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}")
async def api_paywall_create_invoice(
data: CreatePaywallInvoice,
paywall_id: str = Query(None)
data: CreatePaywallInvoice, paywall_id: str = Query(None)
):
paywall = await get_paywall(paywall_id)
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}")
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)
payment_hash = data.payment_hash
if not paywall:

View file

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

View file

@ -60,7 +60,7 @@ class Service(BaseModel):
onchain: Optional[str]
servicename: str # Currently, this will just always be "Streamlabs"
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
def from_row(cls, row: Row) -> "Service":

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,7 +14,7 @@
extension allows the creation and management of users and wallets.
<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
develpoers stack as the user and wallet manager.<br />
developers stack as the user and wallet manager.<br />
<small>
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
>
@ -97,7 +97,7 @@
<q-card-section>
<code
><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>
<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>
<code
>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 }}"
</code>
</q-card-section>

View file

@ -299,7 +299,7 @@
.request(
'GET',
'/usermanager/api/v1/users',
this.g.user.wallets[0].inkey
this.g.user.wallets[0].adminkey
)
.then(function (response) {
self.users = response.data.map(function (obj) {
@ -362,7 +362,7 @@
.request(
'DELETE',
'/usermanager/api/v1/users/' + userId,
self.g.user.wallets[0].inkey
self.g.user.wallets[0].adminkey
)
.then(function (response) {
self.users = _.reject(self.users, function (obj) {
@ -389,7 +389,7 @@
.request(
'GET',
'/usermanager/api/v1/wallets',
this.g.user.wallets[0].inkey
this.g.user.wallets[0].adminkey
)
.then(function (response) {
self.wallets = response.data.map(function (obj) {
@ -447,7 +447,7 @@
.request(
'DELETE',
'/usermanager/api/v1/wallets/' + userId,
self.g.user.wallets[0].inkey
self.g.user.wallets[0].adminkey
)
.then(function (response) {
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.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 .crud import (
@ -27,7 +27,7 @@ from .models import CreateUserData, CreateUserWallet
@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
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}")
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)
if not user:
@ -93,7 +93,7 @@ async def api_usermanager_wallets_create(
@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
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}")
async def api_usermanager_users_wallets(
user_id, wallet: WalletTypeInfo = Depends(get_key_type)
user_id, wallet: WalletTypeInfo = Depends(require_admin_key)
):
return [
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}")
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)
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(
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()

View file

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

View file

@ -1,4 +1,7 @@
import json
import traceback
import httpx
from datetime import datetime
from http import HTTPStatus
@ -30,7 +33,9 @@ async def api_lnurl_response(request: Request, unique_hash):
)
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)
withdrawResponse = {
"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")
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)
now = int(datetime.now().timestamp())
@ -58,20 +67,37 @@ async def api_lnurl_callback(
)
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:
raise HTTPException(status_code=HTTPStatus.OK, detail="Bad request.")
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Bad request.")
if now < link.open_time:
return {"status": "ERROR", "reason": f"Wait {link.open_time - now} seconds."}
try:
usescsv = ""
try:
for x in range(1, link.uses - link.used):
usecv = link.usescsv.split(",")
usescsv += "," + str(usecv[x])
usecsvback = usescsv
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 = {
@ -89,16 +115,34 @@ async def api_lnurl_callback(
payment_request = pr
await pay_invoice(
payment_hash = await pay_invoice(
wallet_id=link.wallet,
payment_request=payment_request,
max_sat=link.max_withdrawable,
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"}
except Exception as e:
await update_withdraw_link(link.id, **changesback)
print(traceback.format_exc())
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:
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:
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(",")
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)
if id_unique_hash == shortuuid.uuid(name=tohash):
found = True
if not found:
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)
withdrawResponse = {
"tag": "withdrawRequest",
"callback": url,
"callback": url + "?id_unique_hash=" + id_unique_hash,
"k1": link.k1,
"minWithdrawable": link.min_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)
wait_time: int = Query(..., ge=1)
is_unique: bool
webhook_url: str = Query(None)
class WithdrawLink(BaseModel):
@ -32,6 +33,7 @@ class WithdrawLink(BaseModel):
used: int = Query(0)
usescsv: str = Query(None)
number: int = Query(0)
webhook_url: str = Query(None)
@property
def is_spent(self) -> bool:

View file

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

View file

@ -70,7 +70,8 @@
<code
>{"title": &lt;string&gt;, "min_withdrawable": &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">
Returns 201 CREATED (application/json)
@ -81,7 +82,7 @@
>curl -X POST {{ request.base_url }}withdraw/api/v1/links -d '{"title":
&lt;string&gt;, "min_withdrawable": &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: {{
user.wallets[0].adminkey }}"
</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,17 +1,12 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user) }}
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
<script src="/withdraw/static/js/index.js"></script>
{% endblock %} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="simpleformDialog.show = true"
>Quick vouchers</q-btn
>
<q-btn unelevated color="primary" @click="formDialog.show = true"
>Advanced withdraw link(s)</q-btn
>
<q-btn unelevated color="primary" @click="simpleformDialog.show = true">Quick vouchers</q-btn>
<q-btn unelevated color="primary" @click="formDialog.show = true">Advanced withdraw link(s)</q-btn>
</q-card-section>
</q-card>
@ -25,14 +20,7 @@
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
</div>
</div>
<q-table
dense
flat
:data="sortedWithdrawLinks"
row-key="id"
:columns="withdrawLinksTable.columns"
:pagination.sync="withdrawLinksTable.pagination"
>
<q-table dense flat :data="sortedWithdrawLinks" row-key="id" :columns="withdrawLinksTable.columns" :pagination.sync="withdrawLinksTable.pagination">
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
@ -41,6 +29,7 @@
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
@ -69,6 +58,17 @@
target="_blank"
><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
unelevated
dense
@ -82,6 +82,11 @@
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</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-btn
flat
@ -101,8 +106,7 @@
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</template> {% endraw %}
</q-table>
</q-card-section>
</q-card>
@ -129,101 +133,45 @@
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormData" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
<q-select filled dense emit-value v-model="formDialog.data.wallet" :options="g.user.walletOptions" label="Wallet *">
</q-select>
<q-input
filled
dense
v-model.trim="formDialog.data.title"
type="text"
label="Link title *"
></q-input>
<q-input
filled
dense
v-model.number="formDialog.data.min_withdrawable"
type="number"
min="10"
label="Min withdrawable (sat, at least 10) *"
></q-input>
<q-input
filled
dense
v-model.number="formDialog.data.max_withdrawable"
type="number"
min="10"
label="Max withdrawable (sat, at least 10) *"
></q-input>
<q-input
filled
dense
v-model.number="formDialog.data.uses"
type="number"
:default="1"
label="Amount of uses *"
></q-input>
<q-input filled dense v-model.trim="formDialog.data.title" type="text" label="Link title *"></q-input>
<q-input filled dense v-model.number="formDialog.data.min_withdrawable" type="number" min="10" label="Min withdrawable (sat, at least 10) *"></q-input>
<q-input filled dense v-model.number="formDialog.data.max_withdrawable" type="number" min="10" label="Max withdrawable (sat, at least 10) *"></q-input>
<q-input filled dense v-model.number="formDialog.data.uses" type="number" max="250" :default="1" label="Amount of uses *"></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 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 filled dense v-model="formDialog.secondMultiplier" :options="formDialog.secondMultiplierOptions">
</q-select>
</div>
</div>
<q-input
filled
dense
v-model="formDialog.data.webhook_url"
type="text"
label="Webhook URL (optional)"
hint="A URL to be called whenever this link gets used."
></q-input>
<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-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-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="
<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.title == null ||
(formDialog.data.min_withdrawable == null || formDialog.data.min_withdrawable < 1) ||
@ -233,67 +181,29 @@
formDialog.data.max_withdrawable < formDialog.data.min_withdrawable
) ||
formDialog.data.uses == null ||
formDialog.data.wait_time == null"
type="submit"
>Create withdraw link</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
formDialog.data.wait_time == null" type="submit">Create withdraw link</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog
v-model="simpleformDialog.show"
position="top"
@hide="simplecloseFormDialog"
>
<q-dialog v-model="simpleformDialog.show" position="top" @hide="simplecloseFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="simplesendFormData" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="simpleformDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
<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>
<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" max="250" :default="1" label="Number of vouchers"></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="
<q-btn unelevated color="primary" :disable="
simpleformDialog.data.wallet == null ||
simpleformDialog.data.max_withdrawable == null ||
simpleformDialog.data.max_withdrawable < 1 ||
simpleformDialog.data.uses == null"
type="submit"
>Create vouchers</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
simpleformDialog.data.uses == null" type="submit">Create vouchers</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
@ -302,19 +212,12 @@
<q-dialog v-model="qrCodeDialog.show" position="top">
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="qrCodeDialog.data.url + '/?lightning=' + qrCodeDialog.data.lnurl"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
<qrcode :value="qrCodeDialog.data.url + '/?lightning=' + qrCodeDialog.data.lnurl" :options="{width: 800}" class="rounded-borders"></qrcode>
{% raw %}
</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"
>
<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
><br />
<strong>Max. withdrawable:</strong> {{

View file

@ -102,3 +102,38 @@ async def print_qr(request: Request, link_id):
return withdraw_renderer().TemplateResponse(
"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,
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:
raise HTTPException(
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 shortuuid # type: ignore
import lnbits.settings as settings
from lnbits.jinja2_templating import Jinja2Templates
from lnbits.requestvars import g
import lnbits.settings as settings
class Extension(NamedTuple):
code: str
@ -26,7 +25,9 @@ class Extension(NamedTuple):
class ExtensionManager:
def __init__(self):
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] = [
x[1] for x in os.walk(os.path.join(settings.LNBITS_PATH, "extensions"))
][0]
@ -160,6 +161,10 @@ def template_renderer(additional_folders: List = []) -> Jinja2Templates:
["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["LNBITS_DENOMINATION"] = settings.LNBITS_DENOMINATION
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_VERSION"] = settings.LNBITS_COMMIT
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:
t.env.globals["VENDORED_JS"] = map(url_for_vendored, get_js_vendored())

View file

@ -1,10 +1,9 @@
import subprocess
import importlib
from environs import Env # type: ignore
import subprocess
from os import path
from typing import List
from environs import Env # type: ignore
env = Env()
env.read_env()
@ -29,11 +28,15 @@ LNBITS_ALLOWED_USERS: List[str] = env.list(
"LNBITS_ALLOWED_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", 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_DENOMINATION = env.str("LNBITS_DENOMINATION", default="sats")
LNBITS_SITE_TAGLINE = env.str(
@ -45,6 +48,7 @@ LNBITS_THEME_OPTIONS: List[str] = env.list(
default="classic, flamingo, mint, salvador, monochrome, autumn",
subcast=str,
)
LNBITS_CUSTOM_LOGO = env.str("LNBITS_CUSTOM_LOGO", default="")
WALLET = wallet_class()
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,63 +1,14 @@
$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
),
'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' {
$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)));
@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"
body[data-theme='#{$theme}'].body--dark {
@ -65,7 +16,7 @@ $themes: (
}
*/
}
@if $name == 'info' {
@if $name=='info' {
[data-theme='#{$theme}'] .q-card--dark,
[data-theme='#{$theme}'] .q-stepper--dark {
background: $color !important;
@ -73,7 +24,8 @@ $themes: (
}
}
[data-theme='#{$theme}'] {
@each $name, $color in $colors {
@each $name,
$color in $colors {
.bg-#{$name} {
background: $color !important;
}
@ -83,6 +35,15 @@ $themes: (
}
}
}
[data-theme='freedom'] .q-drawer--dark {
background: #0a0a0a !important;
}
[data-theme='freedom'] .q-header {
background: #0a0a0a !important;
}
[data-theme='salvador'] .q-drawer--dark {
background: #242424 !important;
}
@ -119,7 +80,6 @@ body.body--dark .q-field--error {
padding-bottom: 5px !important;
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
&.q-item--active {
color: inherit;
font-weight: bold;
@ -136,7 +96,6 @@ body.body--dark .q-field--error {
.q-table__bottom {
padding-left: 6px !important;
}
th:last-child,
td:last-child,
.q-table__bottom {
@ -150,13 +109,11 @@ a.inherit {
}
// QR video
video {
border-radius: 3px;
}
// Material icons font
@font-face {
font-family: 'Material Icons';
font-style: normal;

View file

@ -114,7 +114,7 @@ async def perform_balance_checks():
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:
await payment.set_pending(False)
for send_chan in invoice_listeners:

View file

@ -7,7 +7,6 @@
{% endfor %}
<!---->
<link rel="stylesheet" type="text/css" href="/static/css/base.css" />
{% block styles %}{% endblock %}
<title>{% block title %}{{ SITE_TITLE }}{% endblock %}</title>
<meta charset="utf-8" />
@ -35,10 +34,12 @@
{% endblock %}
<q-toolbar-title>
<q-btn flat no-caps dense size="lg" type="a" href="/">
{% block toolbar_title %} {% if SITE_TITLE != 'LNbits' %} {{
SITE_TITLE }} {% else %} <strong>LN</strong>bits {% endif %} {%
endblock %}</q-btn
>
{% block toolbar_title %} {% 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%} {% endblock %}
</q-btn>
</q-toolbar-title>
{% block beta %}
<q-badge color="yellow" text-color="black" class="q-mr-md">
@ -118,6 +119,16 @@
size="md"
><q-tooltip>elSalvador</q-tooltip>
</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
v-if="g.allowedThemes.includes('flamingo')"
dense

View file

@ -1,7 +1,13 @@
{% extends "base.html" %} {% block beta %}{% endblock %} {% block drawer_toggle
%}{% endblock %} {% block drawer %}{% endblock %} {% block toolbar_title %}
<a href="/" class="inherit">
{% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {% else %}
<strong>LN</strong>bits {% endif %}
<a
href="/"
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>
{% endblock %}

View file

@ -71,6 +71,7 @@ currencies = {
"IMP": "Isle of Man Pound",
"INR": "Indian Rupee",
"IQD": "Iraqi Dinar",
"IRT": "Iranian Toman",
"ISK": "Icelandic Króna",
"JEP": "Jersey Pound",
"JMD": "Jamaican Dollar",
@ -179,6 +180,12 @@ class Provider(NamedTuple):
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",
"bitfinex.com",

View file

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

View file

@ -60,7 +60,9 @@ class Wallet(ABC):
pass
@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
@abstractmethod

View file

@ -18,6 +18,7 @@ from .base import (
Unsupported,
Wallet,
)
from lnbits import bolt11 as lnbits_bolt11
def async_wrap(func):
@ -31,8 +32,8 @@ def async_wrap(func):
return run
def _pay_invoice(ln, bolt11):
return ln.pay(bolt11)
def _pay_invoice(ln, payload):
return ln.call("pay", payload)
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}'."
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:
wrapped = async_wrap(_pay_invoice)
r = await wrapped(self.ln, bolt11)
r = await wrapped(self.ln, payload)
except RpcError as 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,
"amount": amount,
"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,
"description_hash": None,
"description": "",
@ -53,22 +59,29 @@ class FakeWallet(Wallet):
data["tags_set"] = ["d"]
data["memo"] = memo
data["description"] = memo
randomHash = data["privkey"][:6] + hashlib.sha256(
str(random.getrandbits(256)).encode("utf-8")
).hexdigest()[6:]
randomHash = (
data["privkey"][:6]
+ hashlib.sha256(str(random.getrandbits(256)).encode("utf-8")).hexdigest()[
6:
]
)
data["paymenthash"] = randomHash
payment_request = encode(data)
checking_id = randomHash
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)
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)
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:
return PaymentStatus(False)

View file

@ -80,7 +80,7 @@ class LNbitsWallet(Wallet):
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:
r = await client.post(
url=f"{self.endpoint}/api/v1/payments",

View file

@ -93,10 +93,11 @@ class LndWallet(Wallet):
or getenv("LND_INVOICE_MACAROON")
)
encrypted_macaroon = getenv("LND_GRPC_MACAROON_ENCRYPTED")
if encrypted_macaroon:
macaroon = AESCipher(description="macaroon decryption").decrypt(encrypted_macaroon)
macaroon = AESCipher(description="macaroon decryption").decrypt(
encrypted_macaroon
)
self.macaroon = load_macaroon(macaroon)
cert = open(self.cert_path, "rb").read()
@ -143,10 +144,10 @@ class LndWallet(Wallet):
payment_request = str(resp.payment_request)
return InvoiceResponse(True, checking_id, payment_request, None)
async def pay_invoice(self, bolt11: str) -> PaymentResponse:
resp = await self.rpc.SendPayment(
lnrpc.SendPaymentRequest(payment_request=bolt11)
)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
fee_limit_fixed = ln.FeeLimit(fixed=fee_limit_msat // 1000)
req = ln.SendRequest(payment_request=bolt11, fee_limit=fee_limit_fixed)
resp = await self.rpc.SendPaymentSync(req)
if 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")
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.auth = {"Grpc-Metadata-macaroon": self.macaroon}
self.cert = getenv("LND_REST_CERT")
self.cert = getenv("LND_REST_CERT", True)
async def status(self) -> StatusResponse:
try:
@ -97,15 +99,11 @@ class LndRestWallet(Wallet):
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:
# set the fee limit for the payment
invoice = lnbits_bolt11.decode(bolt11)
lnrpcFeeLimit = dict()
if invoice.amount_msat > 1000_000:
lnrpcFeeLimit["percent"] = "1" # in percent
else:
lnrpcFeeLimit["fixed"] = "10" # in sat
lnrpcFeeLimit["fixed_msat"] = "{}".format(fee_limit_msat)
r = await client.post(
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
# returned here are in hex, lnd is weird
checking_id = checking_id.replace("_", "/")
checking_id = base64.b64decode(checking_id).hex()
for p in r.json()["payments"]:

View file

@ -76,7 +76,7 @@ class LNPayWallet(Wallet):
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:
r = await client.post(
f"{self.endpoint}/wallet/{self.wallet_key}/withdraw",

View file

@ -74,7 +74,7 @@ class LntxbotWallet(Wallet):
data = r.json()
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:
r = await client.post(
f"{self.endpoint}/payinvoice",

View file

@ -7,6 +7,7 @@ import getpass
BLOCK_SIZE = 16
import getpass
def load_macaroon(macaroon: str) -> str:
"""Returns hex version of a macaroon encoded in base64 or the file path.
@ -29,6 +30,7 @@ def load_macaroon(macaroon: str) -> str:
pass
return macaroon
class AESCipher(object):
"""This class is compatible with crypto-js/aes.js
@ -39,6 +41,7 @@ class AESCipher(object):
AES.decrypt(encrypted, password).toString(Utf8);
"""
def __init__(self, key=None, description=""):
self.key = key
self.description = description + " "
@ -47,7 +50,6 @@ class AESCipher(object):
length = BLOCK_SIZE - (len(data) % BLOCK_SIZE)
return data + (chr(length) * length).encode()
def unpad(self, data):
return data[: -(data[-1] if type(data[-1]) == int else ord(data[-1]))]
@ -70,8 +72,7 @@ class AESCipher(object):
return final_key[:output]
def decrypt(self, encrypted: str) -> str:
"""Decrypts a string using AES-256-CBC.
"""
"""Decrypts a string using AES-256-CBC."""
passphrase = self.passphrase
encrypted = base64.b64decode(encrypted)
assert encrypted[0:8] == b"Salted__"
@ -92,7 +93,10 @@ class AESCipher(object):
key = key_iv[:32]
iv = key_iv[32:]
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 __name__ == "__main__":

View file

@ -77,7 +77,7 @@ class OpenNodeWallet(Wallet):
payment_request = data["lightning_invoice"]["payreq"]
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:
r = await client.post(
f"{self.endpoint}/v2/withdrawals",

View file

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

View file

@ -25,7 +25,7 @@ class VoidWallet(Wallet):
)
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("")
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:

View file

@ -19,9 +19,10 @@ def app():
loop.run_until_complete(loop.shutdown_asyncgens())
loop.close()
@pytest.fixture
async def client(app):
client = AsyncClient(app=app, base_url=f'http://{HOST}:{PORT}')
client = AsyncClient(app=app, base_url=f"http://{HOST}:{PORT}")
# yield and pass the client to the test
yield client
# close the async client after the test has finished

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