Merge branch 'lnbits:main' into main
This commit is contained in:
commit
5bf8bc3a75
|
@ -10,13 +10,16 @@ DEBUG=false
|
|||
LNBITS_ALLOWED_USERS=""
|
||||
LNBITS_ADMIN_USERS=""
|
||||
# Extensions only admin can access
|
||||
LNBITS_ADMIN_EXTENSIONS="ngrok"
|
||||
LNBITS_ADMIN_EXTENSIONS="ngrok, admin"
|
||||
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available
|
||||
LNBITS_ADMIN_UI=false
|
||||
|
||||
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
||||
|
||||
# Ad space description
|
||||
# LNBITS_AD_SPACE_TITLE="Supported by"
|
||||
# csv ad space, format "<url>;<img-light>;<img-dark>, <url>;<img-light>;<img-dark>", extensions can choose to honor
|
||||
# LNBITS_AD_SPACE=""
|
||||
# LNBITS_AD_SPACE="https://shop.lnbits.com/;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-light.png;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-dark.png"
|
||||
|
||||
# Hides wallet api, extensions can choose to honor
|
||||
LNBITS_HIDE_API=false
|
||||
|
@ -105,6 +108,6 @@ LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
|
|||
LNTIPS_API_ENDPOINT=https://ln.tips
|
||||
|
||||
# Cashu Mint
|
||||
# Use a long-enough random (!) private key.
|
||||
# Use a long-enough random (!) private key.
|
||||
# Once set, you cannot change this key as for now.
|
||||
CASHU_PRIVATE_KEY="SuperSecretPrivateKey"
|
||||
|
|
1
.github/workflows/on-tag.yml
vendored
1
.github/workflows/on-tag.yml
vendored
|
@ -7,6 +7,7 @@ on:
|
|||
push:
|
||||
tags:
|
||||
- "[0-9]+.[0-9]+.[0-9]+"
|
||||
- "[0-9]+.[0-9]+.[0-9]+.[0-9]+"
|
||||
- "[0-9]+.[0-9]+.[0-9]+-*"
|
||||
|
||||
jobs:
|
||||
|
|
5
.github/workflows/tests.yml
vendored
5
.github/workflows/tests.yml
vendored
|
@ -16,7 +16,7 @@ jobs:
|
|||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
env:
|
||||
env:
|
||||
VIRTUAL_ENV: ./venv
|
||||
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
||||
run: |
|
||||
|
@ -43,9 +43,6 @@ jobs:
|
|||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Install dependencies
|
||||
env:
|
||||
VIRTUAL_ENV: ./venv
|
||||
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
FROM python:3.9-slim
|
||||
FROM python:3.10-slim
|
||||
|
||||
RUN apt-get clean
|
||||
RUN apt-get update
|
||||
|
@ -13,7 +13,7 @@ RUN mkdir -p lnbits/data
|
|||
COPY . .
|
||||
|
||||
RUN poetry config virtualenvs.create false
|
||||
RUN poetry install --no-dev --no-root
|
||||
RUN poetry install --only main --no-root
|
||||
RUN poetry run python build.py
|
||||
|
||||
ENV LNBITS_PORT="5000"
|
||||
|
|
4
Makefile
4
Makefile
|
@ -6,7 +6,7 @@ format: prettier isort black
|
|||
|
||||
check: mypy checkprettier checkisort checkblack
|
||||
|
||||
prettier: $(shell find lnbits -name "*.js" -name ".html")
|
||||
prettier: $(shell find lnbits -name "*.js" -o -name ".html")
|
||||
./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
|
||||
|
||||
black:
|
||||
|
@ -18,7 +18,7 @@ mypy:
|
|||
isort:
|
||||
poetry run isort .
|
||||
|
||||
checkprettier: $(shell find lnbits -name "*.js" -name ".html")
|
||||
checkprettier: $(shell find lnbits -name "*.js" -o -name ".html")
|
||||
./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
|
||||
|
||||
checkblack:
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
title: "LNbits docs"
|
||||
|
||||
remote_theme: pmarsceill/just-the-docs
|
||||
logo: "/logos/lnbits-full.png"
|
||||
color_scheme: dark
|
||||
logo: "/logos/lnbits-full--inverse.png"
|
||||
search_enabled: true
|
||||
url: https://legend.lnbits.org
|
||||
aux_links:
|
||||
|
|
72
docs/guide/admin_ui.md
Normal file
72
docs/guide/admin_ui.md
Normal file
|
@ -0,0 +1,72 @@
|
|||
---
|
||||
layout: default
|
||||
title: Admin UI
|
||||
nav_order: 4
|
||||
---
|
||||
|
||||
|
||||
Admin UI
|
||||
========
|
||||
The LNbits Admin UI lets you change LNbits settings via the LNbits frontend.
|
||||
It is disabled by default and the first time you set the enviroment variable LNBITS_ADMIN_UI=true
|
||||
the settings are initialized and saved to the database and will be used from there as long the UI is enabled.
|
||||
From there on the settings from the database are used.
|
||||
|
||||
|
||||
Super User
|
||||
==========
|
||||
With the Admin UI we introduced the super user, it is created with the initialisation of the Admin UI and will be shown with a success message in the server logs.
|
||||
The super user has access to the server and can change settings that may crash the server and make it unresponsive via the frontend and api, like changing funding sources.
|
||||
|
||||
Also only the super user can brrrr satoshis to different wallets.
|
||||
|
||||
The super user is only stored inside the settings table of the database and after the settings are "reset to defaults" and a restart happened,
|
||||
a new super user is created.
|
||||
|
||||
The super user is never sent over the api and the frontend only receives a bool if you are super user or not.
|
||||
|
||||
We also added a decorator for the API routes to check for super user.
|
||||
|
||||
There is also the possibility of posting the super user via webhook to another service when it is created. you can look it up here https://github.com/lnbits/lnbits/blob/main/lnbits/settings.py `class SaaSSettings`
|
||||
|
||||
|
||||
Admin Users
|
||||
===========
|
||||
enviroment variable: LNBITS_ADMIN_USERS, comma-seperated list of user ids
|
||||
Admin Users can change settings in the admin ui aswell, with the exception of funding source settings, because they require e server restart and could potentially make the server inaccessable. Also they have access to all the extension defined in LNBITS_ADMIN_EXTENSIONS.
|
||||
|
||||
|
||||
Allowed Users
|
||||
=============
|
||||
enviroment variable: LNBITS_ALLOWED_USERS, comma-seperated list of user ids
|
||||
By defining this users, LNbits will no longer be useable by the public, only defined users and admins can then access the LNbits frontend.
|
||||
|
||||
|
||||
How to activate
|
||||
=============
|
||||
```
|
||||
$ sudo systemctl stop lnbits.service
|
||||
$ cd ~/lnbits-legend
|
||||
$ sudo nano .env
|
||||
```
|
||||
-> set: `LNBITS_ADMIN_UI=true`
|
||||
|
||||
Now start LNbits once in the terminal window
|
||||
```
|
||||
$ poetry run lnbits
|
||||
```
|
||||
It will now show you the Super User Account:
|
||||
|
||||
`SUCCESS | ✔️ Access super user account at: https://127.0.0.1:5000/wallet?usr=5711d7..`
|
||||
|
||||
The `/wallet?usr=..` is your super user account. You just have to append it to your normal LNbits web domain.
|
||||
|
||||
After that you will find the __`Admin` / `Manage Server`__ between `Wallets` and `Extensions`
|
||||
|
||||
Here you can design the interface, it has TOPUP to fill wallets and you can restrict access rights to extensions only for admins or generally deactivated for everyone. You can make users admins or set up Allowed Users if you want to restrict access. And of course the classic settings of the .env file, e.g. to change the funding source wallet or set a charge fee.
|
||||
|
||||
Do not forget
|
||||
```
|
||||
sudo systemctl start lnbits.service
|
||||
```
|
||||
A little hint, if you set `RESET TO DEFAULTS`, then a new Super User Account will also be created. The old one is then no longer valid.
|
|
@ -5,7 +5,7 @@
|
|||
};
|
||||
outputs = { self, nixpkgs, poetry2nix }@inputs:
|
||||
let
|
||||
supportedSystems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
|
||||
forSystems = systems: f:
|
||||
nixpkgs.lib.genAttrs systems
|
||||
(system: f system (import nixpkgs { inherit system; overlays = [ poetry2nix.overlay self.overlays.default ]; }));
|
||||
|
|
|
@ -1,38 +1,3 @@
|
|||
import asyncio
|
||||
|
||||
import uvloop
|
||||
from loguru import logger
|
||||
from starlette.requests import Request
|
||||
|
||||
from .commands import migrate_databases
|
||||
from .settings import (
|
||||
DEBUG,
|
||||
HOST,
|
||||
LNBITS_COMMIT,
|
||||
LNBITS_DATA_FOLDER,
|
||||
LNBITS_DATABASE_URL,
|
||||
LNBITS_SITE_TITLE,
|
||||
PORT,
|
||||
WALLET,
|
||||
)
|
||||
|
||||
uvloop.install()
|
||||
|
||||
asyncio.create_task(migrate_databases())
|
||||
|
||||
from .app import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
logger.info("Starting LNbits")
|
||||
logger.info(f"Host: {HOST}")
|
||||
logger.info(f"Port: {PORT}")
|
||||
logger.info(f"Debug: {DEBUG}")
|
||||
logger.info(f"Site title: {LNBITS_SITE_TITLE}")
|
||||
logger.info(f"Funding source: {WALLET.__class__.__name__}")
|
||||
logger.info(
|
||||
f"Database: {'PostgreSQL' if LNBITS_DATABASE_URL and LNBITS_DATABASE_URL.startswith('postgres://') else 'CockroachDB' if LNBITS_DATABASE_URL and LNBITS_DATABASE_URL.startswith('cockroachdb://') else 'SQLite'}"
|
||||
)
|
||||
logger.info(f"Data folder: {LNBITS_DATA_FOLDER}")
|
||||
logger.info(f"Git version: {LNBITS_COMMIT}")
|
||||
# logger.info(f"Service fee: {SERVICE_FEE}")
|
||||
|
|
214
lnbits/app.py
214
lnbits/app.py
|
@ -4,21 +4,22 @@ import logging
|
|||
import signal
|
||||
import sys
|
||||
import traceback
|
||||
import warnings
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.exceptions import HTTPException, RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from loguru import logger
|
||||
|
||||
import lnbits.settings
|
||||
from lnbits.core.tasks import register_task_listeners
|
||||
from lnbits.settings import get_wallet_class, set_wallet_class, settings
|
||||
|
||||
from .commands import migrate_databases
|
||||
from .core import core_app
|
||||
from .core.services import check_admin_settings
|
||||
from .core.views.generic import core_html_routes
|
||||
from .helpers import (
|
||||
get_css_vendored,
|
||||
|
@ -28,7 +29,6 @@ from .helpers import (
|
|||
url_for_vendored,
|
||||
)
|
||||
from .requestvars import g
|
||||
from .settings import WALLET
|
||||
from .tasks import (
|
||||
catch_everything_and_restart,
|
||||
check_pending_payments,
|
||||
|
@ -38,10 +38,8 @@ from .tasks import (
|
|||
)
|
||||
|
||||
|
||||
def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||
"""Create application factory.
|
||||
:param config_object: The configuration object to use.
|
||||
"""
|
||||
def create_app() -> FastAPI:
|
||||
|
||||
configure_logger()
|
||||
|
||||
app = FastAPI(
|
||||
|
@ -49,9 +47,10 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
|||
description="API for LNbits, the free and open source bitcoin wallet and accounts system with plugins.",
|
||||
license_info={
|
||||
"name": "MIT License",
|
||||
"url": "https://raw.githubusercontent.com/lnbits/lnbits-legend/main/LICENSE",
|
||||
"url": "https://raw.githubusercontent.com/lnbits/lnbits/main/LICENSE",
|
||||
},
|
||||
)
|
||||
|
||||
app.mount("/static", StaticFiles(packages=[("lnbits", "static")]), name="static")
|
||||
app.mount(
|
||||
"/core/static",
|
||||
|
@ -59,40 +58,15 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
|||
name="core_static",
|
||||
)
|
||||
|
||||
origins = ["*"]
|
||||
g().base_url = f"http://{settings.host}:{settings.port}"
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware, allow_origins=origins, allow_methods=["*"], allow_headers=["*"]
|
||||
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
|
||||
)
|
||||
|
||||
g().config = lnbits.settings
|
||||
g().base_url = f"http://{lnbits.settings.HOST}:{lnbits.settings.PORT}"
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
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 (
|
||||
request.headers
|
||||
and "accept" in request.headers
|
||||
and "text/html" in request.headers["accept"]
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html",
|
||||
{"request": request, "err": f"{exc.errors()} is not a valid UUID."},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=HTTPStatus.NO_CONTENT,
|
||||
content={"detail": exc.errors()},
|
||||
)
|
||||
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
check_funding_source(app)
|
||||
register_startup(app)
|
||||
register_assets(app)
|
||||
register_routes(app)
|
||||
register_async_tasks(app)
|
||||
|
@ -101,33 +75,34 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
|||
return app
|
||||
|
||||
|
||||
def check_funding_source(app: FastAPI) -> None:
|
||||
@app.on_event("startup")
|
||||
async def check_wallet_status():
|
||||
original_sigint_handler = signal.getsignal(signal.SIGINT)
|
||||
async def check_funding_source() -> None:
|
||||
|
||||
def signal_handler(signal, frame):
|
||||
logger.debug(f"SIGINT received, terminating LNbits.")
|
||||
sys.exit(1)
|
||||
original_sigint_handler = signal.getsignal(signal.SIGINT)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
while True:
|
||||
try:
|
||||
error_message, balance = await WALLET.status()
|
||||
if not error_message:
|
||||
break
|
||||
logger.error(
|
||||
f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
|
||||
RuntimeWarning,
|
||||
)
|
||||
except:
|
||||
pass
|
||||
logger.info("Retrying connection to backend in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
signal.signal(signal.SIGINT, original_sigint_handler)
|
||||
logger.success(
|
||||
f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat."
|
||||
)
|
||||
def signal_handler(signal, frame):
|
||||
logger.debug(f"SIGINT received, terminating LNbits.")
|
||||
sys.exit(1)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
WALLET = get_wallet_class()
|
||||
while True:
|
||||
try:
|
||||
error_message, balance = await WALLET.status()
|
||||
if not error_message:
|
||||
break
|
||||
logger.error(
|
||||
f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
|
||||
RuntimeWarning,
|
||||
)
|
||||
except:
|
||||
pass
|
||||
logger.info("Retrying connection to backend in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
signal.signal(signal.SIGINT, original_sigint_handler)
|
||||
logger.info(
|
||||
f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat."
|
||||
)
|
||||
|
||||
|
||||
def register_routes(app: FastAPI) -> None:
|
||||
|
@ -158,12 +133,59 @@ def register_routes(app: FastAPI) -> None:
|
|||
)
|
||||
|
||||
|
||||
def register_startup(app: FastAPI):
|
||||
@app.on_event("startup")
|
||||
async def lnbits_startup():
|
||||
|
||||
try:
|
||||
# 1. wait till migration is done
|
||||
await migrate_databases()
|
||||
|
||||
# 2. setup admin settings
|
||||
await check_admin_settings()
|
||||
|
||||
log_server_info()
|
||||
|
||||
# 3. initialize WALLET
|
||||
set_wallet_class()
|
||||
|
||||
# 4. initialize funding source
|
||||
await check_funding_source()
|
||||
except Exception as e:
|
||||
logger.error(str(e))
|
||||
raise ImportError("Failed to run 'startup' event.")
|
||||
|
||||
|
||||
def log_server_info():
|
||||
logger.info("Starting LNbits")
|
||||
logger.info(f"Host: {settings.host}")
|
||||
logger.info(f"Port: {settings.port}")
|
||||
logger.info(f"Debug: {settings.debug}")
|
||||
logger.info(f"Site title: {settings.lnbits_site_title}")
|
||||
logger.info(f"Funding source: {settings.lnbits_backend_wallet_class}")
|
||||
logger.info(f"Data folder: {settings.lnbits_data_folder}")
|
||||
logger.info(f"Git version: {settings.lnbits_commit}")
|
||||
logger.info(f"Database: {get_db_vendor_name()}")
|
||||
logger.info(f"Service fee: {settings.lnbits_service_fee}")
|
||||
|
||||
|
||||
def get_db_vendor_name():
|
||||
db_url = settings.lnbits_database_url
|
||||
return (
|
||||
"PostgreSQL"
|
||||
if db_url and db_url.startswith("postgres://")
|
||||
else "CockroachDB"
|
||||
if db_url and db_url.startswith("cockroachdb://")
|
||||
else "SQLite"
|
||||
)
|
||||
|
||||
|
||||
def register_assets(app: FastAPI):
|
||||
"""Serve each vendored asset separately or a bundle."""
|
||||
|
||||
@app.on_event("startup")
|
||||
async def vendored_assets_variable():
|
||||
if g().config.DEBUG:
|
||||
if settings.debug:
|
||||
g().VENDORED_JS = map(url_for_vendored, get_js_vendored())
|
||||
g().VENDORED_CSS = map(url_for_vendored, get_css_vendored())
|
||||
else:
|
||||
|
@ -192,12 +214,33 @@ def register_async_tasks(app):
|
|||
|
||||
def register_exception_handlers(app: FastAPI):
|
||||
@app.exception_handler(Exception)
|
||||
async def basic_error(request: Request, err):
|
||||
logger.error("handled error", traceback.format_exc())
|
||||
logger.error("ERROR:", err)
|
||||
async def exception_handler(request: Request, exc: Exception):
|
||||
etype, _, tb = sys.exc_info()
|
||||
traceback.print_exception(etype, err, tb)
|
||||
exc = traceback.format_exc()
|
||||
traceback.print_exception(etype, exc, tb)
|
||||
logger.error(f"Exception: {str(exc)}")
|
||||
# Only the browser sends "text/html" request
|
||||
# not fail proof, but everything else get's a JSON response
|
||||
if (
|
||||
request.headers
|
||||
and "accept" in request.headers
|
||||
and "text/html" in request.headers["accept"]
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": f"Error: {str(exc)}"}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
content={"detail": str(exc)},
|
||||
)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(
|
||||
request: Request, exc: RequestValidationError
|
||||
):
|
||||
logger.error(f"RequestValidationError: {str(exc)}")
|
||||
# Only the browser sends "text/html" request
|
||||
# not fail proof, but everything else get's a JSON response
|
||||
|
||||
if (
|
||||
request.headers
|
||||
|
@ -205,18 +248,43 @@ def register_exception_handlers(app: FastAPI):
|
|||
and "text/html" in request.headers["accept"]
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": err}
|
||||
"error.html",
|
||||
{"request": request, "err": f"Error: {str(exc)}"},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=HTTPStatus.NO_CONTENT,
|
||||
content={"detail": err},
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
content={"detail": str(exc)},
|
||||
)
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
logger.error(f"HTTPException {exc.status_code}: {exc.detail}")
|
||||
# Only the browser sends "text/html" request
|
||||
# not fail proof, but everything else get's a JSON response
|
||||
|
||||
if (
|
||||
request.headers
|
||||
and "accept" in request.headers
|
||||
and "text/html" in request.headers["accept"]
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html",
|
||||
{
|
||||
"request": request,
|
||||
"err": f"HTTP Error {exc.status_code}: {exc.detail}",
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": exc.detail},
|
||||
)
|
||||
|
||||
|
||||
def configure_logger() -> None:
|
||||
logger.remove()
|
||||
log_level: str = "DEBUG" if lnbits.settings.DEBUG else "INFO"
|
||||
log_level: str = "DEBUG" if settings.debug else "INFO"
|
||||
formatter = Formatter()
|
||||
logger.add(sys.stderr, level=log_level, format=formatter.format)
|
||||
|
||||
|
@ -228,7 +296,7 @@ class Formatter:
|
|||
def __init__(self):
|
||||
self.padding = 0
|
||||
self.minimal_fmt: str = "<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | <level>{level}</level> | <level>{message}</level>\n"
|
||||
if lnbits.settings.DEBUG:
|
||||
if settings.debug:
|
||||
self.fmt: str = "<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | <level>{level: <4}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | <level>{message}</level>\n"
|
||||
else:
|
||||
self.fmt: str = self.minimal_fmt
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import hashlib
|
||||
import re
|
||||
import time
|
||||
from binascii import unhexlify
|
||||
from decimal import Decimal
|
||||
from typing import List, NamedTuple, Optional
|
||||
|
||||
|
@ -108,7 +107,7 @@ def decode(pr: str) -> Invoice:
|
|||
message = bytearray([ord(c) for c in hrp]) + data.tobytes()
|
||||
sig = signature[0:64]
|
||||
if invoice.payee:
|
||||
key = VerifyingKey.from_string(unhexlify(invoice.payee), curve=SECP256k1)
|
||||
key = VerifyingKey.from_string(bytes.fromhex(invoice.payee), curve=SECP256k1)
|
||||
key.verify(sig, message, hashlib.sha256, sigdecode=sigdecode_string)
|
||||
else:
|
||||
keys = VerifyingKey.from_public_key_recovery(
|
||||
|
@ -131,7 +130,7 @@ def encode(options):
|
|||
if options["timestamp"]:
|
||||
addr.date = int(options["timestamp"])
|
||||
|
||||
addr.paymenthash = unhexlify(options["paymenthash"])
|
||||
addr.paymenthash = bytes.fromhex(options["paymenthash"])
|
||||
|
||||
if options["description"]:
|
||||
addr.tags.append(("d", options["description"]))
|
||||
|
@ -149,8 +148,8 @@ def encode(options):
|
|||
while len(splits) >= 5:
|
||||
route.append(
|
||||
(
|
||||
unhexlify(splits[0]),
|
||||
unhexlify(splits[1]),
|
||||
bytes.fromhex(splits[0]),
|
||||
bytes.fromhex(splits[1]),
|
||||
int(splits[2]),
|
||||
int(splits[3]),
|
||||
int(splits[4]),
|
||||
|
@ -235,7 +234,7 @@ def lnencode(addr, privkey):
|
|||
raise ValueError("Must include either 'd' or 'h'")
|
||||
|
||||
# We actually sign the hrp, then data (padded to 8 bits with zeroes).
|
||||
privkey = secp256k1.PrivateKey(bytes(unhexlify(privkey)))
|
||||
privkey = secp256k1.PrivateKey(bytes.fromhex(privkey))
|
||||
sig = privkey.ecdsa_sign_recoverable(
|
||||
bytearray([ord(c) for c in hrp]) + data.tobytes()
|
||||
)
|
||||
|
@ -261,7 +260,7 @@ class LnAddr(object):
|
|||
|
||||
def __str__(self):
|
||||
return "LnAddr[{}, amount={}{} tags=[{}]]".format(
|
||||
hexlify(self.pubkey.serialize()).decode("utf-8"),
|
||||
bytes.hex(self.pubkey.serialize()).decode("utf-8"),
|
||||
self.amount,
|
||||
self.currency,
|
||||
", ".join([k + "=" + str(v) for k, v in self.tags]),
|
||||
|
|
|
@ -7,6 +7,8 @@ import warnings
|
|||
import click
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .core import db as core_db
|
||||
from .core import migrations as core_migrations
|
||||
from .db import COCKROACH, POSTGRES, SQLITE
|
||||
|
@ -16,7 +18,6 @@ from .helpers import (
|
|||
get_valid_extensions,
|
||||
url_for_vendored,
|
||||
)
|
||||
from .settings import LNBITS_PATH
|
||||
|
||||
|
||||
@click.command("migrate")
|
||||
|
@ -35,15 +36,17 @@ def transpile_scss():
|
|||
warnings.simplefilter("ignore")
|
||||
from scss.compiler import compile_string # type: ignore
|
||||
|
||||
with open(os.path.join(LNBITS_PATH, "static/scss/base.scss")) as scss:
|
||||
with open(os.path.join(LNBITS_PATH, "static/css/base.css"), "w") as css:
|
||||
with open(os.path.join(settings.lnbits_path, "static/scss/base.scss")) as scss:
|
||||
with open(
|
||||
os.path.join(settings.lnbits_path, "static/css/base.css"), "w"
|
||||
) as css:
|
||||
css.write(compile_string(scss.read()))
|
||||
|
||||
|
||||
def bundle_vendored():
|
||||
for getfiles, outputpath in [
|
||||
(get_js_vendored, os.path.join(LNBITS_PATH, "static/bundle.js")),
|
||||
(get_css_vendored, os.path.join(LNBITS_PATH, "static/bundle.css")),
|
||||
(get_js_vendored, os.path.join(settings.lnbits_path, "static/bundle.js")),
|
||||
(get_css_vendored, os.path.join(settings.lnbits_path, "static/bundle.css")),
|
||||
]:
|
||||
output = ""
|
||||
for path in getfiles():
|
||||
|
|
|
@ -6,6 +6,7 @@ db = Database("database")
|
|||
|
||||
core_app: APIRouter = APIRouter()
|
||||
|
||||
from .views.admin_api import * # noqa
|
||||
from .views.api import * # noqa
|
||||
from .views.generic import * # noqa
|
||||
from .views.public_api import * # noqa
|
||||
|
|
|
@ -4,11 +4,9 @@ from typing import Any, Dict, List, Optional
|
|||
from urllib.parse import urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.db import COCKROACH, POSTGRES, Connection
|
||||
from lnbits.settings import DEFAULT_WALLET_NAME, LNBITS_ADMIN_USERS
|
||||
from lnbits.settings import AdminSettings, EditableSettings, SuperSettings, settings
|
||||
|
||||
from . import db
|
||||
from .models import BalanceCheck, Payment, User, Wallet
|
||||
|
@ -63,9 +61,8 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
|
|||
email=user["email"],
|
||||
extensions=[e[0] for e in extensions],
|
||||
wallets=[Wallet(**w) for w in wallets],
|
||||
admin=user["id"] in [x.strip() for x in LNBITS_ADMIN_USERS]
|
||||
if LNBITS_ADMIN_USERS
|
||||
else False,
|
||||
admin=user["id"] == settings.super_user
|
||||
or user["id"] in settings.lnbits_admin_users,
|
||||
)
|
||||
|
||||
|
||||
|
@ -99,7 +96,7 @@ async def create_wallet(
|
|||
""",
|
||||
(
|
||||
wallet_id,
|
||||
wallet_name or DEFAULT_WALLET_NAME,
|
||||
wallet_name or settings.lnbits_default_wallet_name,
|
||||
user_id,
|
||||
uuid4().hex,
|
||||
uuid4().hex,
|
||||
|
@ -232,8 +229,8 @@ async def get_wallet_payment(
|
|||
async def get_latest_payments_by_extension(ext_name: str, ext_id: str, limit: int = 5):
|
||||
rows = await db.fetchall(
|
||||
f"""
|
||||
SELECT * FROM apipayments
|
||||
WHERE pending = 'false'
|
||||
SELECT * FROM apipayments
|
||||
WHERE pending = 'false'
|
||||
AND extra LIKE ?
|
||||
AND extra LIKE ?
|
||||
ORDER BY time DESC LIMIT {limit}
|
||||
|
@ -454,6 +451,34 @@ async def update_payment_details(
|
|||
return
|
||||
|
||||
|
||||
async def update_payment_extra(
|
||||
payment_hash: str,
|
||||
extra: dict,
|
||||
outgoing: bool = False,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Only update the `extra` field for the payment.
|
||||
Old values in the `extra` JSON object will be kept unless the new `extra` overwrites them.
|
||||
"""
|
||||
|
||||
amount_clause = "AND amount < 0" if outgoing else "AND amount > 0"
|
||||
|
||||
row = await (conn or db).fetchone(
|
||||
f"SELECT hash, extra from apipayments WHERE hash = ? {amount_clause}",
|
||||
(payment_hash,),
|
||||
)
|
||||
if not row:
|
||||
return
|
||||
db_extra = json.loads(row["extra"] if row["extra"] else "{}")
|
||||
db_extra.update(extra)
|
||||
|
||||
await (conn or db).execute(
|
||||
f"UPDATE apipayments SET extra = ? WHERE hash = ? {amount_clause} ",
|
||||
(json.dumps(db_extra), payment_hash),
|
||||
)
|
||||
|
||||
|
||||
async def delete_payment(checking_id: str, conn: Optional[Connection] = None) -> None:
|
||||
await (conn or db).execute(
|
||||
"DELETE FROM apipayments WHERE checking_id = ?", (checking_id,)
|
||||
|
@ -550,3 +575,48 @@ async def get_balance_notify(
|
|||
(wallet_id,),
|
||||
)
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
# admin
|
||||
# --------
|
||||
|
||||
|
||||
async def get_super_settings() -> Optional[SuperSettings]:
|
||||
row = await db.fetchone("SELECT * FROM settings")
|
||||
if not row:
|
||||
return None
|
||||
editable_settings = json.loads(row["editable_settings"])
|
||||
return SuperSettings(**{"super_user": row["super_user"], **editable_settings})
|
||||
|
||||
|
||||
async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSettings]:
|
||||
sets = await get_super_settings()
|
||||
if not sets:
|
||||
return None
|
||||
row_dict = dict(sets)
|
||||
row_dict.pop("super_user")
|
||||
admin_settings = AdminSettings(
|
||||
super_user=is_super_user,
|
||||
lnbits_allowed_funding_sources=settings.lnbits_allowed_funding_sources,
|
||||
**row_dict,
|
||||
)
|
||||
return admin_settings
|
||||
|
||||
|
||||
async def delete_admin_settings():
|
||||
await db.execute("DELETE FROM settings")
|
||||
|
||||
|
||||
async def update_admin_settings(data: EditableSettings):
|
||||
await db.execute(f"UPDATE settings SET editable_settings = ?", (json.dumps(data),))
|
||||
|
||||
|
||||
async def update_super_user(super_user: str):
|
||||
await db.execute("UPDATE settings SET super_user = ?", (super_user,))
|
||||
return await get_super_settings()
|
||||
|
||||
|
||||
async def create_admin_settings(super_user: str, new_settings: dict):
|
||||
sql = f"INSERT INTO settings (super_user, editable_settings) VALUES (?, ?)"
|
||||
await db.execute(sql, (super_user, json.dumps(new_settings)))
|
||||
return await get_super_settings()
|
||||
|
|
|
@ -224,7 +224,7 @@ async def m007_set_invoice_expiries(db):
|
|||
)
|
||||
).fetchall()
|
||||
if len(rows):
|
||||
logger.info(f"Mirgraion: Checking expiry of {len(rows)} invoices")
|
||||
logger.info(f"Migration: Checking expiry of {len(rows)} invoices")
|
||||
for i, (
|
||||
payment_request,
|
||||
checking_id,
|
||||
|
@ -238,7 +238,7 @@ async def m007_set_invoice_expiries(db):
|
|||
invoice.date + invoice.expiry
|
||||
)
|
||||
logger.info(
|
||||
f"Mirgraion: {i+1}/{len(rows)} setting expiry of invoice {invoice.payment_hash} to {expiration_date}"
|
||||
f"Migration: {i+1}/{len(rows)} setting expiry of invoice {invoice.payment_hash} to {expiration_date}"
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
|
@ -258,3 +258,14 @@ async def m007_set_invoice_expiries(db):
|
|||
# catching errors like this won't be necessary in anymore now that we
|
||||
# keep track of db versions so no migration ever runs twice.
|
||||
pass
|
||||
|
||||
|
||||
async def m008_create_admin_settings_table(db):
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
super_user TEXT,
|
||||
editable_settings TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
|
|
@ -4,16 +4,17 @@ import hmac
|
|||
import json
|
||||
import time
|
||||
from sqlite3 import Row
|
||||
from typing import Dict, List, NamedTuple, Optional
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from ecdsa import SECP256k1, SigningKey # type: ignore
|
||||
from fastapi import Query
|
||||
from lnurl import encode as lnurl_encode # type: ignore
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from lnbits.db import Connection
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.settings import WALLET
|
||||
from lnbits.settings import get_wallet_class
|
||||
from lnbits.wallets.base import PaymentStatus
|
||||
|
||||
|
||||
|
@ -65,6 +66,7 @@ class User(BaseModel):
|
|||
wallets: List[Wallet] = []
|
||||
password: Optional[str] = None
|
||||
admin: bool = False
|
||||
super_user: bool = False
|
||||
|
||||
@property
|
||||
def wallet_ids(self) -> List[str]:
|
||||
|
@ -171,6 +173,7 @@ class Payment(BaseModel):
|
|||
f"Checking {'outgoing' if self.is_out else 'incoming'} pending payment {self.checking_id}"
|
||||
)
|
||||
|
||||
WALLET = get_wallet_class()
|
||||
if self.is_out:
|
||||
status = await WALLET.get_payment_status(self.checking_id)
|
||||
else:
|
||||
|
|
|
@ -1,38 +1,45 @@
|
|||
import asyncio
|
||||
import json
|
||||
from binascii import unhexlify
|
||||
from io import BytesIO
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, WebSocket, WebSocketDisconnect
|
||||
from fastapi import Depends, WebSocket
|
||||
from lnurl import LnurlErrorResponse
|
||||
from lnurl import decode as decode_lnurl # type: ignore
|
||||
from loguru import logger
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.db import Connection
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
get_key_type,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.decorators import WalletTypeInfo, require_admin_key
|
||||
from lnbits.helpers import url_for, urlsafe_short_hash
|
||||
from lnbits.requestvars import g
|
||||
from lnbits.settings import FAKE_WALLET, RESERVE_FEE_MIN, RESERVE_FEE_PERCENT, WALLET
|
||||
from lnbits.settings import (
|
||||
FAKE_WALLET,
|
||||
EditableSettings,
|
||||
get_wallet_class,
|
||||
readonly_variables,
|
||||
send_admin_user_to_saas,
|
||||
settings,
|
||||
)
|
||||
from lnbits.wallets.base import PaymentResponse, PaymentStatus
|
||||
|
||||
from . import db
|
||||
from .crud import (
|
||||
check_internal,
|
||||
create_account,
|
||||
create_admin_settings,
|
||||
create_payment,
|
||||
create_wallet,
|
||||
delete_wallet_payment,
|
||||
get_account,
|
||||
get_super_settings,
|
||||
get_wallet,
|
||||
get_wallet_payment,
|
||||
update_payment_details,
|
||||
update_payment_status,
|
||||
update_super_user,
|
||||
)
|
||||
from .models import Payment
|
||||
|
||||
|
@ -65,7 +72,7 @@ async def create_invoice(
|
|||
invoice_memo = None if description_hash else memo
|
||||
|
||||
# use the fake wallet if the invoice is for internal use only
|
||||
wallet = FAKE_WALLET if internal else WALLET
|
||||
wallet = FAKE_WALLET if internal else get_wallet_class()
|
||||
|
||||
ok, checking_id, payment_request, error_message = await wallet.create_invoice(
|
||||
amount=amount,
|
||||
|
@ -193,6 +200,7 @@ async def pay_invoice(
|
|||
else:
|
||||
logger.debug(f"backend: sending payment {temp_id}")
|
||||
# actually pay the external invoice
|
||||
WALLET = get_wallet_class()
|
||||
payment: PaymentResponse = await WALLET.pay_invoice(
|
||||
payment_request, fee_reserve_msat
|
||||
)
|
||||
|
@ -294,7 +302,7 @@ async def perform_lnurlauth(
|
|||
) -> Optional[LnurlErrorResponse]:
|
||||
cb = urlparse(callback)
|
||||
|
||||
k1 = unhexlify(parse_qs(cb.query)["k1"][0])
|
||||
k1 = bytes.fromhex(parse_qs(cb.query)["k1"][0])
|
||||
|
||||
key = wallet.wallet.lnurlauth_key(cb.netloc)
|
||||
|
||||
|
@ -381,7 +389,88 @@ async def check_transaction_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(int(RESERVE_FEE_MIN), int(amount_msat * RESERVE_FEE_PERCENT / 100.0))
|
||||
reserve_min = settings.lnbits_reserve_fee_min
|
||||
reserve_percent = settings.lnbits_reserve_fee_percent
|
||||
return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0))
|
||||
|
||||
|
||||
async def update_wallet_balance(wallet_id: str, amount: int):
|
||||
internal_id = f"internal_{urlsafe_short_hash()}"
|
||||
payment = await create_payment(
|
||||
wallet_id=wallet_id,
|
||||
checking_id=internal_id,
|
||||
payment_request="admin_internal",
|
||||
payment_hash="admin_internal",
|
||||
amount=amount * 1000,
|
||||
memo="Admin top up",
|
||||
pending=False,
|
||||
)
|
||||
# manually send this for now
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
|
||||
await internal_invoice_queue.put(internal_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def check_admin_settings():
|
||||
if settings.lnbits_admin_ui:
|
||||
settings_db = await get_super_settings()
|
||||
if not settings_db:
|
||||
# create new settings if table is empty
|
||||
logger.warning("Settings DB empty. Inserting default settings.")
|
||||
settings_db = await init_admin_settings(settings.super_user)
|
||||
logger.warning("Initialized settings from enviroment variables.")
|
||||
|
||||
if settings.super_user and settings.super_user != settings_db.super_user:
|
||||
# .env super_user overwrites DB super_user
|
||||
settings_db = await update_super_user(settings.super_user)
|
||||
|
||||
update_cached_settings(settings_db.dict())
|
||||
|
||||
# printing settings for debugging
|
||||
logger.debug(f"Admin settings:")
|
||||
for key, value in settings.dict(exclude_none=True).items():
|
||||
logger.debug(f"{key}: {value}")
|
||||
|
||||
http = "https" if settings.lnbits_force_https else "http"
|
||||
admin_url = (
|
||||
f"{http}://{settings.host}:{settings.port}/wallet?usr={settings.super_user}"
|
||||
)
|
||||
logger.success(f"✔️ Access super user account at: {admin_url}")
|
||||
|
||||
# callback for saas
|
||||
if (
|
||||
settings.lnbits_saas_callback
|
||||
and settings.lnbits_saas_secret
|
||||
and settings.lnbits_saas_instance_id
|
||||
):
|
||||
send_admin_user_to_saas()
|
||||
|
||||
|
||||
def update_cached_settings(sets_dict: dict):
|
||||
for key, value in sets_dict.items():
|
||||
if not key in readonly_variables:
|
||||
try:
|
||||
setattr(settings, key, value)
|
||||
except:
|
||||
logger.error(f"error overriding setting: {key}, value: {value}")
|
||||
if "super_user" in sets_dict:
|
||||
setattr(settings, "super_user", sets_dict["super_user"])
|
||||
|
||||
|
||||
async def init_admin_settings(super_user: str = None):
|
||||
account = None
|
||||
if super_user:
|
||||
account = await get_account(super_user)
|
||||
if not account:
|
||||
account = await create_account()
|
||||
super_user = account.id
|
||||
if not account.wallets or len(account.wallets) == 0:
|
||||
await create_wallet(user_id=account.id)
|
||||
|
||||
editable_settings = EditableSettings.from_dict(settings.dict())
|
||||
|
||||
return await create_admin_settings(account.id, editable_settings.dict())
|
||||
|
||||
|
||||
class WebsocketConnectionManager:
|
||||
|
|
|
@ -259,25 +259,30 @@ new Vue({
|
|||
this.parse.camera.show = false
|
||||
},
|
||||
updateBalance: function (credit) {
|
||||
if (LNBITS_DENOMINATION != 'sats') {
|
||||
credit = credit * 100
|
||||
}
|
||||
LNbits.api
|
||||
.request('PUT', '/api/v1/wallet/balance/' + credit, this.g.wallet.inkey)
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
.then(response => {
|
||||
let data = response.data
|
||||
if (data.status === 'ERROR') {
|
||||
this.$q.notify({
|
||||
timeout: 5000,
|
||||
type: 'warning',
|
||||
message: `Failed to update.`
|
||||
})
|
||||
return
|
||||
.request(
|
||||
'PUT',
|
||||
'/admin/api/v1/topup/?usr=' + this.g.user.id,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
{
|
||||
amount: credit,
|
||||
id: this.g.user.wallets[0].id
|
||||
}
|
||||
this.balance = this.balance + data.balance
|
||||
)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message:
|
||||
'Success! Added ' +
|
||||
credit +
|
||||
' sats to ' +
|
||||
this.g.user.wallets[0].id,
|
||||
icon: null
|
||||
})
|
||||
this.balance += parseInt(credit)
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
closeReceiveDialog: function () {
|
||||
|
|
|
@ -4,7 +4,6 @@ from typing import Dict
|
|||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.helpers import get_current_extension_name
|
||||
from lnbits.tasks import SseListenersDict, register_invoice_listener
|
||||
|
||||
from . import db
|
||||
|
|
95
lnbits/core/templates/admin/_tab_funding.html
Normal file
95
lnbits/core/templates/admin/_tab_funding.html
Normal file
|
@ -0,0 +1,95 @@
|
|||
<q-tab-panel name="funding">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none">Wallets Management</h6>
|
||||
<br />
|
||||
<div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<p>Funding Source Info</p>
|
||||
<ul>
|
||||
{%raw%}
|
||||
<li>Funding Source: {{settings.lnbits_backend_wallet_class}}</li>
|
||||
<li>Balance: {{balance / 1000}} sats</li>
|
||||
{%endraw%}
|
||||
</ul>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<p>Active Funding<small> (Requires server restart)</small></p>
|
||||
<q-select
|
||||
:disable="!isSuperUser"
|
||||
filled
|
||||
v-model="formData.lnbits_backend_wallet_class"
|
||||
hint="Select the active funding wallet"
|
||||
:options="settings.lnbits_allowed_funding_sources"
|
||||
></q-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="col-12">
|
||||
<p>Fee reserve</p>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
type="number"
|
||||
filled
|
||||
v-model="formData.lnbits_reserve_fee_min"
|
||||
label="Reserve fee in msats"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
type="number"
|
||||
filled
|
||||
name="lnbits_reserve_fee_percent"
|
||||
v-model="formData.lnbits_reserve_fee_percent"
|
||||
label="Reserve fee in percent"
|
||||
step="0.1"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isSuperUser">
|
||||
<p class="q-my-md">
|
||||
Funding Sources<small> (Requires server restart)</small>
|
||||
</p>
|
||||
<q-list
|
||||
v-for="(fund, idx) in settings.lnbits_allowed_funding_sources"
|
||||
:key="idx"
|
||||
>
|
||||
<q-expansion-item
|
||||
expand-separator
|
||||
icon="payments"
|
||||
:label="fund"
|
||||
v-if="funding_sources.get(fund)"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section
|
||||
v-for="([key, prop], i) in Object.entries(funding_sources.get(fund))"
|
||||
:key="i"
|
||||
>
|
||||
<q-input
|
||||
dense
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData[key]"
|
||||
:label="prop.label"
|
||||
class="q-pr-md"
|
||||
:hint="prop.hint"
|
||||
></q-input>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-list>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
74
lnbits/core/templates/admin/_tab_server.html
Normal file
74
lnbits/core/templates/admin/_tab_server.html
Normal file
|
@ -0,0 +1,74 @@
|
|||
<q-tab-panel name="server">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none">Server Management</h6>
|
||||
<br />
|
||||
<div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<p>Server Info</p>
|
||||
<ul>
|
||||
{%raw%}
|
||||
<li v-if="settings.lnbits_data_folder">
|
||||
SQlite: {{settings.lnbits_data_folder}}
|
||||
</li>
|
||||
<li v-if="settings.lnbits_database_url">
|
||||
Postgres: {{settings.lnbits_database_url}}
|
||||
</li>
|
||||
{%endraw%}
|
||||
</ul>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Service Fee</p>
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_service_fee"
|
||||
label="Service fee (%)"
|
||||
step="0.1"
|
||||
hint="Fee charged per tx (%)"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Miscelaneous</p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Force HTTPS</q-item-label>
|
||||
<q-item-label caption>Prefer secure URLs</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_force_https"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Hide API</q-item-label>
|
||||
<q-item-label caption
|
||||
>Hides wallet api, extensions can choose to honor</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_hide_api"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
117
lnbits/core/templates/admin/_tab_theme.html
Normal file
117
lnbits/core/templates/admin/_tab_theme.html
Normal file
|
@ -0,0 +1,117 @@
|
|||
<q-tab-panel name="theme">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none">UI Management</h6>
|
||||
<br />
|
||||
<div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Site Title</p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_site_title"
|
||||
label="Site title"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Site Tagline</p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_site_tagline"
|
||||
label="Site tagline"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>Site Description</p>
|
||||
<q-input
|
||||
v-model="formData.lnbits_site_description"
|
||||
filled
|
||||
type="textarea"
|
||||
hint="Use plain text or raw HTML"
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Default Wallet Name</p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_default_wallet_name"
|
||||
label="LNbits wallet"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Denomination</p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_denomination"
|
||||
label="sats"
|
||||
hint="The name for the FakeWallet token"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Themes</p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_theme_options"
|
||||
multiple
|
||||
hint="Choose themes available for users"
|
||||
:options="lnbits_theme_options"
|
||||
label="Themes"
|
||||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Custom Logo</p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_custom_logo"
|
||||
label="https://example.com/image.png"
|
||||
hint="URL to logo image"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Ad Space Title</p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_ad_space_title"
|
||||
label="Supported by"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Advertisement Slots</p>
|
||||
<q-input
|
||||
class="q-mb-md"
|
||||
filled
|
||||
v-model="formData.lnbits_ad_space"
|
||||
type="text"
|
||||
label="url;img_light_url;img_dark_url, url..."
|
||||
hint="Ad url and image filepaths in CSV format, extensions can choose to honor"
|
||||
>
|
||||
</q-input>
|
||||
<q-toggle
|
||||
v-model="formData.lnbits_ad_space_enabled"
|
||||
:label="formData.lnbits_ad_space_enabled ? 'Ads enabled' : 'Ads disabled'"
|
||||
/>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
88
lnbits/core/templates/admin/_tab_users.html
Normal file
88
lnbits/core/templates/admin/_tab_users.html
Normal file
|
@ -0,0 +1,88 @@
|
|||
<q-tab-panel name="users">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none">User Management</h6>
|
||||
<br />
|
||||
<div>
|
||||
<p>Admin Users</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddAdmin"
|
||||
@keydown.enter="addAdminUser"
|
||||
type="text"
|
||||
label="User ID"
|
||||
hint="Users with admin privileges"
|
||||
>
|
||||
<q-btn @click="addAdminUser" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
{%raw%}
|
||||
<q-chip
|
||||
v-for="user in formData.lnbits_admin_users"
|
||||
:key="user"
|
||||
removable
|
||||
@remove="removeAdminUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
{{ user }}
|
||||
</q-chip>
|
||||
{%endraw%}
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<div>
|
||||
<p>Allowed Users</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddUser"
|
||||
@keydown.enter="addAllowedUser"
|
||||
type="text"
|
||||
label="User ID"
|
||||
hint="Only these users can use LNbits"
|
||||
>
|
||||
<q-btn @click="addAllowedUser" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
{% raw %}
|
||||
<q-chip
|
||||
v-for="user in formData.lnbits_allowed_users"
|
||||
:key="user"
|
||||
removable
|
||||
@remove="removeAllowedUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
{{ user }}
|
||||
</q-chip>
|
||||
{% endraw %}
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Admin Extensions</p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_admin_extensions"
|
||||
multiple
|
||||
hint="Extensions only user with admin privileges can use"
|
||||
label="Admin extensions"
|
||||
:options="g.extensions.map(e => e.name)"
|
||||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Disabled Extensions</p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_disabled_extensions"
|
||||
:options="g.extensions.map(e => e.name)"
|
||||
multiple
|
||||
hint="Disable extensions *amilk disabled by default as resource heavy"
|
||||
label="Disable extensions"
|
||||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
529
lnbits/core/templates/admin/index.html
Normal file
529
lnbits/core/templates/admin/index.html
Normal file
|
@ -0,0 +1,529 @@
|
|||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col q-my-md">
|
||||
<q-btn
|
||||
label="Save"
|
||||
color="primary"
|
||||
@click="updateSettings"
|
||||
:disabled="!checkChanges"
|
||||
>
|
||||
<q-tooltip v-if="checkChanges"> Save your changes </q-tooltip>
|
||||
<q-badge
|
||||
v-if="checkChanges"
|
||||
color="red"
|
||||
rounded
|
||||
floating
|
||||
style="padding: 6px; border-radius: 6px"
|
||||
/>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="isSuperUser"
|
||||
label="Restart server"
|
||||
color="primary"
|
||||
@click="restartServer"
|
||||
>
|
||||
<q-tooltip v-if="needsRestart">
|
||||
Restart the server for changes to take effect
|
||||
</q-tooltip>
|
||||
<q-badge
|
||||
v-if="needsRestart"
|
||||
color="red"
|
||||
rounded
|
||||
floating
|
||||
style="padding: 6px; border-radius: 6px"
|
||||
/>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="isSuperUser"
|
||||
label="Topup"
|
||||
color="primary"
|
||||
@click="topUpDialog.show = true"
|
||||
>
|
||||
<q-tooltip> Add funds to a wallet. </q-tooltip>
|
||||
</q-btn>
|
||||
<!-- <q-btn
|
||||
label="Download Database Backup"
|
||||
flat
|
||||
@click="downloadBackup"
|
||||
></q-btn> -->
|
||||
<q-btn
|
||||
flat
|
||||
v-if="isSuperUser"
|
||||
label="Reset to defaults"
|
||||
color="primary"
|
||||
@click="deleteSettings"
|
||||
class="float-right"
|
||||
>
|
||||
<q-tooltip> Delete all settings and reset to defaults. </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col q-gutter-y-md">
|
||||
<q-card>
|
||||
<div class="q-pa-md">
|
||||
<div class="q-gutter-y-md">
|
||||
<q-tabs v-model="tab" active-color="primary" align="justify">
|
||||
<q-tab
|
||||
name="funding"
|
||||
label="Funding"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="users"
|
||||
label="Users"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="server"
|
||||
label="Server"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="theme"
|
||||
label="Theme"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
</q-tabs>
|
||||
</div>
|
||||
</div>
|
||||
<q-form name="settings_form" id="settings_form">
|
||||
<q-tab-panels v-model="tab" animated>
|
||||
{% include "admin/_tab_funding.html" %} {% include
|
||||
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %} {%
|
||||
include "admin/_tab_theme.html" %}
|
||||
</q-tab-panels>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<q-dialog v-if="isSuperUser" v-model="topUpDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<p>TopUp a wallet</p>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
dense
|
||||
type="text"
|
||||
filled
|
||||
v-model="wallet.id"
|
||||
label="Wallet ID"
|
||||
hint="Use the wallet ID to topup any wallet"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
dense
|
||||
type="number"
|
||||
filled
|
||||
v-model="wallet.amount"
|
||||
label="Topup amount"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn label="Topup" color="primary" @click="topupWallet"></q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
settings: {},
|
||||
lnbits_theme_options: [
|
||||
'classic',
|
||||
'bitcoin',
|
||||
'flamingo',
|
||||
'freedom',
|
||||
'mint',
|
||||
'autumn',
|
||||
'monochrome',
|
||||
'salvador'
|
||||
],
|
||||
formData: {},
|
||||
formAddAdmin: '',
|
||||
formAddUser: '',
|
||||
isSuperUser: false,
|
||||
wallet: {},
|
||||
cancel: {},
|
||||
topUpDialog: {
|
||||
show: false
|
||||
},
|
||||
tab: 'funding',
|
||||
needsRestart: false,
|
||||
funding_sources: new Map([
|
||||
['VoidWallet', null],
|
||||
[
|
||||
'FakeWallet',
|
||||
{
|
||||
fake_wallet_secret: {
|
||||
value: null,
|
||||
label: 'Secret'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'CLightningWallet',
|
||||
{
|
||||
corelightning_rpc: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'LndRestWallet',
|
||||
{
|
||||
lnd_rest_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
lnd_rest_cert: {
|
||||
value: null,
|
||||
label: 'Certificate'
|
||||
},
|
||||
lnd_rest_macaroon: {
|
||||
value: null,
|
||||
label: 'Macaroon'
|
||||
},
|
||||
lnd_rest_macaroon_encrypted: {
|
||||
value: null,
|
||||
label: 'Encrypted Macaroon'
|
||||
},
|
||||
lnd_cert: {
|
||||
value: null,
|
||||
label: 'Certificate'
|
||||
},
|
||||
lnd_admin_macaroon: {
|
||||
value: null,
|
||||
label: 'Admin Macaroon'
|
||||
},
|
||||
lnd_invoice_macaroon: {
|
||||
value: null,
|
||||
label: 'Invoice Macaroon'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'LndWallet',
|
||||
{
|
||||
lnd_grpc_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
lnd_grpc_cert: {
|
||||
value: null,
|
||||
label: 'Certificate'
|
||||
},
|
||||
lnd_grpc_port: {
|
||||
value: null,
|
||||
label: 'Port'
|
||||
},
|
||||
lnd_grpc_admin_macaroon: {
|
||||
value: null,
|
||||
label: 'Admin Macaroon'
|
||||
},
|
||||
lnd_grpc_invoice_macaroon: {
|
||||
value: null,
|
||||
label: 'Invoice Macaroon'
|
||||
},
|
||||
lnd_grpc_macaroon_encrypted: {
|
||||
value: null,
|
||||
label: 'Encrypted Macaroon'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'LntxbotWallet',
|
||||
{
|
||||
lntxbot_api_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
lntxbot_key: {
|
||||
value: null,
|
||||
label: 'Key'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'LNPayWallet',
|
||||
{
|
||||
lnpay_api_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
lnpay_api_key: {
|
||||
value: null,
|
||||
label: 'API Key'
|
||||
},
|
||||
lnpay_wallet_key: {
|
||||
value: null,
|
||||
label: 'Wallet Key'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'EclairWallet',
|
||||
{
|
||||
eclair_url: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
eclair_pass: {
|
||||
value: null,
|
||||
label: 'Password'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'LNbitsWallet',
|
||||
{
|
||||
lnbits_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
lnbits_key: {
|
||||
value: null,
|
||||
label: 'Admin Key'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'OpenNodeWallet',
|
||||
{
|
||||
opennode_api_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
opennode_key: {
|
||||
value: null,
|
||||
label: 'Key'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'ClicheWallet',
|
||||
{
|
||||
cliche_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'SparkWallet',
|
||||
{
|
||||
spark_url: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
spark_token: {
|
||||
value: null,
|
||||
label: 'Token'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'LnTipsWallet',
|
||||
{
|
||||
lntips_api_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
lntips_api_key: {
|
||||
value: null,
|
||||
label: 'API Key'
|
||||
}
|
||||
}
|
||||
]
|
||||
])
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
this.getSettings()
|
||||
this.balance = +'{{ balance|safe }}'
|
||||
},
|
||||
computed: {
|
||||
checkChanges() {
|
||||
return !_.isEqual(this.settings, this.formData)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addAdminUser() {
|
||||
let addUser = this.formAddAdmin
|
||||
let admin_users = this.formData.lnbits_admin_users
|
||||
if (addUser && addUser.length && !admin_users.includes(addUser)) {
|
||||
//admin_users = [...admin_users, addUser]
|
||||
this.formData.lnbits_admin_users = [...admin_users, addUser]
|
||||
this.formAddAdmin = ''
|
||||
//console.log(this.checkChanges)
|
||||
}
|
||||
},
|
||||
removeAdminUser(user) {
|
||||
let admin_users = this.formData.lnbits_admin_users
|
||||
this.formData.lnbits_admin_users = admin_users.filter(u => u !== user)
|
||||
},
|
||||
addAllowedUser() {
|
||||
let addUser = this.formAddUser
|
||||
let allowed_users = this.formData.lnbits_allowed_users
|
||||
if (addUser && addUser.length && !allowed_users.includes(addUser)) {
|
||||
this.formData.lnbits_allowed_users = [...allowed_users, addUser]
|
||||
this.formAddUser = ''
|
||||
}
|
||||
},
|
||||
removeAllowedUser(user) {
|
||||
let allowed_users = this.formData.lnbits_allowed_users
|
||||
this.formData.lnbits_allowed_users = allowed_users.filter(
|
||||
u => u !== user
|
||||
)
|
||||
},
|
||||
restartServer() {
|
||||
LNbits.api
|
||||
.request('GET', '/admin/api/v1/restart/?usr=' + this.g.user.id)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Success! Restarted Server',
|
||||
icon: null
|
||||
})
|
||||
this.needsRestart = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
topupWallet() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/admin/api/v1/topup/?usr=' + this.g.user.id,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
this.wallet
|
||||
)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message:
|
||||
'Success! Added ' +
|
||||
this.wallet.amount +
|
||||
' to ' +
|
||||
this.wallet.id,
|
||||
icon: null
|
||||
})
|
||||
this.wallet = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
updateFundingData() {
|
||||
this.settings.lnbits_allowed_funding_sources.map(f => {
|
||||
let opts = this.funding_sources.get(f)
|
||||
if (!opts) return
|
||||
|
||||
Object.keys(opts).forEach(e => {
|
||||
opts[e].value = this.settings[e]
|
||||
})
|
||||
})
|
||||
},
|
||||
getSettings() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/admin/api/v1/settings/?usr=' + this.g.user.id,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
this.isSuperUser = response.data.super_user || false
|
||||
this.settings = response.data
|
||||
this.formData = _.clone(this.settings)
|
||||
this.updateFundingData()
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
updateSettings() {
|
||||
let data = _.omit(this.formData, [
|
||||
'super_user',
|
||||
'lnbits_allowed_funding_sources'
|
||||
])
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/admin/api/v1/settings/?usr=' + this.g.user.id,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
data
|
||||
)
|
||||
.then(response => {
|
||||
this.needsRestart =
|
||||
this.settings.lnbits_backend_wallet_class !==
|
||||
this.formData.lnbits_backend_wallet_class
|
||||
this.settings = this.formData
|
||||
this.formData = _.clone(this.settings)
|
||||
this.updateFundingData()
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Success! Settings changed!',
|
||||
icon: null
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteSettings() {
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'Are you sure you want to restore settings to default?'
|
||||
)
|
||||
.onOk(() => {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/admin/api/v1/settings/?usr=' + this.g.user.id
|
||||
)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message:
|
||||
'Success! Restored settings to defaults, restart required!',
|
||||
icon: null
|
||||
})
|
||||
this.needsRestart = true
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
downloadBackup() {
|
||||
LNbits.api
|
||||
.request('GET', '/admin/api/v1/backup/?usr=' + this.g.user.id)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message:
|
||||
'Success! Database backup request, download starts soon!',
|
||||
icon: null
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -82,7 +82,7 @@
|
|||
>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else>{{SITE_DESCRIPTION}}</p>
|
||||
<p v-else>{{SITE_DESCRIPTION | safe}}</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
|
File diff suppressed because it is too large
Load Diff
74
lnbits/core/views/admin_api.py
Normal file
74
lnbits/core/views/admin_api.py
Normal file
|
@ -0,0 +1,74 @@
|
|||
from http import HTTPStatus
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Body, Depends
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.core.models import User
|
||||
from lnbits.core.services import update_cached_settings, update_wallet_balance
|
||||
from lnbits.decorators import check_admin, check_super_user
|
||||
from lnbits.server import server_restart
|
||||
from lnbits.settings import AdminSettings, EditableSettings
|
||||
|
||||
from .. import core_app
|
||||
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
|
||||
|
||||
|
||||
@core_app.get("/admin/api/v1/settings/")
|
||||
async def api_get_settings(
|
||||
user: User = Depends(check_admin), # type: ignore
|
||||
) -> Optional[AdminSettings]:
|
||||
admin_settings = await get_admin_settings(user.super_user)
|
||||
return admin_settings
|
||||
|
||||
|
||||
@core_app.put(
|
||||
"/admin/api/v1/settings/",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_update_settings(data: EditableSettings):
|
||||
await update_admin_settings(data)
|
||||
update_cached_settings(dict(data))
|
||||
return {"status": "Success"}
|
||||
|
||||
|
||||
@core_app.delete(
|
||||
"/admin/api/v1/settings/",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
)
|
||||
async def api_delete_settings() -> None:
|
||||
await delete_admin_settings()
|
||||
server_restart.set()
|
||||
|
||||
|
||||
@core_app.get(
|
||||
"/admin/api/v1/restart/",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
)
|
||||
async def api_restart_server() -> dict[str, str]:
|
||||
server_restart.set()
|
||||
return {"status": "Success"}
|
||||
|
||||
|
||||
@core_app.put(
|
||||
"/admin/api/v1/topup/",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
)
|
||||
async def api_topup_balance(
|
||||
id: str = Body(...), amount: int = Body(...)
|
||||
) -> dict[str, str]:
|
||||
try:
|
||||
await get_wallet(id)
|
||||
except:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="wallet does not exist."
|
||||
)
|
||||
|
||||
await update_wallet_balance(wallet_id=id, amount=int(amount))
|
||||
|
||||
return {"status": "Success"}
|
|
@ -1,12 +1,11 @@
|
|||
import asyncio
|
||||
import binascii
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
from typing import Dict, Optional, Tuple, Union
|
||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
import async_timeout
|
||||
|
@ -26,19 +25,20 @@ from fastapi.params import Body
|
|||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from pydantic.fields import Field
|
||||
from sse_starlette.sse import EventSourceResponse, ServerSentEvent
|
||||
from starlette.responses import HTMLResponse, StreamingResponse
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from lnbits import bolt11, lnurl
|
||||
from lnbits.core.models import Payment, Wallet
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
check_admin,
|
||||
get_key_type,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import url_for, urlsafe_short_hash
|
||||
from lnbits.settings import LNBITS_ADMIN_USERS, LNBITS_SITE_TITLE, WALLET
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.settings import get_wallet_class, settings
|
||||
from lnbits.utils.exchange_rates import (
|
||||
currencies,
|
||||
fiat_amount_as_satoshis,
|
||||
|
@ -47,14 +47,11 @@ from lnbits.utils.exchange_rates import (
|
|||
|
||||
from .. import core_app, db
|
||||
from ..crud import (
|
||||
create_payment,
|
||||
get_payments,
|
||||
get_standalone_payment,
|
||||
get_total_balance,
|
||||
get_wallet,
|
||||
get_wallet_for_key,
|
||||
save_balance_check,
|
||||
update_payment_status,
|
||||
update_wallet,
|
||||
)
|
||||
from ..services import (
|
||||
|
@ -70,6 +67,11 @@ from ..services import (
|
|||
from ..tasks import api_invoice_listeners
|
||||
|
||||
|
||||
@core_app.get("/api/v1/health", status_code=HTTPStatus.OK)
|
||||
async def health():
|
||||
return
|
||||
|
||||
|
||||
@core_app.get("/api/v1/wallet")
|
||||
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
if wallet.wallet_type == 0:
|
||||
|
@ -82,35 +84,6 @@ async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
|||
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
|
||||
|
||||
|
||||
@core_app.put("/api/v1/wallet/balance/{amount}")
|
||||
async def api_update_balance(
|
||||
amount: int, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
if wallet.wallet.user not in LNBITS_ADMIN_USERS:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="Not an admin user"
|
||||
)
|
||||
|
||||
payHash = urlsafe_short_hash()
|
||||
await create_payment(
|
||||
wallet_id=wallet.wallet.id,
|
||||
checking_id=payHash,
|
||||
payment_request="selfPay",
|
||||
payment_hash=payHash,
|
||||
amount=amount * 1000,
|
||||
memo="selfPay",
|
||||
fee=0,
|
||||
)
|
||||
await update_payment_status(checking_id=payHash, pending=False)
|
||||
updatedWallet = await get_wallet(wallet.wallet.id)
|
||||
|
||||
return {
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
"balance": amount,
|
||||
}
|
||||
|
||||
|
||||
@core_app.put("/api/v1/wallet/{new_name}")
|
||||
async def api_update_wallet(
|
||||
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
|
@ -168,16 +141,14 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
|||
if data.description_hash or data.unhashed_description:
|
||||
try:
|
||||
description_hash = (
|
||||
binascii.unhexlify(data.description_hash)
|
||||
if data.description_hash
|
||||
else b""
|
||||
bytes.fromhex(data.description_hash) if data.description_hash else b""
|
||||
)
|
||||
unhashed_description = (
|
||||
binascii.unhexlify(data.unhashed_description)
|
||||
bytes.fromhex(data.unhashed_description)
|
||||
if data.unhashed_description
|
||||
else b""
|
||||
)
|
||||
except binascii.Error:
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'description_hash' and 'unhashed_description' must be a valid hex strings",
|
||||
|
@ -186,7 +157,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
|||
else:
|
||||
description_hash = b""
|
||||
unhashed_description = b""
|
||||
memo = data.memo or LNBITS_SITE_TITLE
|
||||
memo = data.memo or settings.lnbits_site_title
|
||||
|
||||
if data.unit == "sat":
|
||||
amount = int(data.amount)
|
||||
|
@ -242,7 +213,8 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
|||
lnurl_response = resp["reason"]
|
||||
else:
|
||||
lnurl_response = True
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
except (httpx.ConnectError, httpx.RequestError) as ex:
|
||||
logger.error(ex)
|
||||
lnurl_response = False
|
||||
|
||||
return {
|
||||
|
@ -416,7 +388,7 @@ async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
|
|||
|
||||
yield dict(data=jdata, event=typ)
|
||||
except asyncio.CancelledError as e:
|
||||
logger.debug(f"CancelledError on listener {uid}: {e}")
|
||||
logger.debug(f"removing listener for wallet {uid}")
|
||||
api_invoice_listeners.pop(uid)
|
||||
task.cancel()
|
||||
return
|
||||
|
@ -686,13 +658,9 @@ async def img(request: Request, data):
|
|||
)
|
||||
|
||||
|
||||
@core_app.get("/api/v1/audit")
|
||||
async def api_auditor(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
if wallet.wallet.user not in LNBITS_ADMIN_USERS:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="Not an admin user"
|
||||
)
|
||||
|
||||
@core_app.get("/api/v1/audit", dependencies=[Depends(check_admin)])
|
||||
async def api_auditor():
|
||||
WALLET = get_wallet_class()
|
||||
total_balance = await get_total_balance()
|
||||
error_message, node_balance = await WALLET.status()
|
||||
|
||||
|
|
|
@ -13,15 +13,9 @@ from starlette.responses import HTMLResponse, JSONResponse
|
|||
|
||||
from lnbits.core import db
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists
|
||||
from lnbits.decorators import check_admin, check_user_exists
|
||||
from lnbits.helpers import template_renderer, url_for
|
||||
from lnbits.settings import (
|
||||
LNBITS_ADMIN_USERS,
|
||||
LNBITS_ALLOWED_USERS,
|
||||
LNBITS_CUSTOM_LOGO,
|
||||
LNBITS_SITE_TITLE,
|
||||
SERVICE_FEE,
|
||||
)
|
||||
from lnbits.settings import get_wallet_class, settings
|
||||
|
||||
from ...helpers import get_valid_extensions
|
||||
from ..crud import (
|
||||
|
@ -44,7 +38,7 @@ async def favicon():
|
|||
|
||||
|
||||
@core_html_routes.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request, lightning: str = None):
|
||||
async def home(request: Request, lightning: str = ""):
|
||||
return template_renderer().TemplateResponse(
|
||||
"core/index.html", {"request": request, "lnurl": lightning}
|
||||
)
|
||||
|
@ -117,7 +111,6 @@ async def wallet(
|
|||
user_id = usr.hex if usr else None
|
||||
wallet_id = wal.hex if wal else None
|
||||
wallet_name = nme
|
||||
service_fee = int(SERVICE_FEE) if int(SERVICE_FEE) == SERVICE_FEE else SERVICE_FEE
|
||||
|
||||
if not user_id:
|
||||
user = await get_user((await create_account()).id)
|
||||
|
@ -128,12 +121,18 @@ async def wallet(
|
|||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "User does not exist."}
|
||||
)
|
||||
if LNBITS_ALLOWED_USERS and user_id not in LNBITS_ALLOWED_USERS:
|
||||
if (
|
||||
len(settings.lnbits_allowed_users) > 0
|
||||
and user_id not in settings.lnbits_allowed_users
|
||||
and user_id not in settings.lnbits_admin_users
|
||||
and user_id != settings.super_user
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "User not authorized."}
|
||||
)
|
||||
if LNBITS_ADMIN_USERS and user_id in LNBITS_ADMIN_USERS:
|
||||
if user_id == settings.super_user or user_id in settings.lnbits_admin_users:
|
||||
user.admin = True
|
||||
|
||||
if not wallet_id:
|
||||
if user.wallets and not wallet_name: # type: ignore
|
||||
wallet = user.wallets[0] # type: ignore
|
||||
|
@ -163,7 +162,7 @@ async def wallet(
|
|||
"request": request,
|
||||
"user": user.dict(), # type: ignore
|
||||
"wallet": userwallet.dict(),
|
||||
"service_fee": service_fee,
|
||||
"service_fee": settings.lnbits_service_fee,
|
||||
"web_manifest": f"/manifest/{user.id}.webmanifest", # type: ignore
|
||||
},
|
||||
)
|
||||
|
@ -185,7 +184,7 @@ async def lnurl_full_withdraw(request: Request):
|
|||
"k1": "0",
|
||||
"minWithdrawable": 1000 if wallet.withdrawable_balance else 0,
|
||||
"maxWithdrawable": wallet.withdrawable_balance,
|
||||
"defaultDescription": f"{LNBITS_SITE_TITLE} balance withdraw from {wallet.id[0:5]}",
|
||||
"defaultDescription": f"{settings.lnbits_site_title} balance withdraw from {wallet.id[0:5]}",
|
||||
"balanceCheck": url_for("/withdraw", external=True, usr=user.id, wal=wallet.id),
|
||||
}
|
||||
|
||||
|
@ -284,12 +283,12 @@ async def manifest(usr: str):
|
|||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||
|
||||
return {
|
||||
"short_name": LNBITS_SITE_TITLE,
|
||||
"name": LNBITS_SITE_TITLE + " Wallet",
|
||||
"short_name": settings.lnbits_site_title,
|
||||
"name": settings.lnbits_site_title + " Wallet",
|
||||
"icons": [
|
||||
{
|
||||
"src": LNBITS_CUSTOM_LOGO
|
||||
if LNBITS_CUSTOM_LOGO
|
||||
"src": settings.lnbits_custom_logo
|
||||
if settings.lnbits_custom_logo
|
||||
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png",
|
||||
"type": "image/png",
|
||||
"sizes": "900x900",
|
||||
|
@ -311,3 +310,19 @@ async def manifest(usr: str):
|
|||
for wallet in user.wallets
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@core_html_routes.get("/admin", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_admin)): # type: ignore
|
||||
WALLET = get_wallet_class()
|
||||
_, balance = await WALLET.status()
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"admin/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(),
|
||||
"settings": settings.dict(),
|
||||
"balance": balance,
|
||||
},
|
||||
)
|
||||
|
|
|
@ -6,7 +6,6 @@ from urllib.parse import urlparse
|
|||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits import bolt11
|
||||
|
||||
|
|
16
lnbits/db.py
16
lnbits/db.py
|
@ -11,7 +11,7 @@ from sqlalchemy import create_engine
|
|||
from sqlalchemy_aio.base import AsyncConnection
|
||||
from sqlalchemy_aio.strategy import ASYNCIO_STRATEGY # type: ignore
|
||||
|
||||
from .settings import LNBITS_DATA_FOLDER, LNBITS_DATABASE_URL
|
||||
from lnbits.settings import settings
|
||||
|
||||
POSTGRES = "POSTGRES"
|
||||
COCKROACH = "COCKROACH"
|
||||
|
@ -121,8 +121,8 @@ class Database(Compat):
|
|||
def __init__(self, db_name: str):
|
||||
self.name = db_name
|
||||
|
||||
if LNBITS_DATABASE_URL:
|
||||
database_uri = LNBITS_DATABASE_URL
|
||||
if settings.lnbits_database_url:
|
||||
database_uri = settings.lnbits_database_url
|
||||
|
||||
if database_uri.startswith("cockroachdb://"):
|
||||
self.type = COCKROACH
|
||||
|
@ -162,14 +162,16 @@ class Database(Compat):
|
|||
)
|
||||
)
|
||||
else:
|
||||
if os.path.isdir(LNBITS_DATA_FOLDER):
|
||||
self.path = os.path.join(LNBITS_DATA_FOLDER, f"{self.name}.sqlite3")
|
||||
if os.path.isdir(settings.lnbits_data_folder):
|
||||
self.path = os.path.join(
|
||||
settings.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"
|
||||
f"LNBITS_DATA_FOLDER named {settings.lnbits_data_folder} was not created"
|
||||
f" - please 'mkdir {settings.lnbits_data_folder}' and try again"
|
||||
)
|
||||
logger.trace(f"database {self.type} added for {self.name}")
|
||||
self.schema = self.name
|
||||
|
|
|
@ -14,11 +14,7 @@ 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_ADMIN_EXTENSIONS,
|
||||
LNBITS_ADMIN_USERS,
|
||||
LNBITS_ALLOWED_USERS,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
class KeyChecker(SecurityBase):
|
||||
|
@ -150,8 +146,12 @@ async def get_key_type(
|
|||
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
|
||||
)
|
||||
if (
|
||||
LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS
|
||||
) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS):
|
||||
wallet.wallet.user != settings.super_user
|
||||
and wallet.wallet.user not in settings.lnbits_admin_users
|
||||
) and (
|
||||
settings.lnbits_admin_extensions
|
||||
and pathname in settings.lnbits_admin_extensions
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="User not authorized for this extension.",
|
||||
|
@ -227,17 +227,45 @@ async def require_invoice_key(
|
|||
|
||||
async def check_user_exists(usr: UUID4) -> User:
|
||||
g().user = await get_user(usr.hex)
|
||||
|
||||
if not g().user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
|
||||
)
|
||||
|
||||
if LNBITS_ALLOWED_USERS and g().user.id not in LNBITS_ALLOWED_USERS:
|
||||
if (
|
||||
len(settings.lnbits_allowed_users) > 0
|
||||
and g().user.id not in settings.lnbits_allowed_users
|
||||
and g().user.id not in settings.lnbits_admin_users
|
||||
and g().user.id != settings.super_user
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
|
||||
)
|
||||
|
||||
if LNBITS_ADMIN_USERS and g().user.id in LNBITS_ADMIN_USERS:
|
||||
g().user.admin = True
|
||||
|
||||
return g().user
|
||||
|
||||
|
||||
async def check_admin(usr: UUID4) -> User:
|
||||
user = await check_user_exists(usr)
|
||||
if user.id != settings.super_user and not user.id in settings.lnbits_admin_users:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="User not authorized. No admin privileges.",
|
||||
)
|
||||
user.admin = True
|
||||
user.super_user = False
|
||||
if user.id == settings.super_user:
|
||||
user.super_user = True
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def check_super_user(usr: UUID4) -> User:
|
||||
user = await check_admin(usr)
|
||||
if user.id != settings.super_user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="User not authorized. No super user privileges.",
|
||||
)
|
||||
return user
|
||||
|
|
|
@ -2,7 +2,6 @@ import base64
|
|||
import hashlib
|
||||
import hmac
|
||||
import urllib
|
||||
from binascii import unhexlify
|
||||
from http import HTTPStatus
|
||||
from typing import Dict
|
||||
|
||||
|
@ -19,7 +18,7 @@ def generate_bleskomat_lnurl_signature(
|
|||
payload: str, api_key_secret: str, api_key_encoding: str = "hex"
|
||||
):
|
||||
if api_key_encoding == "hex":
|
||||
key = unhexlify(api_key_secret)
|
||||
key = bytes.fromhex(api_key_secret)
|
||||
elif api_key_encoding == "base64":
|
||||
key = base64.b64decode(api_key_secret)
|
||||
else:
|
||||
|
|
|
@ -9,11 +9,13 @@
|
|||
<p>
|
||||
This extension allows you to connect a Bleskomat ATM to an lnbits
|
||||
wallet. It will work with both the
|
||||
<a href="https://github.com/samotari/bleskomat"
|
||||
<a class="text-secondary" href="https://github.com/samotari/bleskomat"
|
||||
>open-source DIY Bleskomat ATM project</a
|
||||
>
|
||||
as well as the
|
||||
<a href="https://www.bleskomat.com/">commercial Bleskomat ATM</a>.
|
||||
<a class="text-secondary" href="https://www.bleskomat.com/"
|
||||
>commercial Bleskomat ATM</a
|
||||
>.
|
||||
</p>
|
||||
<h5 class="text-subtitle1 q-my-none">Connect Your Bleskomat ATM</h5>
|
||||
<div>
|
||||
|
|
|
@ -2,30 +2,28 @@
|
|||
|
||||
This extension allows you to link your Bolt Card (or other compatible NXP NTAG device) with a LNbits instance and use it in a more secure way than a static LNURLw. A technology called [Secure Unique NFC](https://mishka-scan.com/blog/secure-unique-nfc) is utilized in this workflow.
|
||||
|
||||
<a href="https://www.youtube.com/watch?v=wJ7QLFTRjK0">Tutorial</a>
|
||||
<a class="text-secondary" href="https://www.youtube.com/watch?v=wJ7QLFTRjK0">Tutorial</a>
|
||||
|
||||
**Disclaimer:** ***Use this only if you either know what you are doing or are a reckless lightning pioneer. Only you are responsible for all your sats, cards and other devices. Always backup all your card keys!***
|
||||
|
||||
***In order to use this extension you need to be able to setup your own card.*** That means writing a URL template pointing to your LNbits instance, configuring some SUN (SDM) settings and optionally changing the card's keys. There's a [guide](https://www.whitewolftech.com/articles/payment-card/) to set it up with a card reader connected to your computer. It can be done (without setting the keys) with [TagWriter app by NXP](https://play.google.com/store/apps/details?id=com.nxp.nfc.tagwriter) Android app. Last but not least, an OSS android app by name [bolt-nfc-android-app](https://github.com/boltcard/bolt-nfc-android-app) is being developed for these purposes. It's available from Google Play [here](https://play.google.com/store/apps/details?id=com.lightningnfcapp).
|
||||
|
||||
***In order to use this extension you need to be able to setup your own card.*** That means writing a URL template pointing to your LNbits instance, configuring some SUN (SDM) settings and optionally changing the card's keys. There's a [guide](https://www.whitewolftech.com/articles/payment-card/) to set it up with a card reader connected to your computer. It can be done (without setting the keys) with [TagWriter app by NXP](https://play.google.com/store/apps/details?id=com.nxp.nfc.tagwriter) Android app. Last but not least, an OSS android app by name [Boltcard NFC Card Creator](https://github.com/boltcard/bolt-nfc-android-app) is being developed for these purposes. It's available from Google Play [here](https://play.google.com/store/apps/details?id=com.lightningnfcapp).
|
||||
|
||||
## About the keys
|
||||
|
||||
Up to five 16-byte keys can be stored on the card, numbered from 00 to 04. In the empty state they all should be set to zeros (00000000000000000000000000000000). For this extension only two keys need to be set:
|
||||
Up to five 16-byte keys can be stored on the card, numbered from 00 to 04. In the empty state they all should be set to zeros (00000000000000000000000000000000). For this extension only two keys need to be set, but for the security reasons all five keys should be changed from default (empty) state. The keys directly needed by this extension are:
|
||||
|
||||
One for encrypting the card UID and the counter (p parameter), let's called it meta key, key #01 or K1.
|
||||
- One for encrypting the card UID and the counter (p parameter), let's called it meta key, key #01 or K1.
|
||||
|
||||
One for calculating CMAC (c parameter), let's called it file key, key #02 or K2.
|
||||
- One for calculating CMAC (c parameter), let's called it file key, key #02 or K2.
|
||||
|
||||
The key #00, K0 (also know as auth key) is skipped to be use as authentification key. Is not needed by this extension, but can be filled in order to write the keys in cooperation with bolt-nfc-android-app.
|
||||
The key #00, K0 (also know as auth key) is used as authentification key. It is not directly needed by this extension, but should be filled in order to write the keys in cooperation with Boltcard NFC Card Creator. In this case also K3 is set to same value as K1 and K4 as K2, so all keys are changed from default values. Keep that in your mind in case you ever need to reset the keys manually.
|
||||
|
||||
***Always backup all keys that you're trying to write on the card. Without them you may not be able to change them in the future!***
|
||||
|
||||
## Setting the card - bolt-nfc-android-app (easy way)
|
||||
So far, regarding the keys, the app can only write a new key set on an empty card (with zero keys). **When you write non zero (and 'non debug') keys, they can't be rewrite with this app.** You have to do it on your computer.
|
||||
|
||||
- Read the card with the app. Note UID so you can fill it in the extension later.
|
||||
- Write the link on the card. It shoud be like `YOUR_LNBITS_DOMAIN/boltcards/api/v1/scan/{external_id}`
|
||||
- `{external_id}` should be replaced with the External ID found in the LNbits dialog.
|
||||
## Setting the card - Boltcard NFC Card Creator (easy way)
|
||||
Updated for v0.1.3
|
||||
|
||||
- Add new card in the extension.
|
||||
- Set a max sats per transaction. Any transaction greater than this amount will be rejected.
|
||||
|
@ -33,14 +31,31 @@ So far, regarding the keys, the app can only write a new key set on an empty car
|
|||
- Set a card name. This is just for your reference inside LNbits.
|
||||
- Set the card UID. This is the unique identifier on your NFC card and is 7 bytes.
|
||||
- If on an Android device with a newish version of Chrome, you can click the icon next to the input and tap your card to autofill this field.
|
||||
- Otherwise read it with the Android app (Advanced -> Read NFC) and paste it to the field.
|
||||
- Advanced Options
|
||||
- Card Keys (k0, k1, k2) will be automatically generated if not explicitly set.
|
||||
- Set to 16 bytes of 0s (00000000000000000000000000000000) to leave the keys in debug mode.
|
||||
- GENERATE KEY button fill the keys randomly. If there is "debug" in the card name, a debug set of keys is filled instead.
|
||||
- Set to 16 bytes of 0s (00000000000000000000000000000000) to leave the keys in default (empty) state (this is unsecure).
|
||||
- GENERATE KEY button fill the keys randomly.
|
||||
- Click CREATE CARD button
|
||||
- Click the QR code button next to a card to view its details. You can scan the QR code with the Android app to import the keys.
|
||||
- Click the "KEYS / AUTH LINK" button to copy the auth URL to the clipboard. You can then paste this into the Android app to import the keys.
|
||||
- Tap the NFC card to write the keys to the card.
|
||||
- Click the QR code button next to a card to view its details. Backup the keys now! They'll be comfortable in your password manager.
|
||||
- Now you can scan the QR code with the Android app (Create Bolt Card -> SCAN QR CODE).
|
||||
- Or you can Click the "KEYS / AUTH LINK" button to copy the auth URL to the clipboard. Then paste it into the Android app (Create Bolt Card -> PASTE AUTH URL).
|
||||
- Click WRITE CARD NOW and approach the NFC card to set it up. DO NOT REMOVE THE CARD PREMATURELY!
|
||||
|
||||
## Erasing the card - Boltcard NFC Card Creator
|
||||
Updated for v0.1.3
|
||||
|
||||
Since v0.1.2 of Boltcard NFC Card Creator it is possible not only reset the keys but also disable the SUN function and do the complete erase so the card can be use again as a static tag (or set as a new Bolt Card, ofc).
|
||||
|
||||
- Click the QR code button next to a card to view its details and select WIPE
|
||||
- OR click the red cross icon on the right side to reach the same
|
||||
- In the android app (Advanced -> Reset Keys)
|
||||
- Click SCAN QR CODE to scan the QR
|
||||
- Or click WIPE DATA in LNbits to copy and paste in to the app (PASTE KEY JSON)
|
||||
- Click RESET CARD NOW and approach the NFC card to erase it. DO NOT REMOVE THE CARD PREMATURELY!
|
||||
- Now if there is all success the card can be safely delete from LNbits (but keep the keys backuped anyway; batter safe than brick).
|
||||
|
||||
If you somehow find yourself in some non-standard state (for instance only k3 and k4 remains filled after previous unsuccessful reset), then you need edit the key fields manually (for instance leave k0-k2 to zeroes and provide the right k3 and k4).
|
||||
|
||||
## Setting the card - computer (hard way)
|
||||
|
||||
|
@ -48,7 +63,7 @@ Follow the guide.
|
|||
|
||||
The URI should be `lnurlw://YOUR-DOMAIN.COM/boltcards/api/v1/scan/{YOUR_card_external_id}?p=00000000000000000000000000000000&c=0000000000000000`
|
||||
|
||||
Then fill up the card parameters in the extension. Card Auth key (K0) can be omitted. Initical counter can be 0.
|
||||
Then fill up the card parameters in the extension. Card Auth key (K0) can be filled in the extension just for the record. Initical counter can be 0.
|
||||
|
||||
## Setting the card - android NXP app (hard way)
|
||||
- If you don't know the card ID, use NXP TagInfo app to find it out.
|
||||
|
@ -70,4 +85,4 @@ Then fill up the card parameters in the extension. Card Auth key (K0) can be omi
|
|||
- Save & Write
|
||||
- Scan with compatible Wallet
|
||||
|
||||
This app afaik cannot change the keys. If you cannot change them any other way, leave them empty in the extension dialog and remember you're not secure. Card Auth key (K0) can be omitted anyway. Initical counter can be 0.
|
||||
This app afaik cannot change the keys. If you cannot change them any other way, leave them empty in the extension dialog and remember you're not secured. Card Auth key (K0) can be omitted anyway. Initical counter can be 0.
|
||||
|
|
|
@ -171,6 +171,9 @@ async def get_hit(hit_id: str) -> Optional[Hit]:
|
|||
|
||||
|
||||
async def get_hits(cards_ids: Union[str, List[str]]) -> List[Hit]:
|
||||
if len(cards_ids) == 0:
|
||||
return []
|
||||
|
||||
q = ",".join(["?"] * len(cards_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltcards.hits WHERE card_id IN ({q})", (*cards_ids,)
|
||||
|
@ -265,6 +268,9 @@ async def get_refund(refund_id: str) -> Optional[Refund]:
|
|||
|
||||
|
||||
async def get_refunds(hits_ids: Union[str, List[str]]) -> List[Refund]:
|
||||
if len(hits_ids) == 0:
|
||||
return []
|
||||
|
||||
q = ",".join(["?"] * len(hits_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltcards.refunds WHERE hit_id IN ({q})", (*hits_ids,)
|
||||
|
|
|
@ -1,21 +1,13 @@
|
|||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from embit import bech32, compact
|
||||
from fastapi import Request
|
||||
from fastapi.param_functions import Query
|
||||
from fastapi.params import Depends, Query
|
||||
from lnurl import Lnurl, LnurlWithdrawResponse
|
||||
from lnurl import encode as lnurl_encode # type: ignore
|
||||
from lnurl.types import LnurlPayMetadata # type: ignore
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse
|
||||
|
@ -33,7 +25,6 @@ from .crud import (
|
|||
get_hit,
|
||||
get_hits_today,
|
||||
spend_hit,
|
||||
update_card,
|
||||
update_card_counter,
|
||||
update_card_otp,
|
||||
)
|
||||
|
@ -108,15 +99,27 @@ async def lnurl_callback(
|
|||
pr: str = Query(None),
|
||||
k1: str = Query(None),
|
||||
):
|
||||
if not k1:
|
||||
return {"status": "ERROR", "reason": "Missing K1 token"}
|
||||
|
||||
hit = await get_hit(k1)
|
||||
card = await get_card(hit.card_id)
|
||||
|
||||
if not hit:
|
||||
return {"status": "ERROR", "reason": f"LNURL-pay record not found."}
|
||||
if hit.id != k1:
|
||||
return {"status": "ERROR", "reason": "Bad K1"}
|
||||
return {
|
||||
"status": "ERROR",
|
||||
"reason": "Record not found for this charge (bad k1)",
|
||||
}
|
||||
if hit.spent:
|
||||
return {"status": "ERROR", "reason": f"Payment already claimed"}
|
||||
invoice = bolt11.decode(pr)
|
||||
return {"status": "ERROR", "reason": "Payment already claimed"}
|
||||
if not pr:
|
||||
return {"status": "ERROR", "reason": "Missing payment request"}
|
||||
|
||||
try:
|
||||
invoice = bolt11.decode(pr)
|
||||
except:
|
||||
return {"status": "ERROR", "reason": "Failed to decode payment request"}
|
||||
|
||||
card = await get_card(hit.card_id)
|
||||
hit = await spend_hit(id=hit.id, amount=int(invoice.amount_msat / 1000))
|
||||
try:
|
||||
await pay_invoice(
|
||||
|
@ -126,8 +129,8 @@ async def lnurl_callback(
|
|||
extra={"tag": "boltcard", "tag": hit.id},
|
||||
)
|
||||
return {"status": "OK"}
|
||||
except:
|
||||
return {"status": "ERROR", "reason": f"Payment failed"}
|
||||
except Exception as exc:
|
||||
return {"status": "ERROR", "reason": f"Payment failed - {exc}"}
|
||||
|
||||
|
||||
# /boltcards/api/v1/auth?a=00000000000000000000000000000000
|
||||
|
|
|
@ -149,6 +149,7 @@ new Vue({
|
|||
},
|
||||
qrCodeDialog: {
|
||||
show: false,
|
||||
wipe: false,
|
||||
data: null
|
||||
}
|
||||
}
|
||||
|
@ -259,9 +260,10 @@ new Vue({
|
|||
})
|
||||
})
|
||||
},
|
||||
openQrCodeDialog(cardId) {
|
||||
openQrCodeDialog(cardId, wipe) {
|
||||
var card = _.findWhere(this.cards, {id: cardId})
|
||||
this.qrCodeDialog.data = {
|
||||
id: card.id,
|
||||
link: window.location.origin + '/boltcards/api/v1/auth?a=' + card.otp,
|
||||
name: card.card_name,
|
||||
uid: card.uid,
|
||||
|
@ -272,6 +274,17 @@ new Vue({
|
|||
k3: card.k1,
|
||||
k4: card.k2
|
||||
}
|
||||
this.qrCodeDialog.data_wipe = JSON.stringify({
|
||||
action: 'wipe',
|
||||
k0: card.k0,
|
||||
k1: card.k1,
|
||||
k2: card.k2,
|
||||
k3: card.k1,
|
||||
k4: card.k2,
|
||||
uid: card.uid,
|
||||
version: 1
|
||||
})
|
||||
this.qrCodeDialog.wipe = wipe
|
||||
this.qrCodeDialog.show = true
|
||||
},
|
||||
addCardOpen: function () {
|
||||
|
@ -397,8 +410,16 @@ new Vue({
|
|||
let self = this
|
||||
let cards = _.findWhere(this.cards, {id: cardId})
|
||||
|
||||
Quasar.utils.exportFile(
|
||||
cards.card_name + '.json',
|
||||
this.qrCodeDialog.data_wipe,
|
||||
'application/json'
|
||||
)
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this card')
|
||||
.confirmDialog(
|
||||
"Are you sure you want to delete this card? Without access to the card keys you won't be able to reset them in the future!"
|
||||
)
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
Manage your Bolt Cards self custodian way<br />
|
||||
|
||||
<a
|
||||
class="text-secondary"
|
||||
href="https://github.com/lnbits/lnbits/tree/main/lnbits/extensions/boltcards"
|
||||
>More details</a
|
||||
>
|
||||
|
|
|
@ -48,6 +48,7 @@
|
|||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
|
@ -58,7 +59,7 @@
|
|||
dense
|
||||
icon="qr_code"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
@click="openQrCodeDialog(props.row.id)"
|
||||
@click="openQrCodeDialog(props.row.id, false)"
|
||||
>
|
||||
<q-tooltip>Card key credentials</q-tooltip>
|
||||
</q-btn>
|
||||
|
@ -99,7 +100,7 @@
|
|||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteCard(props.row.id)"
|
||||
@click="openQrCodeDialog(props.row.id, true)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
>
|
||||
|
@ -215,6 +216,7 @@
|
|||
emit-value
|
||||
v-model="cardDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
:disable="cardDialog.data.id != null"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
|
@ -283,7 +285,7 @@
|
|||
v-model="toggleAdvanced"
|
||||
label="Show advanced options"
|
||||
></q-toggle>
|
||||
<div v-show="toggleAdvanced">
|
||||
<div v-show="toggleAdvanced" class="q-gutter-y-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
|
@ -358,44 +360,107 @@
|
|||
<q-dialog v-model="qrCodeDialog.show" position="top">
|
||||
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
|
||||
{% raw %}
|
||||
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
||||
<qrcode
|
||||
:value="qrCodeDialog.data.link"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
<p style="word-break: break-all" class="text-center">
|
||||
(Keys for
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=com.lightningnfcapp"
|
||||
target="_blank"
|
||||
>bolt-nfc-android-app</a
|
||||
>)
|
||||
</p>
|
||||
<div class="col q-mt-lg text-center">
|
||||
<q-responsive
|
||||
:ratio="1"
|
||||
class="q-mx-xl q-mb-md"
|
||||
v-show="!qrCodeDialog.wipe"
|
||||
>
|
||||
<qrcode
|
||||
:value="qrCodeDialog.data.link"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
<p class="text-center" v-show="!qrCodeDialog.wipe">
|
||||
(QR for <strong>create</strong> the card in
|
||||
<a
|
||||
class="text-secondary"
|
||||
href="https://play.google.com/store/apps/details?id=com.lightningnfcapp"
|
||||
target="_blank"
|
||||
style="color: inherit"
|
||||
>Boltcard NFC Card Creator</a
|
||||
>)
|
||||
</p>
|
||||
<q-responsive
|
||||
:ratio="1"
|
||||
class="q-mx-xl q-mb-md"
|
||||
v-show="qrCodeDialog.wipe"
|
||||
>
|
||||
<qrcode
|
||||
:value="qrCodeDialog.data_wipe"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
<p class="text-center" v-show="qrCodeDialog.wipe">
|
||||
(QR for <strong>wipe</strong> the card in
|
||||
<a
|
||||
class="text-secondary"
|
||||
href="https://play.google.com/store/apps/details?id=com.lightningnfcapp"
|
||||
target="_blank"
|
||||
style="color: inherit"
|
||||
>Boltcard NFC Card Creator</a
|
||||
>)
|
||||
</p>
|
||||
</div>
|
||||
<div class="col q-mt-md q-mb-md text-center">
|
||||
<q-btn-toggle
|
||||
v-model="qrCodeDialog.wipe"
|
||||
rounded
|
||||
unelevated
|
||||
toggle-color="primary"
|
||||
color="white"
|
||||
text-color="primary"
|
||||
:options="[
|
||||
{label: 'Create', value: false},
|
||||
{label: 'Wipe', value: true}
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<p style="word-break: break-all">
|
||||
<strong>Name:</strong> {{ qrCodeDialog.data.name }}<br />
|
||||
<strong>UID:</strong> {{ qrCodeDialog.data.uid }}<br />
|
||||
<strong>External ID:</strong> {{ qrCodeDialog.data.external_id }}<br />
|
||||
<strong>Lock key:</strong> {{ qrCodeDialog.data.k0 }}<br />
|
||||
<strong>Meta key:</strong> {{ qrCodeDialog.data.k1 }}<br />
|
||||
<strong>File key:</strong> {{ qrCodeDialog.data.k2 }}<br />
|
||||
<br />
|
||||
Always backup all keys that you're trying to write on the card. Without
|
||||
them you may not be able to change them in the future!<br />
|
||||
<strong>Lock key (K0):</strong> {{ qrCodeDialog.data.k0 }}<br />
|
||||
<strong>Meta key (K1 & K3):</strong> {{ qrCodeDialog.data.k1 }}<br />
|
||||
<strong>File key (K2 & K4):</strong> {{ qrCodeDialog.data.k2 }}<br />
|
||||
</p>
|
||||
<p>
|
||||
Always backup all keys that you're trying to write on the card. Without
|
||||
them you may not be able to change them in the future!
|
||||
</p>
|
||||
|
||||
<br />
|
||||
<q-btn
|
||||
unelevated
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(qrCodeDialog.data.link)"
|
||||
label="Keys/Auth link"
|
||||
label="Create link"
|
||||
v-show="!qrCodeDialog.wipe"
|
||||
>
|
||||
<q-tooltip>Click to copy, then paste to NFC Card Creator</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(qrCodeDialog.data_wipe)"
|
||||
label="Wipe data"
|
||||
v-show="qrCodeDialog.wipe"
|
||||
>
|
||||
<q-tooltip>Click to copy, then paste to NFC Card Creator</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
outline
|
||||
color="red"
|
||||
@click="deleteCard(qrCodeDialog.data.id)"
|
||||
label="Delete card"
|
||||
v-show="qrCodeDialog.wipe"
|
||||
v-close-popup
|
||||
>
|
||||
<q-tooltip>Backup the keys, or wipe the card first!</q-tooltip>
|
||||
</q-btn>
|
||||
<q-tooltip>Click to copy, then add to NFC card</q-tooltip>
|
||||
|
||||
{% endraw %}
|
||||
<div class="row q-mt-lg q-gutter-sm">
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
|
|
|
@ -12,7 +12,6 @@ from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
|||
from . import boltcards_ext
|
||||
from .crud import (
|
||||
create_card,
|
||||
create_hit,
|
||||
delete_card,
|
||||
enable_disable_card,
|
||||
get_card,
|
||||
|
@ -22,11 +21,9 @@ from .crud import (
|
|||
get_hits,
|
||||
get_refunds,
|
||||
update_card,
|
||||
update_card_counter,
|
||||
update_card_otp,
|
||||
)
|
||||
from .models import CreateCardData
|
||||
from .nxp424 import decryptSUN, getSunMAC
|
||||
|
||||
|
||||
@boltcards_ext.get("/api/v1/cards")
|
||||
|
@ -150,7 +147,7 @@ async def api_hits(
|
|||
|
||||
|
||||
@boltcards_ext.get("/api/v1/refunds")
|
||||
async def api_hits(
|
||||
async def api_refunds(
|
||||
g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
|
||||
):
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import asyncio
|
||||
import os
|
||||
from binascii import hexlify, unhexlify
|
||||
from hashlib import sha256
|
||||
from typing import Awaitable, Union
|
||||
|
||||
|
@ -12,7 +11,7 @@ from loguru import logger
|
|||
|
||||
from lnbits.core.services import create_invoice, pay_invoice
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
from lnbits.settings import BOLTZ_NETWORK, BOLTZ_URL
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .crud import update_swap_status
|
||||
from .mempool import (
|
||||
|
@ -33,9 +32,7 @@ from .models import (
|
|||
)
|
||||
from .utils import check_balance, get_timestamp, req_wrap
|
||||
|
||||
net = NETWORKS[BOLTZ_NETWORK]
|
||||
logger.trace(f"BOLTZ_URL: {BOLTZ_URL}")
|
||||
logger.trace(f"Bitcoin Network: {net['name']}")
|
||||
net = NETWORKS[settings.boltz_network]
|
||||
|
||||
|
||||
async def create_swap(data: CreateSubmarineSwap) -> SubmarineSwap:
|
||||
|
@ -58,11 +55,11 @@ async def create_swap(data: CreateSubmarineSwap) -> SubmarineSwap:
|
|||
raise
|
||||
|
||||
refund_privkey = ec.PrivateKey(os.urandom(32), True, net)
|
||||
refund_pubkey_hex = hexlify(refund_privkey.sec()).decode("UTF-8")
|
||||
refund_pubkey_hex = bytes.hex(refund_privkey.sec()).decode("UTF-8")
|
||||
|
||||
res = req_wrap(
|
||||
"post",
|
||||
f"{BOLTZ_URL}/createswap",
|
||||
f"{settings.boltz_url}/createswap",
|
||||
json={
|
||||
"type": "submarine",
|
||||
"pairId": "BTC/BTC",
|
||||
|
@ -123,13 +120,13 @@ async def create_reverse_swap(
|
|||
return False
|
||||
|
||||
claim_privkey = ec.PrivateKey(os.urandom(32), True, net)
|
||||
claim_pubkey_hex = hexlify(claim_privkey.sec()).decode("UTF-8")
|
||||
claim_pubkey_hex = bytes.hex(claim_privkey.sec()).decode("UTF-8")
|
||||
preimage = os.urandom(32)
|
||||
preimage_hash = sha256(preimage).hexdigest()
|
||||
|
||||
res = req_wrap(
|
||||
"post",
|
||||
f"{BOLTZ_URL}/createswap",
|
||||
f"{settings.boltz_url}/createswap",
|
||||
json={
|
||||
"type": "reversesubmarine",
|
||||
"pairId": "BTC/BTC",
|
||||
|
@ -313,12 +310,12 @@ async def create_onchain_tx(
|
|||
sequence = 0xFFFFFFFE
|
||||
else:
|
||||
privkey = ec.PrivateKey.from_wif(swap.claim_privkey)
|
||||
preimage = unhexlify(swap.preimage)
|
||||
preimage = bytes.fromhex(swap.preimage)
|
||||
onchain_address = swap.onchain_address
|
||||
sequence = 0xFFFFFFFF
|
||||
|
||||
locktime = swap.timeout_block_height
|
||||
redeem_script = unhexlify(swap.redeem_script)
|
||||
redeem_script = bytes.fromhex(swap.redeem_script)
|
||||
|
||||
fees = get_fee_estimation()
|
||||
|
||||
|
@ -326,7 +323,7 @@ async def create_onchain_tx(
|
|||
|
||||
script_pubkey = script.address_to_scriptpubkey(onchain_address)
|
||||
|
||||
vin = [TransactionInput(unhexlify(txid), vout_cnt, sequence=sequence)]
|
||||
vin = [TransactionInput(bytes.fromhex(txid), vout_cnt, sequence=sequence)]
|
||||
vout = [TransactionOutput(vout_amount - fees, script_pubkey)]
|
||||
tx = Transaction(vin=vin, vout=vout)
|
||||
|
||||
|
@ -409,7 +406,7 @@ def check_boltz_limits(amount):
|
|||
def get_boltz_pairs():
|
||||
res = req_wrap(
|
||||
"get",
|
||||
f"{BOLTZ_URL}/getpairs",
|
||||
f"{settings.boltz_url}/getpairs",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
return res.json()
|
||||
|
@ -418,7 +415,7 @@ def get_boltz_pairs():
|
|||
def get_boltz_status(boltzid):
|
||||
res = req_wrap(
|
||||
"post",
|
||||
f"{BOLTZ_URL}/swapstatus",
|
||||
f"{settings.boltz_url}/swapstatus",
|
||||
json={"id": boltzid},
|
||||
)
|
||||
return res.json()
|
||||
|
|
|
@ -1,20 +1,16 @@
|
|||
import asyncio
|
||||
import json
|
||||
from binascii import hexlify
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from embit.transaction import Transaction
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.settings import BOLTZ_MEMPOOL_SPACE_URL, BOLTZ_MEMPOOL_SPACE_URL_WS
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .utils import req_wrap
|
||||
|
||||
logger.trace(f"BOLTZ_MEMPOOL_SPACE_URL: {BOLTZ_MEMPOOL_SPACE_URL}")
|
||||
logger.trace(f"BOLTZ_MEMPOOL_SPACE_URL_WS: {BOLTZ_MEMPOOL_SPACE_URL_WS}")
|
||||
|
||||
websocket_url = f"{BOLTZ_MEMPOOL_SPACE_URL_WS}/api/v1/ws"
|
||||
websocket_url = f"{settings.boltz_mempool_space_url_ws}/api/v1/ws"
|
||||
|
||||
|
||||
async def wait_for_websocket_message(send, message_string):
|
||||
|
@ -33,7 +29,7 @@ async def wait_for_websocket_message(send, message_string):
|
|||
def get_mempool_tx(address):
|
||||
res = req_wrap(
|
||||
"get",
|
||||
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/address/{address}/txs",
|
||||
f"{settings.boltz_mempool_space_url}/api/address/{address}/txs",
|
||||
headers={"Content-Type": "text/plain"},
|
||||
)
|
||||
txs = res.json()
|
||||
|
@ -70,7 +66,7 @@ def get_fee_estimation() -> int:
|
|||
def get_mempool_fees() -> int:
|
||||
res = req_wrap(
|
||||
"get",
|
||||
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/v1/fees/recommended",
|
||||
f"{settings.boltz_mempool_space_url}/api/v1/fees/recommended",
|
||||
headers={"Content-Type": "text/plain"},
|
||||
)
|
||||
fees = res.json()
|
||||
|
@ -80,18 +76,18 @@ def get_mempool_fees() -> int:
|
|||
def get_mempool_blockheight() -> int:
|
||||
res = req_wrap(
|
||||
"get",
|
||||
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/blocks/tip/height",
|
||||
f"{settings.boltz_mempool_space_url}/api/blocks/tip/height",
|
||||
headers={"Content-Type": "text/plain"},
|
||||
)
|
||||
return int(res.text)
|
||||
|
||||
|
||||
async def send_onchain_tx(tx: Transaction):
|
||||
raw = hexlify(tx.serialize())
|
||||
raw = bytes.hex(tx.serialize())
|
||||
logger.debug(f"Boltz - mempool sending onchain tx...")
|
||||
req_wrap(
|
||||
"post",
|
||||
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/tx",
|
||||
f"{settings.boltz_mempool_space_url}/api/tx",
|
||||
headers={"Content-Type": "text/plain"},
|
||||
content=raw,
|
||||
)
|
||||
|
|
|
@ -24,12 +24,13 @@
|
|||
</p>
|
||||
<p>
|
||||
Link :
|
||||
<a target="_blank" href="https://boltz.exchange"
|
||||
<a class="text-secondary" target="_blank" href="https://boltz.exchange"
|
||||
>https://boltz.exchange
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a
|
||||
class="text-secondary"
|
||||
target="_blank"
|
||||
href="https://github.com/lnbits/lnbits-legend/tree/main/lnbits/extensions/boltz"
|
||||
>More details</a
|
||||
|
@ -38,7 +39,12 @@
|
|||
<p>
|
||||
<small
|
||||
>Created by,
|
||||
<a target="_blank" href="https://github.com/dni">dni</a></small
|
||||
<a
|
||||
class="text-secondary"
|
||||
target="_blank"
|
||||
href="https://github.com/dni"
|
||||
>dni</a
|
||||
></small
|
||||
>
|
||||
</p>
|
||||
</q-card-section>
|
||||
|
|
|
@ -14,7 +14,7 @@ from starlette.requests import Request
|
|||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
from lnbits.settings import BOLTZ_MEMPOOL_SPACE_URL
|
||||
from lnbits.settings import settings
|
||||
|
||||
from . import boltz_ext
|
||||
from .boltz import (
|
||||
|
@ -55,7 +55,7 @@ from .utils import check_balance
|
|||
response_model=str,
|
||||
)
|
||||
async def api_mempool_url():
|
||||
return BOLTZ_MEMPOOL_SPACE_URL
|
||||
return settings.boltz_mempool_space_url
|
||||
|
||||
|
||||
# NORMAL SWAP
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import os
|
||||
import random
|
||||
import time
|
||||
from binascii import hexlify, unhexlify
|
||||
from typing import Any, List, Optional, Union
|
||||
|
||||
from cashu.core.base import MintKeyset
|
||||
|
|
|
@ -4,9 +4,24 @@
|
|||
<p>Create Cashu ecash mints and wallets.</p>
|
||||
<small
|
||||
>Created by
|
||||
<a href="https://github.com/arcbtc" target="_blank">arcbtc</a>,
|
||||
<a href="https://github.com/motorina0" target="_blank">vlad</a>,
|
||||
<a href="https://github.com/calle" target="_blank">calle</a>.</small
|
||||
<a
|
||||
class="text-secondary"
|
||||
href="https://github.com/arcbtc"
|
||||
target="_blank"
|
||||
>arcbtc</a
|
||||
>,
|
||||
<a
|
||||
class="text-secondary"
|
||||
href="https://github.com/motorina0"
|
||||
target="_blank"
|
||||
>vlad</a
|
||||
>,
|
||||
<a
|
||||
class="text-secondary"
|
||||
href="https://github.com/calle"
|
||||
target="_blank"
|
||||
>calle</a
|
||||
>.</small
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
></q-icon>
|
||||
<h4 class="q-mt-none q-mb-md">{{ mint_name }}</h4>
|
||||
<a
|
||||
class="text-secondary"
|
||||
class="q-my-xl text-white"
|
||||
style="font-size: 1.5rem"
|
||||
href="../wallet?mint_id={{ mint_id }}"
|
||||
|
@ -24,7 +25,11 @@
|
|||
<h5 class="q-my-md">Read the following carefully!</h5>
|
||||
<p>
|
||||
This is a
|
||||
<a href="https://cashu.space/" style="color: white" target="”_blank”"
|
||||
<a
|
||||
class="text-secondary"
|
||||
href="https://cashu.space/"
|
||||
style="color: white"
|
||||
target="”_blank”"
|
||||
>Cashu</a
|
||||
>
|
||||
mint. Cashu is an ecash system for Bitcoin.
|
||||
|
|
|
@ -159,7 +159,7 @@ page_container %}
|
|||
size="lg"
|
||||
color="secondary"
|
||||
class="q-mr-md cursor-pointer"
|
||||
@click="recheckInvoice(props.row.hash)"
|
||||
@click="checkInvoice(props.row.hash)"
|
||||
>
|
||||
Check
|
||||
</q-badge>
|
||||
|
@ -616,10 +616,10 @@ page_container %}
|
|||
></q-input>
|
||||
</div>
|
||||
<div v-else class="text-center q-mb-lg">
|
||||
<a :href="'lightning:' + invoiceData.bolt11">
|
||||
<a class="text-secondary" :href="'lightning:' + invoiceData.bolt11">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode
|
||||
:value="invoiceData.bolt11"
|
||||
:value="'lightning:' + invoiceData.bolt11.toUpperCase()"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
>
|
||||
|
@ -681,7 +681,7 @@ page_container %}
|
|||
</div>
|
||||
<div v-else class="text-center q-mb-lg">
|
||||
<div class="text-center q-mb-lg">
|
||||
<!-- <a :href="'cashu:' + sendData.tokensBase64"> -->
|
||||
<!-- <a class="text-secondary" :href="'cashu:' + sendData.tokensBase64"> -->
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode
|
||||
:value="disclaimerDialog.base_url + '?mint_id=' + mintId + '&recv_token=' + sendData.tokensBase64"
|
||||
|
@ -1528,57 +1528,17 @@ page_container %}
|
|||
return proofs.reduce((s, t) => (s += t.amount), 0)
|
||||
},
|
||||
|
||||
deleteProofs: function (proofs) {
|
||||
// delete proofs from this.proofs
|
||||
const usedSecrets = proofs.map(p => p.secret)
|
||||
this.proofs = this.proofs.filter(p => !usedSecrets.includes(p.secret))
|
||||
this.storeProofs()
|
||||
return this.proofs
|
||||
},
|
||||
|
||||
//////////// API ///////////
|
||||
clearAllWorkers: function () {
|
||||
if (this.invoiceCheckListener) {
|
||||
clearInterval(this.invoiceCheckListener)
|
||||
}
|
||||
if (this.tokensCheckSpendableListener) {
|
||||
clearInterval(this.tokensCheckSpendableListener)
|
||||
}
|
||||
},
|
||||
invoiceCheckWorker: async function () {
|
||||
let nInterval = 0
|
||||
this.clearAllWorkers()
|
||||
this.invoiceCheckListener = setInterval(async () => {
|
||||
try {
|
||||
nInterval += 1
|
||||
|
||||
// exit loop after 2m
|
||||
if (nInterval > 40) {
|
||||
console.log('### stopping invoice check worker')
|
||||
this.clearAllWorkers()
|
||||
}
|
||||
console.log('### invoiceCheckWorker setInterval', nInterval)
|
||||
console.log(this.invoiceData)
|
||||
|
||||
// this will throw an error if the invoice is pending
|
||||
await this.recheckInvoice(this.invoiceData.hash, false)
|
||||
|
||||
// only without error (invoice paid) will we reach here
|
||||
console.log('### stopping invoice check worker')
|
||||
this.clearAllWorkers()
|
||||
this.invoiceData.bolt11 = ''
|
||||
this.showInvoiceDetails = false
|
||||
if (window.navigator.vibrate) navigator.vibrate(200)
|
||||
this.$q.notify({
|
||||
timeout: 5000,
|
||||
type: 'positive',
|
||||
message: 'Payment received',
|
||||
position: 'top',
|
||||
actions: [
|
||||
{
|
||||
icon: 'close',
|
||||
color: 'white',
|
||||
handler: () => {}
|
||||
}
|
||||
]
|
||||
})
|
||||
} catch (error) {
|
||||
console.log('not paid yet')
|
||||
}
|
||||
}, 3000)
|
||||
},
|
||||
// MINT
|
||||
|
||||
requestMintButton: async function () {
|
||||
await this.requestMint()
|
||||
|
@ -1586,8 +1546,12 @@ page_container %}
|
|||
await this.invoiceCheckWorker()
|
||||
},
|
||||
|
||||
// /mint
|
||||
|
||||
requestMint: async function () {
|
||||
// gets an invoice from the mint to get new tokens
|
||||
/*
|
||||
gets an invoice from the mint to get new tokens
|
||||
*/
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
|
@ -1611,7 +1575,14 @@ page_container %}
|
|||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// /mint
|
||||
|
||||
mintApi: async function (amounts, payment_hash, verbose = true) {
|
||||
/*
|
||||
asks the mint to check whether the invoice with payment_hash has been paid
|
||||
and requests signing of the attached outputs (blindedMessages)
|
||||
*/
|
||||
console.log('### promises', payment_hash)
|
||||
try {
|
||||
let secrets = await this.generateSecrets(amounts)
|
||||
|
@ -1647,7 +1618,19 @@ page_container %}
|
|||
}
|
||||
this.proofs = this.proofs.concat(proofs)
|
||||
this.storeProofs()
|
||||
|
||||
// update UI
|
||||
await this.setInvoicePaid(payment_hash)
|
||||
tokensBase64 = btoa(JSON.stringify(proofs))
|
||||
|
||||
this.historyTokens.push({
|
||||
status: 'paid',
|
||||
amount: amount,
|
||||
date: currentDateStr(),
|
||||
token: tokensBase64
|
||||
})
|
||||
this.storehistoryTokens()
|
||||
|
||||
return proofs
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
@ -1657,62 +1640,20 @@ page_container %}
|
|||
throw error
|
||||
}
|
||||
},
|
||||
splitToSend: async function (proofs, amount, invlalidate = false) {
|
||||
// splits proofs so the user can keep firstProofs, send scndProofs
|
||||
try {
|
||||
const spendableProofs = proofs.filter(p => !p.reserved)
|
||||
if (this.sumProofs(spendableProofs) < amount) {
|
||||
this.$q.notify({
|
||||
timeout: 5000,
|
||||
type: 'warning',
|
||||
message: 'Balance too low',
|
||||
position: 'top',
|
||||
actions: [
|
||||
{
|
||||
icon: 'close',
|
||||
color: 'white',
|
||||
handler: () => {}
|
||||
}
|
||||
]
|
||||
})
|
||||
throw Error('balance too low.')
|
||||
}
|
||||
let {fristProofs, scndProofs} = await this.split(
|
||||
spendableProofs,
|
||||
amount
|
||||
)
|
||||
|
||||
// set scndProofs in this.proofs as reserved
|
||||
const usedSecrets = proofs.map(p => p.secret)
|
||||
for (let i = 0; i < this.proofs.length; i++) {
|
||||
if (usedSecrets.includes(this.proofs[i].secret)) {
|
||||
this.proofs[i].reserved = true
|
||||
}
|
||||
}
|
||||
if (invlalidate) {
|
||||
// delete tokens from db
|
||||
this.proofs = fristProofs
|
||||
// add new fristProofs, scndProofs to this.proofs
|
||||
this.storeProofs()
|
||||
}
|
||||
|
||||
return {fristProofs, scndProofs}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
LNbits.utils.notifyApiError(error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
// SPLIT
|
||||
|
||||
split: async function (proofs, amount) {
|
||||
/*
|
||||
supplies proofs and requests a split from the mint of these
|
||||
proofs at a specific amount
|
||||
*/
|
||||
try {
|
||||
if (proofs.length == 0) {
|
||||
throw new Error('no proofs provided.')
|
||||
}
|
||||
let {fristProofs, scndProofs} = await this.splitApi(proofs, amount)
|
||||
// delete proofs from this.proofs
|
||||
const usedSecrets = proofs.map(p => p.secret)
|
||||
this.proofs = this.proofs.filter(p => !usedSecrets.includes(p.secret))
|
||||
this.deleteProofs(proofs)
|
||||
// add new fristProofs, scndProofs to this.proofs
|
||||
this.proofs = this.proofs.concat(fristProofs).concat(scndProofs)
|
||||
this.storeProofs()
|
||||
|
@ -1723,6 +1664,9 @@ page_container %}
|
|||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// /split
|
||||
|
||||
splitApi: async function (proofs, amount) {
|
||||
try {
|
||||
const total = this.sumProofs(proofs)
|
||||
|
@ -1782,7 +1726,62 @@ page_container %}
|
|||
}
|
||||
},
|
||||
|
||||
splitToSend: async function (proofs, amount, invlalidate = false) {
|
||||
/*
|
||||
splits proofs so the user can keep firstProofs, send scndProofs.
|
||||
then sets scndProofs as reserved.
|
||||
|
||||
if invalidate, scndProofs (the one to send) are invalidated
|
||||
*/
|
||||
try {
|
||||
const spendableProofs = proofs.filter(p => !p.reserved)
|
||||
if (this.sumProofs(spendableProofs) < amount) {
|
||||
this.$q.notify({
|
||||
timeout: 5000,
|
||||
type: 'warning',
|
||||
message: 'Balance too low',
|
||||
position: 'top',
|
||||
actions: [
|
||||
{
|
||||
icon: 'close',
|
||||
color: 'white',
|
||||
handler: () => {}
|
||||
}
|
||||
]
|
||||
})
|
||||
throw Error('balance too low.')
|
||||
}
|
||||
|
||||
// call /split
|
||||
|
||||
let {fristProofs, scndProofs} = await this.split(
|
||||
spendableProofs,
|
||||
amount
|
||||
)
|
||||
// set scndProofs in this.proofs as reserved
|
||||
const usedSecrets = proofs.map(p => p.secret)
|
||||
for (let i = 0; i < this.proofs.length; i++) {
|
||||
if (usedSecrets.includes(this.proofs[i].secret)) {
|
||||
this.proofs[i].reserved = true
|
||||
}
|
||||
}
|
||||
if (invlalidate) {
|
||||
// delete scndProofs from db
|
||||
this.deleteProofs(scndProofs)
|
||||
}
|
||||
|
||||
return {fristProofs, scndProofs}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
LNbits.utils.notifyApiError(error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
redeem: async function () {
|
||||
/*
|
||||
uses split to receive new tokens.
|
||||
*/
|
||||
this.showReceiveTokens = false
|
||||
console.log('### receive tokens', this.receiveData.tokensBase64)
|
||||
try {
|
||||
|
@ -1793,6 +1792,9 @@ page_container %}
|
|||
const proofs = JSON.parse(tokenJson)
|
||||
const amount = proofs.reduce((s, t) => (s += t.amount), 0)
|
||||
let {fristProofs, scndProofs} = await this.split(proofs, amount)
|
||||
|
||||
// update UI
|
||||
|
||||
// HACK: we need to do this so the balance updates
|
||||
this.proofs = this.proofs.concat([])
|
||||
|
||||
|
@ -1827,13 +1829,18 @@ page_container %}
|
|||
},
|
||||
|
||||
sendTokens: async function () {
|
||||
/*
|
||||
calls splitToSend, displays token and kicks off the spendableWorker
|
||||
*/
|
||||
try {
|
||||
// keep firstProofs, send scndProofs
|
||||
// keep firstProofs, send scndProofs and delete them (invalidate=true)
|
||||
let {fristProofs, scndProofs} = await this.splitToSend(
|
||||
this.proofs,
|
||||
this.sendData.amount,
|
||||
true
|
||||
)
|
||||
|
||||
// update UI
|
||||
this.sendData.tokens = scndProofs
|
||||
console.log('### this.sendData.tokens', this.sendData.tokens)
|
||||
this.sendData.tokensBase64 = btoa(
|
||||
|
@ -1846,33 +1853,19 @@ page_container %}
|
|||
date: currentDateStr(),
|
||||
token: this.sendData.tokensBase64
|
||||
})
|
||||
|
||||
// store "pending" outgoing tokens in history table
|
||||
this.storehistoryTokens()
|
||||
|
||||
this.checkTokenSpendableWorker()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
checkFees: async function (payment_request) {
|
||||
const payload = {
|
||||
pr: payment_request
|
||||
}
|
||||
console.log('#### payload', JSON.stringify(payload))
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'POST',
|
||||
`/cashu/api/v1/${this.mintId}/checkfees`,
|
||||
'',
|
||||
payload
|
||||
)
|
||||
console.log('#### checkFees', payment_request, data.fee)
|
||||
return data.fee
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
LNbits.utils.notifyApiError(error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// /melt
|
||||
|
||||
melt: async function () {
|
||||
// todo: get fees from server and add to inputs
|
||||
this.payInvoiceData.blocking = true
|
||||
|
@ -1924,8 +1917,20 @@ page_container %}
|
|||
]
|
||||
})
|
||||
// delete spent tokens from db
|
||||
this.proofs = fristProofs
|
||||
this.storeProofs()
|
||||
this.deleteProofs(scndProofs)
|
||||
|
||||
// update UI
|
||||
|
||||
tokensBase64 = btoa(JSON.stringify(scndProofs))
|
||||
|
||||
this.historyTokens.push({
|
||||
status: 'paid',
|
||||
amount: -amount,
|
||||
date: currentDateStr(),
|
||||
token: tokensBase64
|
||||
})
|
||||
this.storehistoryTokens()
|
||||
|
||||
console.log({
|
||||
amount: -amount,
|
||||
bolt11: this.payInvoiceData.data.request,
|
||||
|
@ -1953,13 +1958,95 @@ page_container %}
|
|||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// /check
|
||||
|
||||
checkProofsSpendable: async function (proofs, update_history = false) {
|
||||
/*
|
||||
checks with the mint whether an array of proofs is still
|
||||
spendable or already invalidated
|
||||
*/
|
||||
const payload = {
|
||||
proofs: proofs.flat()
|
||||
}
|
||||
console.log('#### payload', JSON.stringify(payload))
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'POST',
|
||||
`/cashu/api/v1/${this.mintId}/check`,
|
||||
'',
|
||||
payload
|
||||
)
|
||||
|
||||
// delete proofs from database if it is spent
|
||||
let spentProofs = proofs.filter((p, pidx) => !data[pidx])
|
||||
if (spentProofs.length) {
|
||||
this.deleteProofs(spentProofs)
|
||||
|
||||
// update UI
|
||||
if (update_history) {
|
||||
tokensBase64 = btoa(JSON.stringify(spentProofs))
|
||||
|
||||
this.historyTokens.push({
|
||||
status: 'paid',
|
||||
amount: -this.sumProofs(spentProofs),
|
||||
date: currentDateStr(),
|
||||
token: tokensBase64
|
||||
})
|
||||
this.storehistoryTokens()
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
LNbits.utils.notifyApiError(error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// /checkfees
|
||||
checkFees: async function (payment_request) {
|
||||
const payload = {
|
||||
pr: payment_request
|
||||
}
|
||||
console.log('#### payload', JSON.stringify(payload))
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'POST',
|
||||
`/cashu/api/v1/${this.mintId}/checkfees`,
|
||||
'',
|
||||
payload
|
||||
)
|
||||
console.log('#### checkFees', payment_request, data.fee)
|
||||
return data.fee
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
LNbits.utils.notifyApiError(error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// /keys
|
||||
|
||||
fetchMintKeys: async function () {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/cashu/api/v1/${this.mintId}/keys`
|
||||
)
|
||||
this.keys = data
|
||||
localStorage.setItem(
|
||||
this.mintKey(this.mintId, 'keys'),
|
||||
JSON.stringify(data)
|
||||
)
|
||||
},
|
||||
setInvoicePaid: async function (payment_hash) {
|
||||
const invoice = this.invoicesCashu.find(i => i.hash === payment_hash)
|
||||
invoice.status = 'paid'
|
||||
this.storeinvoicesCashu()
|
||||
},
|
||||
recheckInvoice: async function (payment_hash, verbose = true) {
|
||||
console.log('### recheckInvoice.hash', payment_hash)
|
||||
checkInvoice: async function (payment_hash, verbose = true) {
|
||||
console.log('### checkInvoice.hash', payment_hash)
|
||||
const invoice = this.invoicesCashu.find(i => i.hash === payment_hash)
|
||||
try {
|
||||
proofs = await this.mint(invoice.amount, invoice.hash, verbose)
|
||||
|
@ -1969,15 +2056,15 @@ page_container %}
|
|||
throw error
|
||||
}
|
||||
},
|
||||
recheckPendingInvoices: async function () {
|
||||
checkPendingInvoices: async function () {
|
||||
for (const invoice of this.invoicesCashu) {
|
||||
if (invoice.status === 'pending' && invoice.sat > 0) {
|
||||
this.recheckInvoice(invoice.hash, false)
|
||||
if (invoice.status === 'pending' && invoice.amount > 0) {
|
||||
this.checkInvoice(invoice.hash, false)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
recheckPendingTokens: async function () {
|
||||
checkPendingTokens: async function () {
|
||||
for (const token of this.historyTokens) {
|
||||
if (token.status === 'pending' && token.amount < 0) {
|
||||
this.checkTokenSpendable(token.token, false)
|
||||
|
@ -1990,6 +2077,113 @@ page_container %}
|
|||
this.storehistoryTokens()
|
||||
},
|
||||
|
||||
checkTokenSpendable: async function (token, verbose = true) {
|
||||
/*
|
||||
checks whether a base64-encoded token (from the history table) has been spent already.
|
||||
if it is spent, the appropraite entry in the history table is set to paid.
|
||||
*/
|
||||
const tokenJson = atob(token)
|
||||
const proofs = JSON.parse(tokenJson)
|
||||
let data = await this.checkProofsSpendable(proofs)
|
||||
|
||||
// iterate through response of form {0: true, 1: false, ...}
|
||||
let paid = false
|
||||
for (const [key, spendable] of Object.entries(data)) {
|
||||
if (!spendable) {
|
||||
this.setTokenPaid(token)
|
||||
paid = true
|
||||
}
|
||||
}
|
||||
if (paid) {
|
||||
console.log('### token paid')
|
||||
if (window.navigator.vibrate) navigator.vibrate(200)
|
||||
this.$q.notify({
|
||||
timeout: 5000,
|
||||
type: 'positive',
|
||||
message: 'Token paid',
|
||||
position: 'top',
|
||||
actions: [
|
||||
{
|
||||
icon: 'close',
|
||||
color: 'white',
|
||||
handler: () => {}
|
||||
}
|
||||
]
|
||||
})
|
||||
} else {
|
||||
console.log('### token not paid yet')
|
||||
if (verbose) {
|
||||
this.$q.notify({
|
||||
timeout: 5000,
|
||||
color: 'grey',
|
||||
message: 'Token still pending',
|
||||
position: 'top',
|
||||
actions: [
|
||||
{
|
||||
icon: 'close',
|
||||
color: 'white',
|
||||
handler: () => {}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
this.sendData.tokens = token
|
||||
}
|
||||
return paid
|
||||
},
|
||||
|
||||
////////////// WORKERS //////////////
|
||||
|
||||
clearAllWorkers: function () {
|
||||
if (this.invoiceCheckListener) {
|
||||
clearInterval(this.invoiceCheckListener)
|
||||
}
|
||||
if (this.tokensCheckSpendableListener) {
|
||||
clearInterval(this.tokensCheckSpendableListener)
|
||||
}
|
||||
},
|
||||
invoiceCheckWorker: async function () {
|
||||
let nInterval = 0
|
||||
this.clearAllWorkers()
|
||||
this.invoiceCheckListener = setInterval(async () => {
|
||||
try {
|
||||
nInterval += 1
|
||||
|
||||
// exit loop after 2m
|
||||
if (nInterval > 40) {
|
||||
console.log('### stopping invoice check worker')
|
||||
this.clearAllWorkers()
|
||||
}
|
||||
console.log('### invoiceCheckWorker setInterval', nInterval)
|
||||
console.log(this.invoiceData)
|
||||
|
||||
// this will throw an error if the invoice is pending
|
||||
await this.checkInvoice(this.invoiceData.hash, false)
|
||||
|
||||
// only without error (invoice paid) will we reach here
|
||||
console.log('### stopping invoice check worker')
|
||||
this.clearAllWorkers()
|
||||
this.invoiceData.bolt11 = ''
|
||||
this.showInvoiceDetails = false
|
||||
if (window.navigator.vibrate) navigator.vibrate(200)
|
||||
this.$q.notify({
|
||||
timeout: 5000,
|
||||
type: 'positive',
|
||||
message: 'Payment received',
|
||||
position: 'top',
|
||||
actions: [
|
||||
{
|
||||
icon: 'close',
|
||||
color: 'white',
|
||||
handler: () => {}
|
||||
}
|
||||
]
|
||||
})
|
||||
} catch (error) {
|
||||
console.log('not paid yet')
|
||||
}
|
||||
}, 3000)
|
||||
},
|
||||
checkTokenSpendableWorker: async function () {
|
||||
let nInterval = 0
|
||||
this.clearAllWorkers()
|
||||
|
@ -2021,83 +2215,6 @@ page_container %}
|
|||
}, 3000)
|
||||
},
|
||||
|
||||
checkTokenSpendable: async function (token, verbose = true) {
|
||||
const tokenJson = atob(token)
|
||||
const proofs = JSON.parse(tokenJson)
|
||||
const payload = {
|
||||
proofs: proofs.flat()
|
||||
}
|
||||
console.log('#### payload', JSON.stringify(payload))
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'POST',
|
||||
`/cashu/api/v1/${this.mintId}/check`,
|
||||
'',
|
||||
payload
|
||||
)
|
||||
// iterate through response of form {0: true, 1: false, ...}
|
||||
let paid = false
|
||||
for (const [key, spendable] of Object.entries(data)) {
|
||||
if (!spendable) {
|
||||
this.setTokenPaid(token)
|
||||
paid = true
|
||||
}
|
||||
}
|
||||
if (paid) {
|
||||
console.log('### token paid')
|
||||
if (window.navigator.vibrate) navigator.vibrate(200)
|
||||
this.$q.notify({
|
||||
timeout: 5000,
|
||||
type: 'positive',
|
||||
message: 'Token paid',
|
||||
position: 'top',
|
||||
actions: [
|
||||
{
|
||||
icon: 'close',
|
||||
color: 'white',
|
||||
handler: () => {}
|
||||
}
|
||||
]
|
||||
})
|
||||
} else {
|
||||
console.log('### token not paid yet')
|
||||
if (verbose) {
|
||||
this.$q.notify({
|
||||
timeout: 5000,
|
||||
color: 'grey',
|
||||
message: 'Token still pending',
|
||||
position: 'top',
|
||||
actions: [
|
||||
{
|
||||
icon: 'close',
|
||||
color: 'white',
|
||||
handler: () => {}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
this.sendData.tokens = token
|
||||
}
|
||||
return paid
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
LNbits.utils.notifyApiError(error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
fetchMintKeys: async function () {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/cashu/api/v1/${this.mintId}/keys`
|
||||
)
|
||||
this.keys = data
|
||||
localStorage.setItem(
|
||||
this.mintKey(this.mintId, 'keys'),
|
||||
JSON.stringify(data)
|
||||
)
|
||||
},
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
@ -2116,62 +2233,62 @@ page_container %}
|
|||
}
|
||||
},
|
||||
|
||||
checkInvoice: function () {
|
||||
console.log('#### checkInvoice')
|
||||
try {
|
||||
const invoice = decode(this.payInvoiceData.data.request)
|
||||
// checkInvoice: function () {
|
||||
// console.log('#### checkInvoice')
|
||||
// try {
|
||||
// const invoice = decode(this.payInvoiceData.data.request)
|
||||
|
||||
const cleanInvoice = {
|
||||
msat: invoice.human_readable_part.amount,
|
||||
sat: invoice.human_readable_part.amount / 1000,
|
||||
fsat: LNbits.utils.formatSat(
|
||||
invoice.human_readable_part.amount / 1000
|
||||
)
|
||||
}
|
||||
// const cleanInvoice = {
|
||||
// msat: invoice.human_readable_part.amount,
|
||||
// sat: invoice.human_readable_part.amount / 1000,
|
||||
// fsat: LNbits.utils.formatSat(
|
||||
// invoice.human_readable_part.amount / 1000
|
||||
// )
|
||||
// }
|
||||
|
||||
_.each(invoice.data.tags, tag => {
|
||||
if (_.isObject(tag) && _.has(tag, 'description')) {
|
||||
if (tag.description === 'payment_hash') {
|
||||
cleanInvoice.hash = tag.value
|
||||
} else if (tag.description === 'description') {
|
||||
cleanInvoice.description = tag.value
|
||||
} else if (tag.description === 'expiry') {
|
||||
var expireDate = new Date(
|
||||
(invoice.data.time_stamp + tag.value) * 1000
|
||||
)
|
||||
cleanInvoice.expireDate = Quasar.utils.date.formatDate(
|
||||
expireDate,
|
||||
'YYYY-MM-DDTHH:mm:ss.SSSZ'
|
||||
)
|
||||
cleanInvoice.expired = false // TODO
|
||||
}
|
||||
}
|
||||
// _.each(invoice.data.tags, tag => {
|
||||
// if (_.isObject(tag) && _.has(tag, 'description')) {
|
||||
// if (tag.description === 'payment_hash') {
|
||||
// cleanInvoice.hash = tag.value
|
||||
// } else if (tag.description === 'description') {
|
||||
// cleanInvoice.description = tag.value
|
||||
// } else if (tag.description === 'expiry') {
|
||||
// var expireDate = new Date(
|
||||
// (invoice.data.time_stamp + tag.value) * 1000
|
||||
// )
|
||||
// cleanInvoice.expireDate = Quasar.utils.date.formatDate(
|
||||
// expireDate,
|
||||
// 'YYYY-MM-DDTHH:mm:ss.SSSZ'
|
||||
// )
|
||||
// cleanInvoice.expired = false // TODO
|
||||
// }
|
||||
// }
|
||||
|
||||
this.payInvoiceData.invoice = cleanInvoice
|
||||
})
|
||||
// this.payInvoiceData.invoice = cleanInvoice
|
||||
// })
|
||||
|
||||
console.log(
|
||||
'#### this.payInvoiceData.invoice',
|
||||
this.payInvoiceData.invoice
|
||||
)
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
timeout: 5000,
|
||||
type: 'warning',
|
||||
message: 'Could not decode invoice',
|
||||
caption: error + '',
|
||||
position: 'top',
|
||||
actions: [
|
||||
{
|
||||
icon: 'close',
|
||||
color: 'white',
|
||||
handler: () => {}
|
||||
}
|
||||
]
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
// console.log(
|
||||
// '#### this.payInvoiceData.invoice',
|
||||
// this.payInvoiceData.invoice
|
||||
// )
|
||||
// } catch (error) {
|
||||
// this.$q.notify({
|
||||
// timeout: 5000,
|
||||
// type: 'warning',
|
||||
// message: 'Could not decode invoice',
|
||||
// caption: error + '',
|
||||
// position: 'top',
|
||||
// actions: [
|
||||
// {
|
||||
// icon: 'close',
|
||||
// color: 'white',
|
||||
// handler: () => {}
|
||||
// }
|
||||
// ]
|
||||
// })
|
||||
// throw error
|
||||
// }
|
||||
// },
|
||||
|
||||
////////////// STORAGE /////////////
|
||||
|
||||
|
@ -2335,8 +2452,9 @@ page_container %}
|
|||
console.log('#### this.mintId', this.mintId)
|
||||
console.log('#### this.mintName', this.mintName)
|
||||
|
||||
this.recheckPendingInvoices()
|
||||
this.recheckPendingTokens()
|
||||
this.checkProofsSpendable(this.proofs, true)
|
||||
this.checkPendingInvoices()
|
||||
this.checkPendingTokens()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -46,9 +46,16 @@ from .models import Cashu
|
|||
|
||||
# --------- extension imports
|
||||
|
||||
# WARNING: Do not set this to False in production! This will create
|
||||
# tokens for free otherwise. This is for testing purposes only!
|
||||
|
||||
LIGHTNING = True
|
||||
|
||||
if not LIGHTNING:
|
||||
logger.warning(
|
||||
"Cashu: LIGHTNING is set False! That means that I will create ecash for free!"
|
||||
)
|
||||
|
||||
########################################
|
||||
############### LNBITS MINTS ###########
|
||||
########################################
|
||||
|
@ -130,6 +137,28 @@ async def keys(cashu_id: str = Query(None)) -> dict[int, str]:
|
|||
return ledger.get_keyset(keyset_id=cashu.keyset_id)
|
||||
|
||||
|
||||
@cashu_ext.get("/api/v1/{cashu_id}/keys/{idBase64Urlsafe}")
|
||||
async def keyset_keys(
|
||||
cashu_id: str = Query(None), idBase64Urlsafe: str = Query(None)
|
||||
) -> dict[int, str]:
|
||||
"""
|
||||
Get the public keys of the mint of a specificy keyset id.
|
||||
The id is encoded in base64_urlsafe and needs to be converted back to
|
||||
normal base64 before it can be processed.
|
||||
"""
|
||||
|
||||
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
|
||||
|
||||
if not cashu:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
|
||||
)
|
||||
|
||||
id = idBase64Urlsafe.replace("-", "+").replace("_", "/")
|
||||
keyset = ledger.get_keyset(keyset_id=id)
|
||||
return keyset
|
||||
|
||||
|
||||
@cashu_ext.get("/api/v1/{cashu_id}/keysets", status_code=HTTPStatus.OK)
|
||||
async def keysets(cashu_id: str = Query(None)) -> dict[str, list[str]]:
|
||||
"""Get the public keys of the mint"""
|
||||
|
@ -182,7 +211,7 @@ async def request_mint(cashu_id: str = Query(None), amount: int = 0) -> GetMintR
|
|||
|
||||
|
||||
@cashu_ext.post("/api/v1/{cashu_id}/mint")
|
||||
async def mint_coins(
|
||||
async def mint(
|
||||
data: MintRequest,
|
||||
cashu_id: str = Query(None),
|
||||
payment_hash: str = Query(None),
|
||||
|
@ -197,6 +226,8 @@ async def mint_coins(
|
|||
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
|
||||
)
|
||||
|
||||
keyset = ledger.keysets.keysets[cashu.keyset_id]
|
||||
|
||||
if LIGHTNING:
|
||||
invoice: Invoice = await ledger.crud.get_lightning_invoice(
|
||||
db=ledger.db, hash=payment_hash
|
||||
|
@ -206,42 +237,55 @@ async def mint_coins(
|
|||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Mint does not know this invoice.",
|
||||
)
|
||||
if invoice.issued == True:
|
||||
if invoice.issued:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.PAYMENT_REQUIRED,
|
||||
detail="Tokens already issued for this invoice.",
|
||||
)
|
||||
|
||||
total_requested = sum([bm.amount for bm in data.blinded_messages])
|
||||
if total_requested > invoice.amount:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.PAYMENT_REQUIRED,
|
||||
detail=f"Requested amount too high: {total_requested}. Invoice amount: {invoice.amount}",
|
||||
)
|
||||
|
||||
status: PaymentStatus = await check_transaction_status(cashu.wallet, payment_hash)
|
||||
|
||||
if status.paid != True:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.PAYMENT_REQUIRED, detail="Invoice not paid."
|
||||
)
|
||||
try:
|
||||
keyset = ledger.keysets.keysets[cashu.keyset_id]
|
||||
|
||||
promises = await ledger._generate_promises(
|
||||
B_s=data.blinded_messages, keyset=keyset
|
||||
)
|
||||
assert len(promises), HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="No promises returned."
|
||||
)
|
||||
# set this invoice as issued
|
||||
await ledger.crud.update_lightning_invoice(
|
||||
db=ledger.db, hash=payment_hash, issued=True
|
||||
)
|
||||
|
||||
status: PaymentStatus = await check_transaction_status(
|
||||
cashu.wallet, payment_hash
|
||||
)
|
||||
|
||||
try:
|
||||
total_requested = sum([bm.amount for bm in data.blinded_messages])
|
||||
if total_requested > invoice.amount:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.PAYMENT_REQUIRED,
|
||||
detail=f"Requested amount too high: {total_requested}. Invoice amount: {invoice.amount}",
|
||||
)
|
||||
|
||||
if not status.paid:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.PAYMENT_REQUIRED, detail="Invoice not paid."
|
||||
)
|
||||
|
||||
promises = await ledger._generate_promises(
|
||||
B_s=data.blinded_messages, keyset=keyset
|
||||
)
|
||||
return promises
|
||||
except (Exception, HTTPException) as e:
|
||||
logger.debug(f"Cashu: /melt {str(e) or getattr(e, 'detail')}")
|
||||
# unset issued flag because something went wrong
|
||||
await ledger.crud.update_lightning_invoice(
|
||||
db=ledger.db, hash=payment_hash, issued=False
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=getattr(e, "status_code")
|
||||
or HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=str(e) or getattr(e, "detail"),
|
||||
)
|
||||
else:
|
||||
# only used for testing when LIGHTNING=false
|
||||
promises = await ledger._generate_promises(
|
||||
B_s=data.blinded_messages, keyset=keyset
|
||||
)
|
||||
return promises
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
|
||||
@cashu_ext.post("/api/v1/{cashu_id}/melt")
|
||||
|
@ -265,37 +309,61 @@ async def melt_coins(
|
|||
detail="Error: Tokens are from another mint.",
|
||||
)
|
||||
|
||||
assert all([ledger._verify_proof(p) for p in proofs]), HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Could not verify proofs.",
|
||||
)
|
||||
# set proofs as pending
|
||||
await ledger._set_proofs_pending(proofs)
|
||||
|
||||
total_provided = sum([p["amount"] for p in proofs])
|
||||
invoice_obj = bolt11.decode(invoice)
|
||||
amount = math.ceil(invoice_obj.amount_msat / 1000)
|
||||
try:
|
||||
ledger._verify_proofs(proofs)
|
||||
|
||||
internal_checking_id = await check_internal(invoice_obj.payment_hash)
|
||||
total_provided = sum([p["amount"] for p in proofs])
|
||||
invoice_obj = bolt11.decode(invoice)
|
||||
amount = math.ceil(invoice_obj.amount_msat / 1000)
|
||||
|
||||
if not internal_checking_id:
|
||||
fees_msat = fee_reserve(invoice_obj.amount_msat)
|
||||
else:
|
||||
fees_msat = 0
|
||||
assert total_provided >= amount + fees_msat / 1000, Exception(
|
||||
f"Provided proofs ({total_provided} sats) not enough for Lightning payment ({amount + fees_msat} sats)."
|
||||
)
|
||||
internal_checking_id = await check_internal(invoice_obj.payment_hash)
|
||||
|
||||
await pay_invoice(
|
||||
wallet_id=cashu.wallet,
|
||||
payment_request=invoice,
|
||||
description=f"pay cashu invoice",
|
||||
extra={"tag": "cashu", "cahsu_name": cashu.name},
|
||||
)
|
||||
if not internal_checking_id:
|
||||
fees_msat = fee_reserve(invoice_obj.amount_msat)
|
||||
else:
|
||||
fees_msat = 0
|
||||
assert total_provided >= amount + math.ceil(fees_msat / 1000), Exception(
|
||||
f"Provided proofs ({total_provided} sats) not enough for Lightning payment ({amount + fees_msat} sats)."
|
||||
)
|
||||
logger.debug(f"Cashu: Initiating payment of {total_provided} sats")
|
||||
try:
|
||||
await pay_invoice(
|
||||
wallet_id=cashu.wallet,
|
||||
payment_request=invoice,
|
||||
description=f"Pay cashu invoice",
|
||||
extra={"tag": "cashu", "cashu_name": cashu.name},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Cashu error paying invoice {invoice_obj.payment_hash}: {e}")
|
||||
raise e
|
||||
finally:
|
||||
logger.debug(
|
||||
f"Cashu: Wallet {cashu.wallet} checking PaymentStatus of {invoice_obj.payment_hash}"
|
||||
)
|
||||
status: PaymentStatus = await check_transaction_status(
|
||||
cashu.wallet, invoice_obj.payment_hash
|
||||
)
|
||||
if status.paid == True:
|
||||
logger.debug(
|
||||
f"Cashu: Payment successful, invalidating proofs for {invoice_obj.payment_hash}"
|
||||
)
|
||||
await ledger._invalidate_proofs(proofs)
|
||||
else:
|
||||
logger.debug(f"Cashu: Payment failed for {invoice_obj.payment_hash}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Cashu: Exception for {invoice_obj.payment_hash}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Cashu: {str(e)}",
|
||||
)
|
||||
finally:
|
||||
logger.debug(f"Cashu: Unset pending for {invoice_obj.payment_hash}")
|
||||
# delete proofs from pending list
|
||||
await ledger._unset_proofs_pending(proofs)
|
||||
|
||||
status: PaymentStatus = await check_transaction_status(
|
||||
cashu.wallet, invoice_obj.payment_hash
|
||||
)
|
||||
if status.paid == True:
|
||||
await ledger._invalidate_proofs(proofs)
|
||||
return GetMeltResponse(paid=status.paid, preimage=status.preimage)
|
||||
|
||||
|
||||
|
@ -333,7 +401,7 @@ async def check_fees(
|
|||
fees_msat = fee_reserve(invoice_obj.amount_msat)
|
||||
else:
|
||||
fees_msat = 0
|
||||
return CheckFeesResponse(fee=fees_msat / 1000)
|
||||
return CheckFeesResponse(fee=math.ceil(fees_msat / 1000))
|
||||
|
||||
|
||||
@cashu_ext.post("/api/v1/{cashu_id}/split")
|
||||
|
|
|
@ -4,7 +4,10 @@
|
|||
StreamerCopilot: get tips via static QR (lnurl-pay) and show an
|
||||
animation<br />
|
||||
<small>
|
||||
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
|
||||
Created by,
|
||||
<a class="text-secondary" href="https://github.com/benarc"
|
||||
>Ben Arc</a
|
||||
></small
|
||||
>
|
||||
</p>
|
||||
</q-card-section>
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
>
|
||||
<div class="col">
|
||||
<qrcode
|
||||
:value="copilot.lnurl"
|
||||
:value="'lightning:' + copilot.lnurl"
|
||||
:options="{width:250}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
|
|
|
@ -11,18 +11,24 @@
|
|||
</h5>
|
||||
<p>
|
||||
Connect your LNbits instance to a
|
||||
<a href="https://github.com/chrislennon/lnbits-discord-bot"
|
||||
<a
|
||||
class="text-secondary"
|
||||
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
|
||||
<a class="text-secondary" 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
|
||||
<a class="text-secondary" href="https://github.com/benarc"
|
||||
>Ben Arc</a
|
||||
></small
|
||||
>
|
||||
</p>
|
||||
</q-card-section>
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
This extension is designed to be used through its API by a Discord Bot,
|
||||
currently you have to install the bot
|
||||
<a
|
||||
class="text-secondary"
|
||||
href="https://github.com/chrislennon/lnbits-discord-bot/#installation"
|
||||
>yourself</a
|
||||
><br />
|
||||
|
|
|
@ -15,7 +15,8 @@
|
|||
Events comes with a shareable ticket scanner, which can be used to
|
||||
register attendees.<br />
|
||||
<small>
|
||||
Created by, <a href="https://github.com/benarc">Ben Arc</a>
|
||||
Created by,
|
||||
<a class="text-secondary" href="https://github.com/benarc">Ben Arc</a>
|
||||
</small>
|
||||
</p>
|
||||
</q-card-section>
|
||||
|
|
|
@ -64,10 +64,10 @@
|
|||
</q-card>
|
||||
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div class="text-center q-mb-lg">
|
||||
<a :href="'lightning:' + receive.paymentReq">
|
||||
<a class="text-secondary" :href="'lightning:' + receive.paymentReq">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode
|
||||
:value="paymentReq"
|
||||
:value="'lightning:' + receive.paymentReq.toUpperCase()"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
|
|
13
lnbits/extensions/gerty/README.md
Normal file
13
lnbits/extensions/gerty/README.md
Normal file
|
@ -0,0 +1,13 @@
|
|||
# Gerty
|
||||
|
||||
## Your desktop bitcoin assistant
|
||||
|
||||
Buy here `<link>`
|
||||
|
||||
blah blah blah
|
||||
|
||||
### Usage
|
||||
|
||||
1. Enable extension
|
||||
2. Fill out form
|
||||
3. point gerty at the server and give it the Gerty ID
|
30
lnbits/extensions/gerty/__init__.py
Normal file
30
lnbits/extensions/gerty/__init__.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.tasks import catch_everything_and_restart
|
||||
|
||||
db = Database("ext_gerty")
|
||||
|
||||
|
||||
gerty_static_files = [
|
||||
{
|
||||
"path": "/gerty/static",
|
||||
"app": StaticFiles(packages=[("lnbits", "extensions/gerty/static")]),
|
||||
"name": "gerty_static",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
gerty_ext: APIRouter = APIRouter(prefix="/gerty", tags=["Gerty"])
|
||||
|
||||
|
||||
def gerty_renderer():
|
||||
return template_renderer(["lnbits/extensions/gerty/templates"])
|
||||
|
||||
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
6
lnbits/extensions/gerty/config.json
Normal file
6
lnbits/extensions/gerty/config.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "Gerty",
|
||||
"short_description": "Desktop bitcoin Assistant",
|
||||
"icon": "sentiment_satisfied",
|
||||
"contributors": ["arcbtc", "blackcoffeebtc"]
|
||||
}
|
137
lnbits/extensions/gerty/crud.py
Normal file
137
lnbits/extensions/gerty/crud.py
Normal file
|
@ -0,0 +1,137 @@
|
|||
import json
|
||||
import time
|
||||
from typing import List, Optional, Union
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import Gerty, Mempool, MempoolEndpoint
|
||||
|
||||
|
||||
async def create_gerty(wallet_id: str, data: Gerty) -> Gerty:
|
||||
gerty_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO gerty.gertys (
|
||||
id,
|
||||
name,
|
||||
utc_offset,
|
||||
type,
|
||||
wallet,
|
||||
lnbits_wallets,
|
||||
mempool_endpoint,
|
||||
exchange,
|
||||
display_preferences,
|
||||
refresh_time,
|
||||
urls
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
gerty_id,
|
||||
data.name,
|
||||
data.utc_offset,
|
||||
data.type,
|
||||
wallet_id,
|
||||
data.lnbits_wallets,
|
||||
data.mempool_endpoint,
|
||||
data.exchange,
|
||||
data.display_preferences,
|
||||
data.refresh_time,
|
||||
data.urls,
|
||||
),
|
||||
)
|
||||
|
||||
gerty = await get_gerty(gerty_id)
|
||||
assert gerty, "Newly created gerty couldn't be retrieved"
|
||||
return gerty
|
||||
|
||||
|
||||
async def update_gerty(gerty_id: str, **kwargs) -> Gerty:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE gerty.gertys SET {q} WHERE id = ?", (*kwargs.values(), gerty_id)
|
||||
)
|
||||
return await get_gerty(gerty_id)
|
||||
|
||||
|
||||
async def get_gerty(gerty_id: str) -> Optional[Gerty]:
|
||||
row = await db.fetchone("SELECT * FROM gerty.gertys WHERE id = ?", (gerty_id,))
|
||||
return Gerty(**row) if row else None
|
||||
|
||||
|
||||
async def get_gertys(wallet_ids: Union[str, List[str]]) -> List[Gerty]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM gerty.gertys WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [Gerty(**row) for row in rows]
|
||||
|
||||
|
||||
async def delete_gerty(gerty_id: str) -> None:
|
||||
await db.execute("DELETE FROM gerty.gertys WHERE id = ?", (gerty_id,))
|
||||
|
||||
|
||||
#############MEMPOOL###########
|
||||
|
||||
|
||||
async def get_mempool_info(endPoint: str, gerty) -> Optional[Mempool]:
|
||||
logger.debug(endPoint)
|
||||
endpoints = MempoolEndpoint()
|
||||
url = ""
|
||||
for endpoint in endpoints:
|
||||
if endPoint == endpoint[0]:
|
||||
url = endpoint[1]
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM gerty.mempool WHERE endpoint = ? AND mempool_endpoint = ?",
|
||||
(
|
||||
endPoint,
|
||||
gerty.mempool_endpoint,
|
||||
),
|
||||
)
|
||||
if not row:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(gerty.mempool_endpoint + url)
|
||||
logger.debug(gerty.mempool_endpoint + url)
|
||||
mempool_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO gerty.mempool (
|
||||
id,
|
||||
data,
|
||||
endpoint,
|
||||
time,
|
||||
mempool_endpoint
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
mempool_id,
|
||||
json.dumps(response.json()),
|
||||
endPoint,
|
||||
int(time.time()),
|
||||
gerty.mempool_endpoint,
|
||||
),
|
||||
)
|
||||
return response.json()
|
||||
if int(time.time()) - row.time > 20:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(gerty.mempool_endpoint + url)
|
||||
await db.execute(
|
||||
"UPDATE gerty.mempool SET data = ?, time = ? WHERE endpoint = ? AND mempool_endpoint = ?",
|
||||
(
|
||||
json.dumps(response.json()),
|
||||
int(time.time()),
|
||||
endPoint,
|
||||
gerty.mempool_endpoint,
|
||||
),
|
||||
)
|
||||
return response.json()
|
||||
return json.loads(row.data)
|
951
lnbits/extensions/gerty/helpers.py
Normal file
951
lnbits/extensions/gerty/helpers.py
Normal file
|
@ -0,0 +1,951 @@
|
|||
import json
|
||||
import os
|
||||
import random
|
||||
import textwrap
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import get_user, get_wallet_for_key
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import satoshis_amount_as_fiat
|
||||
|
||||
from .crud import get_gerty, get_mempool_info
|
||||
from .number_prefixer import *
|
||||
|
||||
|
||||
def get_percent_difference(current, previous, precision=3):
|
||||
difference = (current - previous) / current * 100
|
||||
return "{0}{1}%".format("+" if difference > 0 else "", round(difference, precision))
|
||||
|
||||
|
||||
# A helper function get a nicely formated dict for the text
|
||||
def get_text_item_dict(
|
||||
text: str,
|
||||
font_size: int,
|
||||
x_pos: int = None,
|
||||
y_pos: int = None,
|
||||
gerty_type: str = "Gerty",
|
||||
):
|
||||
# Get line size by font size
|
||||
line_width = 20
|
||||
if font_size <= 12:
|
||||
line_width = 60
|
||||
elif font_size <= 15:
|
||||
line_width = 45
|
||||
elif font_size <= 20:
|
||||
line_width = 35
|
||||
elif font_size <= 40:
|
||||
line_width = 25
|
||||
|
||||
# Get font sizes for Gerty mini
|
||||
if gerty_type.lower() == "mini gerty":
|
||||
if font_size <= 12:
|
||||
font_size = 1
|
||||
if font_size <= 15:
|
||||
font_size = 1
|
||||
elif font_size <= 20:
|
||||
font_size = 2
|
||||
elif font_size <= 40:
|
||||
font_size = 2
|
||||
else:
|
||||
font_size = 5
|
||||
|
||||
# wrap the text
|
||||
wrapper = textwrap.TextWrapper(width=line_width)
|
||||
word_list = wrapper.wrap(text=text)
|
||||
# logger.debug("number of chars = {0}".format(len(text)))
|
||||
|
||||
multilineText = "\n".join(word_list)
|
||||
# logger.debug("number of lines = {0}".format(len(word_list)))
|
||||
|
||||
# logger.debug('multilineText')
|
||||
# logger.debug(multilineText)
|
||||
|
||||
text = {"value": multilineText, "size": font_size}
|
||||
if x_pos is None and y_pos is None:
|
||||
text["position"] = "center"
|
||||
else:
|
||||
text["x"] = x_pos
|
||||
text["y"] = y_pos
|
||||
return text
|
||||
|
||||
|
||||
# format a number for nice display output
|
||||
def format_number(number, precision=None):
|
||||
return "{:,}".format(round(number, precision))
|
||||
|
||||
|
||||
async def get_mining_dashboard(gerty):
|
||||
areas = []
|
||||
if isinstance(gerty.mempool_endpoint, str):
|
||||
# current hashrate
|
||||
r = await get_mempool_info("hashrate_1w", gerty)
|
||||
data = r
|
||||
hashrateNow = data["currentHashrate"]
|
||||
hashrateOneWeekAgo = data["hashrates"][6]["avgHashrate"]
|
||||
|
||||
text = []
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="Current mining hashrate", font_size=12, gerty_type=gerty.type
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0}hash".format(si_format(hashrateNow, 6, True, " ")),
|
||||
font_size=20,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0} vs 7 days ago".format(
|
||||
get_percent_difference(hashrateNow, hashrateOneWeekAgo, 3)
|
||||
),
|
||||
font_size=12,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
areas.append(text)
|
||||
|
||||
r = await get_mempool_info("difficulty_adjustment", gerty)
|
||||
|
||||
# timeAvg
|
||||
text = []
|
||||
progress = "{0}%".format(round(r["progressPercent"], 2))
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="Progress through current epoch",
|
||||
font_size=12,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(text=progress, font_size=60, gerty_type=gerty.type)
|
||||
)
|
||||
areas.append(text)
|
||||
|
||||
# difficulty adjustment
|
||||
text = []
|
||||
stat = r["remainingTime"]
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="Time to next difficulty adjustment",
|
||||
font_size=12,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text=get_time_remaining(stat / 1000, 3),
|
||||
font_size=12,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
areas.append(text)
|
||||
|
||||
# difficultyChange
|
||||
text = []
|
||||
difficultyChange = round(r["difficultyChange"], 2)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="Estimated difficulty change",
|
||||
font_size=12,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0}{1}%".format(
|
||||
"+" if difficultyChange > 0 else "", round(difficultyChange, 2)
|
||||
),
|
||||
font_size=60,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
areas.append(text)
|
||||
|
||||
r = await get_mempool_info("hashrate_1m", gerty)
|
||||
data = r
|
||||
stat = {}
|
||||
stat["current"] = data["currentDifficulty"]
|
||||
stat["previous"] = data["difficulty"][len(data["difficulty"]) - 2]["difficulty"]
|
||||
return areas
|
||||
|
||||
|
||||
async def get_lightning_stats(gerty):
|
||||
data = await get_mempool_info("statistics", gerty)
|
||||
areas = []
|
||||
|
||||
text = []
|
||||
text.append(
|
||||
get_text_item_dict(text="Channel Count", font_size=12, gerty_type=gerty.type)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text=format_number(data["latest"]["channel_count"]),
|
||||
font_size=20,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
difference = get_percent_difference(
|
||||
current=data["latest"]["channel_count"],
|
||||
previous=data["previous"]["channel_count"],
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0} in last 7 days".format(difference),
|
||||
font_size=12,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
areas.append(text)
|
||||
|
||||
text = []
|
||||
text.append(
|
||||
get_text_item_dict(text="Number of Nodes", font_size=12, gerty_type=gerty.type)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text=format_number(data["latest"]["node_count"]),
|
||||
font_size=20,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
difference = get_percent_difference(
|
||||
current=data["latest"]["node_count"], previous=data["previous"]["node_count"]
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0} in last 7 days".format(difference),
|
||||
font_size=12,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
areas.append(text)
|
||||
|
||||
text = []
|
||||
text.append(
|
||||
get_text_item_dict(text="Total Capacity", font_size=12, gerty_type=gerty.type)
|
||||
)
|
||||
avg_capacity = float(data["latest"]["total_capacity"]) / float(100000000)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0} BTC".format(format_number(avg_capacity, 2)),
|
||||
font_size=20,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
difference = get_percent_difference(
|
||||
current=data["latest"]["total_capacity"],
|
||||
previous=data["previous"]["total_capacity"],
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0} in last 7 days".format(difference),
|
||||
font_size=12,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
areas.append(text)
|
||||
|
||||
text = []
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="Average Channel Capacity", font_size=12, gerty_type=gerty.type
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0} sats".format(format_number(data["latest"]["avg_capacity"])),
|
||||
font_size=20,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
difference = get_percent_difference(
|
||||
current=data["latest"]["avg_capacity"],
|
||||
previous=data["previous"]["avg_capacity"],
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0} in last 7 days".format(difference),
|
||||
font_size=12,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
areas.append(text)
|
||||
|
||||
return areas
|
||||
|
||||
|
||||
def get_next_update_time(sleep_time_seconds: int = 0, utc_offset: int = 0):
|
||||
utc_now = datetime.utcnow()
|
||||
next_refresh_time = utc_now + timedelta(0, sleep_time_seconds)
|
||||
local_refresh_time = next_refresh_time + timedelta(hours=utc_offset)
|
||||
return "{0} {1}".format(
|
||||
"I'll wake up at" if gerty_should_sleep(utc_offset) else "Next update at",
|
||||
local_refresh_time.strftime("%H:%M on %e %b %Y"),
|
||||
)
|
||||
|
||||
|
||||
def gerty_should_sleep(utc_offset: int = 0):
|
||||
utc_now = datetime.utcnow()
|
||||
local_time = utc_now + timedelta(hours=utc_offset)
|
||||
hours = local_time.strftime("%H")
|
||||
hours = int(hours)
|
||||
if hours >= 22 and hours <= 23:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
async def get_mining_stat(stat_slug: str, gerty):
|
||||
text = []
|
||||
if stat_slug == "mining_current_hash_rate":
|
||||
stat = await api_get_mining_stat(stat_slug, gerty)
|
||||
current = "{0}hash".format(si_format(stat["current"], 6, True, " "))
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="Current Mining Hashrate", font_size=20, gerty_type=gerty.type
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(text=current, font_size=40, gerty_type=gerty.type)
|
||||
)
|
||||
# compare vs previous time period
|
||||
difference = get_percent_difference(
|
||||
current=stat["current"], previous=stat["1w"]
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0} in last 7 days".format(difference),
|
||||
font_size=12,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
elif stat_slug == "mining_current_difficulty":
|
||||
stat = await api_get_mining_stat(stat_slug, gerty)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="Current Mining Difficulty", font_size=20, gerty_type=gerty.type
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text=format_number(stat["current"]), font_size=40, gerty_type=gerty.type
|
||||
)
|
||||
)
|
||||
difference = get_percent_difference(
|
||||
current=stat["current"], previous=stat["previous"]
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0} since last adjustment".format(difference),
|
||||
font_size=12,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
# text.append(get_text_item_dict("Required threshold for mining proof-of-work", 12))
|
||||
return text
|
||||
|
||||
|
||||
async def api_get_mining_stat(stat_slug: str, gerty):
|
||||
stat = ""
|
||||
if stat_slug == "mining_current_hash_rate":
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await get_mempool_info("hashrate_1m", gerty)
|
||||
data = r
|
||||
stat = {}
|
||||
stat["current"] = data["currentHashrate"]
|
||||
stat["1w"] = data["hashrates"][len(data["hashrates"]) - 7]["avgHashrate"]
|
||||
elif stat_slug == "mining_current_difficulty":
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await get_mempool_info("hashrate_1m", gerty)
|
||||
data = r
|
||||
stat = {}
|
||||
stat["current"] = data["currentDifficulty"]
|
||||
stat["previous"] = data["difficulty"][len(data["difficulty"]) - 2][
|
||||
"difficulty"
|
||||
]
|
||||
return stat
|
||||
|
||||
|
||||
###########################################
|
||||
|
||||
|
||||
async def get_satoshi():
|
||||
maxQuoteLength = 186
|
||||
with open(
|
||||
os.path.join(settings.lnbits_path, "extensions/gerty/static/satoshi.json")
|
||||
) as fd:
|
||||
satoshiQuotes = json.load(fd)
|
||||
quote = satoshiQuotes[random.randint(0, len(satoshiQuotes) - 1)]
|
||||
# logger.debug(quote.text)
|
||||
if len(quote["text"]) > maxQuoteLength:
|
||||
logger.debug("Quote is too long, getting another")
|
||||
return await get_satoshi()
|
||||
else:
|
||||
return quote
|
||||
|
||||
|
||||
# Get a screen slug by its position in the screens_list
|
||||
def get_screen_slug_by_index(index: int, screens_list):
|
||||
if index <= len(screens_list) - 1:
|
||||
return list(screens_list)[index - 1]
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
# Get a list of text items for the screen number
|
||||
async def get_screen_data(screen_num: int, screens_list: dict, gerty):
|
||||
screen_slug = get_screen_slug_by_index(screen_num, screens_list)
|
||||
# first get the relevant slug from the display_preferences
|
||||
areas = []
|
||||
title = ""
|
||||
|
||||
if screen_slug == "dashboard":
|
||||
title = gerty.name
|
||||
areas = await get_dashboard(gerty)
|
||||
if screen_slug == "lnbits_wallets_balance":
|
||||
wallets = await get_lnbits_wallet_balances(gerty)
|
||||
|
||||
for wallet in wallets:
|
||||
text = []
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0}'s Wallet".format(wallet["name"]),
|
||||
font_size=20,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0} sats".format(format_number(wallet["balance"])),
|
||||
font_size=40,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
areas.append(text)
|
||||
elif screen_slug == "url_checker":
|
||||
for url in json.loads(gerty.urls):
|
||||
async with httpx.AsyncClient() as client:
|
||||
text = []
|
||||
try:
|
||||
response = await client.get(url)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text=url,
|
||||
font_size=20,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text=str(response.status_code),
|
||||
font_size=40,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
except:
|
||||
text = []
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text=url,
|
||||
font_size=20,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text=str("DOWN"),
|
||||
font_size=40,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
areas.append(text)
|
||||
elif screen_slug == "fun_satoshi_quotes":
|
||||
areas.append(await get_satoshi_quotes(gerty))
|
||||
elif screen_slug == "fun_exchange_market_rate":
|
||||
areas.append(await get_exchange_rate(gerty))
|
||||
elif screen_slug == "onchain_difficulty_epoch_progress":
|
||||
areas.append(await get_onchain_stat(screen_slug, gerty))
|
||||
elif screen_slug == "onchain_block_height":
|
||||
text = []
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text=format_number(await get_mempool_info("tip_height", gerty)),
|
||||
font_size=80,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
areas.append(text)
|
||||
elif screen_slug == "onchain_difficulty_retarget_date":
|
||||
areas.append(await get_onchain_stat(screen_slug, gerty))
|
||||
elif screen_slug == "onchain_difficulty_blocks_remaining":
|
||||
areas.append(await get_onchain_stat(screen_slug, gerty))
|
||||
elif screen_slug == "onchain_difficulty_epoch_time_remaining":
|
||||
areas.append(await get_onchain_stat(screen_slug, gerty))
|
||||
elif screen_slug == "dashboard_onchain":
|
||||
title = "Onchain Data"
|
||||
areas = await get_onchain_dashboard(gerty)
|
||||
elif screen_slug == "mempool_recommended_fees":
|
||||
areas.append(await get_mempool_stat(screen_slug, gerty))
|
||||
elif screen_slug == "mempool_tx_count":
|
||||
areas.append(await get_mempool_stat(screen_slug, gerty))
|
||||
elif screen_slug == "mining_current_hash_rate":
|
||||
areas.append(await get_mining_stat(screen_slug, gerty))
|
||||
elif screen_slug == "mining_current_difficulty":
|
||||
areas.append(await get_mining_stat(screen_slug, gerty))
|
||||
elif screen_slug == "dashboard_mining":
|
||||
title = "Mining Data"
|
||||
areas = await get_mining_dashboard(gerty)
|
||||
elif screen_slug == "lightning_dashboard":
|
||||
title = "Lightning Network"
|
||||
areas = await get_lightning_stats(gerty)
|
||||
|
||||
data = {}
|
||||
data["title"] = title
|
||||
data["areas"] = areas
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# Get the dashboard screen
|
||||
async def get_dashboard(gerty):
|
||||
areas = []
|
||||
# XC rate
|
||||
text = []
|
||||
amount = await satoshis_amount_as_fiat(100000000, gerty.exchange)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text=format_number(amount), font_size=40, gerty_type=gerty.type
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="BTC{0} price".format(gerty.exchange),
|
||||
font_size=15,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
areas.append(text)
|
||||
# balance
|
||||
text = []
|
||||
wallets = await get_lnbits_wallet_balances(gerty)
|
||||
text = []
|
||||
for wallet in wallets:
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0}".format(wallet["name"]), font_size=15, gerty_type=gerty.type
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0} sats".format(format_number(wallet["balance"])),
|
||||
font_size=20,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
areas.append(text)
|
||||
|
||||
# Mempool fees
|
||||
text = []
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text=format_number(await get_mempool_info("tip_height", gerty)),
|
||||
font_size=40,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="Current block height", font_size=15, gerty_type=gerty.type
|
||||
)
|
||||
)
|
||||
areas.append(text)
|
||||
|
||||
# difficulty adjustment time
|
||||
text = []
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text=await get_time_remaining_next_difficulty_adjustment(gerty),
|
||||
font_size=15,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="until next difficulty adjustment", font_size=12, gerty_type=gerty.type
|
||||
)
|
||||
)
|
||||
areas.append(text)
|
||||
|
||||
return areas
|
||||
|
||||
|
||||
async def get_lnbits_wallet_balances(gerty):
|
||||
# Get Wallet info
|
||||
wallets = []
|
||||
if gerty.lnbits_wallets != "":
|
||||
for lnbits_wallet in json.loads(gerty.lnbits_wallets):
|
||||
wallet = await get_wallet_for_key(key=lnbits_wallet)
|
||||
if wallet:
|
||||
wallets.append(
|
||||
{
|
||||
"name": wallet.name,
|
||||
"balance": wallet.balance_msat / 1000,
|
||||
"inkey": wallet.inkey,
|
||||
}
|
||||
)
|
||||
return wallets
|
||||
|
||||
|
||||
async def get_placeholder_text():
|
||||
return [
|
||||
get_text_item_dict(
|
||||
text="Some placeholder text",
|
||||
x_pos=15,
|
||||
y_pos=10,
|
||||
font_size=50,
|
||||
gerty_type=gerty.type,
|
||||
),
|
||||
get_text_item_dict(
|
||||
text="Some placeholder text",
|
||||
x_pos=15,
|
||||
y_pos=10,
|
||||
font_size=50,
|
||||
gerty_type=gerty.type,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def get_satoshi_quotes(gerty):
|
||||
# Get Satoshi quotes
|
||||
text = []
|
||||
quote = await get_satoshi()
|
||||
if quote:
|
||||
if quote["text"]:
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text=quote["text"], font_size=15, gerty_type=gerty.type
|
||||
)
|
||||
)
|
||||
if quote["date"]:
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="Satoshi Nakamoto - {0}".format(quote["date"]),
|
||||
font_size=15,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
# Get Exchange Value
|
||||
async def get_exchange_rate(gerty):
|
||||
text = []
|
||||
if gerty.exchange != "":
|
||||
try:
|
||||
amount = await satoshis_amount_as_fiat(100000000, gerty.exchange)
|
||||
if amount:
|
||||
price = format_number(amount)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="Current {0}/BTC price".format(gerty.exchange),
|
||||
font_size=15,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(text=price, font_size=80, gerty_type=gerty.type)
|
||||
)
|
||||
except:
|
||||
pass
|
||||
return text
|
||||
|
||||
|
||||
async def get_onchain_stat(stat_slug: str, gerty):
|
||||
text = []
|
||||
if (
|
||||
stat_slug == "onchain_difficulty_epoch_progress"
|
||||
or stat_slug == "onchain_difficulty_retarget_date"
|
||||
or stat_slug == "onchain_difficulty_blocks_remaining"
|
||||
or stat_slug == "onchain_difficulty_epoch_time_remaining"
|
||||
):
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await get_mempool_info("difficulty_adjustment", gerty)
|
||||
if stat_slug == "onchain_difficulty_epoch_progress":
|
||||
stat = round(r["progressPercent"])
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="Progress through current difficulty epoch",
|
||||
font_size=15,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0}%".format(stat), font_size=80, gerty_type=gerty.type
|
||||
)
|
||||
)
|
||||
elif stat_slug == "onchain_difficulty_retarget_date":
|
||||
stat = r["estimatedRetargetDate"]
|
||||
dt = datetime.fromtimestamp(stat / 1000).strftime("%e %b %Y at %H:%M")
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="Date of next difficulty adjustment",
|
||||
font_size=15,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(text=dt, font_size=40, gerty_type=gerty.type)
|
||||
)
|
||||
elif stat_slug == "onchain_difficulty_blocks_remaining":
|
||||
stat = r["remainingBlocks"]
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="Blocks until next difficulty adjustment",
|
||||
font_size=15,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0}".format(format_number(stat)),
|
||||
font_size=80,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
elif stat_slug == "onchain_difficulty_epoch_time_remaining":
|
||||
stat = r["remainingTime"]
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="Time until next difficulty adjustment",
|
||||
font_size=15,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text=get_time_remaining(stat / 1000, 4),
|
||||
font_size=20,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
async def get_onchain_dashboard(gerty):
|
||||
areas = []
|
||||
if isinstance(gerty.mempool_endpoint, str):
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await get_mempool_info("difficulty_adjustment", gerty)
|
||||
text = []
|
||||
stat = round(r["progressPercent"])
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="Progress through epoch", font_size=12, gerty_type=gerty.type
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0}%".format(stat), font_size=60, gerty_type=gerty.type
|
||||
)
|
||||
)
|
||||
areas.append(text)
|
||||
|
||||
text = []
|
||||
stat = r["estimatedRetargetDate"]
|
||||
dt = datetime.fromtimestamp(stat / 1000).strftime("%e %b %Y at %H:%M")
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="Date of next adjustment", font_size=12, gerty_type=gerty.type
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(text=dt, font_size=20, gerty_type=gerty.type)
|
||||
)
|
||||
areas.append(text)
|
||||
|
||||
text = []
|
||||
stat = r["remainingBlocks"]
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="Blocks until adjustment", font_size=12, gerty_type=gerty.type
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0}".format(format_number(stat)),
|
||||
font_size=60,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
areas.append(text)
|
||||
|
||||
text = []
|
||||
stat = r["remainingTime"]
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="Time until adjustment", font_size=12, gerty_type=gerty.type
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text=get_time_remaining(stat / 1000, 4),
|
||||
font_size=20,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
areas.append(text)
|
||||
|
||||
return areas
|
||||
|
||||
|
||||
async def get_time_remaining_next_difficulty_adjustment(gerty):
|
||||
if isinstance(gerty.mempool_endpoint, str):
|
||||
r = await get_mempool_info("difficulty_adjustment", gerty)
|
||||
stat = r["remainingTime"]
|
||||
time = get_time_remaining(stat / 1000, 3)
|
||||
return time
|
||||
|
||||
|
||||
async def get_mempool_stat(stat_slug: str, gerty):
|
||||
text = []
|
||||
if isinstance(gerty.mempool_endpoint, str):
|
||||
if stat_slug == "mempool_tx_count":
|
||||
r = get_mempool_info("mempool", gerty)
|
||||
if stat_slug == "mempool_tx_count":
|
||||
stat = round(r["count"])
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="Transactions in the mempool",
|
||||
font_size=15,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0}".format(format_number(stat)),
|
||||
font_size=80,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
elif stat_slug == "mempool_recommended_fees":
|
||||
y_offset = 60
|
||||
fees = await get_mempool_info("fees_recommended", gerty)
|
||||
pos_y = 80 + y_offset
|
||||
text.append(get_text_item_dict("mempool.space", 40, 160, pos_y, gerty.type))
|
||||
pos_y = 180 + y_offset
|
||||
text.append(
|
||||
get_text_item_dict("Recommended Tx Fees", 20, 240, pos_y, gerty.type)
|
||||
)
|
||||
|
||||
pos_y = 280 + y_offset
|
||||
text.append(
|
||||
get_text_item_dict("{0}".format("None"), 15, 30, pos_y, gerty.type)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict("{0}".format("Low"), 15, 235, pos_y, gerty.type)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict("{0}".format("Medium"), 15, 460, pos_y, gerty.type)
|
||||
)
|
||||
text.append(
|
||||
get_text_item_dict("{0}".format("High"), 15, 750, pos_y, gerty.type)
|
||||
)
|
||||
|
||||
pos_y = 340 + y_offset
|
||||
font_size = 15
|
||||
fee_append = "/vB"
|
||||
fee_rate = fees["economyFee"]
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0} {1}{2}".format(
|
||||
format_number(fee_rate),
|
||||
("sat" if fee_rate == 1 else "sats"),
|
||||
fee_append,
|
||||
),
|
||||
font_size=font_size,
|
||||
x_pos=30,
|
||||
y_pos=pos_y,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
|
||||
fee_rate = fees["hourFee"]
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0} {1}{2}".format(
|
||||
format_number(fee_rate),
|
||||
("sat" if fee_rate == 1 else "sats"),
|
||||
fee_append,
|
||||
),
|
||||
font_size=font_size,
|
||||
x_pos=235,
|
||||
y_pos=pos_y,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
|
||||
fee_rate = fees["halfHourFee"]
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0} {1}{2}".format(
|
||||
format_number(fee_rate),
|
||||
("sat" if fee_rate == 1 else "sats"),
|
||||
fee_append,
|
||||
),
|
||||
font_size=font_size,
|
||||
x_pos=460,
|
||||
y_pos=pos_y,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
|
||||
fee_rate = fees["fastestFee"]
|
||||
text.append(
|
||||
get_text_item_dict(
|
||||
text="{0} {1}{2}".format(
|
||||
format_number(fee_rate),
|
||||
("sat" if fee_rate == 1 else "sats"),
|
||||
fee_append,
|
||||
),
|
||||
font_size=font_size,
|
||||
x_pos=750,
|
||||
y_pos=pos_y,
|
||||
gerty_type=gerty.type,
|
||||
)
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def get_date_suffix(dayNumber):
|
||||
if 4 <= dayNumber <= 20 or 24 <= dayNumber <= 30:
|
||||
return "th"
|
||||
else:
|
||||
return ["st", "nd", "rd"][dayNumber % 10 - 1]
|
||||
|
||||
|
||||
def get_time_remaining(seconds, granularity=2):
|
||||
intervals = (
|
||||
# ('weeks', 604800), # 60 * 60 * 24 * 7
|
||||
("days", 86400), # 60 * 60 * 24
|
||||
("hours", 3600), # 60 * 60
|
||||
("minutes", 60),
|
||||
("seconds", 1),
|
||||
)
|
||||
|
||||
result = []
|
||||
|
||||
for name, count in intervals:
|
||||
value = seconds // count
|
||||
if value:
|
||||
seconds -= value * count
|
||||
if value == 1:
|
||||
name = name.rstrip("s")
|
||||
result.append("{} {}".format(round(value), name))
|
||||
return ", ".join(result[:granularity])
|
59
lnbits/extensions/gerty/migrations.py
Normal file
59
lnbits/extensions/gerty/migrations.py
Normal file
|
@ -0,0 +1,59 @@
|
|||
async def m001_initial(db):
|
||||
"""
|
||||
Initial Gertys table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE gerty.gertys (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
refresh_time INT,
|
||||
name TEXT NOT NULL,
|
||||
lnbits_wallets TEXT,
|
||||
mempool_endpoint TEXT,
|
||||
exchange TEXT,
|
||||
display_preferences TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m002_add_utc_offset_col(db):
|
||||
"""
|
||||
support for UTC offset
|
||||
"""
|
||||
await db.execute("ALTER TABLE gerty.gertys ADD COLUMN utc_offset INT;")
|
||||
|
||||
|
||||
async def m003_add_gerty_model_col(db):
|
||||
"""
|
||||
support for Gerty model col
|
||||
"""
|
||||
await db.execute("ALTER TABLE gerty.gertys ADD COLUMN type TEXT;")
|
||||
|
||||
|
||||
#########MEMPOOL MIGRATIONS########
|
||||
|
||||
|
||||
async def m004_initial(db):
|
||||
"""
|
||||
Initial Gertys table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE gerty.mempool (
|
||||
id TEXT PRIMARY KEY,
|
||||
mempool_endpoint TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
time TIMESTAMP
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m005_add_gerty_model_col(db):
|
||||
"""
|
||||
support for Gerty model col
|
||||
"""
|
||||
await db.execute("ALTER TABLE gerty.gertys ADD COLUMN urls TEXT;")
|
48
lnbits/extensions/gerty/models.py
Normal file
48
lnbits/extensions/gerty/models.py
Normal file
|
@ -0,0 +1,48 @@
|
|||
from sqlite3 import Row
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Gerty(BaseModel):
|
||||
id: str = Query(None)
|
||||
name: str
|
||||
refresh_time: int = Query(None)
|
||||
utc_offset: int = Query(None)
|
||||
wallet: str = Query(None)
|
||||
type: str
|
||||
lnbits_wallets: str = Query(
|
||||
None
|
||||
) # Wallets to keep an eye on, {"wallet-id": "wallet-read-key, etc"}
|
||||
mempool_endpoint: str = Query(None) # Mempool endpoint to use
|
||||
exchange: str = Query(
|
||||
None
|
||||
) # BTC <-> Fiat exchange rate to pull ie "USD", in 0.0001 and sats
|
||||
display_preferences: str = Query(None)
|
||||
urls: str = Query(None)
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Gerty":
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
#########MEMPOOL MODELS###########
|
||||
|
||||
|
||||
class MempoolEndpoint(BaseModel):
|
||||
fees_recommended: str = "/api/v1/fees/recommended"
|
||||
hashrate_1w: str = "/api/v1/mining/hashrate/1w"
|
||||
hashrate_1m: str = "/api/v1/mining/hashrate/1m"
|
||||
statistics: str = "/api/v1/lightning/statistics/latest"
|
||||
difficulty_adjustment: str = "/api/v1/difficulty-adjustment"
|
||||
tip_height: str = "/api/blocks/tip/height"
|
||||
mempool: str = "/api/mempool"
|
||||
|
||||
|
||||
class Mempool(BaseModel):
|
||||
id: str = Query(None)
|
||||
mempool_endpoint: str = Query(None)
|
||||
endpoint: str = Query(None)
|
||||
data: str = Query(None)
|
||||
time: int = Query(None)
|
66
lnbits/extensions/gerty/number_prefixer.py
Normal file
66
lnbits/extensions/gerty/number_prefixer.py
Normal file
|
@ -0,0 +1,66 @@
|
|||
import math
|
||||
|
||||
|
||||
def si_classifier(val):
|
||||
suffixes = {
|
||||
24: {"long_suffix": "yotta", "short_suffix": "Y", "scalar": 10**24},
|
||||
21: {"long_suffix": "zetta", "short_suffix": "Z", "scalar": 10**21},
|
||||
18: {"long_suffix": "exa", "short_suffix": "E", "scalar": 10**18},
|
||||
15: {"long_suffix": "peta", "short_suffix": "P", "scalar": 10**15},
|
||||
12: {"long_suffix": "tera", "short_suffix": "T", "scalar": 10**12},
|
||||
9: {"long_suffix": "giga", "short_suffix": "G", "scalar": 10**9},
|
||||
6: {"long_suffix": "mega", "short_suffix": "M", "scalar": 10**6},
|
||||
3: {"long_suffix": "kilo", "short_suffix": "k", "scalar": 10**3},
|
||||
0: {"long_suffix": "", "short_suffix": "", "scalar": 10**0},
|
||||
-3: {"long_suffix": "milli", "short_suffix": "m", "scalar": 10**-3},
|
||||
-6: {"long_suffix": "micro", "short_suffix": "µ", "scalar": 10**-6},
|
||||
-9: {"long_suffix": "nano", "short_suffix": "n", "scalar": 10**-9},
|
||||
-12: {"long_suffix": "pico", "short_suffix": "p", "scalar": 10**-12},
|
||||
-15: {"long_suffix": "femto", "short_suffix": "f", "scalar": 10**-15},
|
||||
-18: {"long_suffix": "atto", "short_suffix": "a", "scalar": 10**-18},
|
||||
-21: {"long_suffix": "zepto", "short_suffix": "z", "scalar": 10**-21},
|
||||
-24: {"long_suffix": "yocto", "short_suffix": "y", "scalar": 10**-24},
|
||||
}
|
||||
exponent = int(math.floor(math.log10(abs(val)) / 3.0) * 3)
|
||||
return suffixes.get(exponent, None)
|
||||
|
||||
|
||||
def si_formatter(value):
|
||||
"""
|
||||
Return a triple of scaled value, short suffix, long suffix, or None if
|
||||
the value cannot be classified.
|
||||
"""
|
||||
classifier = si_classifier(value)
|
||||
if classifier == None:
|
||||
# Don't know how to classify this value
|
||||
return None
|
||||
|
||||
scaled = value / classifier["scalar"]
|
||||
return (scaled, classifier["short_suffix"], classifier["long_suffix"])
|
||||
|
||||
|
||||
def si_format(value, precision=4, long_form=False, separator=""):
|
||||
"""
|
||||
"SI prefix" formatted string: return a string with the given precision
|
||||
and an appropriate order-of-3-magnitudes suffix, e.g.:
|
||||
si_format(1001.0) => '1.00K'
|
||||
si_format(0.00000000123, long_form=True, separator=' ') => '1.230 nano'
|
||||
"""
|
||||
scaled, short_suffix, long_suffix = si_formatter(value)
|
||||
|
||||
if scaled == None:
|
||||
# Don't know how to format this value
|
||||
return value
|
||||
|
||||
suffix = long_suffix if long_form else short_suffix
|
||||
|
||||
if abs(scaled) < 10:
|
||||
precision = precision - 1
|
||||
elif abs(scaled) < 100:
|
||||
precision = precision - 2
|
||||
else:
|
||||
precision = precision - 3
|
||||
|
||||
return "{scaled:.{precision}f}{separator}{suffix}".format(
|
||||
scaled=scaled, precision=precision, separator=separator, suffix=suffix
|
||||
)
|
BIN
lnbits/extensions/gerty/static/gerty.jpg
Normal file
BIN
lnbits/extensions/gerty/static/gerty.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 29 KiB |
1099
lnbits/extensions/gerty/static/satoshi.json
Normal file
1099
lnbits/extensions/gerty/static/satoshi.json
Normal file
File diff suppressed because it is too large
Load Diff
1099
lnbits/extensions/gerty/static/satoshi_long.json
Normal file
1099
lnbits/extensions/gerty/static/satoshi_long.json
Normal file
File diff suppressed because it is too large
Load Diff
25
lnbits/extensions/gerty/templates/gerty/_api_docs.html
Normal file
25
lnbits/extensions/gerty/templates/gerty/_api_docs.html
Normal file
|
@ -0,0 +1,25 @@
|
|||
<q-card-section>
|
||||
<p>
|
||||
Gerty (your bitcoin assistant): Use the software Gerty or
|
||||
<a
|
||||
class="text-secondary"
|
||||
target="_blank"
|
||||
href="https://shop.lnbits.com/product/gerty-a-bitcoin-assistant"
|
||||
>hardware Gerty</a
|
||||
><br />
|
||||
<small>
|
||||
Created by,
|
||||
<a class="text-secondary" href="https://github.com/blackcoffeexbt"
|
||||
>Black Coffee</a
|
||||
>,
|
||||
<a class="text-secondary" href="https://github.com/benarc"
|
||||
>Ben Arc</a
|
||||
></small
|
||||
>
|
||||
</p>
|
||||
<a
|
||||
class="text-secondary"
|
||||
href="https://shop.lnbits.com/product/gerty-a-bitcoin-assistant"
|
||||
><img src="/gerty/static/gerty.jpg" style="max-width: 100%"
|
||||
/></a>
|
||||
</q-card-section>
|
244
lnbits/extensions/gerty/templates/gerty/gerty.html
Normal file
244
lnbits/extensions/gerty/templates/gerty/gerty.html
Normal file
|
@ -0,0 +1,244 @@
|
|||
{% extends "public.html" %} {% block toolbar_title %} Gerty: {% raw %}{{
|
||||
gertyname }}{% endraw %}{% endblock %}{% block page %} {% raw %}
|
||||
|
||||
<div
|
||||
class="q-pa-md row items-start q-gutter-md"
|
||||
v-if="fun_exchange_market_rate || fun_satoshi_quotes"
|
||||
>
|
||||
<q-card
|
||||
v-if="fun_exchange_market_rate"
|
||||
unelevated
|
||||
class="q-pa-sm"
|
||||
style="background: none !important"
|
||||
>
|
||||
<q-card-section class="text-h1 q-pa-none">
|
||||
<small> <b>{{fun_exchange_market_rate["amount"]}}</b></small>
|
||||
<small class="text-h4"
|
||||
>{{fun_exchange_market_rate["unit"].split(" ")[1]}}</small
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card
|
||||
v-if="fun_satoshi_quotes['quote']"
|
||||
unelevated
|
||||
class="q-pa-none text-body1 blockquote"
|
||||
style="background: none !important"
|
||||
>
|
||||
<blockquote class="text-right" style="max-width: 900px">
|
||||
<p>"{{fun_satoshi_quotes["quote"]}}"</p>
|
||||
<small>~ Satoshi {{fun_satoshi_quotes["date"]}}</small>
|
||||
</blockquote>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="q-pa-md row items-start q-gutter-md" v-if="lnbits_wallets_balance">
|
||||
<q-card
|
||||
class="q-pa-sm"
|
||||
v-for="(wallet, t) in lnbits_wallets_balance"
|
||||
:style="`background-color: ${wallet.color1} !important`"
|
||||
unelevated
|
||||
class="q-pa-none q-pa-sm"
|
||||
>
|
||||
<q-card-section class="text-h1 q-pa-none">
|
||||
<small> <b>{{wallet["amount"]}}</b></small>
|
||||
<small class="text-h4">({{wallet["name"]}})</small>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="q-pa-md row items-start q-gutter-md"
|
||||
v-if="dashboard_onchain || dashboard_mining || lightning_dashboard"
|
||||
>
|
||||
<q-card
|
||||
class="q-pa-sm"
|
||||
v-if="dashboard_onchain[0]"
|
||||
unelevated
|
||||
class="q-pa-sm"
|
||||
>
|
||||
<q-card-section>
|
||||
<div class="text-h6">Onchain</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<p v-for="(item, t) in dashboard_onchain">
|
||||
<b>{{item[0].value}}: </b>{{item[1].value}}
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card class="q-pa-sm" v-if="dashboard_mining" unelevated class="q-pa-sm">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Mining</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<p v-for="(item, t) in dashboard_mining">
|
||||
<b>{{item[0].value}}:</b> {{item[1].value}}
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card class="q-pa-sm" v-if="lightning_dashboard" unelevated class="q-pa-sm">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Lightning (Last 7 days)</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<p v-for="(item, t) in lightning_dashboard">
|
||||
<b>{{item[0].value}}:</b> {{item[1].value}}
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card class="q-pa-sm" v-if="url_checker" unelevated class="q-pa-sm">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Servers to check</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="row q-pb-md" v-for="(item, t) in url_checker">
|
||||
<div class="col-8">
|
||||
<small>
|
||||
<b style="word-wrap: break-word; max-width: 230px; display: block">
|
||||
<a class="text-secondary" class="text-primary">
|
||||
{{item[0].value}}
|
||||
</a>
|
||||
</b>
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<q-chip
|
||||
v-if="item[1].value < 300"
|
||||
square
|
||||
size="sm"
|
||||
color="green"
|
||||
text-color="white"
|
||||
icon="sentiment_satisfied"
|
||||
>
|
||||
{{item[1].value}}
|
||||
</q-chip>
|
||||
<q-chip
|
||||
v-else-if="item[1].value >= 300"
|
||||
square
|
||||
size="sm"
|
||||
color="yellow"
|
||||
text-color="white"
|
||||
icon="sentiment_dissatisfied"
|
||||
>
|
||||
{{item[1].value}}
|
||||
</q-chip>
|
||||
<q-chip
|
||||
v-else
|
||||
square
|
||||
size="sm"
|
||||
color="red"
|
||||
text-color="white"
|
||||
icon="sentiment_dissatisfied"
|
||||
>
|
||||
{{item[1].value}}
|
||||
</q-chip>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
{% endraw %} {% endblock %} {% block scripts %}
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
lnbits_wallets_balance: {},
|
||||
dashboard_onchain: {},
|
||||
fun_satoshi_quotes: {},
|
||||
fun_exchange_market_rate: {},
|
||||
gerty: [],
|
||||
gerty_id: `{{gerty}}`,
|
||||
gertyname: '',
|
||||
walletColors: [
|
||||
{first: '#3f51b5', second: '#1a237e'},
|
||||
{first: '#9c27b0', second: '#4a148c'},
|
||||
{first: '#e91e63', second: '#880e4f'},
|
||||
{first: '#009688', second: '#004d40'},
|
||||
{first: '#ff9800', second: '#e65100'},
|
||||
{first: '#2196f3', second: '#0d47a1'},
|
||||
{first: '#4caf50', second: '#1b5e20'}
|
||||
],
|
||||
gertywallets: []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getGertyInfo: async function () {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/gerty/api/v1/gerty/pages/${this.gerty_id}/${i}`
|
||||
)
|
||||
this.gerty[i] = data
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
}
|
||||
console.log(this.gerty)
|
||||
for (let i = 0; i < this.gerty.length; i++) {
|
||||
if (this.gerty[i].screen.group == 'lnbits_wallets_balance') {
|
||||
for (let q = 0; q < this.gerty[i].screen.areas.length; q++) {
|
||||
this.lnbits_wallets_balance[q] = {
|
||||
name: this.gerty[i].screen.areas[q][0].value,
|
||||
amount: this.gerty[i].screen.areas[q][1].value,
|
||||
color1: this.walletColors[q].first,
|
||||
color2: this.walletColors[q].second
|
||||
}
|
||||
this.gertyname = this.gerty[i].settings.name
|
||||
}
|
||||
}
|
||||
if (this.gerty[i].screen.group == 'url_checker') {
|
||||
this.url_checker = this.gerty[i].screen.areas
|
||||
this.gertyname = this.gerty[i].settings.name
|
||||
}
|
||||
if (this.gerty[i].screen.group == 'dashboard_onchain') {
|
||||
this.dashboard_onchain = this.gerty[i].screen.areas
|
||||
this.gertyname = this.gerty[i].settings.name
|
||||
}
|
||||
if (this.gerty[i].screen.group == 'dashboard_mining') {
|
||||
this.dashboard_mining = this.gerty[i].screen.areas
|
||||
this.gertyname = this.gerty[i].settings.name
|
||||
}
|
||||
if (this.gerty[i].screen.group == 'lightning_dashboard') {
|
||||
this.lightning_dashboard = this.gerty[i].screen.areas
|
||||
this.gertyname = this.gerty[i].settings.name
|
||||
}
|
||||
if (this.gerty[i].screen.group == 'fun_satoshi_quotes') {
|
||||
this.fun_satoshi_quotes['quote'] = this.gerty[
|
||||
i
|
||||
].screen.areas[0][0].value
|
||||
this.fun_satoshi_quotes['date'] = this.gerty[
|
||||
i
|
||||
].screen.areas[0][1].value
|
||||
this.gertyname = this.gerty[i].settings.name
|
||||
}
|
||||
if (this.gerty[i].screen.group == 'fun_exchange_market_rate') {
|
||||
this.fun_exchange_market_rate['unit'] = this.gerty[
|
||||
i
|
||||
].screen.areas[0][0].value
|
||||
this.fun_exchange_market_rate['amount'] = this.gerty[
|
||||
i
|
||||
].screen.areas[0][1].value
|
||||
this.gertyname = this.gerty[i].settings.name
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(this.getGertyInfo, 20000)
|
||||
this.$forceUpdate()
|
||||
return this.gerty
|
||||
}
|
||||
},
|
||||
created: async function () {
|
||||
await this.getGertyInfo()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
798
lnbits/extensions/gerty/templates/gerty/index.html
Normal file
798
lnbits/extensions/gerty/templates/gerty/index.html
Normal file
|
@ -0,0 +1,798 @@
|
|||
{% 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>
|
||||
<q-btn unelevated color="primary" @click="formDialog.show = true"
|
||||
>New Gerty
|
||||
</q-btn>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Gerty</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="gertys"
|
||||
row-key="id"
|
||||
:columns="gertysTable.columns"
|
||||
:pagination.sync="gertysTable.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"
|
||||
:class="`col__${col.name} text-truncate elipsis`"
|
||||
>
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></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="sentiment_satisfied"
|
||||
color="green"
|
||||
type="a"
|
||||
:href="props.row.gerty"
|
||||
target="_blank"
|
||||
>
|
||||
<q-tooltip>Launch software Gerty</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="code"
|
||||
color="pink"
|
||||
type="a"
|
||||
:href="props.row.gertyJson"
|
||||
target="_blank"
|
||||
>
|
||||
<q-tooltip>View Gerty API</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ (col.name == 'tip_options' && col.value ?
|
||||
JSON.parse(col.value).join(", ") : col.value) }}
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="updateformDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteGerty(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col-9">
|
||||
<h6 class="text-subtitle1 q-my-none">
|
||||
{{ SITE_TITLE }} Gerty extension
|
||||
</h6>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<q-btn
|
||||
flat
|
||||
label="Swagger API"
|
||||
type="a"
|
||||
href="../docs#/gerty"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "gerty/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="sendFormDataGerty" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.name"
|
||||
label="Name"
|
||||
placeholder="Son of Gerty"
|
||||
></q-input>
|
||||
<q-checkbox
|
||||
class="q-pl-md"
|
||||
size="xs"
|
||||
v-model="formDialog.data.display_preferences.fun_satoshi_quotes"
|
||||
val="xs"
|
||||
label="Satoshi Quotes"
|
||||
><q-tooltip
|
||||
>Displays random quotes from Satoshi</q-tooltip
|
||||
></q-checkbox
|
||||
>
|
||||
<q-checkbox
|
||||
class="q-pl-md"
|
||||
size="xs"
|
||||
v-model="formDialog.data.display_preferences.fun_exchange_market_rate"
|
||||
val="xs"
|
||||
label="Fiat to BTC price"
|
||||
></q-checkbox>
|
||||
<q-checkbox
|
||||
class="q-pl-md"
|
||||
size="xs"
|
||||
v-model="formDialog.data.display_preferences.lnbits_wallets_balance"
|
||||
val="xs"
|
||||
label="LNbits"
|
||||
></q-checkbox>
|
||||
<q-checkbox
|
||||
class="q-pl-md"
|
||||
size="xs"
|
||||
v-model="formDialog.data.display_preferences.dashboard_onchain"
|
||||
val="xs"
|
||||
label="Onchain"
|
||||
></q-checkbox>
|
||||
<q-checkbox
|
||||
class="q-pl-md"
|
||||
size="xs"
|
||||
v-model="formDialog.data.display_preferences.dashboard_mining"
|
||||
val="xs"
|
||||
label="Mining"
|
||||
></q-checkbox>
|
||||
<q-checkbox
|
||||
class="q-pl-md"
|
||||
size="xs"
|
||||
v-model="formDialog.data.display_preferences.lightning_dashboard"
|
||||
val="xs"
|
||||
label="Lightning"
|
||||
></q-checkbox>
|
||||
<q-checkbox
|
||||
class="q-pl-md"
|
||||
size="xs"
|
||||
v-model="formDialog.data.display_preferences.url_checker"
|
||||
val="xs"
|
||||
label="URL Checker"
|
||||
></q-checkbox>
|
||||
<br />
|
||||
<q-select
|
||||
v-if="formDialog.data.display_preferences.fun_exchange_market_rate"
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.exchange"
|
||||
:options="currencyOptions"
|
||||
label="Exchange rate"
|
||||
></q-select>
|
||||
|
||||
<q-select
|
||||
v-if="formDialog.data.display_preferences.lnbits_wallets_balance"
|
||||
filled
|
||||
multiple
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.lnbits_wallets"
|
||||
use-input
|
||||
use-chips
|
||||
multiple
|
||||
hide-dropdown-icon
|
||||
new-value-mode="add-unique"
|
||||
label="Invoice keys of wallets to watch"
|
||||
>
|
||||
<q-tooltip>Hit enter to add values</q-tooltip>
|
||||
</q-select>
|
||||
|
||||
<q-select
|
||||
v-if="formDialog.data.display_preferences.url_checker"
|
||||
filled
|
||||
multiple
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.urls"
|
||||
use-input
|
||||
use-chips
|
||||
multiple
|
||||
hide-dropdown-icon
|
||||
new-value-mode="add-unique"
|
||||
label="Urls to watch."
|
||||
>
|
||||
<q-tooltip>Hit enter to add values</q-tooltip>
|
||||
</q-select>
|
||||
|
||||
<q-toggle
|
||||
label="*Advanced"
|
||||
v-model="toggleStates.advanced"
|
||||
@input="setAdvanced"
|
||||
></q-toggle>
|
||||
<br />
|
||||
<q-input
|
||||
v-if="toggleStates.advanced"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.mempool_endpoint"
|
||||
label="Mempool link"
|
||||
class="q-pb-sm"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-if="toggleStates.advanced"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.refresh_time"
|
||||
label="Refresh time in seconds"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-tooltip
|
||||
>The amount of time in seconds between screen updates
|
||||
</q-tooltip>
|
||||
</q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="formDialog.data.name == null"
|
||||
type="submit"
|
||||
class="q-mr-md"
|
||||
v-if="!formDialog.data.id"
|
||||
>Create Gerty
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="formDialog.data.name == null"
|
||||
type="submit"
|
||||
>Update Gerty
|
||||
</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
var mapGerty = 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.gerty = ['/gerty/', obj.id].join('')
|
||||
obj.gertyJson = ['/gerty/api/v1/gerty/pages/', obj.id, '/0'].join('')
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
toggleStates: {
|
||||
fun: false,
|
||||
onchain: false,
|
||||
mempool: false,
|
||||
mining: false,
|
||||
lightning: false,
|
||||
advanced: false
|
||||
},
|
||||
oldToggleStates: {},
|
||||
gertys: [],
|
||||
currencyOptions: [
|
||||
'USD',
|
||||
'EUR',
|
||||
'GBP',
|
||||
'AED',
|
||||
'AFN',
|
||||
'ALL',
|
||||
'AMD',
|
||||
'ANG',
|
||||
'AOA',
|
||||
'ARS',
|
||||
'AUD',
|
||||
'AWG',
|
||||
'AZN',
|
||||
'BAM',
|
||||
'BBD',
|
||||
'BDT',
|
||||
'BGN',
|
||||
'BHD',
|
||||
'BIF',
|
||||
'BMD',
|
||||
'BND',
|
||||
'BOB',
|
||||
'BRL',
|
||||
'BSD',
|
||||
'BTN',
|
||||
'BWP',
|
||||
'BYN',
|
||||
'BZD',
|
||||
'CAD',
|
||||
'CDF',
|
||||
'CHF',
|
||||
'CLF',
|
||||
'CLP',
|
||||
'CNH',
|
||||
'CNY',
|
||||
'COP',
|
||||
'CRC',
|
||||
'CUC',
|
||||
'CUP',
|
||||
'CVE',
|
||||
'CZK',
|
||||
'DJF',
|
||||
'DKK',
|
||||
'DOP',
|
||||
'DZD',
|
||||
'EGP',
|
||||
'ERN',
|
||||
'ETB',
|
||||
'EUR',
|
||||
'FJD',
|
||||
'FKP',
|
||||
'GBP',
|
||||
'GEL',
|
||||
'GGP',
|
||||
'GHS',
|
||||
'GIP',
|
||||
'GMD',
|
||||
'GNF',
|
||||
'GTQ',
|
||||
'GYD',
|
||||
'HKD',
|
||||
'HNL',
|
||||
'HRK',
|
||||
'HTG',
|
||||
'HUF',
|
||||
'IDR',
|
||||
'ILS',
|
||||
'IMP',
|
||||
'INR',
|
||||
'IQD',
|
||||
'IRR',
|
||||
'IRT',
|
||||
'ISK',
|
||||
'JEP',
|
||||
'JMD',
|
||||
'JOD',
|
||||
'JPY',
|
||||
'KES',
|
||||
'KGS',
|
||||
'KHR',
|
||||
'KMF',
|
||||
'KPW',
|
||||
'KRW',
|
||||
'KWD',
|
||||
'KYD',
|
||||
'KZT',
|
||||
'LAK',
|
||||
'LBP',
|
||||
'LKR',
|
||||
'LRD',
|
||||
'LSL',
|
||||
'LYD',
|
||||
'MAD',
|
||||
'MDL',
|
||||
'MGA',
|
||||
'MKD',
|
||||
'MMK',
|
||||
'MNT',
|
||||
'MOP',
|
||||
'MRO',
|
||||
'MUR',
|
||||
'MVR',
|
||||
'MWK',
|
||||
'MXN',
|
||||
'MYR',
|
||||
'MZN',
|
||||
'NAD',
|
||||
'NGN',
|
||||
'NIO',
|
||||
'NOK',
|
||||
'NPR',
|
||||
'NZD',
|
||||
'OMR',
|
||||
'PAB',
|
||||
'PEN',
|
||||
'PGK',
|
||||
'PHP',
|
||||
'PKR',
|
||||
'PLN',
|
||||
'PYG',
|
||||
'QAR',
|
||||
'RON',
|
||||
'RSD',
|
||||
'RUB',
|
||||
'RWF',
|
||||
'SAR',
|
||||
'SBD',
|
||||
'SCR',
|
||||
'SDG',
|
||||
'SEK',
|
||||
'SGD',
|
||||
'SHP',
|
||||
'SLL',
|
||||
'SOS',
|
||||
'SRD',
|
||||
'SSP',
|
||||
'STD',
|
||||
'SVC',
|
||||
'SYP',
|
||||
'SZL',
|
||||
'THB',
|
||||
'TJS',
|
||||
'TMT',
|
||||
'TND',
|
||||
'TOP',
|
||||
'TRY',
|
||||
'TTD',
|
||||
'TWD',
|
||||
'TZS',
|
||||
'UAH',
|
||||
'UGX',
|
||||
'USD',
|
||||
'UYU',
|
||||
'UZS',
|
||||
'VEF',
|
||||
'VES',
|
||||
'VND',
|
||||
'VUV',
|
||||
'WST',
|
||||
'XAF',
|
||||
'XAG',
|
||||
'XAU',
|
||||
'XCD',
|
||||
'XDR',
|
||||
'XOF',
|
||||
'XPD',
|
||||
'XPF',
|
||||
'XPT',
|
||||
'YER',
|
||||
'ZAR',
|
||||
'ZMW',
|
||||
'ZWL'
|
||||
],
|
||||
gertysTable: {
|
||||
columns: [
|
||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||
{
|
||||
name: 'exchange',
|
||||
align: 'left',
|
||||
label: 'Exchange',
|
||||
field: 'exchange'
|
||||
},
|
||||
{
|
||||
name: 'mempool_endpoint',
|
||||
align: 'left',
|
||||
label: 'Mempool Endpoint',
|
||||
field: 'mempool_endpoint'
|
||||
},
|
||||
{name: 'id', align: 'left', label: 'Gerty ID', field: 'id'}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
data: {
|
||||
type: 'Mini Gerty',
|
||||
exchange: 'USD',
|
||||
utc_offset: new Date().getTimezoneOffset(),
|
||||
display_preferences: {
|
||||
dashboard: false,
|
||||
fun_satoshi_quotes: false,
|
||||
fun_exchange_market_rate: false,
|
||||
dashboard_onchain: false,
|
||||
mempool_recommended_fees: false,
|
||||
dashboard_mining: false,
|
||||
lightning_dashboard: false,
|
||||
onchain: false,
|
||||
onchain_difficulty_epoch_progress: false,
|
||||
onchain_difficulty_retarget_date: false,
|
||||
onchain_difficulty_blocks_remaining: false,
|
||||
onchain_difficulty_epoch_time_remaining: false,
|
||||
onchain_block_height: false,
|
||||
mempool_tx_count: false,
|
||||
mining_current_hash_rate: false,
|
||||
mining_current_difficulty: false,
|
||||
lnbits_wallets_balance: false,
|
||||
url_checker: false
|
||||
},
|
||||
lnbits_wallets: [],
|
||||
urls: [],
|
||||
mempool_endpoint: 'https://mempool.space',
|
||||
refresh_time: 300
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setAdvanced: function () {
|
||||
self = this
|
||||
self.formDialog.data.mempool_endpoint = 'https://mempool.space'
|
||||
self.formDialog.data.refresh_time = 300
|
||||
},
|
||||
setWallets: function () {
|
||||
self = this
|
||||
if (!self.formDialog.data.display_preferences.lnbits_wallets_balance) {
|
||||
self.formDialog.data.lnbits_wallets = []
|
||||
}
|
||||
},
|
||||
setUrls: function () {
|
||||
self = this
|
||||
if (!self.formDialog.data.display_preferences.url_checker) {
|
||||
self.formDialog.data.urls = []
|
||||
}
|
||||
},
|
||||
setOnchain: function () {
|
||||
self = this
|
||||
self.formDialog.data.display_preferences.onchain_difficulty_epoch_progress =
|
||||
self.toggleStates.onchain
|
||||
self.formDialog.data.display_preferences.onchain_difficulty_retarget_date =
|
||||
self.toggleStates.onchain
|
||||
self.formDialog.data.display_preferences.onchain_difficulty_blocks_remaining = !self
|
||||
.toggleStates.onchain
|
||||
self.formDialog.data.display_preferences.onchain_difficulty_epoch_time_remaining =
|
||||
self.toggleStates.onchain
|
||||
self.formDialog.data.display_preferences.onchain_block_height =
|
||||
self.toggleStates.onchain
|
||||
},
|
||||
setMining: function () {
|
||||
self = this
|
||||
self.formDialog.data.display_preferences.mining_current_hash_rate =
|
||||
self.toggleStates.mining
|
||||
self.formDialog.data.display_preferences.mining_current_difficulty =
|
||||
self.toggleStates.mining
|
||||
},
|
||||
closeFormDialog: function () {
|
||||
this.formDialog.data = {
|
||||
utc_offset: 0,
|
||||
lnbits_wallets: [],
|
||||
urls: [],
|
||||
mempool_endpoint: 'https://mempool.space',
|
||||
refresh_time: 300,
|
||||
display_preferences: {}
|
||||
}
|
||||
},
|
||||
getGertys: function () {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/gerty/api/v1/gerty?all_wallets=true',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.gertys = response.data.map(function (obj) {
|
||||
return mapGerty(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
updateformDialog: function (formId) {
|
||||
var gerty = _.findWhere(this.gertys, {id: formId})
|
||||
this.formDialog.data.id = gerty.id
|
||||
this.formDialog.data.name = gerty.name
|
||||
this.formDialog.data.type = gerty.type
|
||||
this.formDialog.data.utc_offset = gerty.utc_offset
|
||||
this.formDialog.data.lnbits_wallets = JSON.parse(gerty.lnbits_wallets)
|
||||
this.formDialog.data.urls = JSON.parse(gerty.urls)
|
||||
;(this.formDialog.data.exchange = gerty.exchange),
|
||||
(this.formDialog.data.mempool_endpoint = gerty.mempool_endpoint),
|
||||
(this.formDialog.data.refresh_time = gerty.refresh_time),
|
||||
(this.formDialog.data.display_preferences = JSON.parse(
|
||||
gerty.display_preferences
|
||||
)),
|
||||
(this.formDialog.show = true)
|
||||
},
|
||||
sendFormDataGerty: function () {
|
||||
if (this.formDialog.data.id) {
|
||||
this.updateGerty(
|
||||
this.g.user.wallets[0].adminkey,
|
||||
this.formDialog.data
|
||||
)
|
||||
} else {
|
||||
this.createGerty(
|
||||
this.g.user.wallets[0].adminkey,
|
||||
this.formDialog.data
|
||||
)
|
||||
}
|
||||
},
|
||||
createGerty: function () {
|
||||
if (
|
||||
this.formDialog.data.display_preferences.dashboard ||
|
||||
this.formDialog.data.display_preferences.dashboard_onchain ||
|
||||
this.formDialog.data.display_preferences.dashboard_onchain ||
|
||||
this.formDialog.data.display_preferences.lightning_dashboard ||
|
||||
this.formDialog.data.display_preferences.url_checker
|
||||
) {
|
||||
this.formDialog.data.type = 'Gerty'
|
||||
}
|
||||
var data = {
|
||||
name: this.formDialog.data.name,
|
||||
utc_offset: this.formDialog.data.utc_offset,
|
||||
type: this.formDialog.data.type,
|
||||
lnbits_wallets: JSON.stringify(this.formDialog.data.lnbits_wallets),
|
||||
urls: JSON.stringify(this.formDialog.data.urls),
|
||||
exchange: this.formDialog.data.exchange,
|
||||
mempool_endpoint: this.formDialog.data.mempool_endpoint,
|
||||
refresh_time: this.formDialog.data.refresh_time,
|
||||
display_preferences: JSON.stringify(
|
||||
this.formDialog.data.display_preferences
|
||||
)
|
||||
}
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
'/gerty/api/v1/gerty',
|
||||
this.g.user.wallets[0].inkey,
|
||||
data
|
||||
)
|
||||
.then(function (response) {
|
||||
self.formDialog.show = false
|
||||
self.gertys.push(mapGerty(response.data))
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
updateGerty: function (wallet, data) {
|
||||
var self = this
|
||||
if (
|
||||
this.formDialog.data.display_preferences.dashboard ||
|
||||
this.formDialog.data.display_preferences.dashboard_onchain ||
|
||||
this.formDialog.data.display_preferences.dashboard_onchain ||
|
||||
this.formDialog.data.display_preferences.lightning_dashboard ||
|
||||
this.formDialog.data.display_preferences.url_checker
|
||||
) {
|
||||
this.formDialog.data.type = 'Gerty'
|
||||
}
|
||||
data.utc_offset = this.formDialog.data.utc_offset
|
||||
data.type = this.formDialog.data.type
|
||||
data.lnbits_wallets = JSON.stringify(
|
||||
this.formDialog.data.lnbits_wallets
|
||||
)
|
||||
data.urls = JSON.stringify(this.formDialog.data.urls)
|
||||
data.display_preferences = JSON.stringify(
|
||||
this.formDialog.data.display_preferences
|
||||
)
|
||||
LNbits.api
|
||||
.request('PUT', '/gerty/api/v1/gerty/' + data.id, wallet, data)
|
||||
.then(function (response) {
|
||||
self.gertys = _.reject(self.gertys, function (obj) {
|
||||
return obj.id == data.id
|
||||
})
|
||||
self.formDialog.show = false
|
||||
self.gertys.push(mapGerty(response.data))
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteGerty: function (gertyId) {
|
||||
var self = this
|
||||
|
||||
var gerty = _.findWhere(self.gertys, {id: gertyId})
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this Gerty?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/gerty/api/v1/gerty/' + gertyId,
|
||||
_.findWhere(self.g.user.wallets, {id: gerty.wallet}).adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.gertys = _.reject(self.gertys, function (obj) {
|
||||
return obj.id == gertyId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
exportCSV: function () {
|
||||
LNbits.utils.exportCSV(this.gertysTable.columns, this.gertys)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isMiniGerty() {
|
||||
return this.formDialog.data.type == 'Mini Gerty'
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
this.getGertys()
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'formDialog.data.type': {
|
||||
handler(value) {
|
||||
if (value == 'Mini Gerty') {
|
||||
this.formDialog.data.display_preferences.dashboard = false
|
||||
this.formDialog.data.display_preferences.dashboard_onchain = false
|
||||
this.formDialog.data.display_preferences.dashboard_mining = false
|
||||
this.formDialog.data.display_preferences.lightning_dashboard = false
|
||||
this.formDialog.data.display_preferences.fun_satoshi_quotes = false
|
||||
this.formDialog.data.display_preferences.mempool_recommended_fees = false
|
||||
this.formDialog.data.display_preferences.onchain = false
|
||||
this.formDialog.data.display_preferences.url_checker = false
|
||||
}
|
||||
}
|
||||
},
|
||||
toggleStates: {
|
||||
handler(toggleStatesValue) {
|
||||
// Switch all the toggles in each section to the relevant state
|
||||
for (const [toggleKey, toggleValue] of Object.entries(
|
||||
toggleStatesValue
|
||||
)) {
|
||||
if (this.oldToggleStates[toggleKey] !== toggleValue) {
|
||||
for (const [dpKey, dpValue] of Object.entries(
|
||||
this.formDialog.data.display_preferences
|
||||
)) {
|
||||
if (dpKey.indexOf(toggleKey) === 0) {
|
||||
this.formDialog.data.display_preferences[dpKey] = toggleValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// This is a weird hack we have to use to get VueJS to persist the previous toggle state between
|
||||
// watches. VueJS passes the old and new values by reference so when comparing objects they
|
||||
// will have the same values unless we do this
|
||||
this.oldToggleStates = JSON.parse(JSON.stringify(toggleStatesValue))
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %} {% block styles %}
|
||||
<style>
|
||||
.col__display_preferences {
|
||||
border: 1px solid red;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
37
lnbits/extensions/gerty/views.py
Normal file
37
lnbits/extensions/gerty/views.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
import json
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists
|
||||
|
||||
from . import gerty_ext, gerty_renderer
|
||||
from .crud import get_gerty
|
||||
from .views_api import api_gerty_json
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@gerty_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
return gerty_renderer().TemplateResponse(
|
||||
"gerty/index.html", {"request": request, "user": user.dict()}
|
||||
)
|
||||
|
||||
|
||||
@gerty_ext.get("/{gerty_id}", response_class=HTMLResponse)
|
||||
async def display(request: Request, gerty_id):
|
||||
gerty = await get_gerty(gerty_id)
|
||||
if not gerty:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Gerty does not exist."
|
||||
)
|
||||
return gerty_renderer().TemplateResponse(
|
||||
"gerty/gerty.html", {"request": request, "gerty": gerty_id}
|
||||
)
|
191
lnbits/extensions/gerty/views_api.py
Normal file
191
lnbits/extensions/gerty/views_api.py
Normal file
|
@ -0,0 +1,191 @@
|
|||
import json
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus
|
||||
|
||||
import httpx
|
||||
from fastapi import Query
|
||||
from fastapi.params import Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from lnurl import decode as decode_lnurl
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_user, get_wallet_for_key
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.core.views.api import api_payment, api_wallet
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
from lnbits.utils.exchange_rates import satoshis_amount_as_fiat
|
||||
|
||||
from . import gerty_ext
|
||||
from .crud import (
|
||||
create_gerty,
|
||||
delete_gerty,
|
||||
get_gerty,
|
||||
get_gertys,
|
||||
get_mempool_info,
|
||||
update_gerty,
|
||||
)
|
||||
from .helpers import *
|
||||
from .models import Gerty, MempoolEndpoint
|
||||
|
||||
|
||||
@gerty_ext.get("/api/v1/gerty", status_code=HTTPStatus.OK)
|
||||
async def api_gertys(
|
||||
all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
wallet_ids = [wallet.wallet.id]
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
|
||||
|
||||
return [gerty.dict() for gerty in await get_gertys(wallet_ids)]
|
||||
|
||||
|
||||
@gerty_ext.post("/api/v1/gerty", status_code=HTTPStatus.CREATED)
|
||||
@gerty_ext.put("/api/v1/gerty/{gerty_id}", status_code=HTTPStatus.OK)
|
||||
async def api_link_create_or_update(
|
||||
data: Gerty,
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
gerty_id: str = Query(None),
|
||||
):
|
||||
logger.debug(data)
|
||||
if gerty_id:
|
||||
gerty = await get_gerty(gerty_id)
|
||||
if not gerty:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Gerty does not exist"
|
||||
)
|
||||
|
||||
if gerty.wallet != wallet.wallet.id:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Come on, seriously, this isn't your Gerty!",
|
||||
)
|
||||
|
||||
data.wallet = wallet.wallet.id
|
||||
gerty = await update_gerty(gerty_id, **data.dict())
|
||||
else:
|
||||
gerty = await create_gerty(wallet_id=wallet.wallet.id, data=data)
|
||||
|
||||
return {**gerty.dict()}
|
||||
|
||||
|
||||
@gerty_ext.delete("/api/v1/gerty/{gerty_id}")
|
||||
async def api_gerty_delete(
|
||||
gerty_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
gerty = await get_gerty(gerty_id)
|
||||
|
||||
if not gerty:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Gerty does not exist."
|
||||
)
|
||||
|
||||
if gerty.wallet != wallet.wallet.id:
|
||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your Gerty.")
|
||||
|
||||
await delete_gerty(gerty_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
@gerty_ext.get("/api/v1/gerty/satoshiquote", status_code=HTTPStatus.OK)
|
||||
async def api_gerty_satoshi():
|
||||
return await get_satoshi
|
||||
|
||||
|
||||
@gerty_ext.get("/api/v1/gerty/pages/{gerty_id}/{p}")
|
||||
async def api_gerty_json(gerty_id: str, p: int = None): # page number
|
||||
gerty = await get_gerty(gerty_id)
|
||||
|
||||
if not gerty:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Gerty does not exist."
|
||||
)
|
||||
|
||||
display_preferences = json.loads(gerty.display_preferences)
|
||||
|
||||
enabled_screen_count = 0
|
||||
|
||||
enabled_screens = []
|
||||
|
||||
for screen_slug in display_preferences:
|
||||
is_screen_enabled = display_preferences[screen_slug]
|
||||
if is_screen_enabled:
|
||||
enabled_screen_count += 1
|
||||
enabled_screens.append(screen_slug)
|
||||
|
||||
logger.debug("Screeens " + str(enabled_screens))
|
||||
data = await get_screen_data(p, enabled_screens, gerty)
|
||||
|
||||
next_screen_number = 0 if ((p + 1) >= enabled_screen_count) else p + 1
|
||||
|
||||
# get the sleep time
|
||||
sleep_time = gerty.refresh_time if gerty.refresh_time else 300
|
||||
utc_offset = gerty.utc_offset if gerty.utc_offset else 0
|
||||
if gerty_should_sleep(utc_offset):
|
||||
sleep_time_hours = 8
|
||||
sleep_time = 60 * 60 * sleep_time_hours
|
||||
|
||||
return {
|
||||
"settings": {
|
||||
"refreshTime": sleep_time,
|
||||
"requestTimestamp": get_next_update_time(sleep_time, utc_offset),
|
||||
"nextScreenNumber": next_screen_number,
|
||||
"showTextBoundRect": False,
|
||||
"name": gerty.name,
|
||||
},
|
||||
"screen": {
|
||||
"slug": get_screen_slug_by_index(p, enabled_screens),
|
||||
"group": get_screen_slug_by_index(p, enabled_screens),
|
||||
"title": data["title"],
|
||||
"areas": data["areas"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
###########CACHED MEMPOOL##############
|
||||
|
||||
|
||||
@gerty_ext.get("/api/v1/gerty/fees-recommended/{gerty_id}")
|
||||
async def api_gerty_get_fees_recommended(gerty_id):
|
||||
gerty = await get_gerty(gerty_id)
|
||||
return await get_mempool_info("fees_recommended", gerty)
|
||||
|
||||
|
||||
@gerty_ext.get("/api/v1/gerty/hashrate-1w/{gerty_id}")
|
||||
async def api_gerty_get_hashrate_1w(gerty_id):
|
||||
gerty = await get_gerty(gerty_id)
|
||||
return await get_mempool_info("hashrate_1w", gerty)
|
||||
|
||||
|
||||
@gerty_ext.get("/api/v1/gerty/hashrate-1m/{gerty_id}")
|
||||
async def api_gerty_get_hashrate_1m(gerty_id):
|
||||
gerty = await get_gerty(gerty_id)
|
||||
return await get_mempool_info("hashrate_1m", gerty)
|
||||
|
||||
|
||||
@gerty_ext.get("/api/v1/gerty/statistics/{gerty_id}")
|
||||
async def api_gerty_get_statistics(gerty_id):
|
||||
gerty = await get_gerty(gerty_id)
|
||||
return await get_mempool_info("statistics", gerty)
|
||||
|
||||
|
||||
@gerty_ext.get("/api/v1/gerty/difficulty-adjustment/{gerty_id}")
|
||||
async def api_gerty_get_difficulty_adjustment(gerty_id):
|
||||
gerty = await get_gerty(gerty_id)
|
||||
return await get_mempool_info("difficulty_adjustment", gerty)
|
||||
|
||||
|
||||
@gerty_ext.get("/api/v1/gerty/tip-height/{gerty_id}")
|
||||
async def api_gerty_get_tip_height(gerty_id):
|
||||
gerty = await get_gerty(gerty_id)
|
||||
return await get_mempool_info("tip_height", gerty)
|
||||
|
||||
|
||||
@gerty_ext.get("/api/v1/gerty/mempool/{gerty_id}")
|
||||
async def api_gerty_get_mempool(gerty_id):
|
||||
gerty = await get_gerty(gerty_id)
|
||||
return await get_mempool_info("mempool", gerty)
|
|
@ -1,3 +1,3 @@
|
|||
<h1>Hivemind</h1>
|
||||
|
||||
Placeholder for a future <a href="https://bitcoinhivemind.com/">Bitcoin Hivemind</a> extension.
|
||||
Placeholder for a future <a class="text-secondary" href="https://bitcoinhivemind.com/">Bitcoin Hivemind</a> extension.
|
||||
|
|
|
@ -6,9 +6,10 @@
|
|||
This extension is just a placeholder for now.
|
||||
</h5>
|
||||
<p>
|
||||
<a href="https://bitcoinhivemind.com/">Hivemind</a> is a Bitcoin sidechain
|
||||
project for a peer-to-peer oracle protocol that absorbs accurate data into
|
||||
a blockchain so that Bitcoin users can speculate in prediction markets.
|
||||
<a class="text-secondary" href="https://bitcoinhivemind.com/">Hivemind</a>
|
||||
is a Bitcoin sidechain project for a peer-to-peer oracle protocol that
|
||||
absorbs accurate data into a blockchain so that Bitcoin users can
|
||||
speculate in prediction markets.
|
||||
</p>
|
||||
<p>
|
||||
These markets have the potential to revolutionize the emergence of
|
||||
|
@ -17,8 +18,8 @@
|
|||
</p>
|
||||
<p>
|
||||
This extension will become fully operative when the
|
||||
<a href="https://drivechain.xyz/">BIP300</a> soft-fork gets activated and
|
||||
Bitcoin Hivemind is launched.
|
||||
<a class="text-secondary" href="https://drivechain.xyz/">BIP300</a>
|
||||
soft-fork gets activated and Bitcoin Hivemind is launched.
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
|
|
@ -251,10 +251,13 @@ block page %}
|
|||
@hide="closeQrCodeDialog"
|
||||
>
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card text-center">
|
||||
<a :href="'lightning:' + qrCodeDialog.data.payment_request">
|
||||
<a
|
||||
class="text-secondary"
|
||||
:href="'lightning:' + qrCodeDialog.data.payment_request"
|
||||
>
|
||||
<q-responsive :ratio="1" class="q-mx-xs">
|
||||
<qrcode
|
||||
:value="qrCodeDialog.data.payment_request"
|
||||
:value="'lightning:' + qrCodeDialog.data.payment_request.toUpperCase()"
|
||||
:options="{width: 400}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
|
|
|
@ -30,7 +30,7 @@ async def index(request: Request, user: User = Depends(check_user_exists)):
|
|||
|
||||
|
||||
@invoices_ext.get("/pay/{invoice_id}", response_class=HTMLResponse)
|
||||
async def index(request: Request, invoice_id: str):
|
||||
async def pay(request: Request, invoice_id: str):
|
||||
invoice = await get_invoice(invoice_id)
|
||||
|
||||
if not invoice:
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
To use this extension you need a Spotify client ID and client secret. You get
|
||||
these by creating an app in the Spotify developers dashboard
|
||||
<a
|
||||
class="text-secondary"
|
||||
style="color: #43a047"
|
||||
href="https://developer.spotify.com/dashboard/applications"
|
||||
>here
|
||||
|
@ -9,9 +10,14 @@
|
|||
<br /><br />Select the playlists you want people to be able to pay for, share
|
||||
the frontend page, profit :) <br /><br />
|
||||
Made by,
|
||||
<a style="color: #43a047" href="https://twitter.com/arcbtc">benarc</a>.
|
||||
Inspired by,
|
||||
<a
|
||||
class="text-secondary"
|
||||
style="color: #43a047"
|
||||
href="https://twitter.com/arcbtc"
|
||||
>benarc</a
|
||||
>. Inspired by,
|
||||
<a
|
||||
class="text-secondary"
|
||||
style="color: #43a047"
|
||||
href="https://twitter.com/pirosb3/status/1056263089128161280"
|
||||
>pirosb3</a
|
||||
|
|
|
@ -83,7 +83,7 @@
|
|||
<q-card class="q-pa-lg lnbits__dialog-card">
|
||||
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
||||
<qrcode
|
||||
:value="'lightning:' + receive.paymentReq"
|
||||
:value="'lightning:' + receive.paymentReq.toUpperCase()"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
|
|
|
@ -163,6 +163,7 @@
|
|||
<q-td auto-width>{{ props.row.name }}</q-td>
|
||||
<q-td class="text-center" auto-width>
|
||||
<a
|
||||
class="text-secondary"
|
||||
target="_blank"
|
||||
:href="'/wallet?usr=' + props.row.user + '&wal=' + props.row.wallet"
|
||||
>
|
||||
|
@ -191,7 +192,7 @@
|
|||
</q-select>
|
||||
</q-form>
|
||||
|
||||
<a :href="'lightning:' + livestream.lnurl">
|
||||
<a class="text-secondary" :href="'lightning:' + livestream.lnurl">
|
||||
<q-responsive :ratio="1" class="q-mx-sm">
|
||||
<qrcode
|
||||
:value="livestream.lnurl"
|
||||
|
@ -235,10 +236,10 @@
|
|||
<p class="text-subtitle1 q-my-none">
|
||||
Standalone QR Code for this track
|
||||
</p>
|
||||
<a :href="'lightning:' + trackDialog.data.lnurl">
|
||||
<a class="text-secondary" :href="'lightning:' + trackDialog.data.lnurl">
|
||||
<q-responsive :ratio="1" class="q-mx-sm">
|
||||
<qrcode
|
||||
:value="trackDialog.data.lnurl"
|
||||
:value="'lightning:' + trackDialog.data.lnurl.toUpperCase()"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
|
|
|
@ -13,13 +13,16 @@
|
|||
Charge people for using your domain name...<br />
|
||||
|
||||
<a
|
||||
class="text-secondary"
|
||||
href="https://github.com/lnbits/lnbits-legend/tree/main/lnbits/extensions/lnaddress"
|
||||
>More details</a
|
||||
>
|
||||
<br />
|
||||
<small>
|
||||
Created by,
|
||||
<a href="https://twitter.com/talvasconcelos">talvasconcelos</a></small
|
||||
<a class="text-secondary" href="https://twitter.com/talvasconcelos"
|
||||
>talvasconcelos</a
|
||||
></small
|
||||
>
|
||||
</p>
|
||||
</q-card-section>
|
||||
|
|
|
@ -184,10 +184,10 @@
|
|||
</q-card>
|
||||
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div class="text-center q-mb-lg">
|
||||
<a :href="'lightning:' + receive.paymentReq">
|
||||
<a class="text-secondary" :href="'lightning:' + receive.paymentReq">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode
|
||||
:value="paymentReq"
|
||||
:value="'lightning:' + paymentReq.toUpperCase()"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
|
|
|
@ -194,6 +194,7 @@
|
|||
<template v-slot:hint>
|
||||
Check extension
|
||||
<a
|
||||
class="text-secondary"
|
||||
href="https://github.com/lnbits/lnbits-legend/blob/main/lnbits/extensions/lnaddress/README.md"
|
||||
>documentation!</a
|
||||
>
|
||||
|
|
|
@ -9,8 +9,10 @@
|
|||
To access an LNbits wallet from a mobile phone,
|
||||
<ol>
|
||||
<li>
|
||||
Install either <a href="https://zeusln.app">Zeus</a> or
|
||||
<a href="https://bluewallet.io/">BlueWallet</a>;
|
||||
Install either
|
||||
<a class="text-secondary" href="https://zeusln.app">Zeus</a> or
|
||||
<a class="text-secondary" href="https://bluewallet.io/">BlueWallet</a
|
||||
>;
|
||||
</li>
|
||||
<li>
|
||||
Go to <code>Add a wallet / Import wallet</code> on BlueWallet or
|
||||
|
|
|
@ -3,16 +3,17 @@
|
|||
<q-card-section>
|
||||
<p>
|
||||
LndHub is a protocol invented by
|
||||
<a href="https://bluewallet.io/">BlueWallet</a> that allows mobile
|
||||
wallets to query payments and balances, generate invoices and make
|
||||
payments from accounts that exist on a server. The protocol is a
|
||||
collection of HTTP endpoints exposed through the internet.
|
||||
<a class="text-secondary" href="https://bluewallet.io/">BlueWallet</a>
|
||||
that allows mobile wallets to query payments and balances, generate
|
||||
invoices and make payments from accounts that exist on a server. The
|
||||
protocol is a collection of HTTP endpoints exposed through the internet.
|
||||
</p>
|
||||
<p>
|
||||
For a wallet that supports it, reading a QR code that contains the URL
|
||||
along with secret access credentials should enable access. Currently it
|
||||
is supported by <a href="https://zeusln.app">Zeus</a> and
|
||||
<a href="https://bluewallet.io/">BlueWallet</a>.
|
||||
is supported by
|
||||
<a class="text-secondary" href="https://zeusln.app">Zeus</a> and
|
||||
<a class="text-secondary" href="https://bluewallet.io/">BlueWallet</a>.
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
>
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="text-center">
|
||||
<a :href="selectedWallet[type]">
|
||||
<a class="text-secondary" :href="selectedWallet[type]">
|
||||
<q-responsive :ratio="1" class="q-mx-sm">
|
||||
<qrcode
|
||||
:value="selectedWallet[type]"
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
from binascii import unhexlify
|
||||
|
||||
from lnbits.bolt11 import Invoice
|
||||
|
||||
|
||||
def to_buffer(payment_hash: str):
|
||||
return {"type": "Buffer", "data": [b for b in unhexlify(payment_hash)]}
|
||||
return {"type": "Buffer", "data": [b for b in bytes.fromhex(payment_hash)]}
|
||||
|
||||
|
||||
def decoded_as_lndhub(invoice: Invoice):
|
||||
|
|
|
@ -12,7 +12,7 @@ from lnbits import bolt11
|
|||
from lnbits.core.crud import delete_expired_invoices, get_payments
|
||||
from lnbits.core.services import create_invoice, pay_invoice
|
||||
from lnbits.decorators import WalletTypeInfo
|
||||
from lnbits.settings import LNBITS_SITE_TITLE, WALLET
|
||||
from lnbits.settings import get_wallet_class, settings
|
||||
|
||||
from . import lndhub_ext
|
||||
from .decorators import check_wallet, require_admin_key
|
||||
|
@ -21,7 +21,7 @@ from .utils import decoded_as_lndhub, to_buffer
|
|||
|
||||
@lndhub_ext.get("/ext/getinfo")
|
||||
async def lndhub_getinfo():
|
||||
return {"alias": LNBITS_SITE_TITLE}
|
||||
return {"alias": settings.lnbits_site_title}
|
||||
|
||||
|
||||
class AuthData(BaseModel):
|
||||
|
@ -56,7 +56,7 @@ async def lndhub_addinvoice(
|
|||
_, pr = await create_invoice(
|
||||
wallet_id=wallet.wallet.id,
|
||||
amount=int(data.amt),
|
||||
memo=data.memo or LNBITS_SITE_TITLE,
|
||||
memo=data.memo or settings.lnbits_site_title,
|
||||
extra={"tag": "lndhub"},
|
||||
)
|
||||
except:
|
||||
|
@ -165,6 +165,7 @@ async def lndhub_getuserinvoices(
|
|||
limit: int = Query(20, ge=1, le=20),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
WALLET = get_wallet_class()
|
||||
for invoice in await get_payments(
|
||||
wallet_id=wallet.wallet.id,
|
||||
complete=False,
|
||||
|
|
|
@ -14,7 +14,10 @@
|
|||
paid support ticketing, PAYG language services, contact spam
|
||||
protection.<br />
|
||||
<small>
|
||||
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
|
||||
Created by,
|
||||
<a class="text-secondary" href="https://github.com/benarc"
|
||||
>Ben Arc</a
|
||||
></small
|
||||
>
|
||||
</p>
|
||||
</q-card-section>
|
||||
|
|
|
@ -64,10 +64,10 @@
|
|||
</q-card>
|
||||
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div class="text-center q-mb-lg">
|
||||
<a :href="'lightning:' + receive.paymentReq">
|
||||
<a class="text-secondary" :href="'lightning:' + receive.paymentReq">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode
|
||||
:value="paymentReq"
|
||||
:value="'lightning:' + paymentReq.toUpperCase()"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
|
|
|
@ -11,7 +11,10 @@ from .models import createLnurldevice, lnurldevicepayment, lnurldevices
|
|||
async def create_lnurldevice(
|
||||
data: createLnurldevice,
|
||||
) -> lnurldevices:
|
||||
lnurldevice_id = urlsafe_short_hash()
|
||||
if data.device == "pos" or data.device == "atm":
|
||||
lnurldevice_id = str(await get_lnurldeviceposcount())
|
||||
else:
|
||||
lnurldevice_id = urlsafe_short_hash()
|
||||
lnurldevice_key = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
|
@ -79,6 +82,17 @@ async def update_lnurldevice(lnurldevice_id: str, **kwargs) -> Optional[lnurldev
|
|||
return lnurldevices(**row) if row else None
|
||||
|
||||
|
||||
async def get_lnurldeviceposcount() -> int:
|
||||
row = await db.fetchall(
|
||||
"SELECT * FROM lnurldevice.lnurldevices WHERE device = ? OR device = ?",
|
||||
(
|
||||
"pos",
|
||||
"atm",
|
||||
),
|
||||
)
|
||||
return len(row) + 1
|
||||
|
||||
|
||||
async def get_lnurldevice(lnurldevice_id: str) -> lnurldevices:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM lnurldevice.lnurldevices WHERE id = ?", (lnurldevice_id,)
|
||||
|
|
|
@ -17,8 +17,8 @@ class createLnurldevice(BaseModel):
|
|||
wallet: str
|
||||
currency: str
|
||||
device: str
|
||||
profit: float
|
||||
amount: int
|
||||
profit: float = 0
|
||||
amount: Optional[int] = 0
|
||||
pin: int = 0
|
||||
profit1: float = 0
|
||||
amount1: int = 0
|
||||
|
|
|
@ -4,21 +4,25 @@
|
|||
For LNURL based Points of Sale, ATMs, and relay devices<br />
|
||||
Use with: <br />
|
||||
LNPoS
|
||||
<a href="https://lnbits.github.io/lnpos">
|
||||
<a class="text-secondary" href="https://lnbits.github.io/lnpos">
|
||||
https://lnbits.github.io/lnpos</a
|
||||
><br />
|
||||
bitcoinSwitch
|
||||
<a href="https://github.com/lnbits/bitcoinSwitch">
|
||||
<a class="text-secondary" href="https://github.com/lnbits/bitcoinSwitch">
|
||||
https://github.com/lnbits/bitcoinSwitch</a
|
||||
><br />
|
||||
FOSSA
|
||||
<a href="https://github.com/lnbits/fossa">
|
||||
<a class="text-secondary" href="https://github.com/lnbits/fossa">
|
||||
https://github.com/lnbits/fossa</a
|
||||
><br />
|
||||
<small>
|
||||
Created by, <a href="https://github.com/benarc">Ben Arc</a>,
|
||||
<a href="https://github.com/blackcoffeexbt">BC</a>,
|
||||
<a href="https://github.com/motorina0">Vlad Stan</a></small
|
||||
Created by,
|
||||
<a class="text-secondary" href="https://github.com/benarc">Ben Arc</a>,
|
||||
<a class="text-secondary" href="https://github.com/blackcoffeexbt">BC</a
|
||||
>,
|
||||
<a class="text-secondary" href="https://github.com/motorina0"
|
||||
>Vlad Stan</a
|
||||
></small
|
||||
>
|
||||
</p>
|
||||
</q-card-section>
|
||||
|
|
|
@ -476,7 +476,7 @@
|
|||
<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="lnurlValue"
|
||||
:value="'lightning:' + lnurlValue"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
|
|
|
@ -1,19 +1,18 @@
|
|||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits.db import SQLITE
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import CreatePayLinkData, PayLink
|
||||
|
||||
|
||||
async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
|
||||
link_id = urlsafe_short_hash()
|
||||
|
||||
returning = "" if db.type == SQLITE else "RETURNING ID"
|
||||
method = db.execute if db.type == SQLITE else db.fetchone
|
||||
|
||||
result = await (method)(
|
||||
result = await db.execute(
|
||||
f"""
|
||||
INSERT INTO lnurlp.pay_links (
|
||||
id,
|
||||
wallet,
|
||||
description,
|
||||
min,
|
||||
|
@ -29,10 +28,11 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
|
|||
currency,
|
||||
fiat_base_multiplier
|
||||
)
|
||||
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
{returning}
|
||||
""",
|
||||
(
|
||||
link_id,
|
||||
wallet_id,
|
||||
data.description,
|
||||
data.min,
|
||||
|
@ -47,10 +47,6 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
|
|||
data.fiat_base_multiplier,
|
||||
),
|
||||
)
|
||||
if db.type == SQLITE:
|
||||
link_id = result._result_proxy.lastrowid
|
||||
else:
|
||||
link_id = result[0]
|
||||
|
||||
link = await get_pay_link(link_id)
|
||||
assert link, "Newly created link couldn't be retrieved"
|
||||
|
|
|
@ -68,3 +68,76 @@ async def m005_webhook_headers_and_body(db):
|
|||
"""
|
||||
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_headers TEXT;")
|
||||
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_body TEXT;")
|
||||
|
||||
|
||||
async def m006_redux(db):
|
||||
"""
|
||||
Add UUID ID's to links and migrates existing data
|
||||
"""
|
||||
await db.execute("ALTER TABLE lnurlp.pay_links RENAME TO pay_links_old")
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE lnurlp.pay_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
min INTEGER NOT NULL,
|
||||
max INTEGER,
|
||||
currency TEXT,
|
||||
fiat_base_multiplier INTEGER DEFAULT 1,
|
||||
served_meta INTEGER NOT NULL,
|
||||
served_pr INTEGER NOT NULL,
|
||||
webhook_url TEXT,
|
||||
success_text TEXT,
|
||||
success_url TEXT,
|
||||
comment_chars INTEGER DEFAULT 0,
|
||||
webhook_headers TEXT,
|
||||
webhook_body TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
for row in [
|
||||
list(row) for row in await db.fetchall("SELECT * FROM lnurlp.pay_links_old")
|
||||
]:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO lnurlp.pay_links (
|
||||
id,
|
||||
wallet,
|
||||
description,
|
||||
min,
|
||||
served_meta,
|
||||
served_pr,
|
||||
webhook_url,
|
||||
success_text,
|
||||
success_url,
|
||||
currency,
|
||||
comment_chars,
|
||||
max,
|
||||
fiat_base_multiplier,
|
||||
webhook_headers,
|
||||
webhook_body
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
row[0],
|
||||
row[1],
|
||||
row[2],
|
||||
row[3],
|
||||
row[4],
|
||||
row[5],
|
||||
row[6],
|
||||
row[7],
|
||||
row[8],
|
||||
row[9],
|
||||
row[10],
|
||||
row[11],
|
||||
row[12],
|
||||
row[13],
|
||||
row[14],
|
||||
),
|
||||
)
|
||||
|
||||
await db.execute("DROP TABLE lnurlp.pay_links_old")
|
||||
|
|
|
@ -26,7 +26,7 @@ class CreatePayLinkData(BaseModel):
|
|||
|
||||
|
||||
class PayLink(BaseModel):
|
||||
id: int
|
||||
id: str
|
||||
wallet: str
|
||||
description: str
|
||||
min: float
|
||||
|
|
|
@ -2,8 +2,10 @@ import asyncio
|
|||
import json
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core import db as core_db
|
||||
from lnbits.core.crud import update_payment_extra
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.helpers import get_current_extension_name
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
@ -48,19 +50,31 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
if pay_link.webhook_headers:
|
||||
kwargs["headers"] = json.loads(pay_link.webhook_headers)
|
||||
|
||||
r = await client.post(pay_link.webhook_url, **kwargs)
|
||||
await mark_webhook_sent(payment, r.status_code)
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
await mark_webhook_sent(payment, -1)
|
||||
r: httpx.Response = await client.post(pay_link.webhook_url, **kwargs)
|
||||
await mark_webhook_sent(
|
||||
payment.payment_hash,
|
||||
r.status_code,
|
||||
r.is_success,
|
||||
r.reason_phrase,
|
||||
r.text,
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.error(ex)
|
||||
await mark_webhook_sent(
|
||||
payment.payment_hash, -1, False, "Unexpected Error", str(ex)
|
||||
)
|
||||
|
||||
|
||||
async def mark_webhook_sent(payment: Payment, status: int) -> None:
|
||||
payment.extra["wh_status"] = status
|
||||
async def mark_webhook_sent(
|
||||
payment_hash: str, status: int, is_success: bool, reason_phrase="", text=""
|
||||
) -> None:
|
||||
|
||||
await core_db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET extra = ?
|
||||
WHERE hash = ?
|
||||
""",
|
||||
(json.dumps(payment.extra), payment.payment_hash),
|
||||
await update_payment_extra(
|
||||
payment_hash,
|
||||
{
|
||||
"wh_status": status, # keep for backwards compability
|
||||
"wh_success": is_success,
|
||||
"wh_message": reason_phrase,
|
||||
"wh_response": text,
|
||||
},
|
||||
)
|
||||
|
|
|
@ -18,7 +18,10 @@
|
|||
</p>
|
||||
<small
|
||||
>Check
|
||||
<a href="https://github.com/fiatjaf/awesome-lnurl" target="_blank"
|
||||
<a
|
||||
class="text-secondary"
|
||||
href="https://github.com/fiatjaf/awesome-lnurl"
|
||||
target="_blank"
|
||||
>Awesome LNURL</a
|
||||
>
|
||||
for further information.</small
|
||||
|
|
|
@ -4,10 +4,10 @@
|
|||
<q-card class="q-pa-lg">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="text-center">
|
||||
<a href="lightning:{{ lnurl }}">
|
||||
<a class="text-secondary" href="lightning:{{ lnurl }}">
|
||||
<q-responsive :ratio="1" class="q-mx-md">
|
||||
<qrcode
|
||||
value="{{ lnurl }}"
|
||||
value="lightning:{{ lnurl }}"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
|
|
|
@ -284,7 +284,7 @@
|
|||
{% raw %}
|
||||
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
||||
<qrcode
|
||||
:value="qrCodeDialog.data.lnurl"
|
||||
:value="'lightning:' + qrCodeDialog.data.lnurl"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user