Merge branch 'main' into ext-boltcards_keys
This commit is contained in:
commit
e7fdd31cda
96 changed files with 7035 additions and 878 deletions
13
.env.example
13
.env.example
|
|
@ -6,15 +6,17 @@ PORT=5000
|
|||
|
||||
DEBUG=false
|
||||
|
||||
# Find "usr" string in wallet url to explicit allow users or set admins (comma separated list)
|
||||
# Allow users and admins by user IDs (comma separated list)
|
||||
LNBITS_ALLOWED_USERS=""
|
||||
LNBITS_ADMIN_USERS=""
|
||||
# Extensions only admin can access
|
||||
LNBITS_ADMIN_EXTENSIONS="ngrok"
|
||||
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
||||
|
||||
# csv ad image filepaths or urls, extensions can choose to honor
|
||||
LNBITS_AD_SPACE=""
|
||||
# Ad space description
|
||||
# LNBITS_AD_SPACE_TITLE="Supported by"
|
||||
# csv ad space, format "<url>;<img-light>;<img-dark>, <url>;<img-light>;<img-dark>", extensions can choose to honor
|
||||
# LNBITS_AD_SPACE=""
|
||||
|
||||
# Hides wallet api, extensions can choose to honor
|
||||
LNBITS_HIDE_API=false
|
||||
|
|
@ -101,3 +103,8 @@ ECLAIR_PASS=eclairpw
|
|||
# Enter /api in LightningTipBot to get your key
|
||||
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -9,53 +9,10 @@ nav_order: 2
|
|||
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:
|
||||
|
||||
|
|
@ -67,16 +24,16 @@ initWs: async function () {
|
|||
document.domain +
|
||||
':' +
|
||||
location.port +
|
||||
'/extension/ws/' +
|
||||
self.extension.id
|
||||
'/api/v1/ws/' +
|
||||
self.item.id
|
||||
} else {
|
||||
localUrl =
|
||||
'ws://' +
|
||||
document.domain +
|
||||
':' +
|
||||
location.port +
|
||||
'/extension/ws/' +
|
||||
self.extension.id
|
||||
'/api/v1/ws/' +
|
||||
self.item.id
|
||||
}
|
||||
this.ws = new WebSocket(localUrl)
|
||||
this.ws.addEventListener('message', async ({data}) => {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,15 @@ poetry run lnbits
|
|||
# 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.
|
||||
```
|
||||
#### 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
|
||||
|
||||
|
|
@ -75,8 +84,8 @@ LNBITS_DATA_FOLDER=data LNBITS_BACKEND_WALLET_CLASS=LNbitsWallet LNBITS_ENDPOINT
|
|||
```sh
|
||||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits-legend/
|
||||
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3-venv'
|
||||
python3 -m venv venv
|
||||
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3.9-venv'
|
||||
python3.9 -m venv venv
|
||||
# If you have problems here, try `sudo apt install -y pkg-config libpq-dev`
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
# create the data folder and the .env file
|
||||
|
|
@ -106,7 +115,7 @@ docker run --detach --publish 5000:5000 --name lnbits-legend --volume ${PWD}/.en
|
|||
|
||||
## 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).
|
||||
|
||||
|
|
@ -169,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
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import warnings
|
|||
from http import HTTPStatus
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.exceptions import HTTPException, RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
|
@ -68,28 +68,6 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
|||
g().config = lnbits.settings
|
||||
g().base_url = f"http://{lnbits.settings.HOST}:{lnbits.settings.PORT}"
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(
|
||||
request: Request, exc: RequestValidationError
|
||||
):
|
||||
# Only the browser sends "text/html" request
|
||||
# not fail proof, but everything else get's a JSON response
|
||||
|
||||
if (
|
||||
request.headers
|
||||
and "accept" in request.headers
|
||||
and "text/html" in request.headers["accept"]
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html",
|
||||
{"request": request, "err": f"{exc.errors()} is not a valid UUID."},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=HTTPStatus.NO_CONTENT,
|
||||
content={"detail": exc.errors()},
|
||||
)
|
||||
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
check_funding_source(app)
|
||||
|
|
@ -192,12 +170,33 @@ def register_async_tasks(app):
|
|||
|
||||
def register_exception_handlers(app: FastAPI):
|
||||
@app.exception_handler(Exception)
|
||||
async def basic_error(request: Request, err):
|
||||
logger.error("handled error", traceback.format_exc())
|
||||
logger.error("ERROR:", err)
|
||||
async def exception_handler(request: Request, exc: Exception):
|
||||
etype, _, tb = sys.exc_info()
|
||||
traceback.print_exception(etype, err, tb)
|
||||
exc = traceback.format_exc()
|
||||
traceback.print_exception(etype, exc, tb)
|
||||
logger.error(f"Exception: {str(exc)}")
|
||||
# Only the browser sends "text/html" request
|
||||
# not fail proof, but everything else get's a JSON response
|
||||
if (
|
||||
request.headers
|
||||
and "accept" in request.headers
|
||||
and "text/html" in request.headers["accept"]
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": f"Error: {str(exc)}"}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
content={"detail": str(exc)},
|
||||
)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(
|
||||
request: Request, exc: RequestValidationError
|
||||
):
|
||||
logger.error(f"RequestValidationError: {str(exc)}")
|
||||
# Only the browser sends "text/html" request
|
||||
# not fail proof, but everything else get's a JSON response
|
||||
|
||||
if (
|
||||
request.headers
|
||||
|
|
@ -205,12 +204,37 @@ def register_exception_handlers(app: FastAPI):
|
|||
and "text/html" in request.headers["accept"]
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": err}
|
||||
"error.html",
|
||||
{"request": request, "err": f"Error: {str(exc)}"},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=HTTPStatus.NO_CONTENT,
|
||||
content={"detail": err},
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
content={"detail": str(exc)},
|
||||
)
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
logger.error(f"HTTPException {exc.status_code}: {exc.detail}")
|
||||
# Only the browser sends "text/html" request
|
||||
# not fail proof, but everything else get's a JSON response
|
||||
|
||||
if (
|
||||
request.headers
|
||||
and "accept" in request.headers
|
||||
and "text/html" in request.headers["accept"]
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html",
|
||||
{
|
||||
"request": request,
|
||||
"err": f"HTTP Error {exc.status_code}: {exc.detail}",
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": exc.detail},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -65,8 +65,7 @@ async def migrate_databases():
|
|||
(db_name, version, version),
|
||||
)
|
||||
|
||||
async def run_migration(db, migrations_module):
|
||||
db_name = migrations_module.__name__.split(".")[-2]
|
||||
async def run_migration(db, migrations_module, db_name):
|
||||
for key, migrate in migrations_module.__dict__.items():
|
||||
match = match = matcher.match(key)
|
||||
if match:
|
||||
|
|
@ -97,20 +96,24 @@ async def migrate_databases():
|
|||
rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall()
|
||||
current_versions = {row["db"]: row["version"] for row in rows}
|
||||
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():
|
||||
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
|
||||
db_name = ext.db_name or module_str.split(".")[-2]
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
f"Please make sure that the extension `{ext.code}` has a migrations file."
|
||||
)
|
||||
|
||||
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.")
|
||||
|
|
|
|||
|
|
@ -339,37 +339,14 @@ async def delete_expired_invoices(
|
|||
AND time < {db.timestamp_now} - {db.interval_seconds(2592000)}
|
||||
"""
|
||||
)
|
||||
|
||||
# then we delete all expired invoices, checking one by one
|
||||
rows = await (conn or db).fetchall(
|
||||
# then we delete all invoices whose expiry date is in the past
|
||||
await (conn or db).execute(
|
||||
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)}
|
||||
DELETE FROM apipayments
|
||||
WHERE pending = true AND amount > 0
|
||||
AND expiry < {db.timestamp_now}
|
||||
"""
|
||||
)
|
||||
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(
|
||||
"""
|
||||
DELETE FROM apipayments
|
||||
WHERE pending = true AND hash = ?
|
||||
""",
|
||||
(invoice.payment_hash,),
|
||||
)
|
||||
|
||||
|
||||
# payments
|
||||
|
|
@ -396,12 +373,19 @@ async def create_payment(
|
|||
# previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
||||
# 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(
|
||||
"""
|
||||
INSERT INTO apipayments
|
||||
(wallet, checking_id, bolt11, hash, preimage,
|
||||
amount, pending, memo, fee, extra, webhook)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
amount, pending, memo, fee, extra, webhook, expiry)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
wallet_id,
|
||||
|
|
@ -417,6 +401,7 @@ async def create_payment(
|
|||
if extra and extra != {} and type(extra) is dict
|
||||
else None,
|
||||
webhook,
|
||||
db.datetime_to_timestamp(expiration_date),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import datetime
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy.exc import OperationalError # type: ignore
|
||||
|
||||
from lnbits import bolt11
|
||||
|
||||
|
||||
async def m000_create_migrations_table(db):
|
||||
await db.execute(
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from sqlite3 import Row
|
||||
from typing import Dict, List, NamedTuple, Optional
|
||||
|
||||
|
|
@ -83,6 +85,7 @@ class Payment(BaseModel):
|
|||
bolt11: str
|
||||
preimage: str
|
||||
payment_hash: str
|
||||
expiry: Optional[float]
|
||||
extra: Optional[Dict] = {}
|
||||
wallet_id: str
|
||||
webhook: Optional[str]
|
||||
|
|
@ -101,6 +104,7 @@ class Payment(BaseModel):
|
|||
fee=row["fee"],
|
||||
memo=row["memo"],
|
||||
time=row["time"],
|
||||
expiry=row["expiry"],
|
||||
wallet_id=row["wallet"],
|
||||
webhook=row["webhook"],
|
||||
webhook_status=row["webhook_status"],
|
||||
|
|
@ -128,6 +132,10 @@ class Payment(BaseModel):
|
|||
def is_out(self) -> bool:
|
||||
return self.amount < 0
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
return self.expiry < time.time() if self.expiry else False
|
||||
|
||||
@property
|
||||
def is_uncheckable(self) -> bool:
|
||||
return self.checking_id.startswith("internal_")
|
||||
|
|
@ -170,7 +178,13 @@ class Payment(BaseModel):
|
|||
|
||||
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(
|
||||
f"Deleting outgoing failed payment {self.checking_id}: {status}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ import asyncio
|
|||
import json
|
||||
from binascii import unhexlify
|
||||
from io import BytesIO
|
||||
from typing import Dict, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends
|
||||
from fastapi import Depends, WebSocket, WebSocketDisconnect
|
||||
from lnurl import LnurlErrorResponse
|
||||
from lnurl import decode as decode_lnurl # type: ignore
|
||||
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
|
||||
def fee_reserve(amount_msat: int) -> int:
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -183,6 +183,23 @@
|
|||
<div class="col q-pl-md"> </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>
|
||||
|
|
|
|||
|
|
@ -388,9 +388,14 @@
|
|||
{% endif %} {% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD =
|
||||
ADS.split(';') %}
|
||||
<q-card>
|
||||
<a href="{{ AD[0] }}"
|
||||
><img width="100%" src="{{ AD[1] }}"
|
||||
/></a> </q-card
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-mt-none q-mb-sm">{{ AD_TITLE }}</h6>
|
||||
</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 %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,15 @@ from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
|||
import async_timeout
|
||||
import httpx
|
||||
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.params import Body
|
||||
from loguru import logger
|
||||
|
|
@ -56,6 +64,8 @@ from ..services import (
|
|||
create_invoice,
|
||||
pay_invoice,
|
||||
perform_lnurlauth,
|
||||
websocketManager,
|
||||
websocketUpdater,
|
||||
)
|
||||
from ..tasks import api_invoice_listeners
|
||||
|
||||
|
|
@ -155,30 +165,29 @@ class CreateInvoiceData(BaseModel):
|
|||
|
||||
|
||||
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
if data.description_hash:
|
||||
if data.description_hash or data.unhashed_description:
|
||||
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:
|
||||
raise HTTPException(
|
||||
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 = ""
|
||||
else:
|
||||
description_hash = b""
|
||||
unhashed_description = b""
|
||||
memo = data.memo or LNBITS_SITE_TITLE
|
||||
|
||||
if data.unit == "sat":
|
||||
amount = int(data.amount)
|
||||
else:
|
||||
|
|
@ -585,8 +594,8 @@ class DecodePayment(BaseModel):
|
|||
data: str
|
||||
|
||||
|
||||
@core_app.post("/api/v1/payments/decode")
|
||||
async def api_payments_decode(data: DecodePayment):
|
||||
@core_app.post("/api/v1/payments/decode", status_code=HTTPStatus.OK)
|
||||
async def api_payments_decode(data: DecodePayment, response: Response):
|
||||
payment_str = data.data
|
||||
try:
|
||||
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,
|
||||
}
|
||||
except:
|
||||
response.status_code = HTTPStatus.BAD_REQUEST
|
||||
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)):
|
||||
if wallet.wallet.user not in LNBITS_ADMIN_USERS:
|
||||
raise HTTPException(
|
||||
|
|
@ -692,8 +702,39 @@ async def api_auditor(wallet: WalletTypeInfo = Depends(get_key_type)):
|
|||
node_balance, delta = None, None
|
||||
|
||||
return {
|
||||
"node_balance_msats": node_balance,
|
||||
"lnbits_balance_msats": total_balance,
|
||||
"delta_msats": delta,
|
||||
"node_balance_msats": int(node_balance),
|
||||
"lnbits_balance_msats": int(total_balance),
|
||||
"delta_msats": int(delta),
|
||||
"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}
|
||||
|
|
|
|||
47
lnbits/db.py
47
lnbits/db.py
|
|
@ -1,6 +1,7 @@
|
|||
import asyncio
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional
|
||||
|
|
@ -28,6 +29,13 @@ class Compat:
|
|||
return f"{seconds}"
|
||||
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
|
||||
def timestamp_now(self) -> str:
|
||||
if self.type in {POSTGRES, COCKROACH}:
|
||||
|
|
@ -73,18 +81,40 @@ class Connection(Compat):
|
|||
query = query.replace("?", "%s")
|
||||
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:
|
||||
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()
|
||||
|
||||
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()
|
||||
await result.close()
|
||||
return row
|
||||
|
||||
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):
|
||||
|
|
@ -102,6 +132,8 @@ class Database(Compat):
|
|||
import psycopg2 # type: ignore
|
||||
|
||||
def _parse_timestamp(value, _):
|
||||
if value is None:
|
||||
return None
|
||||
f = "%Y-%m-%d %H:%M:%S.%f"
|
||||
if not "." in value:
|
||||
f = "%Y-%m-%d %H:%M:%S"
|
||||
|
|
@ -126,14 +158,7 @@ class Database(Compat):
|
|||
|
||||
psycopg2.extensions.register_type(
|
||||
psycopg2.extensions.new_type(
|
||||
(1184, 1114),
|
||||
"TIMESTAMP2INT",
|
||||
_parse_timestamp
|
||||
# lambda value, curs: time.mktime(
|
||||
# datetime.datetime.strptime(
|
||||
# value, "%Y-%m-%d %H:%M:%S.%f"
|
||||
# ).timetuple()
|
||||
# ),
|
||||
(1184, 1114), "TIMESTAMP2INT", _parse_timestamp
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ 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!***
|
||||
|
||||
***In order to use this extension you need to be able to setup your own card.*** That means writing a URL template pointing to your LNBits instance, configuring some SUN (SDM) settings and optionally changing the card's keys. There's a [guide](https://www.whitewolftech.com/articles/payment-card/) to set it up with a card reader connected to your computer. It can be done (without setting the keys) with [TagWriter app by NXP](https://play.google.com/store/apps/details?id=com.nxp.nfc.tagwriter) Android app. Last but not least, an OSS android app by name [Boltcard NFC Card Creator](https://github.com/boltcard/bolt-nfc-android-app) is being developed for these purposes. It's available from Google Play [here](https://play.google.com/store/apps/details?id=com.lightningnfcapp).
|
||||
|
||||
***In order to use this extension you need to be able to setup your own card.*** That means writing a URL template pointing to your LNbits instance, configuring some SUN (SDM) settings and optionally changing the card's keys. There's a [guide](https://www.whitewolftech.com/articles/payment-card/) to set it up with a card reader connected to your computer. It can be done (without setting the keys) with [TagWriter app by NXP](https://play.google.com/store/apps/details?id=com.nxp.nfc.tagwriter) Android app. Last but not least, an OSS android app by name [Boltcard NFC Card Creator](https://github.com/boltcard/bolt-nfc-android-app) is being developed for these purposes. It's available from Google Play [here](https://play.google.com/store/apps/details?id=com.lightningnfcapp).
|
||||
|
||||
## About the keys
|
||||
|
||||
|
|
@ -20,13 +21,14 @@ The key #00, K0 (also know as auth key) is skipped to be used as authentificatio
|
|||
|
||||
***Always backup all keys that you're trying to write on the card. Without them you may not be able to change them in the future!***
|
||||
|
||||
|
||||
## Setting the card - Boltcard NFC Card Creator (easy way)
|
||||
Updated for v0.1.2
|
||||
Updated for v0.1.3
|
||||
|
||||
- Add new card in the extension.
|
||||
- Set a max sats per transaction. Any transaction greater than this amount will be rejected.
|
||||
- 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.
|
||||
- If on an Android device with a newish version of Chrome, you can click the icon next to the input and tap your card to autofill this field.
|
||||
- Otherwise read it with the Android app (Advanced -> Read NFC) and paste it to the field.
|
||||
|
|
@ -41,7 +43,7 @@ Updated for v0.1.2
|
|||
- Click WRITE CARD NOW and approach the NFC card to set it up. DO NOT REMOVE THE CARD PREMATURELY!
|
||||
|
||||
## Erasing the card - Boltcard NFC Card Creator
|
||||
Updated for v0.1.2
|
||||
Updated for v0.1.3
|
||||
|
||||
Since v0.1.2 of Boltcard NFC Card Creator it is possible not only reset the keys but also disable the SUN function and do the complete erase so the card can be use again as a static tag (or set as a new Bolt Card, ofc).
|
||||
|
||||
|
|
|
|||
11
lnbits/extensions/cashu/README.md
Normal file
11
lnbits/extensions/cashu/README.md
Normal 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
|
||||
48
lnbits/extensions/cashu/__init__.py
Normal file
48
lnbits/extensions/cashu/__init__.py
Normal 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))
|
||||
7
lnbits/extensions/cashu/config.json
Normal file
7
lnbits/extensions/cashu/config.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "Cashu",
|
||||
"short_description": "Ecash mint and wallet",
|
||||
"icon": "account_balance",
|
||||
"contributors": ["calle", "vlad", "arcbtc"],
|
||||
"hidden": false
|
||||
}
|
||||
63
lnbits/extensions/cashu/crud.py
Normal file
63
lnbits/extensions/cashu/crud.py
Normal 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,))
|
||||
33
lnbits/extensions/cashu/migrations.py
Normal file
33
lnbits/extensions/cashu/migrations.py
Normal 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
|
||||
);
|
||||
"""
|
||||
)
|
||||
147
lnbits/extensions/cashu/models.py
Normal file
147
lnbits/extensions/cashu/models.py
Normal 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
|
||||
37
lnbits/extensions/cashu/static/js/base64.js
Normal file
37
lnbits/extensions/cashu/static/js/base64.js
Normal 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
|
||||
})({})
|
||||
39
lnbits/extensions/cashu/static/js/dhke.js
Normal file
39
lnbits/extensions/cashu/static/js/dhke.js
Normal 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
|
||||
}
|
||||
1178
lnbits/extensions/cashu/static/js/noble-secp256k1.js
Normal file
1178
lnbits/extensions/cashu/static/js/noble-secp256k1.js
Normal file
File diff suppressed because it is too large
Load diff
23
lnbits/extensions/cashu/static/js/utils.js
Normal file
23
lnbits/extensions/cashu/static/js/utils.js
Normal 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}`)
|
||||
}
|
||||
33
lnbits/extensions/cashu/tasks.py
Normal file
33
lnbits/extensions/cashu/tasks.py
Normal 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
|
||||
80
lnbits/extensions/cashu/templates/cashu/_api_docs.html
Normal file
80
lnbits/extensions/cashu/templates/cashu/_api_docs.html
Normal 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": <invoice_key>}</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>[<cashu_object>, ...]</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:
|
||||
<invoice_key>"
|
||||
</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": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"name": <string>, "currency": <string*ie USD*>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"currency": <string>, "id": <string>, "name":
|
||||
<string>, "wallet": <string>}</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":
|
||||
<string>, "currency": <string>}' -H "Content-type:
|
||||
application/json" -H "X-Api-Key: <admin_key>"
|
||||
</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/<cashu_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</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/<cashu_id> -H "X-Api-Key:
|
||||
<admin_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item> -->
|
||||
</q-expansion-item>
|
||||
13
lnbits/extensions/cashu/templates/cashu/_cashu.html
Normal file
13
lnbits/extensions/cashu/templates/cashu/_cashu.html
Normal 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>
|
||||
367
lnbits/extensions/cashu/templates/cashu/index.html
Normal file
367
lnbits/extensions/cashu/templates/cashu/index.html
Normal 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 %}
|
||||
76
lnbits/extensions/cashu/templates/cashu/mint.html
Normal file
76
lnbits/extensions/cashu/templates/cashu/mint.html
Normal 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>
|
||||
2343
lnbits/extensions/cashu/templates/cashu/wallet.html
Normal file
2343
lnbits/extensions/cashu/templates/cashu/wallet.html
Normal file
File diff suppressed because it is too large
Load diff
230
lnbits/extensions/cashu/views.py
Normal file
230
lnbits/extensions/cashu/views.py
Normal 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",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
396
lnbits/extensions/cashu/views_api.py
Normal file
396
lnbits/extensions/cashu/views_api.py
Normal 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
|
||||
|
|
@ -7,11 +7,11 @@ from starlette.exceptions import HTTPException
|
|||
|
||||
from lnbits.core import db as core_db
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services import websocketUpdater
|
||||
from lnbits.helpers import get_current_extension_name
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from .crud import get_copilot
|
||||
from .views import updater
|
||||
|
||||
|
||||
async def wait_for_paid_invoices():
|
||||
|
|
@ -65,9 +65,11 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
except (httpx.ConnectError, httpx.RequestError):
|
||||
await mark_webhook_sent(payment, -1)
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -238,7 +238,7 @@
|
|||
document.domain +
|
||||
':' +
|
||||
location.port +
|
||||
'/copilot/ws/' +
|
||||
'/api/v1/ws/' +
|
||||
self.copilot.id
|
||||
} else {
|
||||
localUrl =
|
||||
|
|
@ -246,7 +246,7 @@
|
|||
document.domain +
|
||||
':' +
|
||||
location.port +
|
||||
'/copilot/ws/' +
|
||||
'/api/v1/ws/' +
|
||||
self.copilot.id
|
||||
}
|
||||
this.connection = new WebSocket(localUrl)
|
||||
|
|
|
|||
|
|
@ -35,48 +35,3 @@ async def panel(request: Request):
|
|||
return copilot_renderer().TemplateResponse(
|
||||
"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)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from fastapi.param_functions import Query
|
|||
from fastapi.params import Depends
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.services import websocketUpdater
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
|
||||
from . import copilot_ext
|
||||
|
|
@ -16,7 +17,6 @@ from .crud import (
|
|||
update_copilot,
|
||||
)
|
||||
from .models import CreateCopilotData
|
||||
from .views import updater
|
||||
|
||||
#######################COPILOT##########################
|
||||
|
||||
|
|
@ -92,7 +92,7 @@ async def api_copilot_ws_relay(
|
|||
status_code=HTTPStatus.NOT_FOUND, detail="Copilot does not exist"
|
||||
)
|
||||
try:
|
||||
await updater(copilot_id, data, comment)
|
||||
await websocketUpdater(copilot_id, str(data) + "-" + str(comment))
|
||||
except:
|
||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your copilot")
|
||||
return ""
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@
|
|||
dense
|
||||
v-model.number="formDialog.data.price_per_ticket"
|
||||
type="number"
|
||||
label="Price per ticket "
|
||||
label="Sats per ticket "
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@
|
|||
dense
|
||||
v-model.trim="formDialog.data.company_name"
|
||||
label="Company Name"
|
||||
placeholder="LNBits Labs"
|
||||
placeholder="LNbits Labs"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@
|
|||

|
||||
- on Spotify click the "EDIT SETTINGS" button and paste the copied link in the _Redirect URI's_ prompt
|
||||

|
||||
- 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...)
|
||||
- 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...)
|
||||
- and select what playlist will be available for users to choose songs (you need to have already playlist on Spotify)\
|
||||

|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## 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).
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ The revenue will be sent to a wallet created specifically for that producer, wit
|
|||

|
||||
3. For every different producer added, when adding tracks, a wallet is generated for them\
|
||||

|
||||
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\
|
||||

|
||||
6. You'll see the current track playing and a green icon indicating active track also\
|
||||
|
|
|
|||
|
|
@ -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/.
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from .utils import decoded_as_lndhub, to_buffer
|
|||
|
||||
@lndhub_ext.get("/ext/getinfo")
|
||||
async def lndhub_getinfo():
|
||||
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="bad auth")
|
||||
return {"alias": LNBITS_SITE_TITLE}
|
||||
|
||||
|
||||
class AuthData(BaseModel):
|
||||
|
|
|
|||
|
|
@ -8,12 +8,11 @@ from fastapi import HTTPException
|
|||
|
||||
from lnbits import bolt11
|
||||
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.tasks import register_invoice_listener
|
||||
|
||||
from .crud import get_lnurldevice, get_lnurldevicepayment, update_lnurldevicepayment
|
||||
from .views import updater
|
||||
|
||||
|
||||
async def wait_for_paid_invoices():
|
||||
|
|
@ -36,9 +35,8 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
lnurldevicepayment = await update_lnurldevicepayment(
|
||||
lnurldevicepayment_id=payment.extra.get("id"), payhash="used"
|
||||
)
|
||||
return await updater(
|
||||
return await websocketUpdater(
|
||||
lnurldevicepayment.deviceid,
|
||||
lnurldevicepayment.pin,
|
||||
lnurldevicepayment.payload,
|
||||
str(lnurldevicepayment.pin) + "-" + str(lnurldevicepayment.payload),
|
||||
)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -157,9 +157,9 @@
|
|||
unelevated
|
||||
color="primary"
|
||||
size="md"
|
||||
@click="copyText(wslocation + '/lnurldevice/ws/' + settingsDialog.data.id, 'Link copied to clipboard!')"
|
||||
>{% raw %}{{wslocation}}/lnurldevice/ws/{{settingsDialog.data.id}}{%
|
||||
endraw %}<q-tooltip> Click to copy URL </q-tooltip>
|
||||
@click="copyText(wslocation + '/api/v1/ws/' + settingsDialog.data.id, 'Link copied to clipboard!')"
|
||||
>{% raw %}{{wslocation}}/api/v1/ws/{{settingsDialog.data.id}}{% endraw
|
||||
%}<q-tooltip> Click to copy URL </q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-else
|
||||
|
|
@ -487,6 +487,17 @@
|
|||
@click="copyText(lnurlValue, 'LNURL copied to clipboard!')"
|
||||
>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
|
||||
|
|
@ -534,6 +545,7 @@
|
|||
filter: '',
|
||||
currency: 'USD',
|
||||
lnurlValue: '',
|
||||
websocketMessage: '',
|
||||
switches: 0,
|
||||
lnurldeviceLinks: [],
|
||||
lnurldeviceLinksObj: [],
|
||||
|
|
@ -622,6 +634,11 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
wsMessage: function () {
|
||||
return this.websocketMessage
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openQrCodeDialog: function (lnurldevice_id) {
|
||||
var lnurldevice = _.findWhere(this.lnurldeviceLinks, {
|
||||
|
|
@ -631,11 +648,17 @@
|
|||
this.qrCodeDialog.data = _.clone(lnurldevice)
|
||||
this.qrCodeDialog.data.url =
|
||||
window.location.protocol + '//' + window.location.host
|
||||
this.lnurlValueFetch(this.qrCodeDialog.data.switches[0][3])
|
||||
this.lnurlValueFetch(
|
||||
this.qrCodeDialog.data.switches[0][3],
|
||||
this.qrCodeDialog.data.id
|
||||
)
|
||||
this.qrCodeDialog.show = true
|
||||
},
|
||||
lnurlValueFetch: function (lnurl) {
|
||||
lnurlValueFetch: function (lnurl, switchId) {
|
||||
this.lnurlValue = lnurl
|
||||
this.websocketConnector(
|
||||
'wss://' + window.location.host + '/api/v1/ws/' + switchId
|
||||
)
|
||||
},
|
||||
addSwitch: function () {
|
||||
var self = this
|
||||
|
|
@ -797,6 +820,25 @@
|
|||
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() {
|
||||
this.formDialoglnurldevice.data = {
|
||||
lnurl_toggle: false,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from http import HTTPStatus
|
|||
from io import BytesIO
|
||||
|
||||
import pyqrcode
|
||||
from fastapi import Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi import Request
|
||||
from fastapi.param_functions import Query
|
||||
from fastapi.params import Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
|
@ -63,50 +63,3 @@ async def img(request: Request, lnurldevice_id):
|
|||
status_code=HTTPStatus.NOT_FOUND, detail="LNURLDevice does not exist."
|
||||
)
|
||||
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_pin, lnurldevice_amount):
|
||||
lnurldevice = await get_lnurldevice(lnurldevice_id)
|
||||
if not lnurldevice:
|
||||
return
|
||||
return await manager.send_personal_message(
|
||||
f"{lnurldevice_pin}-{lnurldevice_amount}", lnurldevice_id
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ async def m001_initial(db):
|
|||
id {db.serial_primary_key},
|
||||
wallet TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
amount {db.big_int} NOT NULL,
|
||||
served_meta INTEGER NOT NULL,
|
||||
served_pr INTEGER NOT NULL
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
[](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 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.
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ Customers must use an LNURL pay capable wallet.
|
|||

|
||||
3. After creating some products, click on "PRINT QR CODES"\
|
||||

|
||||
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\
|
||||

|
||||
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 customers to report to merchant after a successful payment\
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ async def m001_initial(db):
|
|||
description TEXT NOT NULL,
|
||||
image TEXT, -- image/png;base64,...
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
price INTEGER NOT NULL,
|
||||
price {db.big_int} NOT NULL,
|
||||
unit TEXT NOT NULL DEFAULT 'sat'
|
||||
);
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@ async def m001_initial(db):
|
|||
Initial paywalls table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
f"""
|
||||
CREATE TABLE paywall.paywalls (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
secret TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
memo TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
amount {db.big_int} NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ 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(
|
||||
"""
|
||||
f"""
|
||||
CREATE TABLE paywall.paywalls (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
memo TEXT NOT NULL,
|
||||
description TEXT NULL,
|
||||
amount INTEGER DEFAULT 0,
|
||||
amount {db.big_int} DEFAULT 0,
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """,
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@ async def m001_initial(db):
|
|||
Creates an improved satsdice table and migrates the existing data.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
f"""
|
||||
CREATE TABLE satsdice.satsdice_pay (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT,
|
||||
title TEXT,
|
||||
min_bet INTEGER,
|
||||
max_bet INTEGER,
|
||||
amount INTEGER DEFAULT 0,
|
||||
amount {db.big_int} DEFAULT 0,
|
||||
served_meta INTEGER NOT NULL,
|
||||
served_pr INTEGER NOT NULL,
|
||||
multiplier FLOAT,
|
||||
|
|
@ -28,11 +28,11 @@ async def m002_initial(db):
|
|||
Creates an improved satsdice table and migrates the existing data.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
f"""
|
||||
CREATE TABLE satsdice.satsdice_withdraw (
|
||||
id TEXT PRIMARY KEY,
|
||||
satsdice_pay TEXT,
|
||||
value INTEGER DEFAULT 1,
|
||||
value {db.big_int} DEFAULT 1,
|
||||
unique_hash TEXT UNIQUE,
|
||||
k1 TEXT,
|
||||
open_time INTEGER,
|
||||
|
|
@ -47,11 +47,11 @@ async def m003_initial(db):
|
|||
Creates an improved satsdice table and migrates the existing data.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
f"""
|
||||
CREATE TABLE satsdice.satsdice_payment (
|
||||
payment_hash TEXT PRIMARY KEY,
|
||||
satsdice_pay TEXT,
|
||||
value INTEGER,
|
||||
value {db.big_int},
|
||||
paid BOOL DEFAULT FALSE,
|
||||
lost BOOL DEFAULT FALSE
|
||||
);
|
||||
|
|
|
|||
|
|
@ -23,5 +23,5 @@ Easilly create invoices that support Lightning Network and on-chain BTC payment.
|
|||

|
||||
- or pay on chain\
|
||||

|
||||
5. You can check the state of your charges in LNBits\
|
||||
5. You can check the state of your charges in LNbits\
|
||||

|
||||
|
|
|
|||
|
|
@ -2,7 +2,5 @@
|
|||
"name": "SatsPay Server",
|
||||
"short_description": "Create onchain and LN charges",
|
||||
"icon": "payment",
|
||||
"contributors": [
|
||||
"arcbtc"
|
||||
]
|
||||
"contributors": ["arcbtc"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,28 @@
|
|||
import json
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.core.views.api import api_payment
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from ..watchonly.crud import get_config, get_fresh_address
|
||||
|
||||
# from lnbits.db import open_ext_db
|
||||
from . import db
|
||||
from .models import Charges, CreateCharge
|
||||
from .helpers import fetch_onchain_balance
|
||||
from .models import Charges, CreateCharge, SatsPayThemes
|
||||
|
||||
###############CHARGES##########################
|
||||
|
||||
|
||||
async def create_charge(user: str, data: CreateCharge) -> Charges:
|
||||
data = CreateCharge(**data.dict())
|
||||
charge_id = urlsafe_short_hash()
|
||||
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)
|
||||
onchainaddress = onchain.address
|
||||
else:
|
||||
|
|
@ -48,9 +53,11 @@ async def create_charge(user: str, data: CreateCharge) -> Charges:
|
|||
completelinktext,
|
||||
time,
|
||||
amount,
|
||||
balance
|
||||
balance,
|
||||
extra,
|
||||
custom_css
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
charge_id,
|
||||
|
|
@ -67,6 +74,8 @@ async def create_charge(user: str, data: CreateCharge) -> Charges:
|
|||
data.time,
|
||||
data.amount,
|
||||
0,
|
||||
data.extra,
|
||||
data.custom_css,
|
||||
),
|
||||
)
|
||||
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,))
|
||||
|
||||
|
||||
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)
|
||||
|
||||
if not charge.paid:
|
||||
if charge.onchainaddress:
|
||||
config = await get_charge_config(charge_id)
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(
|
||||
config.mempool_endpoint
|
||||
+ "/api/address/"
|
||||
+ charge.onchainaddress
|
||||
)
|
||||
respAmount = r.json()["chain_stats"]["funded_txo_sum"]
|
||||
if respAmount > charge.balance:
|
||||
await update_charge(charge_id=charge_id, balance=respAmount)
|
||||
except Exception:
|
||||
pass
|
||||
respAmount = await fetch_onchain_balance(charge)
|
||||
if respAmount > charge.balance:
|
||||
await update_charge(charge_id=charge_id, balance=respAmount)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
if charge.lnbitswallet:
|
||||
invoice_status = await api_payment(charge.payment_hash)
|
||||
|
||||
if invoice_status["paid"]:
|
||||
return await update_charge(charge_id=charge_id, balance=charge.amount)
|
||||
row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,))
|
||||
return Charges.from_row(row) if row else None
|
||||
return await get_charge(charge_id)
|
||||
|
||||
|
||||
async def get_charge_config(charge_id: str):
|
||||
row = await db.fetchone(
|
||||
"""SELECT "user" FROM satspay.charges WHERE id = ?""", (charge_id,)
|
||||
################## 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 "timestamp" DESC """,
|
||||
(user_id,),
|
||||
)
|
||||
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,))
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from .models import Charges
|
||||
|
||||
|
||||
def compact_charge(charge: Charges):
|
||||
return {
|
||||
def public_charge(charge: Charges):
|
||||
c = {
|
||||
"id": charge.id,
|
||||
"description": charge.description,
|
||||
"onchainaddress": charge.onchainaddress,
|
||||
|
|
@ -13,5 +16,44 @@ def compact_charge(charge: Charges):
|
|||
"balance": charge.balance,
|
||||
"paid": charge.paid,
|
||||
"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"]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ async def m001_initial(db):
|
|||
"""
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
f"""
|
||||
CREATE TABLE satspay.charges (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
"user" TEXT,
|
||||
|
|
@ -18,11 +18,47 @@ async def m001_initial(db):
|
|||
completelink TEXT,
|
||||
completelinktext TEXT,
|
||||
time INTEGER,
|
||||
amount INTEGER,
|
||||
balance INTEGER DEFAULT 0,
|
||||
amount {db.big_int},
|
||||
balance {db.big_int} DEFAULT 0,
|
||||
timestamp TIMESTAMP NOT NULL DEFAULT """
|
||||
+ 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;")
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from sqlite3 import Row
|
||||
from typing import Optional
|
||||
|
|
@ -5,6 +6,10 @@ from typing import Optional
|
|||
from fastapi.param_functions import Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
DEFAULT_MEMPOOL_CONFIG = (
|
||||
'{"mempool_endpoint": "https://mempool.space", "network": "Mainnet"}'
|
||||
)
|
||||
|
||||
|
||||
class CreateCharge(BaseModel):
|
||||
onchainwallet: str = Query(None)
|
||||
|
|
@ -13,8 +18,17 @@ class CreateCharge(BaseModel):
|
|||
webhook: str = Query(None)
|
||||
completelink: str = Query(None)
|
||||
completelinktext: str = Query(None)
|
||||
custom_css: Optional[str]
|
||||
time: 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):
|
||||
|
|
@ -28,6 +42,8 @@ class Charges(BaseModel):
|
|||
webhook: Optional[str]
|
||||
completelink: Optional[str]
|
||||
completelinktext: Optional[str] = "Back to Merchant"
|
||||
custom_css: Optional[str]
|
||||
extra: str = DEFAULT_MEMPOOL_CONFIG
|
||||
time: int
|
||||
amount: int
|
||||
balance: int
|
||||
|
|
@ -54,3 +70,22 @@ class Charges(BaseModel):
|
|||
return True
|
||||
else:
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -14,18 +14,23 @@ const retryWithDelay = async function (fn, retryCount = 0) {
|
|||
}
|
||||
|
||||
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.time = minutesToTime(obj.time)
|
||||
charge.timeLeft = minutesToTime(obj.time_left)
|
||||
|
||||
charge.expanded = false
|
||||
charge.displayUrl = ['/satspay/', obj.id].join('')
|
||||
charge.expanded = oldObj.expanded
|
||||
charge.expanded = oldObj.expanded || false
|
||||
charge.pendingBalance = oldObj.pendingBalance || 0
|
||||
charge.extra = charge.extra ? JSON.parse(charge.extra) : charge.extra
|
||||
return charge
|
||||
}
|
||||
|
||||
const mapCSS = (obj, oldObj = {}) => {
|
||||
const theme = _.clone(obj)
|
||||
return theme
|
||||
}
|
||||
|
||||
const minutesToTime = min =>
|
||||
min > 0 ? new Date(min * 1000).toISOString().substring(14, 19) : ''
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import asyncio
|
||||
import json
|
||||
|
||||
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.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():
|
||||
|
|
@ -30,4 +32,9 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
return
|
||||
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -5,7 +5,13 @@
|
|||
WatchOnly extension, we highly reccomend using a fresh extended public Key
|
||||
specifically for SatsPayServer!<br />
|
||||
<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>
|
||||
<br />
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@
|
|||
<q-btn
|
||||
flat
|
||||
disable
|
||||
v-if="!charge.lnbitswallet || charge.time_elapsed"
|
||||
v-if="!charge.payment_request || charge.time_elapsed"
|
||||
style="color: primary; width: 100%"
|
||||
label="lightning⚡"
|
||||
>
|
||||
|
|
@ -131,7 +131,7 @@
|
|||
<q-btn
|
||||
flat
|
||||
disable
|
||||
v-if="!charge.onchainwallet || charge.time_elapsed"
|
||||
v-if="!charge.onchainaddress || charge.time_elapsed"
|
||||
style="color: primary; width: 100%"
|
||||
label="onchain⛓️"
|
||||
>
|
||||
|
|
@ -170,13 +170,17 @@
|
|||
name="check"
|
||||
style="color: green; font-size: 21.4em"
|
||||
></q-icon>
|
||||
<q-btn
|
||||
outline
|
||||
v-if="charge.webhook"
|
||||
type="a"
|
||||
:href="charge.completelink"
|
||||
:label="charge.completelinktext"
|
||||
></q-btn>
|
||||
<div class="row text-center q-mt-lg">
|
||||
<div class="col text-center">
|
||||
<q-btn
|
||||
outline
|
||||
v-if="charge.completelink"
|
||||
type="a"
|
||||
:href="charge.completelink"
|
||||
:label="charge.completelinktext"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="row text-center q-mb-sm">
|
||||
|
|
@ -218,7 +222,7 @@
|
|||
<div class="col text-center">
|
||||
<a
|
||||
style="color: unset"
|
||||
:href="mempool_endpoint + '/address/' + charge.onchainaddress"
|
||||
:href="'https://' + mempoolHostname + '/address/' + charge.onchainaddress"
|
||||
target="_blank"
|
||||
><span
|
||||
class="text-subtitle1"
|
||||
|
|
@ -241,13 +245,17 @@
|
|||
name="check"
|
||||
style="color: green; font-size: 21.4em"
|
||||
></q-icon>
|
||||
<q-btn
|
||||
outline
|
||||
v-if="charge.webhook"
|
||||
type="a"
|
||||
:href="charge.completelink"
|
||||
:label="charge.completelinktext"
|
||||
></q-btn>
|
||||
<div class="row text-center q-mt-lg">
|
||||
<div class="col text-center">
|
||||
<q-btn
|
||||
outline
|
||||
v-if="charge.webhook"
|
||||
type="a"
|
||||
:href="charge.completelink"
|
||||
:label="charge.completelinktext"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="row items-center q-mb-sm">
|
||||
|
|
@ -289,7 +297,17 @@
|
|||
</div>
|
||||
<div class="col-lg- 4 col-md-3 col-sm-1"></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 %}
|
||||
|
||||
<script src="https://mempool.space/mempool.js"></script>
|
||||
|
|
@ -303,7 +321,8 @@
|
|||
data() {
|
||||
return {
|
||||
charge: JSON.parse('{{charge_data | tojson}}'),
|
||||
mempool_endpoint: '{{mempool_endpoint}}',
|
||||
mempoolEndpoint: '{{mempool_endpoint}}',
|
||||
network: '{{network}}',
|
||||
pendingFunds: 0,
|
||||
ws: null,
|
||||
newProgress: 0.4,
|
||||
|
|
@ -316,19 +335,19 @@
|
|||
cancelListener: () => {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
mempoolHostname: function () {
|
||||
let hostname = new URL(this.mempoolEndpoint).hostname
|
||||
if (this.network === 'Testnet') {
|
||||
hostname += '/testnet'
|
||||
}
|
||||
return hostname
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
startPaymentNotifier() {
|
||||
this.cancelListener()
|
||||
if (!this.lnbitswallet) return
|
||||
this.cancelListener = LNbits.events.onInvoicePaid(
|
||||
this.wallet,
|
||||
payment => {
|
||||
this.checkInvoiceBalance()
|
||||
}
|
||||
)
|
||||
},
|
||||
checkBalances: async function () {
|
||||
if (this.charge.hasStaleBalance) return
|
||||
if (!this.charge.payment_request && this.charge.hasOnchainStaleBalance)
|
||||
return
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
|
|
@ -345,7 +364,7 @@
|
|||
const {
|
||||
bitcoin: {addresses: addressesAPI}
|
||||
} = mempoolJS({
|
||||
hostname: new URL(this.mempool_endpoint).hostname
|
||||
hostname: new URL(this.mempoolEndpoint).hostname
|
||||
})
|
||||
|
||||
try {
|
||||
|
|
@ -353,7 +372,8 @@
|
|||
address: this.charge.onchainaddress
|
||||
})
|
||||
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
|
||||
.filter(u => !u.status.confirmed)
|
||||
|
|
@ -388,10 +408,10 @@
|
|||
const {
|
||||
bitcoin: {websocket}
|
||||
} = 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 => {
|
||||
if (this.charge.onchainaddress) {
|
||||
this.trackAddress(this.charge.onchainaddress)
|
||||
|
|
@ -428,13 +448,14 @@
|
|||
}
|
||||
},
|
||||
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()
|
||||
await this.checkBalances()
|
||||
|
||||
// empty for onchain
|
||||
this.wallet.inkey = '{{ wallet_inkey }}'
|
||||
this.startPaymentNotifier()
|
||||
await this.checkBalances()
|
||||
|
||||
if (!this.charge.paid) {
|
||||
this.loopRefresh()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,26 @@
|
|||
<q-btn unelevated color="primary" @click="formDialogCharge.show = true"
|
||||
>New charge
|
||||
</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>
|
||||
|
||||
|
|
@ -203,9 +223,19 @@
|
|||
:href="props.row.webhook"
|
||||
target="_blank"
|
||||
style="color: unset; text-decoration: none"
|
||||
>{{props.row.webhook || props.row.webhook}}</a
|
||||
>{{props.row.webhook}}</a
|
||||
>
|
||||
</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 class="row items-center q-mt-md q-mb-lg">
|
||||
<div class="col-2 q-pr-lg">ID:</div>
|
||||
|
|
@ -254,6 +284,63 @@
|
|||
</q-table>
|
||||
</q-card-section>
|
||||
</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 class="col-12 col-md-5 q-gutter-y-md">
|
||||
|
|
@ -298,32 +385,6 @@
|
|||
>
|
||||
</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="col">
|
||||
<div v-if="walletLinks.length > 0">
|
||||
|
|
@ -372,6 +433,52 @@
|
|||
label="Wallet *"
|
||||
>
|
||||
</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">
|
||||
<q-btn
|
||||
unelevated
|
||||
|
|
@ -389,6 +496,60 @@
|
|||
</q-form>
|
||||
</q-card>
|
||||
</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>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<!-- lnbits/static/vendor
|
||||
|
|
@ -405,15 +566,21 @@
|
|||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
settings: {},
|
||||
filter: '',
|
||||
admin: '{{ admin }}',
|
||||
balance: null,
|
||||
walletLinks: [],
|
||||
chargeLinks: [],
|
||||
themeLinks: [],
|
||||
themeOptions: [],
|
||||
onchainwallet: '',
|
||||
rescanning: false,
|
||||
mempool: {
|
||||
endpoint: ''
|
||||
endpoint: '',
|
||||
network: 'Mainnet'
|
||||
},
|
||||
showAdvanced: false,
|
||||
|
||||
chargesTable: {
|
||||
columns: [
|
||||
|
|
@ -488,7 +655,25 @@
|
|||
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: {
|
||||
show: false,
|
||||
data: {
|
||||
|
|
@ -496,20 +681,35 @@
|
|||
onchainwallet: '',
|
||||
lnbits: false,
|
||||
description: '',
|
||||
custom_css: '',
|
||||
time: null,
|
||||
amount: null
|
||||
}
|
||||
}
|
||||
},
|
||||
formDialogThemes: {
|
||||
show: false,
|
||||
data: {
|
||||
custom_css: ''
|
||||
}
|
||||
},
|
||||
showWebhookResponse: false,
|
||||
webhookResponse: ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
cancelThemes: function (data) {
|
||||
this.formDialogCharge.data.custom_css = ''
|
||||
this.formDialogThemes.show = false
|
||||
},
|
||||
cancelCharge: function (data) {
|
||||
this.formDialogCharge.data.description = ''
|
||||
this.formDialogCharge.data.onchain = false
|
||||
this.formDialogCharge.data.onchainwallet = ''
|
||||
this.formDialogCharge.data.lnbitswallet = ''
|
||||
this.formDialogCharge.data.time = null
|
||||
this.formDialogCharge.data.amount = null
|
||||
this.formDialogCharge.data.webhook = ''
|
||||
this.formDialogCharge.data.custom_css = ''
|
||||
this.formDialogCharge.data.completelink = ''
|
||||
this.formDialogCharge.show = false
|
||||
},
|
||||
|
|
@ -518,7 +718,7 @@
|
|||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/wallet',
|
||||
`/watchonly/api/v1/wallet?network=${this.mempool.network}`,
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
this.walletLinks = data.map(w => ({
|
||||
|
|
@ -538,6 +738,7 @@
|
|||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
this.mempool.endpoint = data.mempool_endpoint
|
||||
this.mempool.network = data.network || 'Mainnet'
|
||||
const url = new URL(this.mempool.endpoint)
|
||||
this.mempool.hostname = url.hostname
|
||||
} catch (error) {
|
||||
|
|
@ -572,12 +773,42 @@
|
|||
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 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 wallet = this.g.user.wallets[0].inkey
|
||||
data.amount = parseInt(data.amount)
|
||||
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)
|
||||
},
|
||||
refreshActiveChargesBalance: async function () {
|
||||
|
|
@ -642,6 +873,65 @@
|
|||
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) {
|
||||
try {
|
||||
const resp = await LNbits.api.request(
|
||||
|
|
@ -685,6 +975,10 @@
|
|||
}
|
||||
})
|
||||
},
|
||||
showWebhookResponseDialog(webhookResponse) {
|
||||
this.webhookResponse = webhookResponse
|
||||
this.showWebhookResponse = true
|
||||
},
|
||||
exportchargeCSV: function () {
|
||||
LNbits.utils.exportCSV(
|
||||
this.chargesTable.columns,
|
||||
|
|
@ -694,9 +988,12 @@
|
|||
}
|
||||
},
|
||||
created: async function () {
|
||||
if (this.admin == 'True') {
|
||||
await this.getThemes()
|
||||
}
|
||||
await this.getCharges()
|
||||
await this.getWalletLinks()
|
||||
await this.getWalletConfig()
|
||||
await this.getWalletLinks()
|
||||
setInterval(() => this.refreshActiveChargesBalance(), 10 * 2000)
|
||||
await this.rescanOnchainAddresses()
|
||||
setInterval(() => this.rescanOnchainAddresses(), 10 * 1000)
|
||||
|
|
|
|||
|
|
@ -1,25 +1,32 @@
|
|||
import json
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Response
|
||||
from fastapi.param_functions import Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists
|
||||
from lnbits.extensions.satspay.helpers import public_charge
|
||||
from lnbits.settings import LNBITS_ADMIN_USERS
|
||||
|
||||
from . import satspay_ext, satspay_renderer
|
||||
from .crud import get_charge, get_charge_config
|
||||
from .crud import get_charge, get_theme
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@satspay_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
admin = False
|
||||
if LNBITS_ADMIN_USERS and user.id in LNBITS_ADMIN_USERS:
|
||||
admin = True
|
||||
return satspay_renderer().TemplateResponse(
|
||||
"satspay/index.html", {"request": request, "user": user.dict()}
|
||||
"satspay/index.html", {"request": request, "user": user.dict(), "admin": admin}
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -30,18 +37,21 @@ async def display(request: Request, charge_id: str):
|
|||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Charge link does not exist."
|
||||
)
|
||||
wallet = await get_wallet(charge.lnbitswallet)
|
||||
onchainwallet_config = await get_charge_config(charge_id)
|
||||
inkey = wallet.inkey if wallet else None
|
||||
mempool_endpoint = (
|
||||
onchainwallet_config.mempool_endpoint if onchainwallet_config else None
|
||||
)
|
||||
|
||||
return satspay_renderer().TemplateResponse(
|
||||
"satspay/display.html",
|
||||
{
|
||||
"request": request,
|
||||
"charge_data": charge.dict(),
|
||||
"wallet_inkey": inkey,
|
||||
"mempool_endpoint": mempool_endpoint,
|
||||
"charge_data": public_charge(charge),
|
||||
"mempool_endpoint": charge.config.mempool_endpoint,
|
||||
"network": charge.config.network,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@satspay_ext.get("/css/{css_id}")
|
||||
async def display(css_id: str, response: Response):
|
||||
theme = await get_theme(css_id)
|
||||
if theme:
|
||||
return Response(content=theme.custom_css, media_type="text/css")
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import json
|
||||
from http import HTTPStatus
|
||||
|
||||
import httpx
|
||||
from fastapi.params import Depends
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
get_key_type,
|
||||
|
|
@ -11,17 +14,22 @@ from lnbits.decorators import (
|
|||
require_invoice_key,
|
||||
)
|
||||
from lnbits.extensions.satspay import satspay_ext
|
||||
from lnbits.settings import LNBITS_ADMIN_EXTENSIONS, LNBITS_ADMIN_USERS
|
||||
|
||||
from .crud import (
|
||||
check_address_balance,
|
||||
create_charge,
|
||||
delete_charge,
|
||||
delete_theme,
|
||||
get_charge,
|
||||
get_charges,
|
||||
get_theme,
|
||||
get_themes,
|
||||
save_theme,
|
||||
update_charge,
|
||||
)
|
||||
from .helpers import compact_charge
|
||||
from .models import CreateCharge
|
||||
from .helpers import call_webhook, public_charge
|
||||
from .models import CreateCharge, SatsPayThemes
|
||||
|
||||
#############################CHARGES##########################
|
||||
|
||||
|
|
@ -58,6 +66,7 @@ async def api_charges_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)):
|
|||
**{"time_elapsed": charge.time_elapsed},
|
||||
**{"time_left": charge.time_left},
|
||||
**{"paid": charge.paid},
|
||||
**{"webhook_message": charge.config.webhook_message},
|
||||
}
|
||||
for charge in await get_charges(wallet.wallet.user)
|
||||
]
|
||||
|
|
@ -119,19 +128,55 @@ async def api_charge_balance(charge_id):
|
|||
status_code=HTTPStatus.NOT_FOUND, detail="Charge does not exist."
|
||||
)
|
||||
|
||||
if charge.paid and charge.webhook:
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.post(
|
||||
charge.webhook,
|
||||
json=compact_charge(charge),
|
||||
timeout=40,
|
||||
)
|
||||
except AssertionError:
|
||||
charge.webhook = None
|
||||
return {
|
||||
**compact_charge(charge),
|
||||
**{"time_elapsed": charge.time_elapsed},
|
||||
**{"time_left": charge.time_left},
|
||||
**{"paid": charge.paid},
|
||||
}
|
||||
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))
|
||||
|
||||
return {**public_charge(charge)}
|
||||
|
||||
|
||||
#############################THEMES##########################
|
||||
|
||||
|
||||
@satspay_ext.post("/api/v1/themes")
|
||||
@satspay_ext.post("/api/v1/themes/{css_id}")
|
||||
async def api_themes_save(
|
||||
data: SatsPayThemes,
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
css_id: str = None,
|
||||
):
|
||||
if LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Only server admins can create themes.",
|
||||
)
|
||||
if css_id:
|
||||
theme = await save_theme(css_id=css_id, data=data)
|
||||
else:
|
||||
data.user = wallet.wallet.user
|
||||
theme = await save_theme(data=data)
|
||||
return theme
|
||||
|
||||
|
||||
@satspay_ext.get("/api/v1/themes")
|
||||
async def api_themes_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
try:
|
||||
return await get_themes(wallet.wallet.user)
|
||||
except HTTPException:
|
||||
logger.error("Error loading satspay themes")
|
||||
logger.error(HTTPException)
|
||||
return ""
|
||||
|
||||
|
||||
@satspay_ext.delete("/api/v1/themes/{theme_id}")
|
||||
async def api_charge_delete(theme_id, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
theme = await get_theme(theme_id)
|
||||
|
||||
if not theme:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Theme does not exist."
|
||||
)
|
||||
|
||||
await delete_theme(theme_id)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Have payments split between multiple wallets
|
||||
|
||||
LNBits Split Payments extension allows for distributing payments across multiple wallets. Set it and forget it. It will keep splitting your payments across wallets forever.
|
||||
LNbits Split Payments extension allows for distributing payments across multiple wallets. Set it and forget it. It will keep splitting your payments across wallets forever.
|
||||
|
||||
## Usage
|
||||
|
||||
|
|
|
|||
|
|
@ -31,14 +31,20 @@
|
|||
style="flex-wrap: nowrap"
|
||||
v-for="(target, t) in targets"
|
||||
>
|
||||
<q-input
|
||||
<q-select
|
||||
dense
|
||||
outlined
|
||||
:options="g.user.wallets.filter(w => w.id !== selectedWallet.id).map(o => ({name: o.name, value: o.id}))"
|
||||
v-model="target.wallet"
|
||||
label="Wallet"
|
||||
:hint="t === targets.length - 1 ? 'A wallet ID or invoice key.' : undefined"
|
||||
@input="targetChanged(false)"
|
||||
></q-input>
|
||||
option-label="name"
|
||||
style="width: 1000px"
|
||||
new-value-mode="add-unique"
|
||||
use-input
|
||||
input-debounce="0"
|
||||
emit-value
|
||||
></q-select>
|
||||
<q-input
|
||||
dense
|
||||
outlined
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ So the goal of the extension is to allow the owner of a domain to sell subdomain
|
|||
4. Get Cloudflare API TOKEN
|
||||
<img src="https://i.imgur.com/BZbktTy.png">
|
||||
<img src="https://i.imgur.com/YDZpW7D.png">
|
||||
5. Open the LNBits subdomains extension and register your domain
|
||||
5. Open the LNbits subdomains extension and register your domain
|
||||
6. Click on the button in the table to open the public form that was generated for your domain
|
||||
|
||||
- Extension also supports webhooks so you can get notified when someone buys a new subdomain\
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import asyncio
|
|||
from loguru import logger
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services import create_invoice, pay_invoice
|
||||
from lnbits.core.services import create_invoice, pay_invoice, websocketUpdater
|
||||
from lnbits.helpers import get_current_extension_name
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
|
|
@ -26,6 +26,16 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
tpos = await get_tpos(payment.extra.get("tposId"))
|
||||
tipAmount = payment.extra.get("tipAmount")
|
||||
|
||||
strippedPayment = {
|
||||
"amount": payment.amount,
|
||||
"fee": payment.fee,
|
||||
"checking_id": payment.checking_id,
|
||||
"payment_hash": payment.payment_hash,
|
||||
"bolt11": payment.bolt11,
|
||||
}
|
||||
|
||||
await websocketUpdater(payment.extra.get("tposId"), str(strippedPayment))
|
||||
|
||||
if tipAmount is None:
|
||||
# no tip amount
|
||||
return
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from lnbits.core.models import Payment
|
|||
from lnbits.core.services import create_invoice
|
||||
from lnbits.core.views.api import api_payment
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
from lnbits.settings import LNBITS_COMMIT
|
||||
|
||||
from . import tpos_ext
|
||||
from .crud import create_tpos, delete_tpos, get_tpos, get_tposs
|
||||
|
|
@ -134,7 +135,8 @@ async def api_tpos_pay_invoice(
|
|||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.get(lnurl, follow_redirects=True)
|
||||
headers = {"user-agent": f"lnbits/tpos commit {LNBITS_COMMIT[:7]}"}
|
||||
r = await client.get(lnurl, follow_redirects=True, headers=headers)
|
||||
if r.is_error:
|
||||
lnurl_response = {"success": False, "detail": "Error loading"}
|
||||
else:
|
||||
|
|
@ -145,6 +147,7 @@ async def api_tpos_pay_invoice(
|
|||
r2 = await client.get(
|
||||
resp["callback"],
|
||||
follow_redirects=True,
|
||||
headers=headers,
|
||||
params={
|
||||
"k1": resp["k1"],
|
||||
"pr": payment_request,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
Monitor an extended public key and generate deterministic fresh public keys with this simple watch only wallet. Invoice payments can also be generated, both through a publically shareable page and API.
|
||||
|
||||
You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/satspay/README.md) extension
|
||||
You can now use this wallet on the LNbits [SatsPayServer](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/satspay/README.md) extension
|
||||
|
||||
<a href="https://www.youtube.com/watch?v=rQMHzQEPwZY">Video demo</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ async def update_address(id: str, **kwargs) -> Optional[Address]:
|
|||
f"""UPDATE watchonly.addresses SET {q} WHERE id = ? """,
|
||||
(*kwargs.values(), id),
|
||||
)
|
||||
row = await db.fetchone("SELECT * FROM watchonly.addresses WHERE id = ?", (id))
|
||||
row = await db.fetchone("SELECT * FROM watchonly.addresses WHERE id = ?", (id,))
|
||||
return Address.from_row(row) if row else None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,25 +3,25 @@ async def m001_initial(db):
|
|||
Initial wallet table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
f"""
|
||||
CREATE TABLE watchonly.wallets (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
"user" TEXT,
|
||||
masterpub TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
address_no INTEGER NOT NULL DEFAULT 0,
|
||||
balance INTEGER NOT NULL
|
||||
balance {db.big_int} NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
f"""
|
||||
CREATE TABLE watchonly.addresses (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
address TEXT NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL
|
||||
amount {db.big_int} NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
|
|
|||
|
|
@ -76,9 +76,13 @@ class CreatePsbt(BaseModel):
|
|||
tx_size: int
|
||||
|
||||
|
||||
class SerializedTransaction(BaseModel):
|
||||
tx_hex: str
|
||||
|
||||
|
||||
class ExtractPsbt(BaseModel):
|
||||
psbtBase64 = "" # // todo snake case
|
||||
inputs: List[TransactionInput]
|
||||
inputs: List[SerializedTransaction]
|
||||
network = "Mainnet"
|
||||
|
||||
|
||||
|
|
@ -87,10 +91,6 @@ class SignedTransaction(BaseModel):
|
|||
tx_json: Optional[str]
|
||||
|
||||
|
||||
class BroadcastTransaction(BaseModel):
|
||||
tx_hex: str
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
mempool_endpoint = "https://mempool.space"
|
||||
receive_gap_limit = 20
|
||||
|
|
|
|||
|
|
@ -272,15 +272,35 @@ async function payment(path) {
|
|||
this.showChecking = false
|
||||
}
|
||||
},
|
||||
|
||||
fetchUtxoHexForPsbt: async function (psbtBase64) {
|
||||
if (this.tx?.inputs && this.tx?.inputs.length) return this.tx.inputs
|
||||
|
||||
const {data: psbtUtxos} = await LNbits.api.request(
|
||||
'PUT',
|
||||
'/watchonly/api/v1/psbt/utxos',
|
||||
this.adminkey,
|
||||
{psbtBase64}
|
||||
)
|
||||
|
||||
const inputs = []
|
||||
for (const utxo of psbtUtxos) {
|
||||
const txHex = await this.fetchTxHex(utxo.tx_id)
|
||||
inputs.push({tx_hex: txHex})
|
||||
}
|
||||
return inputs
|
||||
},
|
||||
extractTxFromPsbt: async function (psbtBase64) {
|
||||
try {
|
||||
const inputs = await this.fetchUtxoHexForPsbt(psbtBase64)
|
||||
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT',
|
||||
'/watchonly/api/v1/psbt/extract',
|
||||
this.adminkey,
|
||||
{
|
||||
psbtBase64,
|
||||
inputs: this.tx.inputs,
|
||||
inputs,
|
||||
network: this.network
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -54,7 +54,10 @@ const watchOnly = async () => {
|
|||
showPayment: false,
|
||||
fetchedUtxos: false,
|
||||
utxosFilter: '',
|
||||
network: null
|
||||
network: null,
|
||||
|
||||
showEnterSignedPsbt: false,
|
||||
signedBase64Psbt: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -173,6 +176,15 @@ const watchOnly = async () => {
|
|||
this.$refs.paymentRef.updateSignedPsbt(psbtBase64)
|
||||
},
|
||||
|
||||
showEnterSignedPsbtDialog: function () {
|
||||
this.signedBase64Psbt = ''
|
||||
this.showEnterSignedPsbt = true
|
||||
},
|
||||
|
||||
checkPsbt: function () {
|
||||
this.$refs.paymentRef.updateSignedPsbt(this.signedBase64Psbt)
|
||||
},
|
||||
|
||||
//################### UTXOs ###################
|
||||
scanAllAddresses: async function () {
|
||||
await this.refreshAddresses()
|
||||
|
|
|
|||
|
|
@ -52,14 +52,38 @@
|
|||
></q-spinner>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-5 q-pr-md">
|
||||
<q-btn
|
||||
<q-btn-dropdown
|
||||
v-if="!showPayment"
|
||||
split
|
||||
unelevated
|
||||
label="New Payment"
|
||||
color="secondary"
|
||||
class="btn-full"
|
||||
@click="goToPaymentView"
|
||||
>New Payment</q-btn
|
||||
>
|
||||
<q-list>
|
||||
<q-item @click="goToPaymentView" clickable v-close-popup>
|
||||
<q-item-section>
|
||||
<q-item-label>New Payment</q-item-label>
|
||||
<q-item-label caption
|
||||
>Create a new payment by selecting Inputs and
|
||||
Outputs</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item
|
||||
@click="showEnterSignedPsbtDialog"
|
||||
clickable
|
||||
v-close-popup
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>From Signed PSBT</q-item-label>
|
||||
<q-item-label caption> Paste a signed PSBT</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
|
||||
<q-btn
|
||||
v-if="showPayment"
|
||||
outline
|
||||
|
|
@ -226,6 +250,36 @@
|
|||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showEnterSignedPsbt" position="top">
|
||||
<q-card class="q-pa-lg lnbits__dialog-card">
|
||||
<h5 class="text-subtitle1 q-my-none">Enter the Signed PSBT</h5>
|
||||
<q-separator></q-separator><br />
|
||||
|
||||
<p>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="signedBase64Psbt"
|
||||
type="textarea"
|
||||
label="Signed PSBT"
|
||||
></q-input>
|
||||
</p>
|
||||
|
||||
<div class="row q-mt-lg q-gutter-sm">
|
||||
<q-btn
|
||||
outline
|
||||
v-close-popup
|
||||
color="grey"
|
||||
@click="checkPsbt"
|
||||
class="q-ml-sm"
|
||||
>Check PSBT</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
<div class="row q-mt-lg q-gutter-sm"></div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
{% endraw %}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -31,11 +31,11 @@ from .crud import (
|
|||
)
|
||||
from .helpers import parse_key
|
||||
from .models import (
|
||||
BroadcastTransaction,
|
||||
Config,
|
||||
CreatePsbt,
|
||||
CreateWallet,
|
||||
ExtractPsbt,
|
||||
SerializedTransaction,
|
||||
SignedTransaction,
|
||||
WalletAccount,
|
||||
)
|
||||
|
|
@ -291,6 +291,24 @@ async def api_psbt_create(
|
|||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@watchonly_ext.put("/api/v1/psbt/utxos")
|
||||
async def api_psbt_extract_tx(
|
||||
req: Request, w: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
"""Extract previous unspent transaction outputs (tx_id, vout) from PSBT"""
|
||||
|
||||
body = await req.json()
|
||||
try:
|
||||
psbt = PSBT.from_base64(body["psbtBase64"])
|
||||
res = []
|
||||
for _, inp in enumerate(psbt.inputs):
|
||||
res.append({"tx_id": inp.txid.hex(), "vout": inp.vout})
|
||||
|
||||
return res
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@watchonly_ext.put("/api/v1/psbt/extract")
|
||||
async def api_psbt_extract_tx(
|
||||
data: ExtractPsbt, w: WalletTypeInfo = Depends(require_admin_key)
|
||||
|
|
@ -327,7 +345,7 @@ async def api_psbt_extract_tx(
|
|||
|
||||
@watchonly_ext.post("/api/v1/tx")
|
||||
async def api_tx_broadcast(
|
||||
data: BroadcastTransaction, w: WalletTypeInfo = Depends(require_admin_key)
|
||||
data: SerializedTransaction, w: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
try:
|
||||
config = await get_config(w.wallet.user)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ LNURL withdraw is a **very powerful tool** and should not have his use limited t
|
|||
|
||||
#### Quick Vouchers
|
||||
|
||||
LNBits Quick Vouchers allows you to easily create a batch of LNURLw's QR codes that you can print and distribute as rewards, onboarding people into Lightning Network, gifts, etc...
|
||||
LNbits Quick Vouchers allows you to easily create a batch of LNURLw's QR codes that you can print and distribute as rewards, onboarding people into Lightning Network, gifts, etc...
|
||||
|
||||
1. Create Quick Vouchers\
|
||||

|
||||
|
|
@ -37,12 +37,12 @@ LNBits Quick Vouchers allows you to easily create a batch of LNURLw's QR codes t
|
|||
- set a title for the LNURLw (it will show up in users wallet)
|
||||
- define the minimum and maximum a user can withdraw, if you want a fixed amount set them both to an equal value
|
||||
- set how many times can the LNURLw be scanned, if it's a one time use or it can be scanned 100 times
|
||||
- LNBits has the "_Time between withdraws_" setting, you can define how long the LNURLw will be unavailable between scans
|
||||
- LNbits has the "_Time between withdraws_" setting, you can define how long the LNURLw will be unavailable between scans
|
||||
- you can set the time in _seconds, minutes or hours_
|
||||
- the "_Use unique withdraw QR..._" reduces the chance of your LNURL withdraw being exploited and depleted by one person, by generating a new QR code every time it's scanned
|
||||
2. Print, share or display your LNURLw link or it's QR code\
|
||||

|
||||
|
||||
**LNBits bonus:** If a user doesn't have a Lightning Network wallet and scans the LNURLw QR code with their smartphone camera, or a QR scanner app, they can follow the link provided to claim their satoshis and get an instant LNBits wallet!
|
||||
**LNbits bonus:** If a user doesn't have a Lightning Network wallet and scans the LNURLw QR code with their smartphone camera, or a QR scanner app, they can follow the link provided to claim their satoshis and get an instant LNbits wallet!
|
||||
|
||||

|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ async def m001_initial(db):
|
|||
Creates an improved withdraw table and migrates the existing data.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
f"""
|
||||
CREATE TABLE withdraw.withdraw_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT,
|
||||
title TEXT,
|
||||
min_withdrawable INTEGER DEFAULT 1,
|
||||
max_withdrawable INTEGER DEFAULT 1,
|
||||
min_withdrawable {db.big_int} DEFAULT 1,
|
||||
max_withdrawable {db.big_int} DEFAULT 1,
|
||||
uses INTEGER DEFAULT 1,
|
||||
wait_time INTEGER,
|
||||
is_unique INTEGER DEFAULT 0,
|
||||
|
|
@ -28,13 +28,13 @@ async def m002_change_withdraw_table(db):
|
|||
Creates an improved withdraw table and migrates the existing data.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
f"""
|
||||
CREATE TABLE withdraw.withdraw_link (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT,
|
||||
title TEXT,
|
||||
min_withdrawable INTEGER DEFAULT 1,
|
||||
max_withdrawable INTEGER DEFAULT 1,
|
||||
min_withdrawable {db.big_int} DEFAULT 1,
|
||||
max_withdrawable {db.big_int} DEFAULT 1,
|
||||
uses INTEGER DEFAULT 1,
|
||||
wait_time INTEGER,
|
||||
is_unique INTEGER DEFAULT 0,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ class Extension(NamedTuple):
|
|||
icon: Optional[str] = None
|
||||
contributors: Optional[List[str]] = None
|
||||
hidden: bool = False
|
||||
migration_module: Optional[str] = None
|
||||
db_name: Optional[str] = None
|
||||
|
||||
|
||||
class ExtensionManager:
|
||||
|
|
@ -66,6 +68,8 @@ class ExtensionManager:
|
|||
config.get("icon"),
|
||||
config.get("contributors"),
|
||||
config.get("hidden") or False,
|
||||
config.get("migration_module"),
|
||||
config.get("db_name"),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -163,6 +167,7 @@ def template_renderer(additional_folders: List = []) -> Jinja2Templates:
|
|||
)
|
||||
|
||||
if settings.LNBITS_AD_SPACE:
|
||||
t.env.globals["AD_TITLE"] = settings.LNBITS_AD_SPACE_TITLE
|
||||
t.env.globals["AD_SPACE"] = settings.LNBITS_AD_SPACE
|
||||
t.env.globals["HIDE_API"] = settings.LNBITS_HIDE_API
|
||||
t.env.globals["SITE_TITLE"] = settings.LNBITS_SITE_TITLE
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from lnbits.settings import FORWARDED_ALLOW_IPS, HOST, PORT
|
|||
)
|
||||
)
|
||||
@click.option("--port", default=PORT, help="Port to listen on")
|
||||
@click.option("--host", default=HOST, help="Host to run LNBits on")
|
||||
@click.option("--host", default=HOST, help="Host to run LNbits on")
|
||||
@click.option(
|
||||
"--forwarded-allow-ips", default=FORWARDED_ALLOW_IPS, help="Allowed proxy servers"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ LNBITS_DISABLED_EXTENSIONS: List[str] = [
|
|||
for x in env.list("LNBITS_DISABLED_EXTENSIONS", default=[], subcast=str)
|
||||
]
|
||||
|
||||
LNBITS_AD_SPACE_TITLE = env.str(
|
||||
"LNBITS_AD_SPACE_TITLE", default="Optional Advert Space"
|
||||
)
|
||||
LNBITS_AD_SPACE = [x.strip(" ") for x in env.list("LNBITS_AD_SPACE", default=[])]
|
||||
LNBITS_HIDE_API = env.bool("LNBITS_HIDE_API", default=False)
|
||||
LNBITS_SITE_TITLE = env.str("LNBITS_SITE_TITLE", default="LNbits")
|
||||
|
|
|
|||
BIN
lnbits/static/images/lnbits-shop-dark.png
Normal file
BIN
lnbits/static/images/lnbits-shop-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
lnbits/static/images/lnbits-shop-light.png
Normal file
BIN
lnbits/static/images/lnbits-shop-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -184,6 +184,7 @@ window.LNbits = {
|
|||
bolt11: data.bolt11,
|
||||
preimage: data.preimage,
|
||||
payment_hash: data.payment_hash,
|
||||
expiry: data.expiry,
|
||||
extra: data.extra,
|
||||
wallet_id: data.wallet_id,
|
||||
webhook: data.webhook,
|
||||
|
|
@ -195,6 +196,11 @@ window.LNbits = {
|
|||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
obj.dateFrom = moment(obj.date).fromNow()
|
||||
obj.expirydate = Quasar.utils.date.formatDate(
|
||||
new Date(obj.expiry * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
obj.expirydateFrom = moment(obj.expirydate).fromNow()
|
||||
obj.msat = obj.amount
|
||||
obj.sat = obj.msat / 1000
|
||||
obj.tag = obj.extra.tag
|
||||
|
|
|
|||
|
|
@ -192,9 +192,13 @@ Vue.component('lnbits-payment-details', {
|
|||
</q-badge>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-3"><b>Date</b>:</div>
|
||||
<div class="col-3"><b>Created</b>:</div>
|
||||
<div class="col-9">{{ payment.date }} ({{ payment.dateFrom }})</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-3"><b>Expiry</b>:</div>
|
||||
<div class="col-9">{{ payment.expirydate }} ({{ payment.expirydateFrom }})</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-3"><b>Description</b>:</div>
|
||||
<div class="col-9">{{ payment.memo }}</div>
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ async def check_pending_payments():
|
|||
|
||||
while True:
|
||||
async with db.connect() as conn:
|
||||
logger.debug(
|
||||
logger.info(
|
||||
f"Task: checking all pending payments (incoming={incoming}, outgoing={outgoing}) of last 15 days"
|
||||
)
|
||||
start_time: float = time.time()
|
||||
|
|
@ -140,7 +140,7 @@ async def check_pending_payments():
|
|||
for payment in pending_payments:
|
||||
await payment.check_status(conn=conn)
|
||||
|
||||
logger.debug(
|
||||
logger.info(
|
||||
f"Task: pending check finished for {len(pending_payments)} payments (took {time.time() - start_time:0.3f} s)"
|
||||
)
|
||||
# we delete expired invoices once upon the first pending check
|
||||
|
|
@ -148,7 +148,7 @@ async def check_pending_payments():
|
|||
logger.debug("Task: deleting all expired invoices")
|
||||
start_time: float = time.time()
|
||||
await delete_expired_invoices(conn=conn)
|
||||
logger.debug(
|
||||
logger.info(
|
||||
f"Task: expired invoice deletion finished (took {time.time() - start_time:0.3f} s)"
|
||||
)
|
||||
|
||||
|
|
|
|||
744
poetry.lock
generated
744
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -12,58 +12,60 @@ script = "build.py"
|
|||
python = "^3.10 | ^3.9 | ^3.8 | ^3.7"
|
||||
aiofiles = "0.8.0"
|
||||
asgiref = "3.4.1"
|
||||
attrs = "21.2.0"
|
||||
attrs = "22.1.0"
|
||||
bech32 = "1.2.0"
|
||||
bitstring = "3.1.9"
|
||||
certifi = "2021.5.30"
|
||||
charset-normalizer = "2.0.6"
|
||||
click = "8.0.1"
|
||||
ecdsa = "0.17.0"
|
||||
certifi = "2022.9.24"
|
||||
charset-normalizer = "2.0.12"
|
||||
click = "8.0.4"
|
||||
ecdsa = "0.18.0"
|
||||
embit = "0.4.9"
|
||||
environs = "9.3.3"
|
||||
fastapi = "0.78.0"
|
||||
environs = "9.5.0"
|
||||
fastapi = "0.83.0"
|
||||
h11 = "0.12.0"
|
||||
httpcore = "0.15.0"
|
||||
httptools = "0.4.0"
|
||||
httpx = "0.23.0"
|
||||
idna = "3.2"
|
||||
importlib-metadata = "4.8.1"
|
||||
idna = "3.4"
|
||||
importlib-metadata = "5.0.0"
|
||||
jinja2 = "3.0.1"
|
||||
lnurl = "0.3.6"
|
||||
markupsafe = "2.0.1"
|
||||
marshmallow = "3.17.0"
|
||||
outcome = "1.1.0"
|
||||
marshmallow = "3.18.0"
|
||||
outcome = "1.2.0"
|
||||
psycopg2-binary = "2.9.1"
|
||||
pycryptodomex = "3.14.1"
|
||||
pydantic = "1.8.2"
|
||||
pydantic = "1.10.2"
|
||||
pypng = "0.0.21"
|
||||
pyqrcode = "1.2.1"
|
||||
pyScss = "1.4.0"
|
||||
python-dotenv = "0.19.0"
|
||||
python-dotenv = "0.21.0"
|
||||
pyyaml = "5.4.1"
|
||||
represent = "1.6.0.post0"
|
||||
rfc3986 = "1.5.0"
|
||||
secp256k1 = "0.14.0"
|
||||
shortuuid = "1.0.1"
|
||||
six = "1.16.0"
|
||||
sniffio = "1.2.0"
|
||||
sqlalchemy = "1.3.23"
|
||||
sniffio = "1.3.0"
|
||||
sqlalchemy = "1.3.24"
|
||||
sqlalchemy-aio = "0.17.0"
|
||||
sse-starlette = "0.6.2"
|
||||
typing-extensions = "3.10.0.2"
|
||||
uvicorn = "0.18.1"
|
||||
typing-extensions = "^4.4.0"
|
||||
uvicorn = "0.18.3"
|
||||
uvloop = "0.16.0"
|
||||
watchgod = "0.7"
|
||||
websockets = "10.0"
|
||||
zipp = "3.5.0"
|
||||
loguru = "0.5.3"
|
||||
cffi = "1.15.0"
|
||||
zipp = "3.9.0"
|
||||
loguru = "0.6.0"
|
||||
cffi = "1.15.1"
|
||||
websocket-client = "1.3.3"
|
||||
grpcio = "^1.49.1"
|
||||
protobuf = "^4.21.6"
|
||||
Cerberus = "^1.3.4"
|
||||
async-timeout = "^4.0.2"
|
||||
pyln-client = "0.11.1"
|
||||
cashu = "^0.6.0"
|
||||
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
isort = "^5.10.1"
|
||||
|
|
|
|||
137
requirements.txt
137
requirements.txt
|
|
@ -1,55 +1,82 @@
|
|||
aiofiles==0.8.0
|
||||
anyio==3.6.1
|
||||
asyncio==3.4.3
|
||||
attrs==21.4.0
|
||||
bech32==1.2.0
|
||||
bitstring==3.1.9
|
||||
cerberus==1.3.4
|
||||
certifi==2022.6.15
|
||||
cffi==1.15.0
|
||||
click==8.1.3
|
||||
ecdsa==0.18.0
|
||||
embit==0.5.0
|
||||
environs==9.5.0
|
||||
fastapi==0.79.0
|
||||
h11==0.12.0
|
||||
httpcore==0.15.0
|
||||
httptools==0.4.0
|
||||
httpx==0.23.0
|
||||
idna==3.3
|
||||
jinja2==3.0.1
|
||||
lnurl==0.3.6
|
||||
loguru==0.6.0
|
||||
markupsafe==2.1.1
|
||||
marshmallow==3.17.0
|
||||
outcome==1.2.0
|
||||
packaging==21.3
|
||||
psycopg2-binary==2.9.3
|
||||
pycparser==2.21
|
||||
pycryptodomex==3.15.0
|
||||
pydantic==1.9.1
|
||||
pyngrok==5.1.0
|
||||
pyparsing==3.0.9
|
||||
pypng==0.20220715.0
|
||||
pyqrcode==1.2.1
|
||||
pyscss==1.4.0
|
||||
python-dotenv==0.20.0
|
||||
pyyaml==6.0
|
||||
represent==1.6.0.post0
|
||||
rfc3986==1.5.0
|
||||
secp256k1==0.14.0
|
||||
shortuuid==1.0.9
|
||||
six==1.16.0
|
||||
sniffio==1.2.0
|
||||
sqlalchemy-aio==0.17.0
|
||||
sqlalchemy==1.3.23
|
||||
sse-starlette==0.10.3
|
||||
starlette==0.19.1
|
||||
typing-extensions==4.3.0
|
||||
uvicorn==0.18.2
|
||||
uvloop==0.16.0
|
||||
watchfiles==0.16.0
|
||||
websockets==10.3
|
||||
websocket-client==1.3.3
|
||||
async-timeout==4.0.2
|
||||
setuptools==65.4.0
|
||||
aiofiles==0.8.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
anyio==3.6.2 ; python_version >= "3.7" and python_version < "4.0"
|
||||
asgiref==3.4.1 ; python_version >= "3.7" and python_version < "4.0"
|
||||
asn1crypto==1.5.1 ; python_version >= "3.7" and python_version < "4.0"
|
||||
async-timeout==4.0.2 ; python_version >= "3.7" and python_version < "4.0"
|
||||
attrs==22.1.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
base58==2.1.1 ; python_version >= "3.7" and python_version < "4.0"
|
||||
bech32==1.2.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
bitstring==3.1.9 ; python_version >= "3.7" and python_version < "4.0"
|
||||
cashu==0.6.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
cerberus==1.3.4 ; python_version >= "3.7" and python_version < "4.0"
|
||||
certifi==2022.9.24 ; python_version >= "3.7" and python_version < "4.0"
|
||||
cffi==1.15.1 ; python_version >= "3.7" and python_version < "4.0"
|
||||
charset-normalizer==2.0.12 ; python_version >= "3.7" and python_version < "4.0"
|
||||
click==8.0.4 ; python_version >= "3.7" and python_version < "4.0"
|
||||
coincurve==17.0.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
colorama==0.4.5 ; python_version >= "3.7" and python_version < "4.0" and platform_system == "Windows" or python_version >= "3.7" and python_version < "4.0" and sys_platform == "win32"
|
||||
cryptography==36.0.2 ; python_version >= "3.7" and python_version < "4.0"
|
||||
ecdsa==0.18.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
embit==0.4.9 ; python_version >= "3.7" and python_version < "4.0"
|
||||
enum34==1.1.10 ; python_version >= "3.7" and python_version < "4.0"
|
||||
environs==9.5.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
fastapi==0.83.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
grpcio==1.50.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
h11==0.12.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
httpcore==0.15.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
httptools==0.4.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
httpx==0.23.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
idna==3.4 ; python_version >= "3.7" and python_version < "4.0"
|
||||
importlib-metadata==5.0.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
iniconfig==1.1.1 ; python_version >= "3.7" and python_version < "4.0"
|
||||
jinja2==3.0.1 ; python_version >= "3.7" and python_version < "4.0"
|
||||
lnurl==0.3.6 ; python_version >= "3.7" and python_version < "4.0"
|
||||
loguru==0.6.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
markupsafe==2.0.1 ; python_version >= "3.7" and python_version < "4.0"
|
||||
marshmallow==3.18.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
outcome==1.2.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
packaging==21.3 ; python_version >= "3.7" and python_version < "4.0"
|
||||
pathlib2==2.3.7.post1 ; python_version >= "3.7" and python_version < "4.0"
|
||||
pluggy==1.0.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
protobuf==4.21.9 ; python_version >= "3.7" and python_version < "4.0"
|
||||
psycopg2-binary==2.9.1 ; python_version >= "3.7" and python_version < "4.0"
|
||||
py==1.11.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
pycparser==2.21 ; python_version >= "3.7" and python_version < "4.0"
|
||||
pycryptodomex==3.14.1 ; python_version >= "3.7" and python_version < "4.0"
|
||||
pydantic==1.10.2 ; python_version >= "3.7" and python_version < "4.0"
|
||||
pyln-bolt7==1.0.246 ; python_version >= "3.7" and python_version < "4.0"
|
||||
pyln-client==0.11.1 ; python_version >= "3.7" and python_version < "4.0"
|
||||
pyln-proto==0.11.1 ; python_version >= "3.7" and python_version < "4.0"
|
||||
pyparsing==3.0.9 ; python_version >= "3.7" and python_version < "4.0"
|
||||
pypng==0.0.21 ; python_version >= "3.7" and python_version < "4.0"
|
||||
pyqrcode==1.2.1 ; python_version >= "3.7" and python_version < "4.0"
|
||||
pyscss==1.4.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
pysocks==1.7.1 ; python_version >= "3.7" and python_version < "4.0"
|
||||
pytest-asyncio==0.19.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
pytest==7.1.3 ; python_version >= "3.7" and python_version < "4.0"
|
||||
python-bitcoinlib==0.11.2 ; python_version >= "3.7" and python_version < "4.0"
|
||||
python-dotenv==0.21.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
pyyaml==5.4.1 ; python_version >= "3.7" and python_version < "4.0"
|
||||
represent==1.6.0.post0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
requests==2.27.1 ; python_version >= "3.7" and python_version < "4.0"
|
||||
rfc3986==1.5.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
rfc3986[idna2008]==1.5.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
secp256k1==0.14.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
setuptools==65.6.3 ; python_version >= "3.7" and python_version < "4.0"
|
||||
shortuuid==1.0.1 ; python_version >= "3.7" and python_version < "4.0"
|
||||
six==1.16.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
sniffio==1.3.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
sqlalchemy-aio==0.17.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
sqlalchemy==1.3.24 ; python_version >= "3.7" and python_version < "4.0"
|
||||
sse-starlette==0.6.2 ; python_version >= "3.7" and python_version < "4.0"
|
||||
starlette==0.19.1 ; python_version >= "3.7" and python_version < "4.0"
|
||||
tomli==2.0.1 ; python_version >= "3.7" and python_version < "4.0"
|
||||
typing-extensions==4.4.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
urllib3==1.26.12 ; python_version >= "3.7" and python_version < "4"
|
||||
uvicorn==0.18.3 ; python_version >= "3.7" and python_version < "4.0"
|
||||
uvloop==0.16.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
watchgod==0.7 ; python_version >= "3.7" and python_version < "4.0"
|
||||
websocket-client==1.3.3 ; python_version >= "3.7" and python_version < "4.0"
|
||||
websockets==10.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
win32-setctime==1.1.0 ; python_version >= "3.7" and python_version < "4.0" and sys_platform == "win32"
|
||||
zipp==3.9.0 ; python_version >= "3.7" and python_version < "4.0"
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ async def from_wallet(from_user):
|
|||
wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_from")
|
||||
await credit_wallet(
|
||||
wallet_id=wallet.id,
|
||||
amount=99999999,
|
||||
amount=999999999,
|
||||
)
|
||||
yield wallet
|
||||
|
||||
|
|
@ -77,7 +77,7 @@ async def to_wallet(to_user):
|
|||
wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_to")
|
||||
await credit_wallet(
|
||||
wallet_id=wallet.id,
|
||||
amount=99999999,
|
||||
amount=999999999,
|
||||
)
|
||||
yield wallet
|
||||
|
||||
|
|
|
|||
|
|
@ -46,11 +46,11 @@ async def test_get_wallet_no_redirect(client):
|
|||
i += 1
|
||||
|
||||
|
||||
# check GET /wallet: wrong user, expect 204
|
||||
# check GET /wallet: wrong user, expect 400
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_wallet_with_nonexistent_user(client):
|
||||
response = await client.get("wallet", params={"usr": "1"})
|
||||
assert response.status_code == 204, (
|
||||
assert response.status_code == 400, (
|
||||
str(response.url) + " " + str(response.status_code)
|
||||
)
|
||||
|
||||
|
|
@ -91,11 +91,11 @@ async def test_get_wallet_with_user_and_wallet(client, to_user, to_wallet):
|
|||
)
|
||||
|
||||
|
||||
# check GET /wallet: wrong wallet and user, expect 204
|
||||
# check GET /wallet: wrong wallet and user, expect 400
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_wallet_with_user_and_wrong_wallet(client, to_user):
|
||||
response = await client.get("wallet", params={"usr": to_user.id, "wal": "1"})
|
||||
assert response.status_code == 204, (
|
||||
assert response.status_code == 400, (
|
||||
str(response.url) + " " + str(response.status_code)
|
||||
)
|
||||
|
||||
|
|
@ -109,20 +109,20 @@ async def test_get_extensions(client, to_user):
|
|||
)
|
||||
|
||||
|
||||
# check GET /extensions: extensions list wrong user, expect 204
|
||||
# check GET /extensions: extensions list wrong user, expect 400
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_extensions_wrong_user(client, to_user):
|
||||
response = await client.get("extensions", params={"usr": "1"})
|
||||
assert response.status_code == 204, (
|
||||
assert response.status_code == 400, (
|
||||
str(response.url) + " " + str(response.status_code)
|
||||
)
|
||||
|
||||
|
||||
# check GET /extensions: no user given, expect code 204 no content
|
||||
# check GET /extensions: no user given, expect code 400 bad request
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_extensions_no_user(client):
|
||||
response = await client.get("extensions")
|
||||
assert response.status_code == 204, ( # no content
|
||||
assert response.status_code == 400, ( # bad request
|
||||
str(response.url) + " " + str(response.status_code)
|
||||
)
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -61,21 +61,21 @@ async def test_endpoints_inkey(client, inkey_headers_to):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skipif(is_fake, reason="this test is only passes with regtest")
|
||||
async def test_endpoints_adminkey_nocontent(client, adminkey_headers_to):
|
||||
async def test_endpoints_adminkey_badrequest(client, adminkey_headers_to):
|
||||
response = await client.post("/boltz/api/v1/swap", headers=adminkey_headers_to)
|
||||
assert response.status_code == 204
|
||||
assert response.status_code == 400
|
||||
response = await client.post(
|
||||
"/boltz/api/v1/swap/reverse", headers=adminkey_headers_to
|
||||
)
|
||||
assert response.status_code == 204
|
||||
assert response.status_code == 400
|
||||
response = await client.post(
|
||||
"/boltz/api/v1/swap/refund", headers=adminkey_headers_to
|
||||
)
|
||||
assert response.status_code == 204
|
||||
assert response.status_code == 400
|
||||
response = await client.post(
|
||||
"/boltz/api/v1/swap/status", headers=adminkey_headers_to
|
||||
)
|
||||
assert response.status_code == 204
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ async def accounting_invoice(invoices_wallet):
|
|||
invoice_data = CreateInvoiceData(
|
||||
status="open",
|
||||
currency="USD",
|
||||
company_name="LNBits, Inc",
|
||||
company_name="LNbits, Inc",
|
||||
first_name="Ben",
|
||||
last_name="Arc",
|
||||
items=[{"amount": 10.20, "description": "Item costs 10.20"}],
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ async def test_invoices_api_create_invoice_valid(client, invoices_wallet):
|
|||
query = {
|
||||
"status": "open",
|
||||
"currency": "EUR",
|
||||
"company_name": "LNBits, Inc.",
|
||||
"company_name": "LNbits, Inc.",
|
||||
"first_name": "Ben",
|
||||
"last_name": "Arc",
|
||||
"email": "ben@legend.arc",
|
||||
|
|
|
|||
|
|
@ -133,6 +133,10 @@ def migrate_db(file: str, schema: str, exclude_tables: List[str] = []):
|
|||
|
||||
for table in tables:
|
||||
tableName = table[0]
|
||||
print(f"Migrating table {tableName}")
|
||||
# hard coded skip for dbversions (already produced during startup)
|
||||
if tableName == "dbversions":
|
||||
continue
|
||||
if tableName in exclude_tables:
|
||||
continue
|
||||
|
||||
|
|
@ -156,7 +160,7 @@ def build_insert_query(schema, tableName, columns):
|
|||
def to_column_type(columnType):
|
||||
if columnType == "TIMESTAMP":
|
||||
return "to_timestamp(%s)"
|
||||
if columnType == "BOOLEAN":
|
||||
if columnType in ["BOOLEAN", "BOOL"]:
|
||||
return "%s::boolean"
|
||||
return "%s"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue