Merge branch 'main' into diagon-alley

This commit is contained in:
ben 2022-12-15 23:33:07 +00:00
commit d4d5b7a14d
148 changed files with 8262 additions and 1244 deletions

View file

@ -1,16 +1,22 @@
HOST=127.0.0.1 HOST=127.0.0.1
PORT=5000 PORT=5000
# uvicorn variable, allow https behind a proxy
# FORWARDED_ALLOW_IPS="*"
DEBUG=false DEBUG=false
# Allow users and admins by user IDs (comma separated list)
LNBITS_ALLOWED_USERS="" LNBITS_ALLOWED_USERS=""
LNBITS_ADMIN_USERS="" LNBITS_ADMIN_USERS=""
# Extensions only admin can access # Extensions only admin can access
LNBITS_ADMIN_EXTENSIONS="nostradmin" LNBITS_ADMIN_EXTENSIONS="nostradmin"
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet" LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
# csv ad image filepaths or urls, extensions can choose to honor # Ad space description
LNBITS_AD_SPACE="" # 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=""
# Hides wallet api, extensions can choose to honor # Hides wallet api, extensions can choose to honor
LNBITS_HIDE_API=false LNBITS_HIDE_API=false
@ -97,3 +103,8 @@ ECLAIR_PASS=eclairpw
# Enter /api in LightningTipBot to get your key # Enter /api in LightningTipBot to get your key
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
LNTIPS_API_ENDPOINT=https://ln.tips LNTIPS_API_ENDPOINT=https://ln.tips
# Cashu Mint
# Use a long-enough random (!) private key.
# Once set, you cannot change this key as for now.
CASHU_PRIVATE_KEY="SuperSecretPrivateKey"

31
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,31 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- LNbits version: [e.g. 0.9.2 or commit hash]
- Database [e.g. sqlite, postgres]
**Additional context**
Add any other context about the problem here.

View file

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[Feature request]"
labels: feature request
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View file

@ -0,0 +1,10 @@
---
name: Something else
about: Anything else that you need to say
title: ''
labels: ''
assignees: ''
---

View file

@ -8,6 +8,7 @@ RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH" ENV PATH="/root/.local/bin:$PATH"
WORKDIR /app WORKDIR /app
RUN mkdir -p lnbits/data
COPY . . COPY . .

View file

@ -9,53 +9,10 @@ nav_order: 2
Websockets Websockets
================= =================
`websockets` are a great way to add a two way instant data channel between server and client. This example was taken from the `copilot` extension, we create a websocket endpoint which can be restricted by `id`, then can feed it data to broadcast to any client on the socket using the `updater(extension_id, data)` function (`extension` has been used in place of an extension name, wreplace to your own extension): `websockets` are a great way to add a two way instant data channel between server and client.
LNbits has a useful in built websocket tool. With a websocket client connect to (obv change `somespecificid`) `wss://legend.lnbits.com/api/v1/ws/somespecificid` (you can use an online websocket tester). Now make a get to `https://legend.lnbits.com/api/v1/ws/somespecificid/somedata`. You can send data to that websocket by using `from lnbits.core.services import websocketUpdater` and the function `websocketUpdater("somespecificid", "somdata")`.
```sh
from fastapi import Request, WebSocket, WebSocketDisconnect
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket, extension_id: str):
await websocket.accept()
websocket.id = extension_id
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_personal_message(self, message: str, extension_id: str):
for connection in self.active_connections:
if connection.id == extension_id:
await connection.send_text(message)
async def broadcast(self, message: str):
for connection in self.active_connections:
await connection.send_text(message)
manager = ConnectionManager()
@extension_ext.websocket("/ws/{extension_id}", name="extension.websocket_by_id")
async def websocket_endpoint(websocket: WebSocket, extension_id: str):
await manager.connect(websocket, extension_id)
try:
while True:
data = await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(websocket)
async def updater(extension_id, data):
extension = await get_extension(extension_id)
if not extension:
return
await manager.send_personal_message(f"{data}", extension_id)
```
Example vue-js function for listening to the websocket: Example vue-js function for listening to the websocket:
@ -67,16 +24,16 @@ initWs: async function () {
document.domain + document.domain +
':' + ':' +
location.port + location.port +
'/extension/ws/' + '/api/v1/ws/' +
self.extension.id self.item.id
} else { } else {
localUrl = localUrl =
'ws://' + 'ws://' +
document.domain + document.domain +
':' + ':' +
location.port + location.port +
'/extension/ws/' + '/api/v1/ws/' +
self.extension.id self.item.id
} }
this.ws = new WebSocket(localUrl) this.ws = new WebSocket(localUrl)
this.ws.addEventListener('message', async ({data}) => { this.ws.addEventListener('message', async ({data}) => {

View file

@ -47,9 +47,20 @@ poetry run lnbits
# adding --debug in the start-up command above to help your troubleshooting and generate a more verbose output # adding --debug in the start-up command above to help your troubleshooting and generate a more verbose output
# Note that you have to add the line DEBUG=true in your .env file, too. # Note that you have to add the line DEBUG=true in your .env file, too.
``` ```
#### Updating the server
```
cd lnbits-legend/
# Stop LNbits with `ctrl + x`
git pull
poetry install --only main
# Start LNbits with `poetry run lnbits`
```
## Option 2: Nix ## Option 2: Nix
> note: currently not supported while we make some architectural changes on the path to leave beta
```sh ```sh
git clone https://github.com/lnbits/lnbits-legend.git git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend/ cd lnbits-legend/
@ -73,8 +84,8 @@ LNBITS_DATA_FOLDER=data LNBITS_BACKEND_WALLET_CLASS=LNbitsWallet LNBITS_ENDPOINT
```sh ```sh
git clone https://github.com/lnbits/lnbits-legend.git git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend/ cd lnbits-legend/
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3-venv' # ensure you have virtualenv installed, on debian/ubuntu 'apt install python3.9-venv'
python3 -m venv venv python3.9 -m venv venv
# If you have problems here, try `sudo apt install -y pkg-config libpq-dev` # If you have problems here, try `sudo apt install -y pkg-config libpq-dev`
./venv/bin/pip install -r requirements.txt ./venv/bin/pip install -r requirements.txt
# create the data folder and the .env file # create the data folder and the .env file
@ -104,7 +115,7 @@ docker run --detach --publish 5000:5000 --name lnbits-legend --volume ${PWD}/.en
## Option 5: Fly.io ## Option 5: Fly.io
Fly.io is a docker container hosting platform that has a generous free tier. You can host LNBits for free on Fly.io for personal use. Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use.
First, sign up for an account at [Fly.io](https://fly.io) (no credit card required). First, sign up for an account at [Fly.io](https://fly.io) (no credit card required).
@ -155,6 +166,7 @@ kill_timeout = 30
HOST="127.0.0.1" HOST="127.0.0.1"
PORT=5000 PORT=5000
LNBITS_FORCE_HTTPS=true LNBITS_FORCE_HTTPS=true
FORWARDED_ALLOW_IPS="*"
LNBITS_DATA_FOLDER="/data" LNBITS_DATA_FOLDER="/data"
${PUT_YOUR_LNBITS_ENV_VARS_HERE} ${PUT_YOUR_LNBITS_ENV_VARS_HERE}
@ -166,7 +178,7 @@ kill_timeout = 30
... ...
``` ```
Next, create a volume to store the sqlite database for LNBits. Be sure to choose the same region for the volume that you chose earlier. Next, create a volume to store the sqlite database for LNbits. Be sure to choose the same region for the volume that you chose earlier.
``` ```
fly volumes create lnbits_data --size 1 fly volumes create lnbits_data --size 1
@ -217,8 +229,8 @@ You need to edit the `.env` file.
```sh ```sh
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL= # add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
# postgres://<user>:<myPassword>@<host>/<lnbits> - alter line bellow with your user, password and db name # postgres://<user>:<myPassword>@<host>:<port>/<lnbits> - alter line bellow with your user, password and db name
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits" LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost:5432/lnbits"
# save and exit # save and exit
``` ```

View file

@ -8,7 +8,7 @@ import warnings
from http import HTTPStatus from http import HTTPStatus
from fastapi import FastAPI, Request 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.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@ -68,28 +68,6 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
g().config = lnbits.settings g().config = lnbits.settings
g().base_url = f"http://{lnbits.settings.HOST}:{lnbits.settings.PORT}" 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) app.add_middleware(GZipMiddleware, minimum_size=1000)
check_funding_source(app) check_funding_source(app)
@ -192,12 +170,33 @@ def register_async_tasks(app):
def register_exception_handlers(app: FastAPI): def register_exception_handlers(app: FastAPI):
@app.exception_handler(Exception) @app.exception_handler(Exception)
async def basic_error(request: Request, err): async def exception_handler(request: Request, exc: Exception):
logger.error("handled error", traceback.format_exc())
logger.error("ERROR:", err)
etype, _, tb = sys.exc_info() etype, _, tb = sys.exc_info()
traceback.print_exception(etype, err, tb) traceback.print_exception(etype, exc, tb)
exc = traceback.format_exc() 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 ( if (
request.headers request.headers
@ -205,12 +204,37 @@ def register_exception_handlers(app: FastAPI):
and "text/html" in request.headers["accept"] and "text/html" in request.headers["accept"]
): ):
return template_renderer().TemplateResponse( return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": err} "error.html",
{"request": request, "err": f"Error: {str(exc)}"},
) )
return JSONResponse( return JSONResponse(
status_code=HTTPStatus.NO_CONTENT, status_code=HTTPStatus.BAD_REQUEST,
content={"detail": err}, 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},
) )

View file

@ -65,8 +65,7 @@ async def migrate_databases():
(db_name, version, version), (db_name, version, version),
) )
async def run_migration(db, migrations_module): async def run_migration(db, migrations_module, db_name):
db_name = migrations_module.__name__.split(".")[-2]
for key, migrate in migrations_module.__dict__.items(): for key, migrate in migrations_module.__dict__.items():
match = match = matcher.match(key) match = match = matcher.match(key)
if match: if match:
@ -97,20 +96,24 @@ async def migrate_databases():
rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall() rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall()
current_versions = {row["db"]: row["version"] for row in rows} current_versions = {row["db"]: row["version"] for row in rows}
matcher = re.compile(r"^m(\d\d\d)_") matcher = re.compile(r"^m(\d\d\d)_")
await run_migration(conn, core_migrations) db_name = core_migrations.__name__.split(".")[-2]
await run_migration(conn, core_migrations, db_name)
for ext in get_valid_extensions(): for ext in get_valid_extensions():
try: try:
ext_migrations = importlib.import_module(
f"lnbits.extensions.{ext.code}.migrations" module_str = (
ext.migration_module or f"lnbits.extensions.{ext.code}.migrations"
) )
ext_migrations = importlib.import_module(module_str)
ext_db = importlib.import_module(f"lnbits.extensions.{ext.code}").db ext_db = importlib.import_module(f"lnbits.extensions.{ext.code}").db
db_name = ext.db_name or module_str.split(".")[-2]
except ImportError: except ImportError:
raise ImportError( raise ImportError(
f"Please make sure that the extension `{ext.code}` has a migrations file." f"Please make sure that the extension `{ext.code}` has a migrations file."
) )
async with ext_db.connect() as ext_conn: async with ext_db.connect() as ext_conn:
await run_migration(ext_conn, ext_migrations) await run_migration(ext_conn, ext_migrations, db_name)
logger.info("✔️ All migrations done.") logger.info("✔️ All migrations done.")

View file

@ -229,6 +229,24 @@ async def get_wallet_payment(
return Payment.from_row(row) if row else None return Payment.from_row(row) if row else None
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'
AND extra LIKE ?
AND extra LIKE ?
ORDER BY time DESC LIMIT {limit}
""",
(
f"%{ext_name}%",
f"%{ext_id}%",
),
)
return rows
async def get_payments( async def get_payments(
*, *,
wallet_id: Optional[str] = None, wallet_id: Optional[str] = None,
@ -321,36 +339,13 @@ async def delete_expired_invoices(
AND time < {db.timestamp_now} - {db.interval_seconds(2592000)} AND time < {db.timestamp_now} - {db.interval_seconds(2592000)}
""" """
) )
# then we delete all invoices whose expiry date is in the past
# then we delete all expired invoices, checking one by one
rows = await (conn or db).fetchall(
f"""
SELECT bolt11
FROM apipayments
WHERE pending = true
AND bolt11 IS NOT NULL
AND amount > 0 AND time < {db.timestamp_now} - {db.interval_seconds(86400)}
"""
)
logger.debug(f"Checking expiry of {len(rows)} invoices")
for i, (payment_request,) in enumerate(rows):
try:
invoice = bolt11.decode(payment_request)
except:
continue
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
if expiration_date > datetime.datetime.utcnow():
continue
logger.debug(
f"Deleting expired invoice {i}/{len(rows)}: {invoice.payment_hash} (expired: {expiration_date})"
)
await (conn or db).execute( await (conn or db).execute(
""" f"""
DELETE FROM apipayments DELETE FROM apipayments
WHERE pending = true AND hash = ? WHERE pending = true AND amount > 0
""", AND expiry < {db.timestamp_now}
(invoice.payment_hash,), """
) )
@ -378,12 +373,19 @@ async def create_payment(
# previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn) # previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
# assert previous_payment is None, "Payment already exists" # assert previous_payment is None, "Payment already exists"
try:
invoice = bolt11.decode(payment_request)
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
except:
# assume maximum bolt11 expiry of 31 days to be on the safe side
expiration_date = datetime.datetime.now() + datetime.timedelta(days=31)
await (conn or db).execute( await (conn or db).execute(
""" """
INSERT INTO apipayments INSERT INTO apipayments
(wallet, checking_id, bolt11, hash, preimage, (wallet, checking_id, bolt11, hash, preimage,
amount, pending, memo, fee, extra, webhook) amount, pending, memo, fee, extra, webhook, expiry)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
wallet_id, wallet_id,
@ -399,6 +401,7 @@ async def create_payment(
if extra and extra != {} and type(extra) is dict if extra and extra != {} and type(extra) is dict
else None, else None,
webhook, webhook,
db.datetime_to_timestamp(expiration_date),
), ),
) )

View file

@ -1,5 +1,10 @@
import datetime
from loguru import logger
from sqlalchemy.exc import OperationalError # type: ignore from sqlalchemy.exc import OperationalError # type: ignore
from lnbits import bolt11
async def m000_create_migrations_table(db): async def m000_create_migrations_table(db):
await db.execute( await db.execute(
@ -51,7 +56,7 @@ async def m001_initial(db):
f""" f"""
CREATE TABLE IF NOT EXISTS apipayments ( CREATE TABLE IF NOT EXISTS apipayments (
payhash TEXT NOT NULL, payhash TEXT NOT NULL,
amount INTEGER NOT NULL, amount {db.big_int} NOT NULL,
fee INTEGER NOT NULL DEFAULT 0, fee INTEGER NOT NULL DEFAULT 0,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
pending BOOLEAN NOT NULL, pending BOOLEAN NOT NULL,
@ -188,3 +193,68 @@ async def m005_balance_check_balance_notify(db):
); );
""" """
) )
async def m006_add_invoice_expiry_to_apipayments(db):
"""
Adds invoice expiry column to apipayments.
"""
try:
await db.execute("ALTER TABLE apipayments ADD COLUMN expiry TIMESTAMP")
except OperationalError:
pass
async def m007_set_invoice_expiries(db):
"""
Precomputes invoice expiry for existing pending incoming payments.
"""
try:
rows = await (
await db.execute(
f"""
SELECT bolt11, checking_id
FROM apipayments
WHERE pending = true
AND amount > 0
AND bolt11 IS NOT NULL
AND expiry IS NULL
AND time < {db.timestamp_now}
"""
)
).fetchall()
if len(rows):
logger.info(f"Mirgraion: Checking expiry of {len(rows)} invoices")
for i, (
payment_request,
checking_id,
) in enumerate(rows):
try:
invoice = bolt11.decode(payment_request)
if invoice.expiry is None:
continue
expiration_date = datetime.datetime.fromtimestamp(
invoice.date + invoice.expiry
)
logger.info(
f"Mirgraion: {i+1}/{len(rows)} setting expiry of invoice {invoice.payment_hash} to {expiration_date}"
)
await db.execute(
"""
UPDATE apipayments SET expiry = ?
WHERE checking_id = ? AND amount > 0
""",
(
db.datetime_to_timestamp(expiration_date),
checking_id,
),
)
except:
continue
except OperationalError:
# this is necessary now because it may be the case that this migration will
# run twice in some environments.
# 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

View file

@ -1,6 +1,8 @@
import datetime
import hashlib import hashlib
import hmac import hmac
import json import json
import time
from sqlite3 import Row from sqlite3 import Row
from typing import Dict, List, NamedTuple, Optional from typing import Dict, List, NamedTuple, Optional
@ -83,6 +85,7 @@ class Payment(BaseModel):
bolt11: str bolt11: str
preimage: str preimage: str
payment_hash: str payment_hash: str
expiry: Optional[float]
extra: Optional[Dict] = {} extra: Optional[Dict] = {}
wallet_id: str wallet_id: str
webhook: Optional[str] webhook: Optional[str]
@ -101,6 +104,7 @@ class Payment(BaseModel):
fee=row["fee"], fee=row["fee"],
memo=row["memo"], memo=row["memo"],
time=row["time"], time=row["time"],
expiry=row["expiry"],
wallet_id=row["wallet"], wallet_id=row["wallet"],
webhook=row["webhook"], webhook=row["webhook"],
webhook_status=row["webhook_status"], webhook_status=row["webhook_status"],
@ -128,6 +132,10 @@ class Payment(BaseModel):
def is_out(self) -> bool: def is_out(self) -> bool:
return self.amount < 0 return self.amount < 0
@property
def is_expired(self) -> bool:
return self.expiry < time.time() if self.expiry else False
@property @property
def is_uncheckable(self) -> bool: def is_uncheckable(self) -> bool:
return self.checking_id.startswith("internal_") return self.checking_id.startswith("internal_")
@ -170,7 +178,13 @@ class Payment(BaseModel):
logger.debug(f"Status: {status}") logger.debug(f"Status: {status}")
if self.is_out and status.failed: if self.is_in and status.pending and self.is_expired and self.expiry:
expiration_date = datetime.datetime.fromtimestamp(self.expiry)
logger.debug(
f"Deleting expired incoming pending payment {self.checking_id}: expired {expiration_date}"
)
await self.delete(conn)
elif self.is_out and status.failed:
logger.warning( logger.warning(
f"Deleting outgoing failed payment {self.checking_id}: {status}" f"Deleting outgoing failed payment {self.checking_id}: {status}"
) )

View file

@ -2,11 +2,11 @@ import asyncio
import json import json
from binascii import unhexlify from binascii import unhexlify
from io import BytesIO from io import BytesIO
from typing import Dict, Optional, Tuple from typing import Dict, List, Optional, Tuple
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
import httpx import httpx
from fastapi import Depends from fastapi import Depends, WebSocket, WebSocketDisconnect
from lnurl import LnurlErrorResponse from lnurl import LnurlErrorResponse
from lnurl import decode as decode_lnurl # type: ignore from lnurl import decode as decode_lnurl # type: ignore
from loguru import logger from loguru import logger
@ -382,3 +382,28 @@ 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 # WARN: this same value must be used for balance check and passed to WALLET.pay_invoice(), it may cause a vulnerability if the values differ
def fee_reserve(amount_msat: int) -> int: def fee_reserve(amount_msat: int) -> int:
return max(int(RESERVE_FEE_MIN), int(amount_msat * RESERVE_FEE_PERCENT / 100.0)) return max(int(RESERVE_FEE_MIN), int(amount_msat * RESERVE_FEE_PERCENT / 100.0))
class WebsocketConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
logger.debug(websocket)
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_data(self, message: str, item_id: str):
for connection in self.active_connections:
if connection.path_params["item_id"] == item_id:
await connection.send_text(message)
websocketManager = WebsocketConnectionManager()
async def websocketUpdater(item_id, data):
return await websocketManager.send_data(f"{data}", item_id)

View file

@ -183,6 +183,23 @@
<div class="col q-pl-md">&nbsp;</div> <div class="col q-pl-md">&nbsp;</div>
</div> </div>
</div> </div>
{% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %}
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
<q-btn flat color="secondary" class="full-width q-mb-md"
>{{ AD_TITLE }}</q-btn
>
<a href="{{ AD[0] }}" class="q-ma-md">
<img
v-if="($q.dark.isActive)"
src="{{ AD[1] }}"
style="max-width: 90%"
/>
<img v-else src="{{ AD[2] }}" style="max-width: 90%" />
</a>
</div>
{% endfor %} {% endif %}
</div> </div>
</div> </div>
</div> </div>

View file

@ -388,9 +388,14 @@
{% endif %} {% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD = {% endif %} {% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD =
ADS.split(';') %} ADS.split(';') %}
<q-card> <q-card>
<a href="{{ AD[0] }}" <q-card-section>
><img width="100%" src="{{ AD[1] }}" <h6 class="text-subtitle1 q-mt-none q-mb-sm">{{ AD_TITLE }}</h6>
/></a> </q-card </q-card-section>
<q-card-section class="q-pa-none">
<a href="{{ AD[0] }}" class="q-ma-md">
<img v-if="($q.dark.isActive)" src="{{ AD[1] }}" />
<img v-else src="{{ AD[2] }}" />
</a> </q-card-section></q-card
>{% endfor %} {% endif %} >{% endfor %} {% endif %}
</div> </div>
</div> </div>

View file

@ -12,7 +12,15 @@ from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
import async_timeout import async_timeout
import httpx import httpx
import pyqrcode import pyqrcode
from fastapi import Depends, Header, Query, Request from fastapi import (
Depends,
Header,
Query,
Request,
Response,
WebSocket,
WebSocketDisconnect,
)
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from fastapi.params import Body from fastapi.params import Body
from loguru import logger from loguru import logger
@ -56,6 +64,8 @@ from ..services import (
create_invoice, create_invoice,
pay_invoice, pay_invoice,
perform_lnurlauth, perform_lnurlauth,
websocketManager,
websocketUpdater,
) )
from ..tasks import api_invoice_listeners from ..tasks import api_invoice_listeners
@ -155,30 +165,29 @@ class CreateInvoiceData(BaseModel):
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet): async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
if data.description_hash: if data.description_hash or data.unhashed_description:
try: try:
description_hash = binascii.unhexlify(data.description_hash) description_hash = (
binascii.unhexlify(data.description_hash)
if data.description_hash
else b""
)
unhashed_description = (
binascii.unhexlify(data.unhashed_description)
if data.unhashed_description
else b""
)
except binascii.Error: except binascii.Error:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="'description_hash' must be a valid hex string", detail="'description_hash' and 'unhashed_description' must be a valid hex strings",
) )
unhashed_description = b""
memo = ""
elif data.unhashed_description:
try:
unhashed_description = binascii.unhexlify(data.unhashed_description)
except binascii.Error:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'unhashed_description' must be a valid hex string",
)
description_hash = b""
memo = "" memo = ""
else: else:
description_hash = b"" description_hash = b""
unhashed_description = b"" unhashed_description = b""
memo = data.memo or LNBITS_SITE_TITLE memo = data.memo or LNBITS_SITE_TITLE
if data.unit == "sat": if data.unit == "sat":
amount = int(data.amount) amount = int(data.amount)
else: else:
@ -585,8 +594,8 @@ class DecodePayment(BaseModel):
data: str data: str
@core_app.post("/api/v1/payments/decode") @core_app.post("/api/v1/payments/decode", status_code=HTTPStatus.OK)
async def api_payments_decode(data: DecodePayment): async def api_payments_decode(data: DecodePayment, response: Response):
payment_str = data.data payment_str = data.data
try: try:
if payment_str[:5] == "LNURL": if payment_str[:5] == "LNURL":
@ -607,6 +616,7 @@ async def api_payments_decode(data: DecodePayment):
"min_final_cltv_expiry": invoice.min_final_cltv_expiry, "min_final_cltv_expiry": invoice.min_final_cltv_expiry,
} }
except: except:
response.status_code = HTTPStatus.BAD_REQUEST
return {"message": "Failed to decode"} return {"message": "Failed to decode"}
@ -676,7 +686,7 @@ async def img(request: Request, data):
) )
@core_app.get("/api/v1/audit/") @core_app.get("/api/v1/audit")
async def api_auditor(wallet: WalletTypeInfo = Depends(get_key_type)): async def api_auditor(wallet: WalletTypeInfo = Depends(get_key_type)):
if wallet.wallet.user not in LNBITS_ADMIN_USERS: if wallet.wallet.user not in LNBITS_ADMIN_USERS:
raise HTTPException( raise HTTPException(
@ -692,8 +702,39 @@ async def api_auditor(wallet: WalletTypeInfo = Depends(get_key_type)):
node_balance, delta = None, None node_balance, delta = None, None
return { return {
"node_balance_msats": node_balance, "node_balance_msats": int(node_balance),
"lnbits_balance_msats": total_balance, "lnbits_balance_msats": int(total_balance),
"delta_msats": delta, "delta_msats": int(delta),
"timestamp": int(time.time()), "timestamp": int(time.time()),
} }
##################UNIVERSAL WEBSOCKET MANAGER########################
@core_app.websocket("/api/v1/ws/{item_id}")
async def websocket_connect(websocket: WebSocket, item_id: str):
await websocketManager.connect(websocket)
try:
while True:
data = await websocket.receive_text()
except WebSocketDisconnect:
websocketManager.disconnect(websocket)
@core_app.post("/api/v1/ws/{item_id}")
async def websocket_update_post(item_id: str, data: str):
try:
await websocketUpdater(item_id, data)
return {"sent": True, "data": data}
except:
return {"sent": False, "data": data}
@core_app.get("/api/v1/ws/{item_id}/{data}")
async def websocket_update_get(item_id: str, data: str):
try:
await websocketUpdater(item_id, data)
return {"sent": True, "data": data}
except:
return {"sent": False, "data": data}

View file

@ -1,6 +1,7 @@
import asyncio import asyncio
import datetime import datetime
import os import os
import re
import time import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Optional from typing import Optional
@ -28,6 +29,13 @@ class Compat:
return f"{seconds}" return f"{seconds}"
return "<nothing>" return "<nothing>"
def datetime_to_timestamp(self, date: datetime.datetime):
if self.type in {POSTGRES, COCKROACH}:
return date.strftime("%Y-%m-%d %H:%M:%S")
elif self.type == SQLITE:
return time.mktime(date.timetuple())
return "<nothing>"
@property @property
def timestamp_now(self) -> str: def timestamp_now(self) -> str:
if self.type in {POSTGRES, COCKROACH}: if self.type in {POSTGRES, COCKROACH}:
@ -73,18 +81,40 @@ class Connection(Compat):
query = query.replace("?", "%s") query = query.replace("?", "%s")
return query return query
def rewrite_values(self, values):
# strip html
CLEANR = re.compile("<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});")
def cleanhtml(raw_html):
if isinstance(raw_html, str):
cleantext = re.sub(CLEANR, "", raw_html)
return cleantext
else:
return raw_html
# tuple to list and back to tuple
value_list = [values] if isinstance(values, str) else list(values)
values = tuple([cleanhtml(l) for l in value_list])
return values
async def fetchall(self, query: str, values: tuple = ()) -> list: async def fetchall(self, query: str, values: tuple = ()) -> list:
result = await self.conn.execute(self.rewrite_query(query), values) result = await self.conn.execute(
self.rewrite_query(query), self.rewrite_values(values)
)
return await result.fetchall() return await result.fetchall()
async def fetchone(self, query: str, values: tuple = ()): async def fetchone(self, query: str, values: tuple = ()):
result = await self.conn.execute(self.rewrite_query(query), values) result = await self.conn.execute(
self.rewrite_query(query), self.rewrite_values(values)
)
row = await result.fetchone() row = await result.fetchone()
await result.close() await result.close()
return row return row
async def execute(self, query: str, values: tuple = ()): async def execute(self, query: str, values: tuple = ()):
return await self.conn.execute(self.rewrite_query(query), values) return await self.conn.execute(
self.rewrite_query(query), self.rewrite_values(values)
)
class Database(Compat): class Database(Compat):
@ -102,6 +132,8 @@ class Database(Compat):
import psycopg2 # type: ignore import psycopg2 # type: ignore
def _parse_timestamp(value, _): def _parse_timestamp(value, _):
if value is None:
return None
f = "%Y-%m-%d %H:%M:%S.%f" f = "%Y-%m-%d %H:%M:%S.%f"
if not "." in value: if not "." in value:
f = "%Y-%m-%d %H:%M:%S" f = "%Y-%m-%d %H:%M:%S"
@ -126,14 +158,7 @@ class Database(Compat):
psycopg2.extensions.register_type( psycopg2.extensions.register_type(
psycopg2.extensions.new_type( psycopg2.extensions.new_type(
(1184, 1114), (1184, 1114), "TIMESTAMP2INT", _parse_timestamp
"TIMESTAMP2INT",
_parse_timestamp
# lambda value, curs: time.mktime(
# datetime.datetime.strptime(
# value, "%Y-%m-%d %H:%M:%S.%f"
# ).timetuple()
# ),
) )
) )
else: else:

View file

@ -95,4 +95,4 @@ async def api_bleskomat_delete(
) )
await delete_bleskomat(bleskomat_id) await delete_bleskomat(bleskomat_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT

View file

@ -6,7 +6,7 @@ This extension allows you to link your Bolt Card (or other compatible NXP NTAG d
**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!*** **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 [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).
## About the keys ## About the keys
@ -25,12 +25,12 @@ So far, regarding the keys, the app can only write a new key set on an empty car
- Read the card with the app. Note UID so you can fill it in the extension later. - 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}` - 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. - `{external_id}` should be replaced with the External ID found in the LNbits dialog.
- Add new card in the extension. - Add new card in the extension.
- Set a max sats per transaction. Any transaction greater than this amount will be rejected. - Set a max sats per transaction. Any transaction greater than this amount will be rejected.
- Set a max sats per day. After the card spends this amount of sats in a day, additional transactions will be rejected. - Set a max sats per day. After the card spends this amount of sats in a day, additional transactions will be rejected.
- Set a card name. This is just for your reference inside LNBits. - 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. - 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. - 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.
- Advanced Options - Advanced Options

View file

@ -380,7 +380,11 @@
<strong>Lock key:</strong> {{ qrCodeDialog.data.k0 }}<br /> <strong>Lock key:</strong> {{ qrCodeDialog.data.k0 }}<br />
<strong>Meta key:</strong> {{ qrCodeDialog.data.k1 }}<br /> <strong>Meta key:</strong> {{ qrCodeDialog.data.k1 }}<br />
<strong>File key:</strong> {{ qrCodeDialog.data.k2 }}<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 />
</p> </p>
<br /> <br />
<q-btn <q-btn
unelevated unelevated

View file

@ -129,7 +129,7 @@ async def api_card_delete(card_id, wallet: WalletTypeInfo = Depends(require_admi
raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN) raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
await delete_card(card_id) await delete_card(card_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
@boltcards_ext.get("/api/v1/hits") @boltcards_ext.get("/api/v1/hits")

View file

@ -0,0 +1,11 @@
# Cashu
## Create ecash mint for pegging in/out of ecash
### Usage
1. Enable extension
2. Create a Mint
3. Share wallet

View file

@ -0,0 +1,48 @@
import asyncio
from environs import Env # type: ignore
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_cashu")
import sys
cashu_static_files = [
{
"path": "/cashu/static",
"app": StaticFiles(directory="lnbits/extensions/cashu/static"),
"name": "cashu_static",
}
]
from cashu.mint.ledger import Ledger
env = Env()
env.read_env()
ledger = Ledger(
db=db,
seed=env.str("CASHU_PRIVATE_KEY", default="SuperSecretPrivateKey"),
derivation_path="0/0/0/1",
)
cashu_ext: APIRouter = APIRouter(prefix="/cashu", tags=["cashu"])
def cashu_renderer():
return template_renderer(["lnbits/extensions/cashu/templates"])
from .tasks import startup_cashu_mint, wait_for_paid_invoices
from .views import * # noqa
from .views_api import * # noqa
def cashu_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(startup_cashu_mint))
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))

View file

@ -0,0 +1,7 @@
{
"name": "Cashu",
"short_description": "Ecash mint and wallet",
"icon": "account_balance",
"contributors": ["calle", "vlad", "arcbtc"],
"hidden": false
}

View file

@ -0,0 +1,63 @@
import os
import random
import time
from binascii import hexlify, unhexlify
from typing import Any, List, Optional, Union
from cashu.core.base import MintKeyset
from embit import bip32, bip39, ec, script
from embit.networks import NETWORKS
from loguru import logger
from lnbits.db import Connection, Database
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import Cashu, Pegs, Promises, Proof
async def create_cashu(
cashu_id: str, keyset_id: str, wallet_id: str, data: Cashu
) -> Cashu:
await db.execute(
"""
INSERT INTO cashu.cashu (id, wallet, name, tickershort, fraction, maxsats, coins, keyset_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
cashu_id,
wallet_id,
data.name,
data.tickershort,
data.fraction,
data.maxsats,
data.coins,
keyset_id,
),
)
cashu = await get_cashu(cashu_id)
assert cashu, "Newly created cashu couldn't be retrieved"
return cashu
async def get_cashu(cashu_id) -> Optional[Cashu]:
row = await db.fetchone("SELECT * FROM cashu.cashu WHERE id = ?", (cashu_id,))
return Cashu(**row) if row else None
async def get_cashus(wallet_ids: Union[str, List[str]]) -> List[Cashu]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM cashu.cashu WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Cashu(**row) for row in rows]
async def delete_cashu(cashu_id) -> None:
await db.execute("DELETE FROM cashu.cashu WHERE id = ?", (cashu_id,))

View file

@ -0,0 +1,33 @@
async def m001_initial(db):
"""
Initial cashu table.
"""
await db.execute(
"""
CREATE TABLE cashu.cashu (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
name TEXT NOT NULL,
tickershort TEXT DEFAULT 'sats',
fraction BOOL,
maxsats INT,
coins INT,
keyset_id TEXT NOT NULL,
issued_sat INT
);
"""
)
"""
Initial cashus table.
"""
await db.execute(
"""
CREATE TABLE cashu.pegs (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
inout BOOL NOT NULL,
amount INT
);
"""
)

View file

@ -0,0 +1,147 @@
from sqlite3 import Row
from typing import List, Union
from fastapi import Query
from pydantic import BaseModel
class Cashu(BaseModel):
id: str = Query(None)
name: str = Query(None)
wallet: str = Query(None)
tickershort: str = Query(None)
fraction: bool = Query(None)
maxsats: int = Query(0)
coins: int = Query(0)
keyset_id: str = Query(None)
@classmethod
def from_row(cls, row: Row):
return cls(**dict(row))
class Pegs(BaseModel):
id: str
wallet: str
inout: str
amount: str
@classmethod
def from_row(cls, row: Row):
return cls(**dict(row))
class PayLnurlWData(BaseModel):
lnurl: str
class Promises(BaseModel):
id: str
amount: int
B_b: str
C_b: str
cashu_id: str
class Proof(BaseModel):
amount: int
secret: str
C: str
reserved: bool = False # whether this proof is reserved for sending
send_id: str = "" # unique ID of send attempt
time_created: str = ""
time_reserved: str = ""
@classmethod
def from_row(cls, row: Row):
return cls(
amount=row[0],
C=row[1],
secret=row[2],
reserved=row[3] or False,
send_id=row[4] or "",
time_created=row[5] or "",
time_reserved=row[6] or "",
)
@classmethod
def from_dict(cls, d: dict):
assert "secret" in d, "no secret in proof"
assert "amount" in d, "no amount in proof"
return cls(
amount=d.get("amount"),
C=d.get("C"),
secret=d.get("secret"),
reserved=d.get("reserved") or False,
send_id=d.get("send_id") or "",
time_created=d.get("time_created") or "",
time_reserved=d.get("time_reserved") or "",
)
def to_dict(self):
return dict(amount=self.amount, secret=self.secret, C=self.C)
def __getitem__(self, key):
return self.__getattribute__(key)
def __setitem__(self, key, val):
self.__setattr__(key, val)
class Proofs(BaseModel):
"""TODO: Use this model"""
proofs: List[Proof]
class Invoice(BaseModel):
amount: int
pr: str
hash: str
issued: bool = False
@classmethod
def from_row(cls, row: Row):
return cls(
amount=int(row[0]),
pr=str(row[1]),
hash=str(row[2]),
issued=bool(row[3]),
)
class BlindedMessage(BaseModel):
amount: int
B_: str
class BlindedSignature(BaseModel):
amount: int
C_: str
@classmethod
def from_dict(cls, d: dict):
return cls(
amount=d["amount"],
C_=d["C_"],
)
class MintPayloads(BaseModel):
blinded_messages: List[BlindedMessage] = []
class SplitPayload(BaseModel):
proofs: List[Proof]
amount: int
output_data: MintPayloads
class CheckPayload(BaseModel):
proofs: List[Proof]
class MeltPayload(BaseModel):
proofs: List[Proof]
amount: int
invoice: str

View file

@ -0,0 +1,37 @@
function unescapeBase64Url(str) {
return (str + '==='.slice((str.length + 3) % 4))
.replace(/-/g, '+')
.replace(/_/g, '/')
}
function escapeBase64Url(str) {
return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
const uint8ToBase64 = (function (exports) {
'use strict'
var fromCharCode = String.fromCharCode
var encode = function encode(uint8array) {
var output = []
for (var i = 0, length = uint8array.length; i < length; i++) {
output.push(fromCharCode(uint8array[i]))
}
return btoa(output.join(''))
}
var asCharCode = function asCharCode(c) {
return c.charCodeAt(0)
}
var decode = function decode(chars) {
return Uint8Array.from(atob(chars), asCharCode)
}
exports.decode = decode
exports.encode = encode
return exports
})({})

View file

@ -0,0 +1,39 @@
async function hashToCurve(secretMessage) {
console.log(
'### secretMessage',
nobleSecp256k1.utils.bytesToHex(secretMessage)
)
let point
while (!point) {
const hash = await nobleSecp256k1.utils.sha256(secretMessage)
const hashHex = nobleSecp256k1.utils.bytesToHex(hash)
const pointX = '02' + hashHex
console.log('### pointX', pointX)
try {
point = nobleSecp256k1.Point.fromHex(pointX)
console.log('### point', point.toHex())
} catch (error) {
secretMessage = await nobleSecp256k1.utils.sha256(secretMessage)
}
}
return point
}
async function step1Alice(secretMessage) {
// todo: document & validate `secretMessage` format
secretMessage = uint8ToBase64.encode(secretMessage)
secretMessage = new TextEncoder().encode(secretMessage)
const Y = await hashToCurve(secretMessage)
const rpk = nobleSecp256k1.utils.randomPrivateKey()
const r = bytesToNumber(rpk)
const P = nobleSecp256k1.Point.fromPrivateKey(r)
const B_ = Y.add(P)
return {B_: B_.toHex(true), r: nobleSecp256k1.utils.bytesToHex(rpk)}
}
function step3Alice(C_, r, A) {
// const rInt = BigInt(r)
const rInt = bytesToNumber(r)
const C = C_.subtract(A.multiply(rInt))
return C
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
function splitAmount(value) {
const chunks = []
for (let i = 0; i < 32; i++) {
const mask = 1 << i
if ((value & mask) !== 0) chunks.push(Math.pow(2, i))
}
return chunks
}
function bytesToNumber(bytes) {
return hexToNumber(nobleSecp256k1.utils.bytesToHex(bytes))
}
function bigIntStringify(key, value) {
return typeof value === 'bigint' ? value.toString() : value
}
function hexToNumber(hex) {
if (typeof hex !== 'string') {
throw new TypeError('hexToNumber: expected string, got ' + typeof hex)
}
return BigInt(`0x${hex}`)
}

View file

@ -0,0 +1,33 @@
import asyncio
import json
from cashu.core.migrations import migrate_databases
from cashu.mint import migrations
from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener
from . import db, ledger
from .crud import get_cashu
async def startup_cashu_mint():
await migrate_databases(db, migrations)
await ledger.load_used_proofs()
await ledger.init_keysets(autosave=False)
pass
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue)
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
if payment.extra and not payment.extra.get("tag") == "cashu":
return
return

View file

@ -0,0 +1,80 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-btn flat label="Swagger API" type="a" href="../docs#/cashu"></q-btn>
<!-- <q-expansion-item group="api" dense expand-separator label="List TPoS">
<q-card>
<q-card-section>
<code><span class="text-blue">GET</span> /cashu/api/v1/mints</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;cashu_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}cashu/api/v1/mints -H "X-Api-Key:
&lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Create a TPoS">
<q-card>
<q-card-section>
<code><span class="text-green">POST</span> /cashu/api/v1/mints</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"name": &lt;string&gt;, "currency": &lt;string*ie USD*&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"currency": &lt;string&gt;, "id": &lt;string&gt;, "name":
&lt;string&gt;, "wallet": &lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}cashu/api/v1/mints -d '{"name":
&lt;string&gt;, "currency": &lt;string&gt;}' -H "Content-type:
application/json" -H "X-Api-Key: &lt;admin_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Delete a TPoS"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/cashu/api/v1/mints/&lt;cashu_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.base_url
}}cashu/api/v1/mints/&lt;cashu_id&gt; -H "X-Api-Key:
&lt;admin_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item> -->
</q-expansion-item>

View file

@ -0,0 +1,13 @@
<q-expansion-item group="extras" icon="info" label="About">
<q-card>
<q-card-section>
<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
>
</q-card-section>
</q-card>
</q-expansion-item>

View file

@ -0,0 +1,367 @@
{% 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>
<b>Cashu mint and wallet</b>
<p></p>
<p>
Here you can create multiple cashu mints that you can share. Each mint
can service many users but all ecash tokens of a mint are only valid
inside that mint and not across different mints. To exchange funds
between mints, use Lightning payments.
</p>
<b>Important</b>
<p></p>
<p>
If you are the operator of this LNbits instance, make sure to set
CASHU_PRIVATE_KEY="randomkey" in your configuration file. Do not
create mints before setting the key and do not change the key once
set.
</p>
</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">Mints</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="cashus"
row-key="id"
:columns="cashusTable.columns"
:pagination.sync="cashusTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="account_balance_wallet"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'wallet/?' + 'mint_id=' + props.row.id"
target="_blank"
><q-tooltip>Shareable wallet</q-tooltip></q-btn
>
<q-btn
unelevated
dense
size="xs"
icon="account_balance"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'mint/' + props.row.id"
target="_blank"
><q-tooltip>Shareable mint page</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="deleteMint(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
<q-btn
class="q-pt-l"
unelevated
color="primary"
@click="formDialog.show = true"
>New Mint</q-btn
>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">{{SITE_TITLE}} Cashu extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "cashu/_api_docs.html" %}
<q-separator></q-separator>
{% include "cashu/_cashu.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="createMint" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialog.data.name"
label="Mint Name"
placeholder="Cashu Mint"
></q-input>
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Cashu wallet *"
></q-select>
<!-- <q-toggle
v-model="toggleAdvanced"
label="Show advanced options"
></q-toggle>
<div v-show="toggleAdvanced">
<div class="row">
<div class="col-5">
<q-checkbox
v-model="formDialog.data.fraction"
color="primary"
label="sats/coins?"
>
<q-tooltip
>Use with hedging extension to create a stablecoin!</q-tooltip
>
</q-checkbox>
</div>
<div class="col-7">
<q-input
v-if="!formDialog.data.fraction"
filled
dense
type="number"
v-model.trim="formDialog.data.cost"
label="Sat coin cost (optional)"
value="1"
type="number"
></q-input>
<q-input
v-if="!formDialog.data.fraction"
filled
dense
v-model.trim="formDialog.data.tickershort"
label="Ticker shorthand"
placeholder="sats"
#
></q-input>
</div>
</div>
<q-input
class="q-mt-md"
filled
dense
type="number"
v-model.trim="formDialog.data.maxsats"
label="Maximum mint liquidity (optional)"
placeholder="∞"
></q-input>
<q-input
class="q-mt-md"
filled
dense
type="number"
v-model.trim="formDialog.data.coins"
label="Coins that 'exist' in mint (optional)"
placeholder="∞"
></q-input>
</div> -->
<div class="row q-mt-md">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.wallet == null || formDialog.data.name == null"
type="submit"
>Create Mint
</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 mapMint = 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.cashu = ['/cashu/', obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
cashus: [],
hostname: location.protocol + '//' + location.host + '/cashu/mint/',
toggleAdvanced: false,
cashusTable: {
columns: [
{name: 'id', align: 'left', label: 'Mint ID', field: 'id'},
{name: 'name', align: 'left', label: 'Name', field: 'name'},
// {
// name: 'tickershort',
// align: 'left',
// label: 'Ticker',
// field: 'tickershort'
// },
{
name: 'wallet',
align: 'left',
label: 'Mint wallet',
field: 'wallet'
}
// {
// name: 'fraction',
// align: 'left',
// label: 'Using fraction',
// field: 'fraction'
// },
// {
// name: 'maxsats',
// align: 'left',
// label: 'Max Sats',
// field: 'maxsats'
// },
// {
// name: 'coins',
// align: 'left',
// label: 'No. of coins',
// field: 'coins'
// }
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
data: {fraction: false}
}
}
},
methods: {
closeFormDialog: function () {
this.formDialog.data = {}
},
getMints: function () {
var self = this
LNbits.api
.request(
'GET',
'/cashu/api/v1/mints?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.cashus = response.data.map(function (obj) {
return mapMint(obj)
})
})
},
createMint: function () {
if (this.formDialog.data.maxliquid == null) {
this.formDialog.data.maxliquid = 0
}
var data = {
name: this.formDialog.data.name,
tickershort: this.formDialog.data.tickershort,
maxliquid: this.formDialog.data.maxliquid
}
var self = this
LNbits.api
.request(
'POST',
'/cashu/api/v1/mints',
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
.inkey,
data
)
.then(function (response) {
self.cashus.push(mapMint(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteMint: function (cashuId) {
var self = this
var cashu = _.findWhere(this.cashus, {id: cashuId})
console.log(cashu)
LNbits.utils
.confirmDialog(
"Are you sure you want to delete this Mint? This mint's users will not be able to redeem their tokens!"
)
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/cashu/api/v1/mints/' + cashuId,
_.findWhere(self.g.user.wallets, {id: cashu.wallet}).adminkey
)
.then(function (response) {
self.cashus = _.reject(self.cashus, function (obj) {
return obj.id == cashuId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportCSV: function () {
LNbits.utils.exportCSV(this.cashusTable.columns, this.cashus)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getMints()
}
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,76 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card class="q-pa-lg q-mb-xl">
<q-card-section class="q-pa-none">
<center>
<q-icon
name="account_balance"
class="text-grey"
style="font-size: 10rem"
></q-icon>
<h4 class="q-mt-none q-mb-md">{{ mint_name }}</h4>
<a
class="q-my-xl text-white"
style="font-size: 1.5rem"
href="../wallet?mint_id={{ mint_id }}"
>Open wallet</a
>
</center>
</q-card-section>
</q-card>
<q-card class="q-pa-lg q-mb-xl">
<q-card-section class="q-pa-none">
<h5 class="q-my-md">Read the following carefully!</h5>
<p>
This is a
<a href="https://cashu.space/" style="color: white" target="”_blank”"
>Cashu</a
>
mint. Cashu is an ecash system for Bitcoin.
</p>
<p>
<strong>Open this page in your native browser</strong><br />
Before you continue to the wallet, make sure to open this page in your
device's native browser application (Safari for iOS, Chrome for
Android). Do not use Cashu in an embedded browser that opens when you
click a link in a messenger.
</p>
<p>
<strong>Add wallet to home screen</strong><br />
You can add Cashu to your home screen as a progressive web app (PWA).
After opening the wallet in your browser (click the link above), on
Android (Chrome), click the menu at the upper right. On iOS (Safari),
click the share button. Now press the Add to Home screen button.
</p>
<p>
<strong>Backup your wallet</strong><br />
Ecash is a bearer asset. That means losing access to your wallet will
make you lose your funds. The wallet stores ecash tokens on your
device's database. If you lose the link or delete your your data
without backing up, you will lose your tokens. Press the Backup button
in the wallet to download a copy of your tokens.
</p>
<p>
<strong>This service is in BETA</strong> <br />
We hold no responsibility for people losing access to funds. Use at
your own risk!
</p>
</q-card-section>
</q-card>
</div>
{% endblock %} {% block scripts %}
<script>
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {}
}
})
</script>
{% endblock %}
</div>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,230 @@
from http import HTTPStatus
from fastapi import Request
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import cashu_ext, cashu_renderer
from .crud import get_cashu
templates = Jinja2Templates(directory="templates")
@cashu_ext.get("/", response_class=HTMLResponse)
async def index(
request: Request,
user: User = Depends(check_user_exists), # type: ignore
):
return cashu_renderer().TemplateResponse(
"cashu/index.html", {"request": request, "user": user.dict()}
)
@cashu_ext.get("/wallet")
async def wallet(request: Request, mint_id: str):
cashu = await get_cashu(mint_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
return cashu_renderer().TemplateResponse(
"cashu/wallet.html",
{
"request": request,
"web_manifest": f"/cashu/manifest/{mint_id}.webmanifest",
"mint_name": cashu.name,
},
)
@cashu_ext.get("/mint/{mintID}")
async def cashu(request: Request, mintID):
cashu = await get_cashu(mintID)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
return cashu_renderer().TemplateResponse(
"cashu/mint.html",
{"request": request, "mint_name": cashu.name, "mint_id": mintID},
)
@cashu_ext.get("/manifest/{cashu_id}.webmanifest")
async def manifest(cashu_id: str):
cashu = await get_cashu(cashu_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
return {
"short_name": "Cashu",
"name": "Cashu" + " - " + cashu.name,
"icons": [
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-512-512.png",
"type": "image/png",
"sizes": "512x512",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-96-96.png",
"type": "image/png",
"sizes": "96x96",
},
],
"id": "/cashu/wallet?mint_id=" + cashu_id,
"start_url": "/cashu/wallet?mint_id=" + cashu_id,
"background_color": "#1F2234",
"description": "Cashu ecash wallet",
"display": "standalone",
"scope": "/cashu/",
"theme_color": "#1F2234",
"protocol_handlers": [
{"protocol": "cashu", "url": "&recv_token=%s"},
{"protocol": "lightning", "url": "&lightning=%s"},
],
"shortcuts": [
{
"name": "Cashu" + " - " + cashu.name,
"short_name": "Cashu",
"description": "Cashu" + " - " + cashu.name,
"url": "/cashu/wallet?mint_id=" + cashu_id,
"icons": [
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-512-512.png",
"sizes": "512x512",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-192-192.png",
"sizes": "192x192",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-144-144.png",
"sizes": "144x144",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-96-96.png",
"sizes": "96x96",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-72-72.png",
"sizes": "72x72",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-48-48.png",
"sizes": "48x48",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/16.png",
"sizes": "16x16",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/20.png",
"sizes": "20x20",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/29.png",
"sizes": "29x29",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/32.png",
"sizes": "32x32",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/40.png",
"sizes": "40x40",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/50.png",
"sizes": "50x50",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/57.png",
"sizes": "57x57",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/58.png",
"sizes": "58x58",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/60.png",
"sizes": "60x60",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/64.png",
"sizes": "64x64",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/72.png",
"sizes": "72x72",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/76.png",
"sizes": "76x76",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/80.png",
"sizes": "80x80",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/87.png",
"sizes": "87x87",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/100.png",
"sizes": "100x100",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/114.png",
"sizes": "114x114",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/120.png",
"sizes": "120x120",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/128.png",
"sizes": "128x128",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/144.png",
"sizes": "144x144",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/152.png",
"sizes": "152x152",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/167.png",
"sizes": "167x167",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/180.png",
"sizes": "180x180",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/192.png",
"sizes": "192x192",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/256.png",
"sizes": "256x256",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/512.png",
"sizes": "512x512",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/1024.png",
"sizes": "1024x1024",
},
],
}
],
}

View file

@ -0,0 +1,396 @@
import json
import math
from http import HTTPStatus
from typing import Dict, List, Union
import httpx
# -------- cashu imports
from cashu.core.base import (
BlindedSignature,
CheckFeesRequest,
CheckFeesResponse,
CheckRequest,
GetMeltResponse,
GetMintResponse,
Invoice,
MeltRequest,
MintRequest,
PostSplitResponse,
Proof,
SplitRequest,
)
from fastapi import Query
from fastapi.params import Depends
from lnurl import decode as decode_lnurl
from loguru import logger
from secp256k1 import PublicKey
from starlette.exceptions import HTTPException
from lnbits import bolt11
from lnbits.core.crud import check_internal, get_user
from lnbits.core.services import (
check_transaction_status,
create_invoice,
fee_reserve,
pay_invoice,
)
from lnbits.core.views.api import api_payment
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from lnbits.helpers import urlsafe_short_hash
from lnbits.wallets.base import PaymentStatus
from . import cashu_ext, ledger
from .crud import create_cashu, delete_cashu, get_cashu, get_cashus
from .models import Cashu
# --------- extension imports
LIGHTNING = True
########################################
############### LNBITS MINTS ###########
########################################
@cashu_ext.get("/api/v1/mints", status_code=HTTPStatus.OK)
async def api_cashus(
all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type) # type: ignore
):
"""
Get all mints of this wallet.
"""
wallet_ids = [wallet.wallet.id]
if all_wallets:
user = await get_user(wallet.wallet.user)
if user:
wallet_ids = user.wallet_ids
return [cashu.dict() for cashu in await get_cashus(wallet_ids)]
@cashu_ext.post("/api/v1/mints", status_code=HTTPStatus.CREATED)
async def api_cashu_create(
data: Cashu,
wallet: WalletTypeInfo = Depends(get_key_type), # type: ignore
):
"""
Create a new mint for this wallet.
"""
cashu_id = urlsafe_short_hash()
# generate a new keyset in cashu
keyset = await ledger.load_keyset(cashu_id)
cashu = await create_cashu(
cashu_id=cashu_id, keyset_id=keyset.id, wallet_id=wallet.wallet.id, data=data
)
logger.debug(cashu)
return cashu.dict()
@cashu_ext.delete("/api/v1/mints/{cashu_id}")
async def api_cashu_delete(
cashu_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) # type: ignore
):
"""
Delete an existing cashu mint.
"""
cashu = await get_cashu(cashu_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Cashu mint does not exist."
)
if cashu.wallet != wallet.wallet.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Not your Cashu mint."
)
await delete_cashu(cashu_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
#######################################
########### CASHU ENDPOINTS ###########
#######################################
@cashu_ext.get("/api/v1/{cashu_id}/keys", status_code=HTTPStatus.OK)
async def keys(cashu_id: str = Query(None)) -> dict[int, str]:
"""Get the public keys of the mint"""
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
return ledger.get_keyset(keyset_id=cashu.keyset_id)
@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"""
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
return {"keysets": [cashu.keyset_id]}
@cashu_ext.get("/api/v1/{cashu_id}/mint")
async def request_mint(cashu_id: str = Query(None), amount: int = 0) -> GetMintResponse:
"""
Request minting of new tokens. The mint responds with a Lightning invoice.
This endpoint can be used for a Lightning invoice UX flow.
Call `POST /mint` after paying the invoice.
"""
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
# create an invoice that the wallet needs to pay
try:
payment_hash, payment_request = await create_invoice(
wallet_id=cashu.wallet,
amount=amount,
memo=f"{cashu.name}",
extra={"tag": "cashu"},
)
invoice = Invoice(
amount=amount, pr=payment_request, hash=payment_hash, issued=False
)
# await store_lightning_invoice(cashu_id, invoice)
await ledger.crud.store_lightning_invoice(invoice=invoice, db=ledger.db)
except Exception as e:
logger.error(e)
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
print(f"Lightning invoice: {payment_request}")
resp = GetMintResponse(pr=payment_request, hash=payment_hash)
# return {"pr": payment_request, "hash": payment_hash}
return resp
@cashu_ext.post("/api/v1/{cashu_id}/mint")
async def mint_coins(
data: MintRequest,
cashu_id: str = Query(None),
payment_hash: str = Query(None),
) -> List[BlindedSignature]:
"""
Requests the minting of tokens belonging to a paid payment request.
Call this endpoint after `GET /mint`.
"""
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
if cashu is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
if LIGHTNING:
invoice: Invoice = await ledger.crud.get_lightning_invoice(
db=ledger.db, hash=payment_hash
)
if invoice is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Mint does not know this invoice.",
)
if invoice.issued == True:
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 LIGHTNING and 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."
)
await ledger.crud.update_lightning_invoice(
db=ledger.db, hash=payment_hash, issued=True
)
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")
async def melt_coins(
payload: MeltRequest, cashu_id: str = Query(None)
) -> GetMeltResponse:
"""Invalidates proofs and pays a Lightning invoice."""
cashu: Union[None, Cashu] = await get_cashu(cashu_id)
if cashu is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
proofs = payload.proofs
invoice = payload.invoice
# !!!!!!! MAKE SURE THAT PROOFS ARE ONLY FROM THIS CASHU KEYSET ID
# THIS IS NECESSARY BECAUSE THE CASHU BACKEND WILL ACCEPT ANY VALID
# TOKENS
assert all([p.id == cashu.keyset_id for p in proofs]), HTTPException(
status_code=HTTPStatus.METHOD_NOT_ALLOWED,
detail="Error: Tokens are from another mint.",
)
# set proofs as pending
await ledger._set_proofs_pending(proofs)
try:
ledger._verify_proofs(proofs)
total_provided = sum([p["amount"] for p in proofs])
invoice_obj = bolt11.decode(invoice)
amount = math.ceil(invoice_obj.amount_msat / 1000)
internal_checking_id = await check_internal(invoice_obj.payment_hash)
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")
await pay_invoice(
wallet_id=cashu.wallet,
payment_request=invoice,
description=f"Pay cashu invoice",
extra={"tag": "cashu", "cashu_name": cashu.name},
)
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("Cashu: Payment successful, invalidating proofs")
await ledger._invalidate_proofs(proofs)
except Exception as e:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Cashu: {str(e)}",
)
finally:
# delete proofs from pending list
await ledger._unset_proofs_pending(proofs)
return GetMeltResponse(paid=status.paid, preimage=status.preimage)
@cashu_ext.post("/api/v1/{cashu_id}/check")
async def check_spendable(
payload: CheckRequest, cashu_id: str = Query(None)
) -> Dict[int, bool]:
"""Check whether a secret has been spent already or not."""
cashu: Union[None, Cashu] = await get_cashu(cashu_id)
if cashu is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
return await ledger.check_spendable(payload.proofs)
@cashu_ext.post("/api/v1/{cashu_id}/checkfees")
async def check_fees(
payload: CheckFeesRequest, cashu_id: str = Query(None)
) -> CheckFeesResponse:
"""
Responds with the fees necessary to pay a Lightning invoice.
Used by wallets for figuring out the fees they need to supply.
This is can be useful for checking whether an invoice is internal (Cashu-to-Cashu).
"""
cashu: Union[None, Cashu] = await get_cashu(cashu_id)
if cashu is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
invoice_obj = bolt11.decode(payload.pr)
internal_checking_id = await check_internal(invoice_obj.payment_hash)
if not internal_checking_id:
fees_msat = fee_reserve(invoice_obj.amount_msat)
else:
fees_msat = 0
return CheckFeesResponse(fee=math.ceil(fees_msat / 1000))
@cashu_ext.post("/api/v1/{cashu_id}/split")
async def split(
payload: SplitRequest, cashu_id: str = Query(None)
) -> PostSplitResponse:
"""
Requetst a set of tokens with amount "total" to be split into two
newly minted sets with amount "split" and "total-split".
"""
cashu: Union[None, Cashu] = await get_cashu(cashu_id)
if cashu is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
proofs = payload.proofs
# !!!!!!! MAKE SURE THAT PROOFS ARE ONLY FROM THIS CASHU KEYSET ID
# THIS IS NECESSARY BECAUSE THE CASHU BACKEND WILL ACCEPT ANY VALID
# TOKENS
if not all([p.id == cashu.keyset_id for p in proofs]):
raise HTTPException(
status_code=HTTPStatus.METHOD_NOT_ALLOWED,
detail="Error: Tokens are from another mint.",
)
amount = payload.amount
outputs = payload.outputs.blinded_messages
assert outputs, Exception("no outputs provided.")
split_return = None
try:
keyset = ledger.keysets.keysets[cashu.keyset_id]
split_return = await ledger.split(proofs, amount, outputs, keyset)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(exc),
)
if not split_return:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="there was an error with the split",
)
frst_promises, scnd_promises = split_return
resp = PostSplitResponse(fst=frst_promises, snd=scnd_promises)
return resp

View file

@ -7,11 +7,11 @@ from starlette.exceptions import HTTPException
from lnbits.core import db as core_db from lnbits.core import db as core_db
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.core.services import websocketUpdater
from lnbits.helpers import get_current_extension_name from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from .crud import get_copilot from .crud import get_copilot
from .views import updater
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
@ -65,9 +65,11 @@ async def on_invoice_paid(payment: Payment) -> None:
except (httpx.ConnectError, httpx.RequestError): except (httpx.ConnectError, httpx.RequestError):
await mark_webhook_sent(payment, -1) await mark_webhook_sent(payment, -1)
if payment.extra.get("comment"): if payment.extra.get("comment"):
await updater(copilot.id, data, payment.extra.get("comment")) await websocketUpdater(
copilot.id, str(data) + "-" + str(payment.extra.get("comment"))
)
await updater(copilot.id, data, "none") await websocketUpdater(copilot.id, str(data) + "-none")
async def mark_webhook_sent(payment: Payment, status: int) -> None: async def mark_webhook_sent(payment: Payment, status: int) -> None:

View file

@ -238,7 +238,7 @@
document.domain + document.domain +
':' + ':' +
location.port + location.port +
'/copilot/ws/' + '/api/v1/ws/' +
self.copilot.id self.copilot.id
} else { } else {
localUrl = localUrl =
@ -246,7 +246,7 @@
document.domain + document.domain +
':' + ':' +
location.port + location.port +
'/copilot/ws/' + '/api/v1/ws/' +
self.copilot.id self.copilot.id
} }
this.connection = new WebSocket(localUrl) this.connection = new WebSocket(localUrl)

View file

@ -35,48 +35,3 @@ async def panel(request: Request):
return copilot_renderer().TemplateResponse( return copilot_renderer().TemplateResponse(
"copilot/panel.html", {"request": request} "copilot/panel.html", {"request": request}
) )
##################WEBSOCKET ROUTES########################
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket, copilot_id: str):
await websocket.accept()
websocket.id = copilot_id # type: ignore
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_personal_message(self, message: str, copilot_id: str):
for connection in self.active_connections:
if connection.id == copilot_id: # type: ignore
await connection.send_text(message)
async def broadcast(self, message: str):
for connection in self.active_connections:
await connection.send_text(message)
manager = ConnectionManager()
@copilot_ext.websocket("/ws/{copilot_id}", name="copilot.websocket_by_id")
async def websocket_endpoint(websocket: WebSocket, copilot_id: str):
await manager.connect(websocket, copilot_id)
try:
while True:
data = await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(websocket)
async def updater(copilot_id, data, comment):
copilot = await get_copilot(copilot_id)
if not copilot:
return
await manager.send_personal_message(f"{data + '-' + comment}", copilot_id)

View file

@ -5,6 +5,7 @@ from fastapi.param_functions import Query
from fastapi.params import Depends from fastapi.params import Depends
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from lnbits.core.services import websocketUpdater
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from . import copilot_ext from . import copilot_ext
@ -16,7 +17,6 @@ from .crud import (
update_copilot, update_copilot,
) )
from .models import CreateCopilotData from .models import CreateCopilotData
from .views import updater
#######################COPILOT########################## #######################COPILOT##########################
@ -92,7 +92,7 @@ async def api_copilot_ws_relay(
status_code=HTTPStatus.NOT_FOUND, detail="Copilot does not exist" status_code=HTTPStatus.NOT_FOUND, detail="Copilot does not exist"
) )
try: try:
await updater(copilot_id, data, comment) await websocketUpdater(copilot_id, str(data) + "-" + str(comment))
except: except:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your copilot") raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your copilot")
return "" return ""

View file

@ -65,7 +65,7 @@ async def api_discordbot_users_delete(
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist." status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
) )
await delete_discordbot_user(user_id) await delete_discordbot_user(user_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
# Activate Extension # Activate Extension
@ -129,4 +129,4 @@ async def api_discordbot_wallets_delete(
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
) )
await delete_discordbot_wallet(wallet_id, get_wallet.user) await delete_discordbot_wallet(wallet_id, get_wallet.user)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT

View file

@ -1,7 +1,10 @@
import asyncio
from fastapi import APIRouter from fastapi import APIRouter
from lnbits.db import Database from lnbits.db import Database
from lnbits.helpers import template_renderer from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_events") db = Database("ext_events")
@ -13,5 +16,11 @@ def events_renderer():
return template_renderer(["lnbits/extensions/events/templates"]) return template_renderer(["lnbits/extensions/events/templates"])
from .tasks import wait_for_paid_invoices
from .views import * # noqa from .views import * # noqa
from .views_api import * # noqa from .views_api import * # noqa
def events_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))

View file

@ -0,0 +1,39 @@
import asyncio
import json
from http import HTTPStatus
from urllib.parse import urlparse
import httpx
from fastapi import HTTPException
from loguru import logger
from lnbits import bolt11
from lnbits.core.models import Payment
from lnbits.core.services import pay_invoice
from lnbits.extensions.events.models import CreateTicket
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener
from .views_api import api_ticket_send_ticket
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue, get_current_extension_name())
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
# (avoid loops)
if (
"events" == payment.extra.get("tag")
and payment.extra.get("name")
and payment.extra.get("email")
):
CreateTicket.name = str(payment.extra.get("name"))
CreateTicket.email = str(payment.extra.get("email"))
await api_ticket_send_ticket(payment.memo, payment.payment_hash, CreateTicket)
return

View file

@ -135,7 +135,14 @@
var self = this var self = this
axios axios
.get('/events/api/v1/tickets/' + '{{ event_id }}') .get(
'/events/api/v1/tickets/' +
'{{ event_id }}' +
'/' +
self.formDialog.data.name +
'/' +
self.formDialog.data.email
)
.then(function (response) { .then(function (response) {
self.paymentReq = response.data.payment_request self.paymentReq = response.data.payment_request
self.paymentCheck = response.data.payment_hash self.paymentCheck = response.data.payment_hash

View file

@ -260,7 +260,7 @@
dense dense
v-model.number="formDialog.data.price_per_ticket" v-model.number="formDialog.data.price_per_ticket"
type="number" type="number"
label="Price per ticket " label="Sats per ticket "
></q-input> ></q-input>
</div> </div>
</div> </div>

View file

@ -2,6 +2,7 @@ from http import HTTPStatus
from fastapi.param_functions import Query from fastapi.param_functions import Query
from fastapi.params import Depends from fastapi.params import Depends
from loguru import logger
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from starlette.requests import Request from starlette.requests import Request
@ -78,7 +79,7 @@ async def api_form_delete(event_id, wallet: WalletTypeInfo = Depends(get_key_typ
await delete_event(event_id) await delete_event(event_id)
await delete_event_tickets(event_id) await delete_event_tickets(event_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
#########Tickets########## #########Tickets##########
@ -96,8 +97,8 @@ async def api_tickets(
return [ticket.dict() for ticket in await get_tickets(wallet_ids)] return [ticket.dict() for ticket in await get_tickets(wallet_ids)]
@events_ext.get("/api/v1/tickets/{event_id}") @events_ext.get("/api/v1/tickets/{event_id}/{name}/{email}")
async def api_ticket_make_ticket(event_id): async def api_ticket_make_ticket(event_id, name, email):
event = await get_event(event_id) event = await get_event(event_id)
if not event: if not event:
raise HTTPException( raise HTTPException(
@ -108,11 +109,10 @@ async def api_ticket_make_ticket(event_id):
wallet_id=event.wallet, wallet_id=event.wallet,
amount=event.price_per_ticket, amount=event.price_per_ticket,
memo=f"{event_id}", memo=f"{event_id}",
extra={"tag": "events"}, extra={"tag": "events", "name": name, "email": email},
) )
except Exception as e: except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
return {"payment_hash": payment_hash, "payment_request": payment_request} return {"payment_hash": payment_hash, "payment_request": payment_request}
@ -156,7 +156,7 @@ async def api_ticket_delete(ticket_id, wallet: WalletTypeInfo = Depends(get_key_
) )
await delete_ticket(ticket_id) await delete_ticket(ticket_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
# Event Tickets # Event Tickets

View file

@ -118,7 +118,7 @@
dense dense
v-model.trim="formDialog.data.company_name" v-model.trim="formDialog.data.company_name"
label="Company Name" label="Company Name"
placeholder="LNBits Labs" placeholder="LNbits Labs"
></q-input> ></q-input>
<q-input <q-input
filled filled

View file

@ -22,8 +22,8 @@
![redirect url](https://i.imgur.com/GMzl0lG.png) ![redirect url](https://i.imgur.com/GMzl0lG.png)
- on Spotify click the "EDIT SETTINGS" button and paste the copied link in the _Redirect URI's_ prompt - on Spotify click the "EDIT SETTINGS" button and paste the copied link in the _Redirect URI's_ prompt
![spotify app setting](https://i.imgur.com/vb0x4Tl.png) ![spotify app setting](https://i.imgur.com/vb0x4Tl.png)
- back on LNBits, click "AUTORIZE ACCESS" and "Agree" on the page that will open - back on LNbits, click "AUTORIZE ACCESS" and "Agree" on the page that will open
- choose on which device the LNBits Jukebox extensions will stream to, you may have to be logged in in order to select the device (browser, smartphone app, etc...) - choose on which device the LNbits Jukebox extensions will stream to, you may have to be logged in in order to select the device (browser, smartphone app, etc...)
- and select what playlist will be available for users to choose songs (you need to have already playlist on Spotify)\ - and select what playlist will be available for users to choose songs (you need to have already playlist on Spotify)\
![select playlists](https://i.imgur.com/g4dbtED.png) ![select playlists](https://i.imgur.com/g4dbtED.png)

View file

@ -1,4 +1,4 @@
from typing import List, Optional from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
@ -6,11 +6,9 @@ from . import db
from .models import CreateJukeboxPayment, CreateJukeLinkData, Jukebox, JukeboxPayment from .models import CreateJukeboxPayment, CreateJukeLinkData, Jukebox, JukeboxPayment
async def create_jukebox( async def create_jukebox(data: CreateJukeLinkData) -> Jukebox:
data: CreateJukeLinkData, inkey: Optional[str] = ""
) -> Jukebox:
juke_id = urlsafe_short_hash() juke_id = urlsafe_short_hash()
result = await db.execute( await db.execute(
""" """
INSERT INTO jukebox.jukebox (id, "user", title, wallet, sp_user, sp_secret, sp_access_token, sp_refresh_token, sp_device, sp_playlists, price, profit) INSERT INTO jukebox.jukebox (id, "user", title, wallet, sp_user, sp_secret, sp_access_token, sp_refresh_token, sp_device, sp_playlists, price, profit)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@ -36,13 +34,13 @@ async def create_jukebox(
async def update_jukebox( async def update_jukebox(
data: CreateJukeLinkData, juke_id: Optional[str] = "" data: Union[CreateJukeLinkData, Jukebox], juke_id: str = ""
) -> Optional[Jukebox]: ) -> Optional[Jukebox]:
q = ", ".join([f"{field[0]} = ?" for field in data]) q = ", ".join([f"{field[0]} = ?" for field in data])
items = [f"{field[1]}" for field in data] items = [f"{field[1]}" for field in data]
items.append(juke_id) items.append(juke_id)
q = q.replace("user", '"user"', 1) # hack to make user be "user"! q = q.replace("user", '"user"', 1) # hack to make user be "user"!
await db.execute(f"UPDATE jukebox.jukebox SET {q} WHERE id = ?", (items)) await db.execute(f"UPDATE jukebox.jukebox SET {q} WHERE id = ?", (items,))
row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,)) row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,))
return Jukebox(**row) if row else None return Jukebox(**row) if row else None
@ -72,7 +70,7 @@ async def delete_jukebox(juke_id: str):
""" """
DELETE FROM jukebox.jukebox WHERE id = ? DELETE FROM jukebox.jukebox WHERE id = ?
""", """,
(juke_id), (juke_id,),
) )
@ -80,7 +78,7 @@ async def delete_jukebox(juke_id: str):
async def create_jukebox_payment(data: CreateJukeboxPayment) -> JukeboxPayment: async def create_jukebox_payment(data: CreateJukeboxPayment) -> JukeboxPayment:
result = await db.execute( await db.execute(
""" """
INSERT INTO jukebox.jukebox_payment (payment_hash, juke_id, song_id, paid) INSERT INTO jukebox.jukebox_payment (payment_hash, juke_id, song_id, paid)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)

View file

@ -1,6 +1,3 @@
from sqlite3 import Row
from typing import NamedTuple, Optional
from fastapi.param_functions import Query from fastapi.param_functions import Query
from pydantic import BaseModel from pydantic import BaseModel
from pydantic.main import BaseModel from pydantic.main import BaseModel
@ -20,19 +17,19 @@ class CreateJukeLinkData(BaseModel):
class Jukebox(BaseModel): class Jukebox(BaseModel):
id: Optional[str] id: str
user: Optional[str] user: str
title: Optional[str] title: str
wallet: Optional[str] wallet: str
inkey: Optional[str] inkey: str
sp_user: Optional[str] sp_user: str
sp_secret: Optional[str] sp_secret: str
sp_access_token: Optional[str] sp_access_token: str
sp_refresh_token: Optional[str] sp_refresh_token: str
sp_device: Optional[str] sp_device: str
sp_playlists: Optional[str] sp_playlists: str
price: Optional[int] price: int
profit: Optional[int] profit: int
class JukeboxPayment(BaseModel): class JukeboxPayment(BaseModel):

View file

@ -17,6 +17,7 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment) -> None: async def on_invoice_paid(payment: Payment) -> None:
if payment.extra:
if payment.extra.get("tag") != "jukebox": if payment.extra.get("tag") != "jukebox":
# not a jukebox invoice # not a jukebox invoice
return return

View file

@ -17,7 +17,9 @@ templates = Jinja2Templates(directory="templates")
@jukebox_ext.get("/", response_class=HTMLResponse) @jukebox_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)): async def index(
request: Request, user: User = Depends(check_user_exists) # type: ignore
):
return jukebox_renderer().TemplateResponse( return jukebox_renderer().TemplateResponse(
"jukebox/index.html", {"request": request, "user": user.dict()} "jukebox/index.html", {"request": request, "user": user.dict()}
) )
@ -31,6 +33,7 @@ async def connect_to_jukebox(request: Request, juke_id):
status_code=HTTPStatus.NOT_FOUND, detail="Jukebox does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Jukebox does not exist."
) )
devices = await api_get_jukebox_device_check(juke_id) devices = await api_get_jukebox_device_check(juke_id)
deviceConnected = False
for device in devices["devices"]: for device in devices["devices"]:
if device["id"] == jukebox.sp_device.split("-")[1]: if device["id"] == jukebox.sp_device.split("-")[1]:
deviceConnected = True deviceConnected = True
@ -48,5 +51,5 @@ async def connect_to_jukebox(request: Request, juke_id):
else: else:
return jukebox_renderer().TemplateResponse( return jukebox_renderer().TemplateResponse(
"jukebox/error.html", "jukebox/error.html",
{"request": request, "jukebox": jukebox.jukebox(req=request)}, {"request": request, "jukebox": jukebox.dict()},
) )

View file

@ -3,7 +3,6 @@ import json
from http import HTTPStatus from http import HTTPStatus
import httpx import httpx
from fastapi import Request
from fastapi.param_functions import Query from fastapi.param_functions import Query
from fastapi.params import Depends from fastapi.params import Depends
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
@ -29,9 +28,7 @@ from .models import CreateJukeboxPayment, CreateJukeLinkData
@jukebox_ext.get("/api/v1/jukebox") @jukebox_ext.get("/api/v1/jukebox")
async def api_get_jukeboxs( async def api_get_jukeboxs(
req: Request, wallet: WalletTypeInfo = Depends(require_admin_key), # type: ignore
wallet: WalletTypeInfo = Depends(require_admin_key),
all_wallets: bool = Query(False),
): ):
wallet_user = wallet.wallet.user wallet_user = wallet.wallet.user
@ -53,54 +50,52 @@ async def api_check_credentials_callbac(
access_token: str = Query(None), access_token: str = Query(None),
refresh_token: str = Query(None), refresh_token: str = Query(None),
): ):
sp_code = ""
sp_access_token = ""
sp_refresh_token = ""
try:
jukebox = await get_jukebox(juke_id) jukebox = await get_jukebox(juke_id)
except: if not jukebox:
raise HTTPException(detail="No Jukebox", status_code=HTTPStatus.FORBIDDEN) raise HTTPException(detail="No Jukebox", status_code=HTTPStatus.FORBIDDEN)
if code: if code:
jukebox.sp_access_token = code jukebox.sp_access_token = code
jukebox = await update_jukebox(jukebox, juke_id=juke_id) await update_jukebox(jukebox, juke_id=juke_id)
if access_token: if access_token:
jukebox.sp_access_token = access_token jukebox.sp_access_token = access_token
jukebox.sp_refresh_token = refresh_token jukebox.sp_refresh_token = refresh_token
jukebox = await update_jukebox(jukebox, juke_id=juke_id) await update_jukebox(jukebox, juke_id=juke_id)
return "<h1>Success!</h1><h2>You can close this window</h2>" return "<h1>Success!</h1><h2>You can close this window</h2>"
@jukebox_ext.get("/api/v1/jukebox/{juke_id}") @jukebox_ext.get("/api/v1/jukebox/{juke_id}", dependencies=[Depends(require_admin_key)])
async def api_check_credentials_check( async def api_check_credentials_check(juke_id: str = Query(None)):
juke_id: str = Query(None), wallet: WalletTypeInfo = Depends(require_admin_key)
):
jukebox = await get_jukebox(juke_id) jukebox = await get_jukebox(juke_id)
return jukebox return jukebox
@jukebox_ext.post("/api/v1/jukebox", status_code=HTTPStatus.CREATED) @jukebox_ext.post(
"/api/v1/jukebox",
status_code=HTTPStatus.CREATED,
dependencies=[Depends(require_admin_key)],
)
@jukebox_ext.put("/api/v1/jukebox/{juke_id}", status_code=HTTPStatus.OK) @jukebox_ext.put("/api/v1/jukebox/{juke_id}", status_code=HTTPStatus.OK)
async def api_create_update_jukebox( async def api_create_update_jukebox(
data: CreateJukeLinkData, data: CreateJukeLinkData, juke_id: str = Query(None)
juke_id: str = Query(None),
wallet: WalletTypeInfo = Depends(require_admin_key),
): ):
if juke_id: if juke_id:
jukebox = await update_jukebox(data, juke_id=juke_id) jukebox = await update_jukebox(data, juke_id=juke_id)
else: else:
jukebox = await create_jukebox(data, inkey=wallet.wallet.inkey) jukebox = await create_jukebox(data)
return jukebox return jukebox
@jukebox_ext.delete("/api/v1/jukebox/{juke_id}") @jukebox_ext.delete(
"/api/v1/jukebox/{juke_id}", dependencies=[Depends(require_admin_key)]
)
async def api_delete_item( async def api_delete_item(
juke_id=None, wallet: WalletTypeInfo = Depends(require_admin_key) juke_id: str = Query(None),
): ):
await delete_jukebox(juke_id) await delete_jukebox(juke_id)
try: # try:
return [{**jukebox} for jukebox in await get_jukeboxs(wallet.wallet.user)] # return [{**jukebox} for jukebox in await get_jukeboxs(wallet.wallet.user)]
except: # except:
raise HTTPException(status_code=HTTPStatus.NO_CONTENT, detail="No Jukebox") # raise HTTPException(status_code=HTTPStatus.NO_CONTENT, detail="No Jukebox")
################JUKEBOX ENDPOINTS################## ################JUKEBOX ENDPOINTS##################
@ -114,9 +109,8 @@ async def api_get_jukebox_song(
sp_playlist: str = Query(None), sp_playlist: str = Query(None),
retry: bool = Query(False), retry: bool = Query(False),
): ):
try:
jukebox = await get_jukebox(juke_id) jukebox = await get_jukebox(juke_id)
except: if not jukebox:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes") raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
tracks = [] tracks = []
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
@ -152,14 +146,13 @@ async def api_get_jukebox_song(
} }
) )
except: except:
something = None pass
return [track for track in tracks] return [track for track in tracks]
async def api_get_token(juke_id=None): async def api_get_token(juke_id):
try:
jukebox = await get_jukebox(juke_id) jukebox = await get_jukebox(juke_id)
except: if not jukebox:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes") raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
@ -187,7 +180,7 @@ async def api_get_token(juke_id=None):
jukebox.sp_access_token = r.json()["access_token"] jukebox.sp_access_token = r.json()["access_token"]
await update_jukebox(jukebox, juke_id=juke_id) await update_jukebox(jukebox, juke_id=juke_id)
except: except:
something = None pass
return True return True
@ -198,9 +191,8 @@ async def api_get_token(juke_id=None):
async def api_get_jukebox_device_check( async def api_get_jukebox_device_check(
juke_id: str = Query(None), retry: bool = Query(False) juke_id: str = Query(None), retry: bool = Query(False)
): ):
try:
jukebox = await get_jukebox(juke_id) jukebox = await get_jukebox(juke_id)
except: if not jukebox:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes") raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
rDevice = await client.get( rDevice = await client.get(
@ -221,7 +213,7 @@ async def api_get_jukebox_device_check(
status_code=HTTPStatus.FORBIDDEN, detail="Failed to get auth" status_code=HTTPStatus.FORBIDDEN, detail="Failed to get auth"
) )
else: else:
return api_get_jukebox_device_check(juke_id, retry=True) return await api_get_jukebox_device_check(juke_id, retry=True)
else: else:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="No device connected" status_code=HTTPStatus.FORBIDDEN, detail="No device connected"
@ -233,10 +225,8 @@ async def api_get_jukebox_device_check(
@jukebox_ext.get("/api/v1/jukebox/jb/invoice/{juke_id}/{song_id}") @jukebox_ext.get("/api/v1/jukebox/jb/invoice/{juke_id}/{song_id}")
async def api_get_jukebox_invoice(juke_id, song_id): async def api_get_jukebox_invoice(juke_id, song_id):
try:
jukebox = await get_jukebox(juke_id) jukebox = await get_jukebox(juke_id)
if not jukebox:
except:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox") raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
try: try:
@ -266,8 +256,7 @@ async def api_get_jukebox_invoice(juke_id, song_id):
invoice=invoice[1], payment_hash=payment_hash, juke_id=juke_id, song_id=song_id invoice=invoice[1], payment_hash=payment_hash, juke_id=juke_id, song_id=song_id
) )
jukebox_payment = await create_jukebox_payment(data) jukebox_payment = await create_jukebox_payment(data)
return jukebox_payment
return data
@jukebox_ext.get("/api/v1/jukebox/jb/checkinvoice/{pay_hash}/{juke_id}") @jukebox_ext.get("/api/v1/jukebox/jb/checkinvoice/{pay_hash}/{juke_id}")
@ -296,13 +285,12 @@ async def api_get_jukebox_invoice_paid(
pay_hash: str = Query(None), pay_hash: str = Query(None),
retry: bool = Query(False), retry: bool = Query(False),
): ):
try:
jukebox = await get_jukebox(juke_id) jukebox = await get_jukebox(juke_id)
except: if not jukebox:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox") raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
await api_get_jukebox_invoice_check(pay_hash, juke_id) await api_get_jukebox_invoice_check(pay_hash, juke_id)
jukebox_payment = await get_jukebox_payment(pay_hash) jukebox_payment = await get_jukebox_payment(pay_hash)
if jukebox_payment.paid: if jukebox_payment and jukebox_payment.paid:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
r = await client.get( r = await client.get(
"https://api.spotify.com/v1/me/player/currently-playing?market=ES", "https://api.spotify.com/v1/me/player/currently-playing?market=ES",
@ -407,9 +395,8 @@ async def api_get_jukebox_invoice_paid(
async def api_get_jukebox_currently( async def api_get_jukebox_currently(
retry: bool = Query(False), juke_id: str = Query(None) retry: bool = Query(False), juke_id: str = Query(None)
): ):
try:
jukebox = await get_jukebox(juke_id) jukebox = await get_jukebox(juke_id)
except: if not jukebox:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox") raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:

View file

@ -2,7 +2,7 @@
## Help DJ's and music producers conduct music livestreams ## Help DJ's and music producers conduct music livestreams
LNBits Livestream extension produces a static QR code that can be shown on screen while livestreaming a DJ set for example. If someone listening to the livestream likes a song and want to support the DJ and/or the producer he can scan the QR code with a LNURL-pay capable wallet. LNbits Livestream extension produces a static QR code that can be shown on screen while livestreaming a DJ set for example. If someone listening to the livestream likes a song and want to support the DJ and/or the producer he can scan the QR code with a LNURL-pay capable wallet.
When scanned, the QR code sends up information about the song playing at the moment (name and the producer of that song). Also, if the user likes the song and would like to support the producer, he can send a tip and a message for that specific track. If the user sends an amount over a specific threshold they will be given a link to download it (optional). When scanned, the QR code sends up information about the song playing at the moment (name and the producer of that song). Also, if the user likes the song and would like to support the producer, he can send a tip and a message for that specific track. If the user sends an amount over a specific threshold they will be given a link to download it (optional).
@ -25,7 +25,7 @@ The revenue will be sent to a wallet created specifically for that producer, wit
![adjust percentage](https://i.imgur.com/9weHKAB.jpg) ![adjust percentage](https://i.imgur.com/9weHKAB.jpg)
3. For every different producer added, when adding tracks, a wallet is generated for them\ 3. For every different producer added, when adding tracks, a wallet is generated for them\
![producer wallet](https://i.imgur.com/YFIZ7Tm.jpg) ![producer wallet](https://i.imgur.com/YFIZ7Tm.jpg)
4. On the bottom of the LNBits DJ Livestream extension you'll find the static QR code ([LNURL-pay](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) you can add to the livestream or if you're a street performer you can print it and have it displayed 4. On the bottom of the LNbits DJ Livestream extension you'll find the static QR code ([LNURL-pay](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) you can add to the livestream or if you're a street performer you can print it and have it displayed
5. After all tracks and producers are added, you can start "playing" songs\ 5. After all tracks and producers are added, you can start "playing" songs\
![play tracks](https://i.imgur.com/7ytiBkq.jpg) ![play tracks](https://i.imgur.com/7ytiBkq.jpg)
6. You'll see the current track playing and a green icon indicating active track also\ 6. You'll see the current track playing and a green icon indicating active track also\

View file

@ -60,14 +60,14 @@ async def api_update_track(track_id, g: WalletTypeInfo = Depends(get_key_type)):
ls = await get_or_create_livestream_by_wallet(g.wallet.id) ls = await get_or_create_livestream_by_wallet(g.wallet.id)
await update_current_track(ls.id, id) await update_current_track(ls.id, id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
@livestream_ext.put("/api/v1/livestream/fee/{fee_pct}") @livestream_ext.put("/api/v1/livestream/fee/{fee_pct}")
async def api_update_fee(fee_pct, g: WalletTypeInfo = Depends(get_key_type)): async def api_update_fee(fee_pct, g: WalletTypeInfo = Depends(get_key_type)):
ls = await get_or_create_livestream_by_wallet(g.wallet.id) ls = await get_or_create_livestream_by_wallet(g.wallet.id)
await update_livestream_fee(ls.id, int(fee_pct)) await update_livestream_fee(ls.id, int(fee_pct))
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
@livestream_ext.post("/api/v1/livestream/tracks") @livestream_ext.post("/api/v1/livestream/tracks")
@ -93,8 +93,8 @@ async def api_add_track(
return return
@livestream_ext.route("/api/v1/livestream/tracks/{track_id}") @livestream_ext.delete("/api/v1/livestream/tracks/{track_id}")
async def api_delete_track(track_id, g: WalletTypeInfo = Depends(get_key_type)): async def api_delete_track(track_id, g: WalletTypeInfo = Depends(get_key_type)):
ls = await get_or_create_livestream_by_wallet(g.wallet.id) ls = await get_or_create_livestream_by_wallet(g.wallet.id)
await delete_track_from_livestream(ls.id, track_id) await delete_track_from_livestream(ls.id, track_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT

View file

@ -93,7 +93,7 @@ async def api_domain_delete(domain_id, g: WalletTypeInfo = Depends(get_key_type)
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your domain") raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your domain")
await delete_domain(domain_id) await delete_domain(domain_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
# ADDRESSES # ADDRESSES
@ -253,4 +253,4 @@ async def api_address_delete(address_id, g: WalletTypeInfo = Depends(get_key_typ
) )
await delete_address(address_id) await delete_address(address_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT

View file

@ -3,4 +3,4 @@
Lndhub has nothing to do with lnd, it is just the name of the HTTP/JSON protocol https://bluewallet.io/ uses to talk to their Lightning custodian server at https://lndhub.io/. Lndhub has nothing to do with lnd, it is just the name of the HTTP/JSON protocol https://bluewallet.io/ uses to talk to their Lightning custodian server at https://lndhub.io/.
Despite not having been planned to this, Lndhub because somewhat a standard for custodian wallet communication when https://t.me/lntxbot and https://zeusln.app/ implemented the same interface. And with this extension LNBits joins the same club. Despite not having been planned to this, Lndhub because somewhat a standard for custodian wallet communication when https://t.me/lntxbot and https://zeusln.app/ implemented the same interface. And with this extension LNbits joins the same club.

View file

@ -21,7 +21,7 @@ from .utils import decoded_as_lndhub, to_buffer
@lndhub_ext.get("/ext/getinfo") @lndhub_ext.get("/ext/getinfo")
async def lndhub_getinfo(): async def lndhub_getinfo():
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="bad auth") return {"alias": LNBITS_SITE_TITLE}
class AuthData(BaseModel): class AuthData(BaseModel):

View file

@ -78,7 +78,7 @@ async def api_form_delete(form_id, wallet: WalletTypeInfo = Depends(get_key_type
await delete_form(form_id) await delete_form(form_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
#########tickets########## #########tickets##########
@ -160,4 +160,4 @@ async def api_ticket_delete(ticket_id, wallet: WalletTypeInfo = Depends(get_key_
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your ticket.") raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your ticket.")
await delete_ticket(ticket_id) await delete_ticket(ticket_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT

View file

@ -23,9 +23,22 @@ async def create_lnurldevice(
currency, currency,
device, device,
profit, profit,
amount amount,
pin,
profit1,
amount1,
pin1,
profit2,
amount2,
pin2,
profit3,
amount3,
pin3,
profit4,
amount4,
pin4
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
lnurldevice_id, lnurldevice_id,
@ -36,6 +49,19 @@ async def create_lnurldevice(
data.device, data.device,
data.profit, data.profit,
data.amount, data.amount,
data.pin,
data.profit1,
data.amount1,
data.pin1,
data.profit2,
data.amount2,
data.pin2,
data.profit3,
data.amount3,
data.pin3,
data.profit4,
data.amount4,
data.pin4,
), ),
) )
return await get_lnurldevice(lnurldevice_id) return await get_lnurldevice(lnurldevice_id)

View file

@ -8,6 +8,7 @@ from typing import Optional
from embit import bech32, compact from embit import bech32, compact
from fastapi import Request from fastapi import Request
from fastapi.param_functions import Query from fastapi.param_functions import Query
from loguru import logger
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from lnbits.core.services import create_invoice from lnbits.core.services import create_invoice
@ -91,6 +92,9 @@ async def lnurl_v1_params(
device_id: str = Query(None), device_id: str = Query(None),
p: str = Query(None), p: str = Query(None),
atm: str = Query(None), atm: str = Query(None),
gpio: str = Query(None),
profit: str = Query(None),
amount: str = Query(None),
): ):
device = await get_lnurldevice(device_id) device = await get_lnurldevice(device_id)
if not device: if not device:
@ -101,20 +105,28 @@ async def lnurl_v1_params(
paymentcheck = await get_lnurlpayload(p) paymentcheck = await get_lnurlpayload(p)
if device.device == "atm": if device.device == "atm":
if paymentcheck: if paymentcheck:
if paymentcheck.payhash != "payment_hash":
return {"status": "ERROR", "reason": f"Payment already claimed"} return {"status": "ERROR", "reason": f"Payment already claimed"}
if device.device == "switch": if device.device == "switch":
price_msat = ( price_msat = (
await fiat_amount_as_satoshis(float(device.profit), device.currency) await fiat_amount_as_satoshis(float(profit), device.currency)
if device.currency != "sat" if device.currency != "sat"
else amount_in_cent else amount_in_cent
) * 1000 ) * 1000
# Check they're not trying to trick the switch!
check = False
for switch in device.switches(request):
if switch[0] == gpio and switch[1] == profit and switch[2] == amount:
check = True
if not check:
return {"status": "ERROR", "reason": f"Switch params wrong"}
lnurldevicepayment = await create_lnurldevicepayment( lnurldevicepayment = await create_lnurldevicepayment(
deviceid=device.id, deviceid=device.id,
payload="bla", payload=amount,
sats=price_msat, sats=price_msat,
pin=1, pin=gpio,
payhash="bla", payhash="bla",
) )
if not lnurldevicepayment: if not lnurldevicepayment:
@ -126,7 +138,7 @@ async def lnurl_v1_params(
), ),
"minSendable": price_msat, "minSendable": price_msat,
"maxSendable": price_msat, "maxSendable": price_msat,
"metadata": await device.lnurlpay_metadata(), "metadata": device.lnurlpay_metadata,
} }
if len(p) % 4 > 0: if len(p) % 4 > 0:
p += "=" * (4 - (len(p) % 4)) p += "=" * (4 - (len(p) % 4))
@ -165,7 +177,7 @@ async def lnurl_v1_params(
"callback": request.url_for( "callback": request.url_for(
"lnurldevice.lnurl_callback", paymentid=lnurldevicepayment.id "lnurldevice.lnurl_callback", paymentid=lnurldevicepayment.id
), ),
"k1": lnurldevicepayment.id, "k1": p,
"minWithdrawable": price_msat * 1000, "minWithdrawable": price_msat * 1000,
"maxWithdrawable": price_msat * 1000, "maxWithdrawable": price_msat * 1000,
"defaultDescription": device.title, "defaultDescription": device.title,
@ -188,7 +200,7 @@ async def lnurl_v1_params(
), ),
"minSendable": price_msat * 1000, "minSendable": price_msat * 1000,
"maxSendable": price_msat * 1000, "maxSendable": price_msat * 1000,
"metadata": await device.lnurlpay_metadata(), "metadata": device.lnurlpay_metadata,
} }
@ -215,14 +227,13 @@ async def lnurl_callback(
status_code=HTTPStatus.FORBIDDEN, detail="No payment request" status_code=HTTPStatus.FORBIDDEN, detail="No payment request"
) )
else: else:
if lnurldevicepayment.id != k1: if lnurldevicepayment.payload != k1:
return {"status": "ERROR", "reason": "Bad K1"} return {"status": "ERROR", "reason": "Bad K1"}
if lnurldevicepayment.payhash != "payment_hash": if lnurldevicepayment.payhash != "payment_hash":
return {"status": "ERROR", "reason": f"Payment already claimed"} return {"status": "ERROR", "reason": f"Payment already claimed"}
lnurldevicepayment = await update_lnurldevicepayment( lnurldevicepayment = await update_lnurldevicepayment(
lnurldevicepayment_id=paymentid, payhash=lnurldevicepayment.payload lnurldevicepayment_id=paymentid, payhash=lnurldevicepayment.payload
) )
await pay_invoice( await pay_invoice(
wallet_id=device.wallet, wallet_id=device.wallet,
payment_request=pr, payment_request=pr,
@ -233,11 +244,17 @@ async def lnurl_callback(
if device.device == "switch": if device.device == "switch":
payment_hash, payment_request = await create_invoice( payment_hash, payment_request = await create_invoice(
wallet_id=device.wallet, wallet_id=device.wallet,
amount=lnurldevicepayment.sats / 1000, amount=int(lnurldevicepayment.sats / 1000),
memo=device.title + "-" + lnurldevicepayment.id, memo=device.id + " PIN " + str(lnurldevicepayment.pin),
unhashed_description=(await device.lnurlpay_metadata()).encode("utf-8"), unhashed_description=device.lnurlpay_metadata.encode("utf-8"),
extra={"tag": "Switch", "id": paymentid, "time": device.amount}, extra={
"tag": "Switch",
"pin": str(lnurldevicepayment.pin),
"amount": str(lnurldevicepayment.payload),
"id": paymentid,
},
) )
lnurldevicepayment = await update_lnurldevicepayment( lnurldevicepayment = await update_lnurldevicepayment(
lnurldevicepayment_id=paymentid, payhash=payment_hash lnurldevicepayment_id=paymentid, payhash=payment_hash
) )
@ -248,9 +265,9 @@ async def lnurl_callback(
payment_hash, payment_request = await create_invoice( payment_hash, payment_request = await create_invoice(
wallet_id=device.wallet, wallet_id=device.wallet,
amount=lnurldevicepayment.sats / 1000, amount=int(lnurldevicepayment.sats / 1000),
memo=device.title, memo=device.title,
unhashed_description=(await device.lnurlpay_metadata()).encode("utf-8"), unhashed_description=device.lnurlpay_metadata.encode("utf-8"),
extra={"tag": "PoS"}, extra={"tag": "PoS"},
) )
lnurldevicepayment = await update_lnurldevicepayment( lnurldevicepayment = await update_lnurldevicepayment(

View file

@ -88,3 +88,52 @@ async def m003_redux(db):
await db.execute( await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN amount INT DEFAULT 0;" "ALTER TABLE lnurldevice.lnurldevices ADD COLUMN amount INT DEFAULT 0;"
) )
async def m004_redux(db):
"""
Add 'meta' for storing various metadata about the wallet
"""
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN pin INT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN profit1 FLOAT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN amount1 INT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN pin1 INT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN profit2 FLOAT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN amount2 INT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN pin2 INT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN profit3 FLOAT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN amount3 INT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN pin3 INT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN profit4 FLOAT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN amount4 INT DEFAULT 0"
)
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN pin4 INT DEFAULT 0"
)

View file

@ -1,12 +1,13 @@
import json import json
from sqlite3 import Row from sqlite3 import Row
from typing import Optional from typing import List, Optional
from fastapi import Request from fastapi import Request
from lnurl import Lnurl from lnurl import Lnurl
from lnurl import encode as lnurl_encode # type: ignore from lnurl import encode as lnurl_encode # type: ignore
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
from lnurl.types import LnurlPayMetadata # type: ignore from lnurl.types import LnurlPayMetadata # type: ignore
from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
from pydantic.main import BaseModel from pydantic.main import BaseModel
@ -18,6 +19,19 @@ class createLnurldevice(BaseModel):
device: str device: str
profit: float profit: float
amount: int amount: int
pin: int = 0
profit1: float = 0
amount1: int = 0
pin1: int = 0
profit2: float = 0
amount2: int = 0
pin2: int = 0
profit3: float = 0
amount3: int = 0
pin3: int = 0
profit4: float = 0
amount4: int = 0
pin4: int = 0
class lnurldevices(BaseModel): class lnurldevices(BaseModel):
@ -29,18 +43,122 @@ class lnurldevices(BaseModel):
device: str device: str
profit: float profit: float
amount: int amount: int
pin: int
profit1: float
amount1: int
pin1: int
profit2: float
amount2: int
pin2: int
profit3: float
amount3: int
pin3: int
profit4: float
amount4: int
pin4: int
timestamp: str timestamp: str
def from_row(cls, row: Row) -> "lnurldevices": def from_row(cls, row: Row) -> "lnurldevices":
return cls(**dict(row)) return cls(**dict(row))
def lnurl(self, req: Request) -> Lnurl: @property
url = req.url_for("lnurldevice.lnurl_v1_params", device_id=self.id) def lnurlpay_metadata(self) -> LnurlPayMetadata:
return lnurl_encode(url)
async def lnurlpay_metadata(self) -> LnurlPayMetadata:
return LnurlPayMetadata(json.dumps([["text/plain", self.title]])) return LnurlPayMetadata(json.dumps([["text/plain", self.title]]))
def switches(self, req: Request) -> List:
switches = []
if self.profit > 0:
url = req.url_for("lnurldevice.lnurl_v1_params", device_id=self.id)
switches.append(
[
str(self.pin),
str(self.profit),
str(self.amount),
lnurl_encode(
url
+ "?gpio="
+ str(self.pin)
+ "&profit="
+ str(self.profit)
+ "&amount="
+ str(self.amount)
),
]
)
if self.profit1 > 0:
url = req.url_for("lnurldevice.lnurl_v1_params", device_id=self.id)
switches.append(
[
str(self.pin1),
str(self.profit1),
str(self.amount1),
lnurl_encode(
url
+ "?gpio="
+ str(self.pin1)
+ "&profit="
+ str(self.profit1)
+ "&amount="
+ str(self.amount1)
),
]
)
if self.profit2 > 0:
url = req.url_for("lnurldevice.lnurl_v1_params", device_id=self.id)
switches.append(
[
str(self.pin2),
str(self.profit2),
str(self.amount2),
lnurl_encode(
url
+ "?gpio="
+ str(self.pin2)
+ "&profit="
+ str(self.profit2)
+ "&amount="
+ str(self.amount2)
),
]
)
if self.profit3 > 0:
url = req.url_for("lnurldevice.lnurl_v1_params", device_id=self.id)
switches.append(
[
str(self.pin3),
str(self.profit3),
str(self.amount3),
lnurl_encode(
url
+ "?gpio="
+ str(self.pin3)
+ "&profit="
+ str(self.profit3)
+ "&amount="
+ str(self.amount3)
),
]
)
if self.profit4 > 0:
url = req.url_for("lnurldevice.lnurl_v1_params", device_id=self.id)
switches.append(
[
str(self.pin4),
str(self.profit4),
str(self.amount4),
lnurl_encode(
url
+ "?gpio="
+ str(self.pin4)
+ "&profit="
+ str(self.profit4)
+ "&amount="
+ str(self.amount4)
),
]
)
return switches
class lnurldevicepayment(BaseModel): class lnurldevicepayment(BaseModel):
id: str id: str

View file

@ -8,12 +8,11 @@ from fastapi import HTTPException
from lnbits import bolt11 from lnbits import bolt11
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.core.services import pay_invoice from lnbits.core.services import pay_invoice, websocketUpdater
from lnbits.helpers import get_current_extension_name from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from .crud import get_lnurldevice, get_lnurldevicepayment, update_lnurldevicepayment from .crud import get_lnurldevice, get_lnurldevicepayment, update_lnurldevicepayment
from .views import updater
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
@ -36,5 +35,8 @@ async def on_invoice_paid(payment: Payment) -> None:
lnurldevicepayment = await update_lnurldevicepayment( lnurldevicepayment = await update_lnurldevicepayment(
lnurldevicepayment_id=payment.extra.get("id"), payhash="used" lnurldevicepayment_id=payment.extra.get("id"), payhash="used"
) )
return await updater(lnurldevicepayment.deviceid) return await websocketUpdater(
lnurldevicepayment.deviceid,
str(lnurldevicepayment.pin) + "-" + str(lnurldevicepayment.payload),
)
return return

View file

@ -105,7 +105,7 @@
@click="openQrCodeDialog(props.row.id)" @click="openQrCodeDialog(props.row.id)"
><q-tooltip v-if="protocol == 'http:'"> ><q-tooltip v-if="protocol == 'http:'">
LNURLs only work over HTTPS </q-tooltip LNURLs only work over HTTPS </q-tooltip
><q-tooltip v-else> view LNURL </q-tooltip></q-btn ><q-tooltip v-else> view LNURLS </q-tooltip></q-btn
> >
</q-td> </q-td>
<q-td <q-td
@ -157,9 +157,9 @@
unelevated unelevated
color="primary" color="primary"
size="md" size="md"
@click="copyText(wslocation + '/lnurldevice/ws/' + settingsDialog.data.id, 'Link copied to clipboard!')" @click="copyText(wslocation + '/api/v1/ws/' + settingsDialog.data.id, 'Link copied to clipboard!')"
>{% raw %}{{wslocation}}/lnurldevice/ws/{{settingsDialog.data.id}}{% >{% raw %}{{wslocation}}/api/v1/ws/{{settingsDialog.data.id}}{% endraw
endraw %}<q-tooltip> Click to copy URL </q-tooltip> %}<q-tooltip> Click to copy URL </q-tooltip>
</q-btn> </q-btn>
<q-btn <q-btn
v-else v-else
@ -230,6 +230,30 @@
label="Profit margin (% added to invoices/deducted from faucets)" label="Profit margin (% added to invoices/deducted from faucets)"
></q-input> ></q-input>
<div v-else> <div v-else>
<q-btn
unelevated
class="q-mb-lg"
round
size="sm"
icon="add"
@click="addSwitch"
v-model="switches"
color="primary"
></q-btn>
<q-btn
unelevated
class="q-mb-lg"
round
size="sm"
icon="remove"
@click="removeSwitch"
v-model="switches"
color="primary"
></q-btn>
<div v-if="switches >= 0">
<div class="row">
<div class="col">
<q-input <q-input
ref="setAmount" ref="setAmount"
filled filled
@ -243,6 +267,8 @@
:step="'0.01'" :step="'0.01'"
value="0.00" value="0.00"
></q-input> ></q-input>
</div>
<div class="col q-ml-md">
<q-input <q-input
filled filled
dense dense
@ -252,7 +278,173 @@
label="milesecs to turn Switch on for (1sec = 1000ms)" label="milesecs to turn Switch on for (1sec = 1000ms)"
></q-input> ></q-input>
</div> </div>
<div class="col q-ml-md">
<q-input
filled
dense
v-model.trim="formDialoglnurldevice.data.pin"
type="number"
label="GPIO to turn on"
></q-input>
</div>
</div>
</div>
<div v-if="switches >= 1">
<div class="row">
<div class="col">
<q-input
ref="setAmount"
filled
dense
v-model.trim="formDialoglnurldevice.data.profit1"
class="q-pb-md"
:label="'Amount (' + formDialoglnurldevice.data.currency + ') *'"
:mask="'#.##'"
fill-mask="0"
reverse-fill-mask
:step="'0.01'"
value="0.00"
></q-input>
</div>
<div class="col q-ml-md">
<q-input
filled
dense
v-model.trim="formDialoglnurldevice.data.amount1"
type="number"
value="1000"
label="milesecs to turn Switch on for (1sec = 1000ms)"
></q-input>
</div>
<div class="col q-ml-md">
<q-input
filled
dense
v-model.trim="formDialoglnurldevice.data.pin1"
type="number"
label="GPIO to turn on"
></q-input>
</div>
</div>
</div>
<div v-if="switches >= 2">
<div class="row">
<div class="col">
<q-input
ref="setAmount"
filled
dense
v-model.trim="formDialoglnurldevice.data.profit2"
class="q-pb-md"
:label="'Amount (' + formDialoglnurldevice.data.currency + ') *'"
:mask="'#.##'"
fill-mask="0"
reverse-fill-mask
:step="'0.01'"
value="0.00"
></q-input>
</div>
<div class="col q-ml-md">
<q-input
filled
dense
v-model.trim="formDialoglnurldevice.data.amount2"
type="number"
value="1000"
label="milesecs to turn Switch on for (1sec = 1000ms)"
></q-input>
</div>
<div class="col q-ml-md">
<q-input
filled
dense
v-model.trim="formDialoglnurldevice.data.pin2"
type="number"
label="GPIO to turn on"
></q-input>
</div>
</div>
</div>
<div v-if="switches >= 3">
<div class="row">
<div class="col">
<q-input
ref="setAmount"
filled
dense
v-model.trim="formDialoglnurldevice.data.profit3"
class="q-pb-md"
:label="'Amount (' + formDialoglnurldevice.data.currency + ') *'"
:mask="'#.##'"
fill-mask="0"
reverse-fill-mask
:step="'0.01'"
value="0.00"
></q-input>
</div>
<div class="col q-ml-md">
<q-input
filled
dense
v-model.trim="formDialoglnurldevice.data.amount3"
type="number"
value="1000"
label="milesecs to turn Switch on for (1sec = 1000ms)"
></q-input>
</div>
<div class="col q-ml-md">
<q-input
filled
dense
v-model.trim="formDialoglnurldevice.data.pin3"
type="number"
label="GPIO to turn on"
></q-input>
</div>
</div>
</div>
<div v-if="switches >= 4">
<div class="row">
<div class="col">
<q-input
ref="setAmount"
filled
dense
v-model.trim="formDialoglnurldevice.data.profit4"
class="q-pb-md"
:label="'Amount (' + formDialoglnurldevice.data.currency + ') *'"
:mask="'#.##'"
fill-mask="0"
reverse-fill-mask
:step="'0.01'"
value="0.00"
></q-input>
</div>
<div class="col q-ml-md">
<q-input
filled
dense
v-model.trim="formDialoglnurldevice.data.amount4"
type="number"
value="1000"
label="milesecs to turn Switch on for (1sec = 1000ms)"
></q-input>
</div>
<div class="col q-ml-md">
<q-input
filled
dense
v-model.trim="formDialoglnurldevice.data.pin4"
type="number"
label="GPIO to turn on"
></q-input>
</div>
</div>
</div>
</div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
v-if="formDialoglnurldevice.data.id" v-if="formDialoglnurldevice.data.id"
@ -284,24 +476,37 @@
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card"> <q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md"> <q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode <qrcode
:value="qrCodeDialog.data.url + '/?lightning=' + qrCodeDialog.data.lnurl" :value="lnurlValue"
:options="{width: 800}" :options="{width: 800}"
class="rounded-borders" class="rounded-borders"
></qrcode> ></qrcode>
{% raw %}
</q-responsive> </q-responsive>
<p style="word-break: break-all">
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
</p>
{% endraw %}
<div class="row q-mt-lg q-gutter-sm">
<q-btn <q-btn
outline outline
color="grey" color="grey"
@click="copyText(qrCodeDialog.data.lnurl, 'LNURL copied to clipboard!')" @click="copyText(lnurlValue, 'LNURL copied to clipboard!')"
class="q-ml-sm"
>Copy LNURL</q-btn >Copy LNURL</q-btn
> >
<q-chip
v-if="websocketMessage == 'WebSocket NOT supported by your Browser!' || websocketMessage == 'Connection closed'"
clickable
color="red"
text-color="white"
icon="error"
>{% raw %}{{ wsMessage }}{% endraw %}</q-chip
>
<q-chip v-else clickable color="green" text-color="white" icon="check"
>{% raw %}{{ wsMessage }}{% endraw %}</q-chip
>
<br />
<div class="row q-mt-lg q-gutter-sm">
<q-btn
v-for="switch_ in qrCodeDialog.data.switches"
outline
color="primary"
:label="'Switch PIN:' + switch_[0]"
@click="lnurlValueFetch(switch_[3])"
></q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn> <q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div> </div>
</q-card> </q-card>
@ -333,11 +538,15 @@
mixins: [windowMixin], mixins: [windowMixin],
data: function () { data: function () {
return { return {
tab: 'mails',
protocol: window.location.protocol, protocol: window.location.protocol,
location: window.location.hostname, location: window.location.hostname,
wslocation: window.location.hostname, wslocation: window.location.hostname,
filter: '', filter: '',
currency: 'USD', currency: 'USD',
lnurlValue: '',
websocketMessage: '',
switches: 0,
lnurldeviceLinks: [], lnurldeviceLinks: [],
lnurldeviceLinksObj: [], lnurldeviceLinksObj: [],
devices: [ devices: [
@ -386,12 +595,6 @@
label: 'device', label: 'device',
field: 'device' field: 'device'
}, },
{
name: 'profit',
align: 'left',
label: 'profit',
field: 'profit'
},
{ {
name: 'currency', name: 'currency',
align: 'left', align: 'left',
@ -431,6 +634,11 @@
} }
} }
}, },
computed: {
wsMessage: function () {
return this.websocketMessage
}
},
methods: { methods: {
openQrCodeDialog: function (lnurldevice_id) { openQrCodeDialog: function (lnurldevice_id) {
var lnurldevice = _.findWhere(this.lnurldeviceLinks, { var lnurldevice = _.findWhere(this.lnurldeviceLinks, {
@ -440,8 +648,26 @@
this.qrCodeDialog.data = _.clone(lnurldevice) this.qrCodeDialog.data = _.clone(lnurldevice)
this.qrCodeDialog.data.url = this.qrCodeDialog.data.url =
window.location.protocol + '//' + window.location.host window.location.protocol + '//' + window.location.host
this.lnurlValueFetch(
this.qrCodeDialog.data.switches[0][3],
this.qrCodeDialog.data.id
)
this.qrCodeDialog.show = true this.qrCodeDialog.show = true
}, },
lnurlValueFetch: function (lnurl, switchId) {
this.lnurlValue = lnurl
this.websocketConnector(
'wss://' + window.location.host + '/api/v1/ws/' + switchId
)
},
addSwitch: function () {
var self = this
self.switches = self.switches + 1
},
removeSwitch: function () {
var self = this
self.switches = self.switches - 1
},
cancellnurldevice: function (data) { cancellnurldevice: function (data) {
var self = this var self = this
self.formDialoglnurldevice.show = false self.formDialoglnurldevice.show = false
@ -498,7 +724,9 @@
.then(function (response) { .then(function (response) {
if (response.data) { if (response.data) {
self.lnurldeviceLinks = response.data.map(maplnurldevice) self.lnurldeviceLinks = response.data.map(maplnurldevice)
console.log('response.data')
console.log(response.data) console.log(response.data)
console.log('response.data')
} }
}) })
.catch(function (error) { .catch(function (error) {
@ -592,6 +820,25 @@
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}, },
websocketConnector: function (websocketUrl) {
if ('WebSocket' in window) {
self = this
var ws = new WebSocket(websocketUrl)
self.updateWsMessage('Websocket connected')
ws.onmessage = function (evt) {
var received_msg = evt.data
self.updateWsMessage('Message recieved: ' + received_msg)
}
ws.onclose = function () {
self.updateWsMessage('Connection closed')
}
} else {
self.updateWsMessage('WebSocket NOT supported by your Browser!')
}
},
updateWsMessage: function (message) {
this.websocketMessage = message
},
clearFormDialoglnurldevice() { clearFormDialoglnurldevice() {
this.formDialoglnurldevice.data = { this.formDialoglnurldevice.data = {
lnurl_toggle: false, lnurl_toggle: false,

View file

@ -2,7 +2,7 @@ from http import HTTPStatus
from io import BytesIO from io import BytesIO
import pyqrcode import pyqrcode
from fastapi import Request, WebSocket, WebSocketDisconnect from fastapi import Request
from fastapi.param_functions import Query from fastapi.param_functions import Query
from fastapi.params import Depends from fastapi.params import Depends
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@ -63,48 +63,3 @@ async def img(request: Request, lnurldevice_id):
status_code=HTTPStatus.NOT_FOUND, detail="LNURLDevice does not exist." status_code=HTTPStatus.NOT_FOUND, detail="LNURLDevice does not exist."
) )
return lnurldevice.lnurl(request) return lnurldevice.lnurl(request)
##################WEBSOCKET ROUTES########################
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket, lnurldevice_id: str):
await websocket.accept()
websocket.id = lnurldevice_id
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_personal_message(self, message: str, lnurldevice_id: str):
for connection in self.active_connections:
if connection.id == lnurldevice_id:
await connection.send_text(message)
async def broadcast(self, message: str):
for connection in self.active_connections:
await connection.send_text(message)
manager = ConnectionManager()
@lnurldevice_ext.websocket("/ws/{lnurldevice_id}", name="lnurldevice.lnurldevice_by_id")
async def websocket_endpoint(websocket: WebSocket, lnurldevice_id: str):
await manager.connect(websocket, lnurldevice_id)
try:
while True:
data = await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(websocket)
async def updater(lnurldevice_id):
lnurldevice = await get_lnurldevice(lnurldevice_id)
if not lnurldevice:
return
await manager.send_personal_message(f"{lnurldevice.amount}", lnurldevice_id)

View file

@ -39,10 +39,10 @@ async def api_lnurldevice_create_or_update(
): ):
if not lnurldevice_id: if not lnurldevice_id:
lnurldevice = await create_lnurldevice(data) lnurldevice = await create_lnurldevice(data)
return {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(req)}} return {**lnurldevice.dict(), **{"switches": lnurldevice.switches(req)}}
else: else:
lnurldevice = await update_lnurldevice(data, lnurldevice_id=lnurldevice_id) lnurldevice = await update_lnurldevice(data, lnurldevice_id=lnurldevice_id)
return {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(req)}} return {**lnurldevice.dict(), **{"switches": lnurldevice.switches(req)}}
@lnurldevice_ext.get("/api/v1/lnurlpos") @lnurldevice_ext.get("/api/v1/lnurlpos")
@ -52,7 +52,7 @@ async def api_lnurldevices_retrieve(
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
try: try:
return [ return [
{**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(req)}} {**lnurldevice.dict(), **{"switches": lnurldevice.switches(req)}}
for lnurldevice in await get_lnurldevices(wallet_ids) for lnurldevice in await get_lnurldevices(wallet_ids)
] ]
except: except:
@ -78,7 +78,7 @@ async def api_lnurldevice_retrieve(
) )
if not lnurldevice.lnurl_toggle: if not lnurldevice.lnurl_toggle:
return {**lnurldevice.dict()} return {**lnurldevice.dict()}
return {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(req)}} return {**lnurldevice.dict(), **{"switches": lnurldevice.switches(req)}}
@lnurldevice_ext.delete("/api/v1/lnurlpos/{lnurldevice_id}") @lnurldevice_ext.delete("/api/v1/lnurlpos/{lnurldevice_id}")

View file

@ -21,13 +21,15 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
served_meta, served_meta,
served_pr, served_pr,
webhook_url, webhook_url,
webhook_headers,
webhook_body,
success_text, success_text,
success_url, success_url,
comment_chars, comment_chars,
currency, currency,
fiat_base_multiplier fiat_base_multiplier
) )
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?)
{returning} {returning}
""", """,
( (
@ -36,6 +38,8 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
data.min, data.min,
data.max, data.max,
data.webhook_url, data.webhook_url,
data.webhook_headers,
data.webhook_body,
data.success_text, data.success_text,
data.success_url, data.success_url,
data.comment_chars, data.comment_chars,

View file

@ -8,7 +8,7 @@ async def m001_initial(db):
id {db.serial_primary_key}, id {db.serial_primary_key},
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
amount INTEGER NOT NULL, amount {db.big_int} NOT NULL,
served_meta INTEGER NOT NULL, served_meta INTEGER NOT NULL,
served_pr INTEGER NOT NULL served_pr INTEGER NOT NULL
); );
@ -60,3 +60,11 @@ async def m004_fiat_base_multiplier(db):
await db.execute( await db.execute(
"ALTER TABLE lnurlp.pay_links ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;" "ALTER TABLE lnurlp.pay_links ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;"
) )
async def m005_webhook_headers_and_body(db):
"""
Add headers and body to webhooks
"""
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;")

View file

@ -18,6 +18,8 @@ class CreatePayLinkData(BaseModel):
currency: str = Query(None) currency: str = Query(None)
comment_chars: int = Query(0, ge=0, lt=800) comment_chars: int = Query(0, ge=0, lt=800)
webhook_url: str = Query(None) webhook_url: str = Query(None)
webhook_headers: str = Query(None)
webhook_body: str = Query(None)
success_text: str = Query(None) success_text: str = Query(None)
success_url: str = Query(None) success_url: str = Query(None)
fiat_base_multiplier: int = Query(100, ge=1) fiat_base_multiplier: int = Query(100, ge=1)
@ -31,6 +33,8 @@ class PayLink(BaseModel):
served_meta: int served_meta: int
served_pr: int served_pr: int
webhook_url: Optional[str] webhook_url: Optional[str]
webhook_headers: Optional[str]
webhook_body: Optional[str]
success_text: Optional[str] success_text: Optional[str]
success_url: Optional[str] success_url: Optional[str]
currency: Optional[str] currency: Optional[str]

View file

@ -33,17 +33,22 @@ async def on_invoice_paid(payment: Payment) -> None:
if pay_link and pay_link.webhook_url: if pay_link and pay_link.webhook_url:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:
r = await client.post( kwargs = {
pay_link.webhook_url, "json": {
json={
"payment_hash": payment.payment_hash, "payment_hash": payment.payment_hash,
"payment_request": payment.bolt11, "payment_request": payment.bolt11,
"amount": payment.amount, "amount": payment.amount,
"comment": payment.extra.get("comment"), "comment": payment.extra.get("comment"),
"lnurlp": pay_link.id, "lnurlp": pay_link.id,
}, },
timeout=40, "timeout": 40,
) }
if pay_link.webhook_body:
kwargs["json"]["body"] = json.loads(pay_link.webhook_body)
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) await mark_webhook_sent(payment, r.status_code)
except (httpx.ConnectError, httpx.RequestError): except (httpx.ConnectError, httpx.RequestError):
await mark_webhook_sent(payment, -1) await mark_webhook_sent(payment, -1)

View file

@ -213,6 +213,24 @@
label="Webhook URL (optional)" label="Webhook URL (optional)"
hint="A URL to be called whenever this link receives a payment." hint="A URL to be called whenever this link receives a payment."
></q-input> ></q-input>
<q-input
filled
dense
v-if="formDialog.data.webhook_url"
v-model="formDialog.data.webhook_headers"
type="text"
label="Webhook headers (optional)"
hint="Custom data as JSON string, send headers along with the webhook."
></q-input>
<q-input
filled
dense
v-if="formDialog.data.webhook_url"
v-model="formDialog.data.webhook_body"
type="text"
label="Webhook custom data (optional)"
hint="Custom data as JSON string, will get posted along with webhook 'body' field."
></q-input>
<q-input <q-input
filled filled
dense dense

View file

@ -1,3 +1,4 @@
import json
from http import HTTPStatus from http import HTTPStatus
from fastapi import Request from fastapi import Request
@ -90,6 +91,24 @@ async def api_link_create_or_update(
detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST
) )
if data.webhook_headers:
try:
json.loads(data.webhook_headers)
except ValueError:
raise HTTPException(
detail="Invalid JSON in webhook_headers.",
status_code=HTTPStatus.BAD_REQUEST,
)
if data.webhook_body:
try:
json.loads(data.webhook_body)
except ValueError:
raise HTTPException(
detail="Invalid JSON in webhook_body.",
status_code=HTTPStatus.BAD_REQUEST,
)
# database only allows int4 entries for min and max. For fiat currencies, # database only allows int4 entries for min and max. For fiat currencies,
# we multiply by data.fiat_base_multiplier (usually 100) to save the value in cents. # we multiply by data.fiat_base_multiplier (usually 100) to save the value in cents.
if data.currency and data.fiat_base_multiplier: if data.currency and data.fiat_base_multiplier:

View file

@ -80,7 +80,7 @@ async def api_lnurlpayout_delete(
) )
await delete_lnurlpayout(lnurlpayout_id) await delete_lnurlpayout(lnurlpayout_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
@lnurlpayout_ext.get("/api/v1/lnurlpayouts/{lnurlpayout_id}", status_code=HTTPStatus.OK) @lnurlpayout_ext.get("/api/v1/lnurlpayouts/{lnurlpayout_id}", status_code=HTTPStatus.OK)

View file

@ -1,3 +1,4 @@
# type: ignore
from os import getenv from os import getenv
from fastapi import Request from fastapi import Request
@ -34,7 +35,9 @@ ngrok_tunnel = ngrok.connect(port)
@ngrok_ext.get("/") @ngrok_ext.get("/")
async def index(request: Request, user: User = Depends(check_user_exists)): async def index(
request: Request, user: User = Depends(check_user_exists) # type: ignore
):
return ngrok_renderer().TemplateResponse( return ngrok_renderer().TemplateResponse(
"ngrok/index.html", {"request": request, "ngrok": string5, "user": user.dict()} "ngrok/index.html", {"request": request, "ngrok": string5, "user": user.dict()}
) )

View file

@ -4,11 +4,11 @@
[![video tutorial offline shop](http://img.youtube.com/vi/_XAvM_LNsoo/0.jpg)](https://youtu.be/_XAvM_LNsoo 'video tutorial offline shop') [![video tutorial offline shop](http://img.youtube.com/vi/_XAvM_LNsoo/0.jpg)](https://youtu.be/_XAvM_LNsoo 'video tutorial offline shop')
LNBits Offline Shop allows for merchants to receive Bitcoin payments while offline and without any electronic device. LNbits Offline Shop allows for merchants to receive Bitcoin payments while offline and without any electronic device.
Merchant will create items and associate a QR code ([a LNURLp](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) with a price. He can then print the QR codes and display them on their shop. When a costumer chooses an item, scans the QR code, gets the description and price. After payment, the costumer gets a confirmation code that the merchant can validate to be sure the payment was successful. Merchant will create items and associate a QR code ([a LNURLp](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) with a price. He can then print the QR codes and display them on their shop. When a customer chooses an item, scans the QR code, gets the description and price. After payment, the customer gets a confirmation code that the merchant can validate to be sure the payment was successful.
Costumers must use an LNURL pay capable wallet. Customers must use an LNURL pay capable wallet.
[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets) [**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets)
@ -18,18 +18,18 @@ Costumers must use an LNURL pay capable wallet.
![offline shop back office](https://i.imgur.com/Ei7cxj9.png) ![offline shop back office](https://i.imgur.com/Ei7cxj9.png)
2. Begin by creating an item, click "ADD NEW ITEM" 2. Begin by creating an item, click "ADD NEW ITEM"
- set the item name and a small description - set the item name and a small description
- you can set an optional, preferably square image, that will show up on the costumer wallet - _depending on wallet_ - you can set an optional, preferably square image, that will show up on the customer wallet - _depending on wallet_
- set the item price, if you choose a fiat currency the bitcoin conversion will happen at the time costumer scans to pay\ - set the item price, if you choose a fiat currency the bitcoin conversion will happen at the time customer scans to pay\
![add new item](https://i.imgur.com/pkZqRgj.png) ![add new item](https://i.imgur.com/pkZqRgj.png)
3. After creating some products, click on "PRINT QR CODES"\ 3. After creating some products, click on "PRINT QR CODES"\
![print qr codes](https://i.imgur.com/2GAiSTe.png) ![print qr codes](https://i.imgur.com/2GAiSTe.png)
4. You'll see a QR code for each product in your LNBits Offline Shop with a title and price ready for printing\ 4. You'll see a QR code for each product in your LNbits Offline Shop with a title and price ready for printing\
![qr codes sheet](https://i.imgur.com/faEqOcd.png) ![qr codes sheet](https://i.imgur.com/faEqOcd.png)
5. Place the printed QR codes on your shop, or at the fair stall, or have them as a menu style laminated sheet 5. Place the printed QR codes on your shop, or at the fair stall, or have them as a menu style laminated sheet
6. Choose what type of confirmation do you want costumers to report to merchant after a successful payment\ 6. Choose what type of confirmation do you want customers to report to merchant after a successful payment\
![wordlist](https://i.imgur.com/9aM6NUL.png) ![wordlist](https://i.imgur.com/9aM6NUL.png)
- Wordlist is the default option: after a successful payment the costumer will receive a word from this list, **sequentially**. Starting in _albatross_ as costumers pay for the items they will get the next word in the list until _zebra_, then it starts at the top again. The list can be changed, for example if you think A-Z is a big list to track, you can use _apple_, _banana_, _coconut_\ - Wordlist is the default option: after a successful payment the customer will receive a word from this list, **sequentially**. Starting in _albatross_ as customers pay for the items they will get the next word in the list until _zebra_, then it starts at the top again. The list can be changed, for example if you think A-Z is a big list to track, you can use _apple_, _banana_, _coconut_\
![totp authenticator](https://i.imgur.com/MrJXFxz.png) ![totp authenticator](https://i.imgur.com/MrJXFxz.png)
- TOTP (time-based one time password) can be used instead. If you use Google Authenticator just scan the presented QR with the app and after a successful payment the user will get the password that you can check with GA\ - TOTP (time-based one time password) can be used instead. If you use Google Authenticator just scan the presented QR with the app and after a successful payment the user will get the password that you can check with GA\
![disable confirmations](https://i.imgur.com/2OFs4yi.png) ![disable confirmations](https://i.imgur.com/2OFs4yi.png)

View file

@ -22,7 +22,7 @@ async def m001_initial(db):
description TEXT NOT NULL, description TEXT NOT NULL,
image TEXT, -- image/png;base64,... image TEXT, -- image/png;base64,...
enabled BOOLEAN NOT NULL DEFAULT true, enabled BOOLEAN NOT NULL DEFAULT true,
price INTEGER NOT NULL, price {db.big_int} NOT NULL,
unit TEXT NOT NULL DEFAULT 'sat' unit TEXT NOT NULL DEFAULT 'sat'
); );
""" """

View file

@ -93,7 +93,7 @@ async def api_add_or_update_item(
async def api_delete_item(item_id, wallet: WalletTypeInfo = Depends(get_key_type)): async def api_delete_item(item_id, wallet: WalletTypeInfo = Depends(get_key_type)):
shop = await get_or_create_shop_by_wallet(wallet.wallet.id) shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
await delete_item_from_shop(shop.id, item_id) await delete_item_from_shop(shop.id, item_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
class CreateMethodData(BaseModel): class CreateMethodData(BaseModel):

View file

@ -3,14 +3,14 @@ async def m001_initial(db):
Initial paywalls table. Initial paywalls table.
""" """
await db.execute( await db.execute(
""" f"""
CREATE TABLE paywall.paywalls ( CREATE TABLE paywall.paywalls (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
secret TEXT NOT NULL, secret TEXT NOT NULL,
url TEXT NOT NULL, url TEXT NOT NULL,
memo TEXT NOT NULL, memo TEXT NOT NULL,
amount INTEGER NOT NULL, amount {db.big_int} NOT NULL,
time TIMESTAMP NOT NULL DEFAULT """ time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now + db.timestamp_now
+ """ + """
@ -25,14 +25,14 @@ async def m002_redux(db):
""" """
await db.execute("ALTER TABLE paywall.paywalls RENAME TO paywalls_old") await db.execute("ALTER TABLE paywall.paywalls RENAME TO paywalls_old")
await db.execute( await db.execute(
""" f"""
CREATE TABLE paywall.paywalls ( CREATE TABLE paywall.paywalls (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
url TEXT NOT NULL, url TEXT NOT NULL,
memo TEXT NOT NULL, memo TEXT NOT NULL,
description TEXT NULL, description TEXT NULL,
amount INTEGER DEFAULT 0, amount {db.big_int} DEFAULT 0,
time TIMESTAMP NOT NULL DEFAULT """ time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now + db.timestamp_now
+ """, + """,

View file

@ -49,7 +49,7 @@ async def api_paywall_delete(
) )
await delete_paywall(paywall_id) await delete_paywall(paywall_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT) return "", HTTPStatus.NO_CONTENT
@paywall_ext.post("/api/v1/paywalls/invoice/{paywall_id}") @paywall_ext.post("/api/v1/paywalls/invoice/{paywall_id}")

View file

@ -8,7 +8,6 @@ from .models import (
CreateSatsDiceLink, CreateSatsDiceLink,
CreateSatsDicePayment, CreateSatsDicePayment,
CreateSatsDiceWithdraw, CreateSatsDiceWithdraw,
HashCheck,
satsdiceLink, satsdiceLink,
satsdicePayment, satsdicePayment,
satsdiceWithdraw, satsdiceWithdraw,
@ -76,7 +75,7 @@ async def get_satsdice_pays(wallet_ids: Union[str, List[str]]) -> List[satsdiceL
return [satsdiceLink(**row) for row in rows] return [satsdiceLink(**row) for row in rows]
async def update_satsdice_pay(link_id: int, **kwargs) -> Optional[satsdiceLink]: async def update_satsdice_pay(link_id: str, **kwargs) -> satsdiceLink:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute( await db.execute(
f"UPDATE satsdice.satsdice_pay SET {q} WHERE id = ?", f"UPDATE satsdice.satsdice_pay SET {q} WHERE id = ?",
@ -85,10 +84,10 @@ async def update_satsdice_pay(link_id: int, **kwargs) -> Optional[satsdiceLink]:
row = await db.fetchone( row = await db.fetchone(
"SELECT * FROM satsdice.satsdice_pay WHERE id = ?", (link_id,) "SELECT * FROM satsdice.satsdice_pay WHERE id = ?", (link_id,)
) )
return satsdiceLink(**row) if row else None return satsdiceLink(**row)
async def increment_satsdice_pay(link_id: int, **kwargs) -> Optional[satsdiceLink]: async def increment_satsdice_pay(link_id: str, **kwargs) -> Optional[satsdiceLink]:
q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()]) q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
await db.execute( await db.execute(
f"UPDATE satsdice.satsdice_pay SET {q} WHERE id = ?", f"UPDATE satsdice.satsdice_pay SET {q} WHERE id = ?",
@ -100,7 +99,7 @@ async def increment_satsdice_pay(link_id: int, **kwargs) -> Optional[satsdiceLin
return satsdiceLink(**row) if row else None return satsdiceLink(**row) if row else None
async def delete_satsdice_pay(link_id: int) -> None: async def delete_satsdice_pay(link_id: str) -> None:
await db.execute("DELETE FROM satsdice.satsdice_pay WHERE id = ?", (link_id,)) await db.execute("DELETE FROM satsdice.satsdice_pay WHERE id = ?", (link_id,))
@ -119,9 +118,15 @@ async def create_satsdice_payment(data: CreateSatsDicePayment) -> satsdicePaymen
) )
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
""", """,
(data["payment_hash"], data["satsdice_pay"], data["value"], False, False), (
data.payment_hash,
data.satsdice_pay,
data.value,
False,
False,
),
) )
payment = await get_satsdice_payment(data["payment_hash"]) payment = await get_satsdice_payment(data.payment_hash)
assert payment, "Newly created withdraw couldn't be retrieved" assert payment, "Newly created withdraw couldn't be retrieved"
return payment return payment
@ -134,9 +139,7 @@ async def get_satsdice_payment(payment_hash: str) -> Optional[satsdicePayment]:
return satsdicePayment(**row) if row else None return satsdicePayment(**row) if row else None
async def update_satsdice_payment( async def update_satsdice_payment(payment_hash: str, **kwargs) -> satsdicePayment:
payment_hash: int, **kwargs
) -> Optional[satsdicePayment]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute( await db.execute(
@ -147,7 +150,7 @@ async def update_satsdice_payment(
"SELECT * FROM satsdice.satsdice_payment WHERE payment_hash = ?", "SELECT * FROM satsdice.satsdice_payment WHERE payment_hash = ?",
(payment_hash,), (payment_hash,),
) )
return satsdicePayment(**row) if row else None return satsdicePayment(**row)
##################SATSDICE WITHDRAW LINKS ##################SATSDICE WITHDRAW LINKS
@ -168,16 +171,16 @@ async def create_satsdice_withdraw(data: CreateSatsDiceWithdraw) -> satsdiceWith
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
""", """,
( (
data["payment_hash"], data.payment_hash,
data["satsdice_pay"], data.satsdice_pay,
data["value"], data.value,
urlsafe_short_hash(), urlsafe_short_hash(),
urlsafe_short_hash(), urlsafe_short_hash(),
int(datetime.now().timestamp()), int(datetime.now().timestamp()),
data["used"], data.used,
), ),
) )
withdraw = await get_satsdice_withdraw(data["payment_hash"], 0) withdraw = await get_satsdice_withdraw(data.payment_hash, 0)
assert withdraw, "Newly created withdraw couldn't be retrieved" assert withdraw, "Newly created withdraw couldn't be retrieved"
return withdraw return withdraw
@ -247,7 +250,7 @@ async def delete_satsdice_withdraw(withdraw_id: str) -> None:
) )
async def create_withdraw_hash_check(the_hash: str, lnurl_id: str) -> HashCheck: async def create_withdraw_hash_check(the_hash: str, lnurl_id: str):
await db.execute( await db.execute(
""" """
INSERT INTO satsdice.hash_checkw ( INSERT INTO satsdice.hash_checkw (
@ -262,18 +265,14 @@ async def create_withdraw_hash_check(the_hash: str, lnurl_id: str) -> HashCheck:
return hashCheck return hashCheck
async def get_withdraw_hash_checkw(the_hash: str, lnurl_id: str) -> Optional[HashCheck]: async def get_withdraw_hash_checkw(the_hash: str, lnurl_id: str):
rowid = await db.fetchone( rowid = await db.fetchone(
"SELECT * FROM satsdice.hash_checkw WHERE id = ?", (the_hash,) "SELECT * FROM satsdice.hash_checkw WHERE id = ?", (the_hash,)
) )
rowlnurl = await db.fetchone( rowlnurl = await db.fetchone(
"SELECT * FROM satsdice.hash_checkw WHERE lnurl_id = ?", (lnurl_id,) "SELECT * FROM satsdice.hash_checkw WHERE lnurl_id = ?", (lnurl_id,)
) )
if not rowlnurl: if not rowlnurl or not rowid:
await create_withdraw_hash_check(the_hash, lnurl_id)
return {"lnurl": True, "hash": False}
else:
if not rowid:
await create_withdraw_hash_check(the_hash, lnurl_id) await create_withdraw_hash_check(the_hash, lnurl_id)
return {"lnurl": True, "hash": False} return {"lnurl": True, "hash": False}
else: else:

View file

@ -1,4 +1,3 @@
import hashlib
import json import json
import math import math
from http import HTTPStatus from http import HTTPStatus
@ -83,15 +82,18 @@ async def api_lnurlp_callback(
success_action = link.success_action(payment_hash=payment_hash, req=req) success_action = link.success_action(payment_hash=payment_hash, req=req)
data: CreateSatsDicePayment = { data = CreateSatsDicePayment(
"satsdice_pay": link.id, satsdice_pay=link.id,
"value": amount_received / 1000, value=amount_received / 1000,
"payment_hash": payment_hash, payment_hash=payment_hash,
} )
await create_satsdice_payment(data) await create_satsdice_payment(data)
payResponse = {"pr": payment_request, "successAction": success_action, "routes": []} payResponse: dict = {
"pr": payment_request,
"successAction": success_action,
"routes": [],
}
return json.dumps(payResponse) return json.dumps(payResponse)
@ -133,9 +135,7 @@ async def api_lnurlw_response(req: Request, unique_hash: str = Query(None)):
name="satsdice.api_lnurlw_callback", name="satsdice.api_lnurlw_callback",
) )
async def api_lnurlw_callback( async def api_lnurlw_callback(
req: Request,
unique_hash: str = Query(None), unique_hash: str = Query(None),
k1: str = Query(None),
pr: str = Query(None), pr: str = Query(None),
): ):
@ -146,6 +146,7 @@ async def api_lnurlw_callback(
return {"status": "ERROR", "reason": "spent"} return {"status": "ERROR", "reason": "spent"}
paylink = await get_satsdice_pay(link.satsdice_pay) paylink = await get_satsdice_pay(link.satsdice_pay)
if paylink:
await update_satsdice_withdraw(link.id, used=1) await update_satsdice_withdraw(link.id, used=1)
await pay_invoice( await pay_invoice(
wallet_id=paylink.wallet, wallet_id=paylink.wallet,

View file

@ -3,14 +3,14 @@ async def m001_initial(db):
Creates an improved satsdice table and migrates the existing data. Creates an improved satsdice table and migrates the existing data.
""" """
await db.execute( await db.execute(
""" f"""
CREATE TABLE satsdice.satsdice_pay ( CREATE TABLE satsdice.satsdice_pay (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT, wallet TEXT,
title TEXT, title TEXT,
min_bet INTEGER, min_bet INTEGER,
max_bet INTEGER, max_bet INTEGER,
amount INTEGER DEFAULT 0, amount {db.big_int} DEFAULT 0,
served_meta INTEGER NOT NULL, served_meta INTEGER NOT NULL,
served_pr INTEGER NOT NULL, served_pr INTEGER NOT NULL,
multiplier FLOAT, multiplier FLOAT,
@ -28,11 +28,11 @@ async def m002_initial(db):
Creates an improved satsdice table and migrates the existing data. Creates an improved satsdice table and migrates the existing data.
""" """
await db.execute( await db.execute(
""" f"""
CREATE TABLE satsdice.satsdice_withdraw ( CREATE TABLE satsdice.satsdice_withdraw (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
satsdice_pay TEXT, satsdice_pay TEXT,
value INTEGER DEFAULT 1, value {db.big_int} DEFAULT 1,
unique_hash TEXT UNIQUE, unique_hash TEXT UNIQUE,
k1 TEXT, k1 TEXT,
open_time INTEGER, open_time INTEGER,
@ -47,11 +47,11 @@ async def m003_initial(db):
Creates an improved satsdice table and migrates the existing data. Creates an improved satsdice table and migrates the existing data.
""" """
await db.execute( await db.execute(
""" f"""
CREATE TABLE satsdice.satsdice_payment ( CREATE TABLE satsdice.satsdice_payment (
payment_hash TEXT PRIMARY KEY, payment_hash TEXT PRIMARY KEY,
satsdice_pay TEXT, satsdice_pay TEXT,
value INTEGER, value {db.big_int},
paid BOOL DEFAULT FALSE, paid BOOL DEFAULT FALSE,
lost BOOL DEFAULT FALSE lost BOOL DEFAULT FALSE
); );

View file

@ -4,7 +4,7 @@ from typing import Dict, Optional
from fastapi import Request from fastapi import Request
from fastapi.param_functions import Query from fastapi.param_functions import Query
from lnurl import Lnurl, LnurlWithdrawResponse from lnurl import Lnurl
from lnurl import encode as lnurl_encode # type: ignore from lnurl import encode as lnurl_encode # type: ignore
from lnurl.types import LnurlPayMetadata # type: ignore from lnurl.types import LnurlPayMetadata # type: ignore
from pydantic import BaseModel from pydantic import BaseModel
@ -80,8 +80,7 @@ class satsdiceWithdraw(BaseModel):
def is_spent(self) -> bool: def is_spent(self) -> bool:
return self.used >= 1 return self.used >= 1
@property def lnurl_response(self, req: Request):
def lnurl_response(self, req: Request) -> LnurlWithdrawResponse:
url = req.url_for("satsdice.api_lnurlw_callback", unique_hash=self.unique_hash) url = req.url_for("satsdice.api_lnurlw_callback", unique_hash=self.unique_hash)
withdrawResponse = { withdrawResponse = {
"tag": "withdrawRequest", "tag": "withdrawRequest",
@ -99,7 +98,7 @@ class HashCheck(BaseModel):
lnurl_id: str lnurl_id: str
@classmethod @classmethod
def from_row(cls, row: Row) -> "Hash": def from_row(cls, row: Row):
return cls(**dict(row)) return cls(**dict(row))

View file

@ -1,6 +1,8 @@
import random import random
from http import HTTPStatus from http import HTTPStatus
from io import BytesIO
import pyqrcode
from fastapi import Request from fastapi import Request
from fastapi.param_functions import Query from fastapi.param_functions import Query
from fastapi.params import Depends from fastapi.params import Depends
@ -20,13 +22,15 @@ from .crud import (
get_satsdice_withdraw, get_satsdice_withdraw,
update_satsdice_payment, update_satsdice_payment,
) )
from .models import CreateSatsDiceWithdraw, satsdiceLink from .models import CreateSatsDiceWithdraw
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@satsdice_ext.get("/", response_class=HTMLResponse) @satsdice_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)): async def index(
request: Request, user: User = Depends(check_user_exists) # type: ignore
):
return satsdice_renderer().TemplateResponse( return satsdice_renderer().TemplateResponse(
"satsdice/index.html", {"request": request, "user": user.dict()} "satsdice/index.html", {"request": request, "user": user.dict()}
) )
@ -67,7 +71,7 @@ async def displaywin(
) )
withdrawLink = await get_satsdice_withdraw(payment_hash) withdrawLink = await get_satsdice_withdraw(payment_hash)
payment = await get_satsdice_payment(payment_hash) payment = await get_satsdice_payment(payment_hash)
if payment.lost: if not payment or payment.lost:
return satsdice_renderer().TemplateResponse( return satsdice_renderer().TemplateResponse(
"satsdice/error.html", "satsdice/error.html",
{"request": request, "link": satsdicelink.id, "paid": False, "lost": True}, {"request": request, "link": satsdicelink.id, "paid": False, "lost": True},
@ -96,13 +100,18 @@ async def displaywin(
) )
await update_satsdice_payment(payment_hash, paid=1) await update_satsdice_payment(payment_hash, paid=1)
paylink = await get_satsdice_payment(payment_hash) paylink = await get_satsdice_payment(payment_hash)
if not paylink:
return satsdice_renderer().TemplateResponse(
"satsdice/error.html",
{"request": request, "link": satsdicelink.id, "paid": False, "lost": True},
)
data: CreateSatsDiceWithdraw = { data = CreateSatsDiceWithdraw(
"satsdice_pay": satsdicelink.id, satsdice_pay=satsdicelink.id,
"value": paylink.value * satsdicelink.multiplier, value=paylink.value * satsdicelink.multiplier,
"payment_hash": payment_hash, payment_hash=payment_hash,
"used": 0, used=0,
} )
withdrawLink = await create_satsdice_withdraw(data) withdrawLink = await create_satsdice_withdraw(data)
return satsdice_renderer().TemplateResponse( return satsdice_renderer().TemplateResponse(
@ -121,9 +130,12 @@ async def displaywin(
@satsdice_ext.get("/img/{link_id}", response_class=HTMLResponse) @satsdice_ext.get("/img/{link_id}", response_class=HTMLResponse)
async def img(link_id): async def img(link_id):
link = await get_satsdice_pay(link_id) or abort( link = await get_satsdice_pay(link_id)
HTTPStatus.NOT_FOUND, "satsdice link does not exist." if not link:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="satsdice link does not exist."
) )
qr = pyqrcode.create(link.lnurl) qr = pyqrcode.create(link.lnurl)
stream = BytesIO() stream = BytesIO()
qr.svg(stream, scale=3) qr.svg(stream, scale=3)

View file

@ -15,9 +15,10 @@ from .crud import (
delete_satsdice_pay, delete_satsdice_pay,
get_satsdice_pay, get_satsdice_pay,
get_satsdice_pays, get_satsdice_pays,
get_withdraw_hash_checkw,
update_satsdice_pay, update_satsdice_pay,
) )
from .models import CreateSatsDiceLink, CreateSatsDiceWithdraws, satsdiceLink from .models import CreateSatsDiceLink
################LNURL pay ################LNURL pay
@ -25,13 +26,15 @@ from .models import CreateSatsDiceLink, CreateSatsDiceWithdraws, satsdiceLink
@satsdice_ext.get("/api/v1/links") @satsdice_ext.get("/api/v1/links")
async def api_links( async def api_links(
request: Request, request: Request,
wallet: WalletTypeInfo = Depends(get_key_type), wallet: WalletTypeInfo = Depends(get_key_type), # type: ignore
all_wallets: bool = Query(False), all_wallets: bool = Query(False),
): ):
wallet_ids = [wallet.wallet.id] wallet_ids = [wallet.wallet.id]
if all_wallets: if all_wallets:
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids user = await get_user(wallet.wallet.user)
if user:
wallet_ids = user.wallet_ids
try: try:
links = await get_satsdice_pays(wallet_ids) links = await get_satsdice_pays(wallet_ids)
@ -46,7 +49,7 @@ async def api_links(
@satsdice_ext.get("/api/v1/links/{link_id}") @satsdice_ext.get("/api/v1/links/{link_id}")
async def api_link_retrieve( async def api_link_retrieve(
link_id: str = Query(None), wallet: WalletTypeInfo = Depends(get_key_type) link_id: str = Query(None), wallet: WalletTypeInfo = Depends(get_key_type) # type: ignore
): ):
link = await get_satsdice_pay(link_id) link = await get_satsdice_pay(link_id)
@ -67,7 +70,7 @@ async def api_link_retrieve(
@satsdice_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) @satsdice_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
async def api_link_create_or_update( async def api_link_create_or_update(
data: CreateSatsDiceLink, data: CreateSatsDiceLink,
wallet: WalletTypeInfo = Depends(get_key_type), wallet: WalletTypeInfo = Depends(get_key_type), # type: ignore
link_id: str = Query(None), link_id: str = Query(None),
): ):
if data.min_bet > data.max_bet: if data.min_bet > data.max_bet:
@ -95,10 +98,10 @@ async def api_link_create_or_update(
@satsdice_ext.delete("/api/v1/links/{link_id}") @satsdice_ext.delete("/api/v1/links/{link_id}")
async def api_link_delete( async def api_link_delete(
wallet: WalletTypeInfo = Depends(get_key_type), link_id: str = Query(None) wallet: WalletTypeInfo = Depends(get_key_type), # type: ignore
link_id: str = Query(None),
): ):
link = await get_satsdice_pay(link_id) link = await get_satsdice_pay(link_id)
if not link: if not link:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
@ -117,11 +120,12 @@ async def api_link_delete(
##########LNURL withdraw ##########LNURL withdraw
@satsdice_ext.get("/api/v1/withdraws/{the_hash}/{lnurl_id}") @satsdice_ext.get(
"/api/v1/withdraws/{the_hash}/{lnurl_id}", dependencies=[Depends(get_key_type)]
)
async def api_withdraw_hash_retrieve( async def api_withdraw_hash_retrieve(
wallet: WalletTypeInfo = Depends(get_key_type),
lnurl_id: str = Query(None), lnurl_id: str = Query(None),
the_hash: str = Query(None), the_hash: str = Query(None),
): ):
hashCheck = await get_withdraw_hash_check(the_hash, lnurl_id) hashCheck = await get_withdraw_hash_checkw(the_hash, lnurl_id)
return hashCheck return hashCheck

View file

@ -18,10 +18,10 @@ Easilly create invoices that support Lightning Network and on-chain BTC payment.
![charge form](https://i.imgur.com/F10yRiW.png) ![charge form](https://i.imgur.com/F10yRiW.png)
3. The charge will appear on the _Charges_ section\ 3. The charge will appear on the _Charges_ section\
![charges](https://i.imgur.com/zqHpVxc.png) ![charges](https://i.imgur.com/zqHpVxc.png)
4. Your costumer/payee will get the payment page 4. Your customer/payee will get the payment page
- they can choose to pay on LN\ - they can choose to pay on LN\
![offchain payment](https://i.imgur.com/4191SMV.png) ![offchain payment](https://i.imgur.com/4191SMV.png)
- or pay on chain\ - or pay on chain\
![onchain payment](https://i.imgur.com/wzLRR5N.png) ![onchain payment](https://i.imgur.com/wzLRR5N.png)
5. You can check the state of your charges in LNBits\ 5. You can check the state of your charges in LNbits\
![invoice state](https://i.imgur.com/JnBd22p.png) ![invoice state](https://i.imgur.com/JnBd22p.png)

View file

@ -2,7 +2,5 @@
"name": "SatsPay Server", "name": "SatsPay Server",
"short_description": "Create onchain and LN charges", "short_description": "Create onchain and LN charges",
"icon": "payment", "icon": "payment",
"contributors": [ "contributors": ["arcbtc"]
"arcbtc"
]
} }

View file

@ -1,23 +1,28 @@
import json
from typing import List, Optional from typing import List, Optional
import httpx from loguru import logger
from lnbits.core.services import create_invoice from lnbits.core.services import create_invoice
from lnbits.core.views.api import api_payment from lnbits.core.views.api import api_payment
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from ..watchonly.crud import get_config, get_fresh_address from ..watchonly.crud import get_config, get_fresh_address
# from lnbits.db import open_ext_db
from . import db from . import db
from .models import Charges, CreateCharge from .helpers import fetch_onchain_balance
from .models import Charges, CreateCharge, SatsPayThemes
###############CHARGES########################## ###############CHARGES##########################
async def create_charge(user: str, data: CreateCharge) -> Charges: async def create_charge(user: str, data: CreateCharge) -> Charges:
data = CreateCharge(**data.dict())
charge_id = urlsafe_short_hash() charge_id = urlsafe_short_hash()
if data.onchainwallet: if data.onchainwallet:
config = await get_config(user)
data.extra = json.dumps(
{"mempool_endpoint": config.mempool_endpoint, "network": config.network}
)
onchain = await get_fresh_address(data.onchainwallet) onchain = await get_fresh_address(data.onchainwallet)
onchainaddress = onchain.address onchainaddress = onchain.address
else: else:
@ -48,9 +53,11 @@ async def create_charge(user: str, data: CreateCharge) -> Charges:
completelinktext, completelinktext,
time, time,
amount, amount,
balance balance,
extra,
custom_css
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
charge_id, charge_id,
@ -67,6 +74,8 @@ async def create_charge(user: str, data: CreateCharge) -> Charges:
data.time, data.time,
data.amount, data.amount,
0, 0,
data.extra,
data.custom_css,
), ),
) )
return await get_charge(charge_id) return await get_charge(charge_id)
@ -98,34 +107,118 @@ async def delete_charge(charge_id: str) -> None:
await db.execute("DELETE FROM satspay.charges WHERE id = ?", (charge_id,)) await db.execute("DELETE FROM satspay.charges WHERE id = ?", (charge_id,))
async def check_address_balance(charge_id: str) -> List[Charges]: async def check_address_balance(charge_id: str) -> Optional[Charges]:
charge = await get_charge(charge_id) charge = await get_charge(charge_id)
if not charge.paid: if not charge.paid:
if charge.onchainaddress: if charge.onchainaddress:
config = await get_charge_config(charge_id)
try: try:
async with httpx.AsyncClient() as client: respAmount = await fetch_onchain_balance(charge)
r = await client.get(
config.mempool_endpoint
+ "/api/address/"
+ charge.onchainaddress
)
respAmount = r.json()["chain_stats"]["funded_txo_sum"]
if respAmount > charge.balance: if respAmount > charge.balance:
await update_charge(charge_id=charge_id, balance=respAmount) await update_charge(charge_id=charge_id, balance=respAmount)
except Exception: except Exception as e:
pass logger.warning(e)
if charge.lnbitswallet: if charge.lnbitswallet:
invoice_status = await api_payment(charge.payment_hash) invoice_status = await api_payment(charge.payment_hash)
if invoice_status["paid"]: if invoice_status["paid"]:
return await update_charge(charge_id=charge_id, balance=charge.amount) return await update_charge(charge_id=charge_id, balance=charge.amount)
row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,)) return await get_charge(charge_id)
return Charges.from_row(row) if row else None
async def get_charge_config(charge_id: str): ################## SETTINGS ###################
row = await db.fetchone(
"""SELECT "user" FROM satspay.charges WHERE id = ?""", (charge_id,)
async def save_theme(data: SatsPayThemes, css_id: str = None):
# insert or update
if css_id:
await db.execute(
"""
UPDATE satspay.themes SET custom_css = ?, title = ? WHERE css_id = ?
""",
(data.custom_css, data.title, css_id),
)
else:
css_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO satspay.themes (
css_id,
title,
user,
custom_css
)
VALUES (?, ?, ?, ?)
""",
(
css_id,
data.title,
data.user,
data.custom_css,
),
)
return await get_theme(css_id)
async def get_theme(css_id: str) -> SatsPayThemes:
row = await db.fetchone("SELECT * FROM satspay.themes WHERE css_id = ?", (css_id,))
return SatsPayThemes.from_row(row) if row else None
async def get_themes(user_id: str) -> List[SatsPayThemes]:
rows = await db.fetchall(
"""SELECT * FROM satspay.themes WHERE "user" = ? ORDER BY "timestamp" DESC """,
(user_id,),
) )
return await get_config(row.user) return await get_config(row.user)
################## SETTINGS ###################
async def save_theme(data: SatsPayThemes, css_id: str = None):
# insert or update
if css_id:
await db.execute(
"""
UPDATE satspay.themes SET custom_css = ?, title = ? WHERE css_id = ?
""",
(data.custom_css, data.title, css_id),
)
else:
css_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO satspay.themes (
css_id,
title,
"user",
custom_css
)
VALUES (?, ?, ?, ?)
""",
(
css_id,
data.title,
data.user,
data.custom_css,
),
)
return await get_theme(css_id)
async def get_theme(css_id: str) -> SatsPayThemes:
row = await db.fetchone("SELECT * FROM satspay.themes WHERE css_id = ?", (css_id,))
return SatsPayThemes.from_row(row) if row else None
async def get_themes(user_id: str) -> List[SatsPayThemes]:
rows = await db.fetchall(
"""SELECT * FROM satspay.themes WHERE "user" = ? ORDER BY "title" DESC """,
(user_id,),
)
return [SatsPayThemes.from_row(row) for row in rows]
async def delete_theme(theme_id: str) -> None:
await db.execute("DELETE FROM satspay.themes WHERE css_id = ?", (theme_id,))

View file

@ -1,8 +1,11 @@
import httpx
from loguru import logger
from .models import Charges from .models import Charges
def compact_charge(charge: Charges): def public_charge(charge: Charges):
return { c = {
"id": charge.id, "id": charge.id,
"description": charge.description, "description": charge.description,
"onchainaddress": charge.onchainaddress, "onchainaddress": charge.onchainaddress,
@ -13,5 +16,44 @@ def compact_charge(charge: Charges):
"balance": charge.balance, "balance": charge.balance,
"paid": charge.paid, "paid": charge.paid,
"timestamp": charge.timestamp, "timestamp": charge.timestamp,
"completelink": charge.completelink, # should be secret? "time_elapsed": charge.time_elapsed,
"time_left": charge.time_left,
"paid": charge.paid,
"custom_css": charge.custom_css,
} }
if charge.paid:
c["completelink"] = charge.completelink
c["completelinktext"] = charge.completelinktext
return c
async def call_webhook(charge: Charges):
async with httpx.AsyncClient() as client:
try:
r = await client.post(
charge.webhook,
json=public_charge(charge),
timeout=40,
)
return {
"webhook_success": r.is_success,
"webhook_message": r.reason_phrase,
"webhook_response": r.text,
}
except Exception as e:
logger.warning(f"Failed to call webhook for charge {charge.id}")
logger.warning(e)
return {"webhook_success": False, "webhook_message": str(e)}
async def fetch_onchain_balance(charge: Charges):
endpoint = (
f"{charge.config.mempool_endpoint}/testnet"
if charge.config.network == "Testnet"
else charge.config.mempool_endpoint
)
async with httpx.AsyncClient() as client:
r = await client.get(endpoint + "/api/address/" + charge.onchainaddress)
return r.json()["chain_stats"]["funded_txo_sum"]

View file

@ -4,7 +4,7 @@ async def m001_initial(db):
""" """
await db.execute( await db.execute(
""" f"""
CREATE TABLE satspay.charges ( CREATE TABLE satspay.charges (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
"user" TEXT, "user" TEXT,
@ -18,11 +18,47 @@ async def m001_initial(db):
completelink TEXT, completelink TEXT,
completelinktext TEXT, completelinktext TEXT,
time INTEGER, time INTEGER,
amount INTEGER, amount {db.big_int},
balance INTEGER DEFAULT 0, balance {db.big_int} DEFAULT 0,
timestamp TIMESTAMP NOT NULL DEFAULT """ timestamp TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now + db.timestamp_now
+ """ + """
); );
""" """
) )
async def m002_add_charge_extra_data(db):
"""
Add 'extra' column for storing various config about the charge (JSON format)
"""
await db.execute(
"""ALTER TABLE satspay.charges
ADD COLUMN extra TEXT DEFAULT '{"mempool_endpoint": "https://mempool.space", "network": "Mainnet"}';
"""
)
async def m003_add_themes_table(db):
"""
Themes table
"""
await db.execute(
"""
CREATE TABLE satspay.themes (
css_id TEXT NOT NULL PRIMARY KEY,
"user" TEXT,
title TEXT,
custom_css TEXT
);
"""
)
async def m004_add_custom_css_to_charges(db):
"""
Add custom css option column to the 'charges' table
"""
await db.execute("ALTER TABLE satspay.charges ADD COLUMN custom_css TEXT;")

View file

@ -1,3 +1,4 @@
import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from sqlite3 import Row from sqlite3 import Row
from typing import Optional from typing import Optional
@ -5,6 +6,10 @@ from typing import Optional
from fastapi.param_functions import Query from fastapi.param_functions import Query
from pydantic import BaseModel from pydantic import BaseModel
DEFAULT_MEMPOOL_CONFIG = (
'{"mempool_endpoint": "https://mempool.space", "network": "Mainnet"}'
)
class CreateCharge(BaseModel): class CreateCharge(BaseModel):
onchainwallet: str = Query(None) onchainwallet: str = Query(None)
@ -13,8 +18,17 @@ class CreateCharge(BaseModel):
webhook: str = Query(None) webhook: str = Query(None)
completelink: str = Query(None) completelink: str = Query(None)
completelinktext: str = Query(None) completelinktext: str = Query(None)
custom_css: Optional[str]
time: int = Query(..., ge=1) time: int = Query(..., ge=1)
amount: int = Query(..., ge=1) amount: int = Query(..., ge=1)
extra: str = DEFAULT_MEMPOOL_CONFIG
class ChargeConfig(BaseModel):
mempool_endpoint: Optional[str]
network: Optional[str]
webhook_success: Optional[bool] = False
webhook_message: Optional[str]
class Charges(BaseModel): class Charges(BaseModel):
@ -28,6 +42,8 @@ class Charges(BaseModel):
webhook: Optional[str] webhook: Optional[str]
completelink: Optional[str] completelink: Optional[str]
completelinktext: Optional[str] = "Back to Merchant" completelinktext: Optional[str] = "Back to Merchant"
custom_css: Optional[str]
extra: str = DEFAULT_MEMPOOL_CONFIG
time: int time: int
amount: int amount: int
balance: int balance: int
@ -54,3 +70,22 @@ class Charges(BaseModel):
return True return True
else: else:
return False return False
@property
def config(self) -> ChargeConfig:
charge_config = json.loads(self.extra)
return ChargeConfig(**charge_config)
def must_call_webhook(self):
return self.webhook and self.paid and self.config.webhook_success == False
class SatsPayThemes(BaseModel):
css_id: str = Query(None)
title: str = Query(None)
custom_css: str = Query(None)
user: Optional[str]
@classmethod
def from_row(cls, row: Row) -> "SatsPayThemes":
return cls(**dict(row))

View file

@ -14,18 +14,23 @@ const retryWithDelay = async function (fn, retryCount = 0) {
} }
const mapCharge = (obj, oldObj = {}) => { const mapCharge = (obj, oldObj = {}) => {
const charge = _.clone(obj) const charge = {...oldObj, ...obj}
charge.progress = obj.time_left < 0 ? 1 : 1 - obj.time_left / obj.time charge.progress = obj.time_left < 0 ? 1 : 1 - obj.time_left / obj.time
charge.time = minutesToTime(obj.time) charge.time = minutesToTime(obj.time)
charge.timeLeft = minutesToTime(obj.time_left) charge.timeLeft = minutesToTime(obj.time_left)
charge.expanded = false
charge.displayUrl = ['/satspay/', obj.id].join('') charge.displayUrl = ['/satspay/', obj.id].join('')
charge.expanded = oldObj.expanded charge.expanded = oldObj.expanded || false
charge.pendingBalance = oldObj.pendingBalance || 0 charge.pendingBalance = oldObj.pendingBalance || 0
charge.extra = charge.extra ? JSON.parse(charge.extra) : charge.extra
return charge return charge
} }
const mapCSS = (obj, oldObj = {}) => {
const theme = _.clone(obj)
return theme
}
const minutesToTime = min => const minutesToTime = min =>
min > 0 ? new Date(min * 1000).toISOString().substring(14, 19) : '' min > 0 ? new Date(min * 1000).toISOString().substring(14, 19) : ''

View file

@ -1,4 +1,5 @@
import asyncio import asyncio
import json
from loguru import logger from loguru import logger
@ -7,7 +8,8 @@ from lnbits.extensions.satspay.crud import check_address_balance, get_charge
from lnbits.helpers import get_current_extension_name from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
# from .crud import get_ticket, set_ticket_paid from .crud import update_charge
from .helpers import call_webhook
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
@ -30,4 +32,9 @@ async def on_invoice_paid(payment: Payment) -> None:
return return
await payment.set_pending(False) await payment.set_pending(False)
await check_address_balance(charge_id=charge.id) charge = await check_address_balance(charge_id=charge.id)
if charge.must_call_webhook():
resp = await call_webhook(charge)
extra = {**charge.config.dict(), **resp}
await update_charge(charge_id=charge.id, extra=json.dumps(extra))

View file

@ -5,7 +5,13 @@
WatchOnly extension, we highly reccomend using a fresh extended public Key WatchOnly extension, we highly reccomend using a fresh extended public Key
specifically for SatsPayServer!<br /> specifically for SatsPayServer!<br />
<small> <small>
Created by, <a href="https://github.com/benarc">Ben Arc</a></small Created by, <a href="https://github.com/benarc">Ben Arc</a>,
<a
target="_blank"
style="color: unset"
href="https://github.com/motorina0"
>motorina0</a
></small
> >
</p> </p>
<br /> <br />

View file

@ -109,7 +109,7 @@
<q-btn <q-btn
flat flat
disable disable
v-if="!charge.lnbitswallet || charge.time_elapsed" v-if="!charge.payment_request || charge.time_elapsed"
style="color: primary; width: 100%" style="color: primary; width: 100%"
label="lightning⚡" label="lightning⚡"
> >
@ -131,7 +131,7 @@
<q-btn <q-btn
flat flat
disable disable
v-if="!charge.onchainwallet || charge.time_elapsed" v-if="!charge.onchainaddress || charge.time_elapsed"
style="color: primary; width: 100%" style="color: primary; width: 100%"
label="onchain⛓" label="onchain⛓"
> >
@ -170,14 +170,18 @@
name="check" name="check"
style="color: green; font-size: 21.4em" style="color: green; font-size: 21.4em"
></q-icon> ></q-icon>
<div class="row text-center q-mt-lg">
<div class="col text-center">
<q-btn <q-btn
outline outline
v-if="charge.webhook" v-if="charge.completelink"
type="a" type="a"
:href="charge.completelink" :href="charge.completelink"
:label="charge.completelinktext" :label="charge.completelinktext"
></q-btn> ></q-btn>
</div> </div>
</div>
</div>
<div v-else> <div v-else>
<div class="row text-center q-mb-sm"> <div class="row text-center q-mb-sm">
<div class="col text-center"> <div class="col text-center">
@ -218,7 +222,7 @@
<div class="col text-center"> <div class="col text-center">
<a <a
style="color: unset" style="color: unset"
:href="mempool_endpoint + '/address/' + charge.onchainaddress" :href="'https://' + mempoolHostname + '/address/' + charge.onchainaddress"
target="_blank" target="_blank"
><span ><span
class="text-subtitle1" class="text-subtitle1"
@ -241,6 +245,8 @@
name="check" name="check"
style="color: green; font-size: 21.4em" style="color: green; font-size: 21.4em"
></q-icon> ></q-icon>
<div class="row text-center q-mt-lg">
<div class="col text-center">
<q-btn <q-btn
outline outline
v-if="charge.webhook" v-if="charge.webhook"
@ -249,6 +255,8 @@
:label="charge.completelinktext" :label="charge.completelinktext"
></q-btn> ></q-btn>
</div> </div>
</div>
</div>
<div v-else> <div v-else>
<div class="row items-center q-mb-sm"> <div class="row items-center q-mb-sm">
<div class="col text-center"> <div class="col text-center">
@ -289,7 +297,17 @@
</div> </div>
<div class="col-lg- 4 col-md-3 col-sm-1"></div> <div class="col-lg- 4 col-md-3 col-sm-1"></div>
</div> </div>
{% endblock %} {% block styles %}
<link
href="/satspay/css/{{ charge_data.custom_css }}"
rel="stylesheet"
type="text/css"
/>
<style>
header button.q-btn-dropdown {
display: none;
}
</style>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script src="https://mempool.space/mempool.js"></script> <script src="https://mempool.space/mempool.js"></script>
@ -303,7 +321,8 @@
data() { data() {
return { return {
charge: JSON.parse('{{charge_data | tojson}}'), charge: JSON.parse('{{charge_data | tojson}}'),
mempool_endpoint: '{{mempool_endpoint}}', mempoolEndpoint: '{{mempool_endpoint}}',
network: '{{network}}',
pendingFunds: 0, pendingFunds: 0,
ws: null, ws: null,
newProgress: 0.4, newProgress: 0.4,
@ -316,19 +335,19 @@
cancelListener: () => {} cancelListener: () => {}
} }
}, },
methods: { computed: {
startPaymentNotifier() { mempoolHostname: function () {
this.cancelListener() let hostname = new URL(this.mempoolEndpoint).hostname
if (!this.lnbitswallet) return if (this.network === 'Testnet') {
this.cancelListener = LNbits.events.onInvoicePaid( hostname += '/testnet'
this.wallet, }
payment => { return hostname
this.checkInvoiceBalance()
} }
)
}, },
methods: {
checkBalances: async function () { checkBalances: async function () {
if (this.charge.hasStaleBalance) return if (!this.charge.payment_request && this.charge.hasOnchainStaleBalance)
return
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'GET', 'GET',
@ -345,7 +364,7 @@
const { const {
bitcoin: {addresses: addressesAPI} bitcoin: {addresses: addressesAPI}
} = mempoolJS({ } = mempoolJS({
hostname: new URL(this.mempool_endpoint).hostname hostname: new URL(this.mempoolEndpoint).hostname
}) })
try { try {
@ -353,7 +372,8 @@
address: this.charge.onchainaddress address: this.charge.onchainaddress
}) })
const newBalance = utxos.reduce((t, u) => t + u.value, 0) const newBalance = utxos.reduce((t, u) => t + u.value, 0)
this.charge.hasStaleBalance = this.charge.balance === newBalance this.charge.hasOnchainStaleBalance =
this.charge.balance === newBalance
this.pendingFunds = utxos this.pendingFunds = utxos
.filter(u => !u.status.confirmed) .filter(u => !u.status.confirmed)
@ -388,10 +408,10 @@
const { const {
bitcoin: {websocket} bitcoin: {websocket}
} = mempoolJS({ } = mempoolJS({
hostname: new URL(this.mempool_endpoint).hostname hostname: new URL(this.mempoolEndpoint).hostname
}) })
this.ws = new WebSocket('wss://mempool.space/api/v1/ws') this.ws = new WebSocket(`wss://${this.mempoolHostname}/api/v1/ws`)
this.ws.addEventListener('open', x => { this.ws.addEventListener('open', x => {
if (this.charge.onchainaddress) { if (this.charge.onchainaddress) {
this.trackAddress(this.charge.onchainaddress) this.trackAddress(this.charge.onchainaddress)
@ -428,13 +448,14 @@
} }
}, },
created: async function () { created: async function () {
if (this.charge.lnbitswallet) this.payInvoice() // Remove a user defined theme
if (this.charge.custom_css) {
document.body.setAttribute('data-theme', '')
}
if (this.charge.payment_request) this.payInvoice()
else this.payOnchain() else this.payOnchain()
await this.checkBalances()
// empty for onchain await this.checkBalances()
this.wallet.inkey = '{{ wallet_inkey }}'
this.startPaymentNotifier()
if (!this.charge.paid) { if (!this.charge.paid) {
this.loopRefresh() this.loopRefresh()

View file

@ -8,6 +8,26 @@
<q-btn unelevated color="primary" @click="formDialogCharge.show = true" <q-btn unelevated color="primary" @click="formDialogCharge.show = true"
>New charge >New charge
</q-btn> </q-btn>
<q-btn
v-if="admin == 'True'"
unelevated
color="primary"
@click="getThemes();formDialogThemes.show = true"
>New CSS Theme
</q-btn>
<q-btn
v-else
disable
unelevated
color="primary"
@click="getThemes();formDialogThemes.show = true"
>New CSS Theme
<q-tooltip
>For security reason, custom css is only available to server
admins.</q-tooltip
></q-btn
>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -203,9 +223,19 @@
:href="props.row.webhook" :href="props.row.webhook"
target="_blank" target="_blank"
style="color: unset; text-decoration: none" style="color: unset; text-decoration: none"
>{{props.row.webhook || props.row.webhook}}</a >{{props.row.webhook}}</a
> >
</div> </div>
<div class="col-4 q-pr-lg">
<q-badge
v-if="props.row.webhook_message"
@click="showWebhookResponseDialog(props.row.extra.webhook_response)"
color="blue"
class="cursor-pointer"
>
{{props.row.webhook_message }}
</q-badge>
</div>
</div> </div>
<div class="row items-center q-mt-md q-mb-lg"> <div class="row items-center q-mt-md q-mb-lg">
<div class="col-2 q-pr-lg">ID:</div> <div class="col-2 q-pr-lg">ID:</div>
@ -254,6 +284,63 @@
</q-table> </q-table>
</q-card-section> </q-card-section>
</q-card> </q-card>
<q-card v-if="admin == 'True'">
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Themes</h5>
</div>
</div>
<q-table
dense
flat
:data="themeLinks"
row-key="id"
:columns="customCSSTable.columns"
:pagination.sync="customCSSTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="updateformDialog(props.row.css_id)"
icon="edit"
color="light-blue"
></q-btn>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteTheme(props.row.css_id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div> </div>
<div class="col-12 col-md-5 q-gutter-y-md"> <div class="col-12 col-md-5 q-gutter-y-md">
@ -298,32 +385,6 @@
> >
</q-input> </q-input>
<q-input
filled
dense
v-model.trim="formDialogCharge.data.webhook"
type="url"
label="Webhook (URL to send transaction data to once paid)"
>
</q-input>
<q-input
filled
dense
v-model.trim="formDialogCharge.data.completelink"
type="url"
label="Completed button URL"
>
</q-input>
<q-input
filled
dense
v-model.trim="formDialogCharge.data.completelinktext"
type="text"
label="Completed button text (ie 'Back to merchant')"
>
</q-input>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div v-if="walletLinks.length > 0"> <div v-if="walletLinks.length > 0">
@ -372,6 +433,52 @@
label="Wallet *" label="Wallet *"
> >
</q-select> </q-select>
<q-toggle
v-model="showAdvanced"
label="Show advanced options"
></q-toggle>
<div v-if="showAdvanced" class="row">
<div class="col">
<q-input
filled
dense
v-model.trim="formDialogCharge.data.webhook"
type="url"
label="Webhook (URL to send transaction data to once paid)"
class="q-mt-lg"
>
</q-input>
<q-input
filled
dense
v-model.trim="formDialogCharge.data.completelink"
type="url"
label="Completed button URL"
class="q-mt-lg"
>
</q-input>
<q-input
filled
dense
v-model.trim="formDialogCharge.data.completelinktext"
type="text"
label="Completed button text (ie 'Back to merchant')"
class="q-mt-lg"
>
</q-input>
<q-select
filled
dense
emit-value
v-model="formDialogCharge.data.custom_css"
:options="themeOptions"
label="Custom CSS theme (optional)"
class="q-mt-lg"
>
</q-select>
</div>
</div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
unelevated unelevated
@ -389,6 +496,60 @@
</q-form> </q-form>
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="formDialogThemes.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormDataThemes" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialogThemes.data.title"
type="text"
label="*Title"
></q-input>
<q-input
filled
dense
v-model.trim="formDialogThemes.data.custom_css"
type="textarea"
label="Custom CSS"
>
</q-input>
<div class="row q-mt-lg">
<q-btn
v-if="formDialogThemes.data.css_id"
unelevated
color="primary"
type="submit"
>Update CSS theme</q-btn
>
<q-btn v-else unelevated color="primary" type="submit"
>Save CSS theme</q-btn
>
<q-btn @click="cancelThemes" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="showWebhookResponse" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-input
filled
dense
readonly
v-model.trim="webhookResponse"
type="textarea"
label="Response"
></q-input>
<div class="row q-mt-lg">
<q-btn flat v-close-popup color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div> </div>
{% endblock %} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block scripts %} {{ window_vars(user) }}
<!-- lnbits/static/vendor <!-- lnbits/static/vendor
@ -405,15 +566,21 @@
mixins: [windowMixin], mixins: [windowMixin],
data: function () { data: function () {
return { return {
settings: {},
filter: '', filter: '',
admin: '{{ admin }}',
balance: null, balance: null,
walletLinks: [], walletLinks: [],
chargeLinks: [], chargeLinks: [],
themeLinks: [],
themeOptions: [],
onchainwallet: '', onchainwallet: '',
rescanning: false, rescanning: false,
mempool: { mempool: {
endpoint: '' endpoint: '',
network: 'Mainnet'
}, },
showAdvanced: false,
chargesTable: { chargesTable: {
columns: [ columns: [
@ -488,7 +655,25 @@
rowsPerPage: 10 rowsPerPage: 10
} }
}, },
customCSSTable: {
columns: [
{
name: 'css_id',
align: 'left',
label: 'ID',
field: 'css_id'
},
{
name: 'title',
align: 'left',
label: 'Title',
field: 'title'
}
],
pagination: {
rowsPerPage: 10
}
},
formDialogCharge: { formDialogCharge: {
show: false, show: false,
data: { data: {
@ -496,20 +681,35 @@
onchainwallet: '', onchainwallet: '',
lnbits: false, lnbits: false,
description: '', description: '',
custom_css: '',
time: null, time: null,
amount: null amount: null
} }
},
formDialogThemes: {
show: false,
data: {
custom_css: ''
} }
},
showWebhookResponse: false,
webhookResponse: ''
} }
}, },
methods: { methods: {
cancelThemes: function (data) {
this.formDialogCharge.data.custom_css = ''
this.formDialogThemes.show = false
},
cancelCharge: function (data) { cancelCharge: function (data) {
this.formDialogCharge.data.description = '' this.formDialogCharge.data.description = ''
this.formDialogCharge.data.onchain = false
this.formDialogCharge.data.onchainwallet = '' this.formDialogCharge.data.onchainwallet = ''
this.formDialogCharge.data.lnbitswallet = '' this.formDialogCharge.data.lnbitswallet = ''
this.formDialogCharge.data.time = null this.formDialogCharge.data.time = null
this.formDialogCharge.data.amount = null this.formDialogCharge.data.amount = null
this.formDialogCharge.data.webhook = '' this.formDialogCharge.data.webhook = ''
this.formDialogCharge.data.custom_css = ''
this.formDialogCharge.data.completelink = '' this.formDialogCharge.data.completelink = ''
this.formDialogCharge.show = false this.formDialogCharge.show = false
}, },
@ -518,7 +718,7 @@
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'GET', 'GET',
'/watchonly/api/v1/wallet', `/watchonly/api/v1/wallet?network=${this.mempool.network}`,
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
this.walletLinks = data.map(w => ({ this.walletLinks = data.map(w => ({
@ -538,6 +738,7 @@
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
this.mempool.endpoint = data.mempool_endpoint this.mempool.endpoint = data.mempool_endpoint
this.mempool.network = data.network || 'Mainnet'
const url = new URL(this.mempool.endpoint) const url = new URL(this.mempool.endpoint)
this.mempool.hostname = url.hostname this.mempool.hostname = url.hostname
} catch (error) { } catch (error) {
@ -572,12 +773,42 @@
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} }
}, },
sendFormDataCharge: function () {
getThemes: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/satspay/api/v1/themes',
this.g.user.wallets[0].inkey
)
this.themeLinks = data.map(c =>
mapCSS(
c,
this.themeLinks.find(old => old.css_id === c.css_id)
)
)
this.themeOptions = data.map(w => ({
id: w.css_id,
label: w.title + ' - ' + w.css_id
}))
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
sendFormDataThemes: function () {
const wallet = this.g.user.wallets[0].inkey const wallet = this.g.user.wallets[0].inkey
const data = this.formDialogThemes.data
this.createTheme(wallet, data)
},
sendFormDataCharge: function () {
this.formDialogCharge.data.custom_css = this.formDialogCharge.data.custom_css?.id
const data = this.formDialogCharge.data const data = this.formDialogCharge.data
const wallet = this.g.user.wallets[0].inkey
data.amount = parseInt(data.amount) data.amount = parseInt(data.amount)
data.time = parseInt(data.time) data.time = parseInt(data.time)
data.onchainwallet = this.onchainwallet?.id data.lnbitswallet = data.lnbits ? data.lnbitswallet : null
data.onchainwallet = data.onchain ? this.onchainwallet?.id : null
this.createCharge(wallet, data) this.createCharge(wallet, data)
}, },
refreshActiveChargesBalance: async function () { refreshActiveChargesBalance: async function () {
@ -642,6 +873,65 @@
this.rescanning = false this.rescanning = false
} }
}, },
updateformDialog: function (themeId) {
const theme = _.findWhere(this.themeLinks, {css_id: themeId})
this.formDialogThemes.data.css_id = theme.css_id
this.formDialogThemes.data.title = theme.title
this.formDialogThemes.data.custom_css = theme.custom_css
this.formDialogThemes.show = true
},
createTheme: async function (wallet, data) {
try {
if (data.css_id) {
const resp = await LNbits.api.request(
'POST',
'/satspay/api/v1/themes/' + data.css_id,
wallet,
data
)
this.themeLinks = _.reject(this.themeLinks, function (obj) {
return obj.css_id === data.css_id
})
this.themeLinks.unshift(mapCSS(resp.data))
} else {
const resp = await LNbits.api.request(
'POST',
'/satspay/api/v1/themes',
wallet,
data
)
this.themeLinks.unshift(mapCSS(resp.data))
}
this.formDialogThemes.show = false
this.formDialogThemes.data = {
title: '',
custom_css: ''
}
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
deleteTheme: function (themeId) {
const theme = _.findWhere(this.themeLinks, {id: themeId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this theme?')
.onOk(async () => {
try {
const response = await LNbits.api.request(
'DELETE',
'/satspay/api/v1/themes/' + themeId,
this.g.user.wallets[0].adminkey
)
this.themeLinks = _.reject(this.themeLinks, function (obj) {
return obj.css_id === themeId
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
})
},
createCharge: async function (wallet, data) { createCharge: async function (wallet, data) {
try { try {
const resp = await LNbits.api.request( const resp = await LNbits.api.request(
@ -685,6 +975,10 @@
} }
}) })
}, },
showWebhookResponseDialog(webhookResponse) {
this.webhookResponse = webhookResponse
this.showWebhookResponse = true
},
exportchargeCSV: function () { exportchargeCSV: function () {
LNbits.utils.exportCSV( LNbits.utils.exportCSV(
this.chargesTable.columns, this.chargesTable.columns,
@ -694,9 +988,12 @@
} }
}, },
created: async function () { created: async function () {
if (this.admin == 'True') {
await this.getThemes()
}
await this.getCharges() await this.getCharges()
await this.getWalletLinks()
await this.getWalletConfig() await this.getWalletConfig()
await this.getWalletLinks()
setInterval(() => this.refreshActiveChargesBalance(), 10 * 2000) setInterval(() => this.refreshActiveChargesBalance(), 10 * 2000)
await this.rescanOnchainAddresses() await this.rescanOnchainAddresses()
setInterval(() => this.rescanOnchainAddresses(), 10 * 1000) setInterval(() => this.rescanOnchainAddresses(), 10 * 1000)

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