diff --git a/.env.example b/.env.example index e76296ab..898f90bd 100644 --- a/.env.example +++ b/.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 ";;, ;;", 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" diff --git a/docs/devs/websockets.md b/docs/devs/websockets.md index 0638e4f2..9ea53a62 100644 --- a/docs/devs/websockets.md +++ b/docs/devs/websockets.md @@ -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}) => { diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 072c4d91..9f8b26da 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -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 diff --git a/lnbits/app.py b/lnbits/app.py index 075828ef..fccfffd1 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -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}, ) diff --git a/lnbits/commands.py b/lnbits/commands.py index 0f7454f2..a519405a 100644 --- a/lnbits/commands.py +++ b/lnbits/commands.py @@ -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.") diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index 881d1001..2baa0507 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -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), ), ) diff --git a/lnbits/core/migrations.py b/lnbits/core/migrations.py index d92f384a..2bffa5c7 100644 --- a/lnbits/core/migrations.py +++ b/lnbits/core/migrations.py @@ -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 diff --git a/lnbits/core/models.py b/lnbits/core/models.py index 216acafd..62f8aa39 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -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}" ) diff --git a/lnbits/core/services.py b/lnbits/core/services.py index 5d993b4c..beb0f97a 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -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) diff --git a/lnbits/core/templates/core/index.html b/lnbits/core/templates/core/index.html index 68a7b7ed..5f26cb03 100644 --- a/lnbits/core/templates/core/index.html +++ b/lnbits/core/templates/core/index.html @@ -183,6 +183,23 @@
 
+ + {% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %} +
+ {{ AD_TITLE }} + + + + + +
+ {% endfor %} {% endif %} diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html index 4bf6067c..22fbd05d 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -388,9 +388,14 @@ {% endif %} {% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %} - +
{{ AD_TITLE }}
+ + + + + + {% endfor %} {% endif %} diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 983d5a26..21342d68 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -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} diff --git a/lnbits/db.py b/lnbits/db.py index f52b0391..7d294197 100644 --- a/lnbits/db.py +++ b/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 "" + 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 "" + @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: diff --git a/lnbits/extensions/boltcards/README.md b/lnbits/extensions/boltcards/README.md index f612ae2f..3a00e265 100644 --- a/lnbits/extensions/boltcards/README.md +++ b/lnbits/extensions/boltcards/README.md @@ -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). diff --git a/lnbits/extensions/cashu/README.md b/lnbits/extensions/cashu/README.md new file mode 100644 index 00000000..8f53b474 --- /dev/null +++ b/lnbits/extensions/cashu/README.md @@ -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 diff --git a/lnbits/extensions/cashu/__init__.py b/lnbits/extensions/cashu/__init__.py new file mode 100644 index 00000000..e6507bba --- /dev/null +++ b/lnbits/extensions/cashu/__init__.py @@ -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)) diff --git a/lnbits/extensions/cashu/config.json b/lnbits/extensions/cashu/config.json new file mode 100644 index 00000000..af202d43 --- /dev/null +++ b/lnbits/extensions/cashu/config.json @@ -0,0 +1,7 @@ +{ + "name": "Cashu", + "short_description": "Ecash mint and wallet", + "icon": "account_balance", + "contributors": ["calle", "vlad", "arcbtc"], + "hidden": false +} diff --git a/lnbits/extensions/cashu/crud.py b/lnbits/extensions/cashu/crud.py new file mode 100644 index 00000000..773a11fd --- /dev/null +++ b/lnbits/extensions/cashu/crud.py @@ -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,)) diff --git a/lnbits/extensions/cashu/migrations.py b/lnbits/extensions/cashu/migrations.py new file mode 100644 index 00000000..b54c4108 --- /dev/null +++ b/lnbits/extensions/cashu/migrations.py @@ -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 + ); + """ + ) diff --git a/lnbits/extensions/cashu/models.py b/lnbits/extensions/cashu/models.py new file mode 100644 index 00000000..c820d12e --- /dev/null +++ b/lnbits/extensions/cashu/models.py @@ -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 diff --git a/lnbits/extensions/cashu/static/js/base64.js b/lnbits/extensions/cashu/static/js/base64.js new file mode 100644 index 00000000..b150882f --- /dev/null +++ b/lnbits/extensions/cashu/static/js/base64.js @@ -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 +})({}) diff --git a/lnbits/extensions/cashu/static/js/dhke.js b/lnbits/extensions/cashu/static/js/dhke.js new file mode 100644 index 00000000..41c2fb46 --- /dev/null +++ b/lnbits/extensions/cashu/static/js/dhke.js @@ -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 +} diff --git a/lnbits/extensions/cashu/static/js/noble-secp256k1.js b/lnbits/extensions/cashu/static/js/noble-secp256k1.js new file mode 100644 index 00000000..6a6bd441 --- /dev/null +++ b/lnbits/extensions/cashu/static/js/noble-secp256k1.js @@ -0,0 +1,1178 @@ +;(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' + ? factory(exports) + : typeof define === 'function' && define.amd + ? define(['exports'], factory) + : ((global = + typeof globalThis !== 'undefined' ? globalThis : global || self), + factory((global.nobleSecp256k1 = {}))) +})(this, function (exports) { + 'use strict' + + const _nodeResolve_empty = {} + + const nodeCrypto = /*#__PURE__*/ Object.freeze({ + __proto__: null, + default: _nodeResolve_empty + }) + + /*! noble-secp256k1 - MIT License (c) 2019 Paul Miller (paulmillr.com) */ + const _0n = BigInt(0) + const _1n = BigInt(1) + const _2n = BigInt(2) + const _3n = BigInt(3) + const _8n = BigInt(8) + const POW_2_256 = _2n ** BigInt(256) + const CURVE = { + a: _0n, + b: BigInt(7), + P: POW_2_256 - _2n ** BigInt(32) - BigInt(977), + n: POW_2_256 - BigInt('432420386565659656852420866394968145599'), + h: _1n, + Gx: BigInt( + '55066263022277343669578718895168534326250603453777594175500187360389116729240' + ), + Gy: BigInt( + '32670510020758816978083085130507043184471273380659243275938904335757337482424' + ), + beta: BigInt( + '0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee' + ) + } + function weistrass(x) { + const {a, b} = CURVE + const x2 = mod(x * x) + const x3 = mod(x2 * x) + return mod(x3 + a * x + b) + } + const USE_ENDOMORPHISM = CURVE.a === _0n + class JacobianPoint { + constructor(x, y, z) { + this.x = x + this.y = y + this.z = z + } + static fromAffine(p) { + if (!(p instanceof Point)) { + throw new TypeError('JacobianPoint#fromAffine: expected Point') + } + return new JacobianPoint(p.x, p.y, _1n) + } + static toAffineBatch(points) { + const toInv = invertBatch(points.map(p => p.z)) + return points.map((p, i) => p.toAffine(toInv[i])) + } + static normalizeZ(points) { + return JacobianPoint.toAffineBatch(points).map(JacobianPoint.fromAffine) + } + equals(other) { + if (!(other instanceof JacobianPoint)) + throw new TypeError('JacobianPoint expected') + const {x: X1, y: Y1, z: Z1} = this + const {x: X2, y: Y2, z: Z2} = other + const Z1Z1 = mod(Z1 ** _2n) + const Z2Z2 = mod(Z2 ** _2n) + const U1 = mod(X1 * Z2Z2) + const U2 = mod(X2 * Z1Z1) + const S1 = mod(mod(Y1 * Z2) * Z2Z2) + const S2 = mod(mod(Y2 * Z1) * Z1Z1) + return U1 === U2 && S1 === S2 + } + negate() { + return new JacobianPoint(this.x, mod(-this.y), this.z) + } + double() { + const {x: X1, y: Y1, z: Z1} = this + const A = mod(X1 ** _2n) + const B = mod(Y1 ** _2n) + const C = mod(B ** _2n) + const D = mod(_2n * (mod((X1 + B) ** _2n) - A - C)) + const E = mod(_3n * A) + const F = mod(E ** _2n) + const X3 = mod(F - _2n * D) + const Y3 = mod(E * (D - X3) - _8n * C) + const Z3 = mod(_2n * Y1 * Z1) + return new JacobianPoint(X3, Y3, Z3) + } + add(other) { + if (!(other instanceof JacobianPoint)) + throw new TypeError('JacobianPoint expected') + const {x: X1, y: Y1, z: Z1} = this + const {x: X2, y: Y2, z: Z2} = other + if (X2 === _0n || Y2 === _0n) return this + if (X1 === _0n || Y1 === _0n) return other + const Z1Z1 = mod(Z1 ** _2n) + const Z2Z2 = mod(Z2 ** _2n) + const U1 = mod(X1 * Z2Z2) + const U2 = mod(X2 * Z1Z1) + const S1 = mod(mod(Y1 * Z2) * Z2Z2) + const S2 = mod(mod(Y2 * Z1) * Z1Z1) + const H = mod(U2 - U1) + const r = mod(S2 - S1) + if (H === _0n) { + if (r === _0n) { + return this.double() + } else { + return JacobianPoint.ZERO + } + } + const HH = mod(H ** _2n) + const HHH = mod(H * HH) + const V = mod(U1 * HH) + const X3 = mod(r ** _2n - HHH - _2n * V) + const Y3 = mod(r * (V - X3) - S1 * HHH) + const Z3 = mod(Z1 * Z2 * H) + return new JacobianPoint(X3, Y3, Z3) + } + subtract(other) { + return this.add(other.negate()) + } + multiplyUnsafe(scalar) { + const P0 = JacobianPoint.ZERO + if (typeof scalar === 'bigint' && scalar === _0n) return P0 + let n = normalizeScalar(scalar) + if (n === _1n) return this + if (!USE_ENDOMORPHISM) { + let p = P0 + let d = this + while (n > _0n) { + if (n & _1n) p = p.add(d) + d = d.double() + n >>= _1n + } + return p + } + let {k1neg, k1, k2neg, k2} = splitScalarEndo(n) + let k1p = P0 + let k2p = P0 + let d = this + while (k1 > _0n || k2 > _0n) { + if (k1 & _1n) k1p = k1p.add(d) + if (k2 & _1n) k2p = k2p.add(d) + d = d.double() + k1 >>= _1n + k2 >>= _1n + } + if (k1neg) k1p = k1p.negate() + if (k2neg) k2p = k2p.negate() + k2p = new JacobianPoint(mod(k2p.x * CURVE.beta), k2p.y, k2p.z) + return k1p.add(k2p) + } + precomputeWindow(W) { + const windows = USE_ENDOMORPHISM ? 128 / W + 1 : 256 / W + 1 + const points = [] + let p = this + let base = p + for (let window = 0; window < windows; window++) { + base = p + points.push(base) + for (let i = 1; i < 2 ** (W - 1); i++) { + base = base.add(p) + points.push(base) + } + p = base.double() + } + return points + } + wNAF(n, affinePoint) { + if (!affinePoint && this.equals(JacobianPoint.BASE)) + affinePoint = Point.BASE + const W = (affinePoint && affinePoint._WINDOW_SIZE) || 1 + if (256 % W) { + throw new Error( + 'Point#wNAF: Invalid precomputation window, must be power of 2' + ) + } + let precomputes = affinePoint && pointPrecomputes.get(affinePoint) + if (!precomputes) { + precomputes = this.precomputeWindow(W) + if (affinePoint && W !== 1) { + precomputes = JacobianPoint.normalizeZ(precomputes) + pointPrecomputes.set(affinePoint, precomputes) + } + } + let p = JacobianPoint.ZERO + let f = JacobianPoint.ZERO + const windows = 1 + (USE_ENDOMORPHISM ? 128 / W : 256 / W) + const windowSize = 2 ** (W - 1) + const mask = BigInt(2 ** W - 1) + const maxNumber = 2 ** W + const shiftBy = BigInt(W) + for (let window = 0; window < windows; window++) { + const offset = window * windowSize + let wbits = Number(n & mask) + n >>= shiftBy + if (wbits > windowSize) { + wbits -= maxNumber + n += _1n + } + if (wbits === 0) { + let pr = precomputes[offset] + if (window % 2) pr = pr.negate() + f = f.add(pr) + } else { + let cached = precomputes[offset + Math.abs(wbits) - 1] + if (wbits < 0) cached = cached.negate() + p = p.add(cached) + } + } + return {p, f} + } + multiply(scalar, affinePoint) { + let n = normalizeScalar(scalar) + let point + let fake + if (USE_ENDOMORPHISM) { + const {k1neg, k1, k2neg, k2} = splitScalarEndo(n) + let {p: k1p, f: f1p} = this.wNAF(k1, affinePoint) + let {p: k2p, f: f2p} = this.wNAF(k2, affinePoint) + if (k1neg) k1p = k1p.negate() + if (k2neg) k2p = k2p.negate() + k2p = new JacobianPoint(mod(k2p.x * CURVE.beta), k2p.y, k2p.z) + point = k1p.add(k2p) + fake = f1p.add(f2p) + } else { + const {p, f} = this.wNAF(n, affinePoint) + point = p + fake = f + } + return JacobianPoint.normalizeZ([point, fake])[0] + } + toAffine(invZ = invert(this.z)) { + const {x, y, z} = this + const iz1 = invZ + const iz2 = mod(iz1 * iz1) + const iz3 = mod(iz2 * iz1) + const ax = mod(x * iz2) + const ay = mod(y * iz3) + const zz = mod(z * iz1) + if (zz !== _1n) throw new Error('invZ was invalid') + return new Point(ax, ay) + } + } + JacobianPoint.BASE = new JacobianPoint(CURVE.Gx, CURVE.Gy, _1n) + JacobianPoint.ZERO = new JacobianPoint(_0n, _1n, _0n) + const pointPrecomputes = new WeakMap() + class Point { + constructor(x, y) { + this.x = x + this.y = y + } + _setWindowSize(windowSize) { + this._WINDOW_SIZE = windowSize + pointPrecomputes.delete(this) + } + static fromCompressedHex(bytes) { + const isShort = bytes.length === 32 + const x = bytesToNumber(isShort ? bytes : bytes.subarray(1)) + if (!isValidFieldElement(x)) throw new Error('Point is not on curve') + const y2 = weistrass(x) + let y = sqrtMod(y2) + const isYOdd = (y & _1n) === _1n + if (isShort) { + if (isYOdd) y = mod(-y) + } else { + const isFirstByteOdd = (bytes[0] & 1) === 1 + if (isFirstByteOdd !== isYOdd) y = mod(-y) + } + const point = new Point(x, y) + point.assertValidity() + return point + } + static fromUncompressedHex(bytes) { + const x = bytesToNumber(bytes.subarray(1, 33)) + const y = bytesToNumber(bytes.subarray(33, 65)) + const point = new Point(x, y) + point.assertValidity() + return point + } + static fromHex(hex) { + const bytes = ensureBytes(hex) + const len = bytes.length + const header = bytes[0] + if (len === 32 || (len === 33 && (header === 0x02 || header === 0x03))) { + return this.fromCompressedHex(bytes) + } + if (len === 65 && header === 0x04) return this.fromUncompressedHex(bytes) + throw new Error( + `Point.fromHex: received invalid point. Expected 32-33 compressed bytes or 65 uncompressed bytes, not ${len}` + ) + } + static fromPrivateKey(privateKey) { + return Point.BASE.multiply(normalizePrivateKey(privateKey)) + } + static fromSignature(msgHash, signature, recovery) { + msgHash = ensureBytes(msgHash) + const h = truncateHash(msgHash) + const {r, s} = normalizeSignature(signature) + if (recovery !== 0 && recovery !== 1) { + throw new Error('Cannot recover signature: invalid recovery bit') + } + const prefix = recovery & 1 ? '03' : '02' + const R = Point.fromHex(prefix + numTo32bStr(r)) + const {n} = CURVE + const rinv = invert(r, n) + const u1 = mod(-h * rinv, n) + const u2 = mod(s * rinv, n) + const Q = Point.BASE.multiplyAndAddUnsafe(R, u1, u2) + if (!Q) throw new Error('Cannot recover signature: point at infinify') + Q.assertValidity() + return Q + } + toRawBytes(isCompressed = false) { + return hexToBytes(this.toHex(isCompressed)) + } + toHex(isCompressed = false) { + const x = numTo32bStr(this.x) + if (isCompressed) { + const prefix = this.y & _1n ? '03' : '02' + return `${prefix}${x}` + } else { + return `04${x}${numTo32bStr(this.y)}` + } + } + toHexX() { + return this.toHex(true).slice(2) + } + toRawX() { + return this.toRawBytes(true).slice(1) + } + assertValidity() { + const msg = 'Point is not on elliptic curve' + const {x, y} = this + if (!isValidFieldElement(x) || !isValidFieldElement(y)) + throw new Error(msg) + const left = mod(y * y) + const right = weistrass(x) + if (mod(left - right) !== _0n) throw new Error(msg) + } + equals(other) { + return this.x === other.x && this.y === other.y + } + negate() { + return new Point(this.x, mod(-this.y)) + } + double() { + return JacobianPoint.fromAffine(this).double().toAffine() + } + add(other) { + return JacobianPoint.fromAffine(this) + .add(JacobianPoint.fromAffine(other)) + .toAffine() + } + subtract(other) { + return this.add(other.negate()) + } + multiply(scalar) { + return JacobianPoint.fromAffine(this).multiply(scalar, this).toAffine() + } + multiplyAndAddUnsafe(Q, a, b) { + const P = JacobianPoint.fromAffine(this) + const aP = + a === _0n || a === _1n || this !== Point.BASE + ? P.multiplyUnsafe(a) + : P.multiply(a) + const bQ = JacobianPoint.fromAffine(Q).multiplyUnsafe(b) + const sum = aP.add(bQ) + return sum.equals(JacobianPoint.ZERO) ? undefined : sum.toAffine() + } + } + Point.BASE = new Point(CURVE.Gx, CURVE.Gy) + Point.ZERO = new Point(_0n, _0n) + function sliceDER(s) { + return Number.parseInt(s[0], 16) >= 8 ? '00' + s : s + } + function parseDERInt(data) { + if (data.length < 2 || data[0] !== 0x02) { + throw new Error(`Invalid signature integer tag: ${bytesToHex(data)}`) + } + const len = data[1] + const res = data.subarray(2, len + 2) + if (!len || res.length !== len) { + throw new Error(`Invalid signature integer: wrong length`) + } + if (res[0] === 0x00 && res[1] <= 0x7f) { + throw new Error('Invalid signature integer: trailing length') + } + return {data: bytesToNumber(res), left: data.subarray(len + 2)} + } + function parseDERSignature(data) { + if (data.length < 2 || data[0] != 0x30) { + throw new Error(`Invalid signature tag: ${bytesToHex(data)}`) + } + if (data[1] !== data.length - 2) { + throw new Error('Invalid signature: incorrect length') + } + const {data: r, left: sBytes} = parseDERInt(data.subarray(2)) + const {data: s, left: rBytesLeft} = parseDERInt(sBytes) + if (rBytesLeft.length) { + throw new Error( + `Invalid signature: left bytes after parsing: ${bytesToHex(rBytesLeft)}` + ) + } + return {r, s} + } + class Signature { + constructor(r, s) { + this.r = r + this.s = s + this.assertValidity() + } + static fromCompact(hex) { + const arr = isUint8a(hex) + const name = 'Signature.fromCompact' + if (typeof hex !== 'string' && !arr) + throw new TypeError(`${name}: Expected string or Uint8Array`) + const str = arr ? bytesToHex(hex) : hex + if (str.length !== 128) throw new Error(`${name}: Expected 64-byte hex`) + return new Signature( + hexToNumber(str.slice(0, 64)), + hexToNumber(str.slice(64, 128)) + ) + } + static fromDER(hex) { + const arr = isUint8a(hex) + if (typeof hex !== 'string' && !arr) + throw new TypeError(`Signature.fromDER: Expected string or Uint8Array`) + const {r, s} = parseDERSignature(arr ? hex : hexToBytes(hex)) + return new Signature(r, s) + } + static fromHex(hex) { + return this.fromDER(hex) + } + assertValidity() { + const {r, s} = this + if (!isWithinCurveOrder(r)) + throw new Error('Invalid Signature: r must be 0 < r < n') + if (!isWithinCurveOrder(s)) + throw new Error('Invalid Signature: s must be 0 < s < n') + } + hasHighS() { + const HALF = CURVE.n >> _1n + return this.s > HALF + } + normalizeS() { + return this.hasHighS() ? new Signature(this.r, CURVE.n - this.s) : this + } + toDERRawBytes(isCompressed = false) { + return hexToBytes(this.toDERHex(isCompressed)) + } + toDERHex(isCompressed = false) { + const sHex = sliceDER(numberToHexUnpadded(this.s)) + if (isCompressed) return sHex + const rHex = sliceDER(numberToHexUnpadded(this.r)) + const rLen = numberToHexUnpadded(rHex.length / 2) + const sLen = numberToHexUnpadded(sHex.length / 2) + const length = numberToHexUnpadded(rHex.length / 2 + sHex.length / 2 + 4) + return `30${length}02${rLen}${rHex}02${sLen}${sHex}` + } + toRawBytes() { + return this.toDERRawBytes() + } + toHex() { + return this.toDERHex() + } + toCompactRawBytes() { + return hexToBytes(this.toCompactHex()) + } + toCompactHex() { + return numTo32bStr(this.r) + numTo32bStr(this.s) + } + } + function concatBytes(...arrays) { + if (!arrays.every(isUint8a)) throw new Error('Uint8Array list expected') + if (arrays.length === 1) return arrays[0] + const length = arrays.reduce((a, arr) => a + arr.length, 0) + const result = new Uint8Array(length) + for (let i = 0, pad = 0; i < arrays.length; i++) { + const arr = arrays[i] + result.set(arr, pad) + pad += arr.length + } + return result + } + function isUint8a(bytes) { + return bytes instanceof Uint8Array + } + const hexes = Array.from({length: 256}, (v, i) => + i.toString(16).padStart(2, '0') + ) + function bytesToHex(uint8a) { + if (!(uint8a instanceof Uint8Array)) throw new Error('Expected Uint8Array') + let hex = '' + for (let i = 0; i < uint8a.length; i++) { + hex += hexes[uint8a[i]] + } + return hex + } + function numTo32bStr(num) { + if (num > POW_2_256) throw new Error('Expected number < 2^256') + return num.toString(16).padStart(64, '0') + } + function numTo32b(num) { + return hexToBytes(numTo32bStr(num)) + } + function numberToHexUnpadded(num) { + const hex = num.toString(16) + return hex.length & 1 ? `0${hex}` : hex + } + function hexToNumber(hex) { + if (typeof hex !== 'string') { + throw new TypeError('hexToNumber: expected string, got ' + typeof hex) + } + return BigInt(`0x${hex}`) + } + function hexToBytes(hex) { + if (typeof hex !== 'string') { + throw new TypeError('hexToBytes: expected string, got ' + typeof hex) + } + if (hex.length % 2) + throw new Error('hexToBytes: received invalid unpadded hex' + hex.length) + const array = new Uint8Array(hex.length / 2) + for (let i = 0; i < array.length; i++) { + const j = i * 2 + const hexByte = hex.slice(j, j + 2) + const byte = Number.parseInt(hexByte, 16) + if (Number.isNaN(byte) || byte < 0) + throw new Error('Invalid byte sequence') + array[i] = byte + } + return array + } + function bytesToNumber(bytes) { + return hexToNumber(bytesToHex(bytes)) + } + function ensureBytes(hex) { + return hex instanceof Uint8Array ? Uint8Array.from(hex) : hexToBytes(hex) + } + function normalizeScalar(num) { + if (typeof num === 'number' && Number.isSafeInteger(num) && num > 0) + return BigInt(num) + if (typeof num === 'bigint' && isWithinCurveOrder(num)) return num + throw new TypeError('Expected valid private scalar: 0 < scalar < curve.n') + } + function mod(a, b = CURVE.P) { + const result = a % b + return result >= _0n ? result : b + result + } + function pow2(x, power) { + const {P} = CURVE + let res = x + while (power-- > _0n) { + res *= res + res %= P + } + return res + } + function sqrtMod(x) { + const {P} = CURVE + const _6n = BigInt(6) + const _11n = BigInt(11) + const _22n = BigInt(22) + const _23n = BigInt(23) + const _44n = BigInt(44) + const _88n = BigInt(88) + const b2 = (x * x * x) % P + const b3 = (b2 * b2 * x) % P + const b6 = (pow2(b3, _3n) * b3) % P + const b9 = (pow2(b6, _3n) * b3) % P + const b11 = (pow2(b9, _2n) * b2) % P + const b22 = (pow2(b11, _11n) * b11) % P + const b44 = (pow2(b22, _22n) * b22) % P + const b88 = (pow2(b44, _44n) * b44) % P + const b176 = (pow2(b88, _88n) * b88) % P + const b220 = (pow2(b176, _44n) * b44) % P + const b223 = (pow2(b220, _3n) * b3) % P + const t1 = (pow2(b223, _23n) * b22) % P + const t2 = (pow2(t1, _6n) * b2) % P + return pow2(t2, _2n) + } + function invert(number, modulo = CURVE.P) { + if (number === _0n || modulo <= _0n) { + throw new Error( + `invert: expected positive integers, got n=${number} mod=${modulo}` + ) + } + let a = mod(number, modulo) + let b = modulo + let x = _0n, + u = _1n + while (a !== _0n) { + const q = b / a + const r = b % a + const m = x - u * q + ;(b = a), (a = r), (x = u), (u = m) + } + const gcd = b + if (gcd !== _1n) throw new Error('invert: does not exist') + return mod(x, modulo) + } + function invertBatch(nums, p = CURVE.P) { + const scratch = new Array(nums.length) + const lastMultiplied = nums.reduce((acc, num, i) => { + if (num === _0n) return acc + scratch[i] = acc + return mod(acc * num, p) + }, _1n) + const inverted = invert(lastMultiplied, p) + nums.reduceRight((acc, num, i) => { + if (num === _0n) return acc + scratch[i] = mod(acc * scratch[i], p) + return mod(acc * num, p) + }, inverted) + return scratch + } + const divNearest = (a, b) => (a + b / _2n) / b + const POW_2_128 = _2n ** BigInt(128) + function splitScalarEndo(k) { + const {n} = CURVE + const a1 = BigInt('0x3086d221a7d46bcde86c90e49284eb15') + const b1 = -_1n * BigInt('0xe4437ed6010e88286f547fa90abfe4c3') + const a2 = BigInt('0x114ca50f7a8e2f3f657c1108d9d44cfd8') + const b2 = a1 + const c1 = divNearest(b2 * k, n) + const c2 = divNearest(-b1 * k, n) + let k1 = mod(k - c1 * a1 - c2 * a2, n) + let k2 = mod(-c1 * b1 - c2 * b2, n) + const k1neg = k1 > POW_2_128 + const k2neg = k2 > POW_2_128 + if (k1neg) k1 = n - k1 + if (k2neg) k2 = n - k2 + if (k1 > POW_2_128 || k2 > POW_2_128) { + throw new Error('splitScalarEndo: Endomorphism failed, k=' + k) + } + return {k1neg, k1, k2neg, k2} + } + function truncateHash(hash) { + const {n} = CURVE + const byteLength = hash.length + const delta = byteLength * 8 - 256 + let h = bytesToNumber(hash) + if (delta > 0) h = h >> BigInt(delta) + if (h >= n) h -= n + return h + } + class HmacDrbg { + constructor() { + this.v = new Uint8Array(32).fill(1) + this.k = new Uint8Array(32).fill(0) + this.counter = 0 + } + hmac(...values) { + return utils.hmacSha256(this.k, ...values) + } + hmacSync(...values) { + if (typeof utils.hmacSha256Sync !== 'function') + throw new Error('utils.hmacSha256Sync is undefined, you need to set it') + const res = utils.hmacSha256Sync(this.k, ...values) + if (res instanceof Promise) + throw new Error('To use sync sign(), ensure utils.hmacSha256 is sync') + return res + } + incr() { + if (this.counter >= 1000) { + throw new Error('Tried 1,000 k values for sign(), all were invalid') + } + this.counter += 1 + } + async reseed(seed = new Uint8Array()) { + this.k = await this.hmac(this.v, Uint8Array.from([0x00]), seed) + this.v = await this.hmac(this.v) + if (seed.length === 0) return + this.k = await this.hmac(this.v, Uint8Array.from([0x01]), seed) + this.v = await this.hmac(this.v) + } + reseedSync(seed = new Uint8Array()) { + this.k = this.hmacSync(this.v, Uint8Array.from([0x00]), seed) + this.v = this.hmacSync(this.v) + if (seed.length === 0) return + this.k = this.hmacSync(this.v, Uint8Array.from([0x01]), seed) + this.v = this.hmacSync(this.v) + } + async generate() { + this.incr() + this.v = await this.hmac(this.v) + return this.v + } + generateSync() { + this.incr() + this.v = this.hmacSync(this.v) + return this.v + } + } + function isWithinCurveOrder(num) { + return _0n < num && num < CURVE.n + } + function isValidFieldElement(num) { + return _0n < num && num < CURVE.P + } + function kmdToSig(kBytes, m, d) { + const k = bytesToNumber(kBytes) + if (!isWithinCurveOrder(k)) return + const {n} = CURVE + const q = Point.BASE.multiply(k) + const r = mod(q.x, n) + if (r === _0n) return + const s = mod(invert(k, n) * mod(m + d * r, n), n) + if (s === _0n) return + const sig = new Signature(r, s) + const recovery = (q.x === sig.r ? 0 : 2) | Number(q.y & _1n) + return {sig, recovery} + } + function normalizePrivateKey(key) { + let num + if (typeof key === 'bigint') { + num = key + } else if ( + typeof key === 'number' && + Number.isSafeInteger(key) && + key > 0 + ) { + num = BigInt(key) + } else if (typeof key === 'string') { + if (key.length !== 64) throw new Error('Expected 32 bytes of private key') + num = hexToNumber(key) + } else if (isUint8a(key)) { + if (key.length !== 32) throw new Error('Expected 32 bytes of private key') + num = bytesToNumber(key) + } else { + throw new TypeError('Expected valid private key') + } + if (!isWithinCurveOrder(num)) + throw new Error('Expected private key: 0 < key < n') + return num + } + function normalizePublicKey(publicKey) { + if (publicKey instanceof Point) { + publicKey.assertValidity() + return publicKey + } else { + return Point.fromHex(publicKey) + } + } + function normalizeSignature(signature) { + if (signature instanceof Signature) { + signature.assertValidity() + return signature + } + try { + return Signature.fromDER(signature) + } catch (error) { + return Signature.fromCompact(signature) + } + } + function getPublicKey(privateKey, isCompressed = false) { + return Point.fromPrivateKey(privateKey).toRawBytes(isCompressed) + } + function recoverPublicKey( + msgHash, + signature, + recovery, + isCompressed = false + ) { + return Point.fromSignature(msgHash, signature, recovery).toRawBytes( + isCompressed + ) + } + function isPub(item) { + const arr = isUint8a(item) + const str = typeof item === 'string' + const len = (arr || str) && item.length + if (arr) return len === 33 || len === 65 + if (str) return len === 66 || len === 130 + if (item instanceof Point) return true + return false + } + function getSharedSecret(privateA, publicB, isCompressed = false) { + if (isPub(privateA)) + throw new TypeError('getSharedSecret: first arg must be private key') + if (!isPub(publicB)) + throw new TypeError('getSharedSecret: second arg must be public key') + const b = normalizePublicKey(publicB) + b.assertValidity() + return b.multiply(normalizePrivateKey(privateA)).toRawBytes(isCompressed) + } + function bits2int(bytes) { + const slice = bytes.length > 32 ? bytes.slice(0, 32) : bytes + return bytesToNumber(slice) + } + function bits2octets(bytes) { + const z1 = bits2int(bytes) + const z2 = mod(z1, CURVE.n) + return int2octets(z2 < _0n ? z1 : z2) + } + function int2octets(num) { + if (typeof num !== 'bigint') throw new Error('Expected bigint') + const hex = numTo32bStr(num) + return hexToBytes(hex) + } + function initSigArgs(msgHash, privateKey, extraEntropy) { + if (msgHash == null) + throw new Error(`sign: expected valid message hash, not "${msgHash}"`) + const h1 = ensureBytes(msgHash) + const d = normalizePrivateKey(privateKey) + const seedArgs = [int2octets(d), bits2octets(h1)] + if (extraEntropy != null) { + if (extraEntropy === true) extraEntropy = utils.randomBytes(32) + const e = ensureBytes(extraEntropy) + if (e.length !== 32) + throw new Error('sign: Expected 32 bytes of extra data') + seedArgs.push(e) + } + const seed = concatBytes(...seedArgs) + const m = bits2int(h1) + return {seed, m, d} + } + function finalizeSig(recSig, opts) { + let {sig, recovery} = recSig + const {canonical, der, recovered} = Object.assign( + {canonical: true, der: true}, + opts + ) + if (canonical && sig.hasHighS()) { + sig = sig.normalizeS() + recovery ^= 1 + } + const hashed = der ? sig.toDERRawBytes() : sig.toCompactRawBytes() + return recovered ? [hashed, recovery] : hashed + } + async function sign(msgHash, privKey, opts = {}) { + const {seed, m, d} = initSigArgs(msgHash, privKey, opts.extraEntropy) + let sig + const drbg = new HmacDrbg() + await drbg.reseed(seed) + while (!(sig = kmdToSig(await drbg.generate(), m, d))) await drbg.reseed() + return finalizeSig(sig, opts) + } + function signSync(msgHash, privKey, opts = {}) { + const {seed, m, d} = initSigArgs(msgHash, privKey, opts.extraEntropy) + let sig + const drbg = new HmacDrbg() + drbg.reseedSync(seed) + while (!(sig = kmdToSig(drbg.generateSync(), m, d))) drbg.reseedSync() + return finalizeSig(sig, opts) + } + const vopts = {strict: true} + function verify(signature, msgHash, publicKey, opts = vopts) { + let sig + try { + sig = normalizeSignature(signature) + msgHash = ensureBytes(msgHash) + } catch (error) { + return false + } + const {r, s} = sig + if (opts.strict && sig.hasHighS()) return false + const h = truncateHash(msgHash) + let P + try { + P = normalizePublicKey(publicKey) + } catch (error) { + return false + } + const {n} = CURVE + const sinv = invert(s, n) + const u1 = mod(h * sinv, n) + const u2 = mod(r * sinv, n) + const R = Point.BASE.multiplyAndAddUnsafe(P, u1, u2) + if (!R) return false + const v = mod(R.x, n) + return v === r + } + function finalizeSchnorrChallenge(ch) { + return mod(bytesToNumber(ch), CURVE.n) + } + function hasEvenY(point) { + return (point.y & _1n) === _0n + } + class SchnorrSignature { + constructor(r, s) { + this.r = r + this.s = s + this.assertValidity() + } + static fromHex(hex) { + const bytes = ensureBytes(hex) + if (bytes.length !== 64) + throw new TypeError( + `SchnorrSignature.fromHex: expected 64 bytes, not ${bytes.length}` + ) + const r = bytesToNumber(bytes.subarray(0, 32)) + const s = bytesToNumber(bytes.subarray(32, 64)) + return new SchnorrSignature(r, s) + } + assertValidity() { + const {r, s} = this + if (!isValidFieldElement(r) || !isWithinCurveOrder(s)) + throw new Error('Invalid signature') + } + toHex() { + return numTo32bStr(this.r) + numTo32bStr(this.s) + } + toRawBytes() { + return hexToBytes(this.toHex()) + } + } + function schnorrGetPublicKey(privateKey) { + return Point.fromPrivateKey(privateKey).toRawX() + } + function initSchnorrSigArgs(message, privateKey, auxRand) { + if (message == null) + throw new TypeError(`sign: Expected valid message, not "${message}"`) + const m = ensureBytes(message) + const d0 = normalizePrivateKey(privateKey) + const rand = ensureBytes(auxRand) + if (rand.length !== 32) + throw new TypeError('sign: Expected 32 bytes of aux randomness') + const P = Point.fromPrivateKey(d0) + const px = P.toRawX() + const d = hasEvenY(P) ? d0 : CURVE.n - d0 + return {m, P, px, d, rand} + } + function initSchnorrNonce(d, t0h) { + return numTo32b(d ^ bytesToNumber(t0h)) + } + function finalizeSchnorrNonce(k0h) { + const k0 = mod(bytesToNumber(k0h), CURVE.n) + if (k0 === _0n) + throw new Error('sign: Creation of signature failed. k is zero') + const R = Point.fromPrivateKey(k0) + const rx = R.toRawX() + const k = hasEvenY(R) ? k0 : CURVE.n - k0 + return {R, rx, k} + } + function finalizeSchnorrSig(R, k, e, d) { + return new SchnorrSignature(R.x, mod(k + e * d, CURVE.n)).toRawBytes() + } + async function schnorrSign( + message, + privateKey, + auxRand = utils.randomBytes() + ) { + const {m, px, d, rand} = initSchnorrSigArgs(message, privateKey, auxRand) + const t = initSchnorrNonce(d, await utils.taggedHash(TAGS.aux, rand)) + const {R, rx, k} = finalizeSchnorrNonce( + await utils.taggedHash(TAGS.nonce, t, px, m) + ) + const e = finalizeSchnorrChallenge( + await utils.taggedHash(TAGS.challenge, rx, px, m) + ) + const sig = finalizeSchnorrSig(R, k, e, d) + const isValid = await schnorrVerify(sig, m, px) + if (!isValid) throw new Error('sign: Invalid signature produced') + return sig + } + function schnorrSignSync(message, privateKey, auxRand = utils.randomBytes()) { + const {m, px, d, rand} = initSchnorrSigArgs(message, privateKey, auxRand) + const t = initSchnorrNonce(d, utils.taggedHashSync(TAGS.aux, rand)) + const {R, rx, k} = finalizeSchnorrNonce( + utils.taggedHashSync(TAGS.nonce, t, px, m) + ) + const e = finalizeSchnorrChallenge( + utils.taggedHashSync(TAGS.challenge, rx, px, m) + ) + const sig = finalizeSchnorrSig(R, k, e, d) + const isValid = schnorrVerifySync(sig, m, px) + if (!isValid) throw new Error('sign: Invalid signature produced') + return sig + } + function initSchnorrVerify(signature, message, publicKey) { + const raw = signature instanceof SchnorrSignature + const sig = raw ? signature : SchnorrSignature.fromHex(signature) + if (raw) sig.assertValidity() + return { + ...sig, + m: ensureBytes(message), + P: normalizePublicKey(publicKey) + } + } + function finalizeSchnorrVerify(r, P, s, e) { + const R = Point.BASE.multiplyAndAddUnsafe( + P, + normalizePrivateKey(s), + mod(-e, CURVE.n) + ) + if (!R || !hasEvenY(R) || R.x !== r) return false + return true + } + async function schnorrVerify(signature, message, publicKey) { + try { + const {r, s, m, P} = initSchnorrVerify(signature, message, publicKey) + const e = finalizeSchnorrChallenge( + await utils.taggedHash(TAGS.challenge, numTo32b(r), P.toRawX(), m) + ) + return finalizeSchnorrVerify(r, P, s, e) + } catch (error) { + return false + } + } + function schnorrVerifySync(signature, message, publicKey) { + try { + const {r, s, m, P} = initSchnorrVerify(signature, message, publicKey) + const e = finalizeSchnorrChallenge( + utils.taggedHashSync(TAGS.challenge, numTo32b(r), P.toRawX(), m) + ) + return finalizeSchnorrVerify(r, P, s, e) + } catch (error) { + return false + } + } + const schnorr = { + Signature: SchnorrSignature, + getPublicKey: schnorrGetPublicKey, + sign: schnorrSign, + verify: schnorrVerify, + signSync: schnorrSignSync, + verifySync: schnorrVerifySync + } + Point.BASE._setWindowSize(8) + const crypto = { + node: nodeCrypto, + web: typeof self === 'object' && 'crypto' in self ? self.crypto : undefined + } + const TAGS = { + challenge: 'BIP0340/challenge', + aux: 'BIP0340/aux', + nonce: 'BIP0340/nonce' + } + const TAGGED_HASH_PREFIXES = {} + const utils = { + isValidPrivateKey(privateKey) { + try { + normalizePrivateKey(privateKey) + return true + } catch (error) { + return false + } + }, + privateAdd: (privateKey, tweak) => { + const p = normalizePrivateKey(privateKey) + const t = normalizePrivateKey(tweak) + return numTo32b(mod(p + t, CURVE.n)) + }, + privateNegate: privateKey => { + const p = normalizePrivateKey(privateKey) + return numTo32b(CURVE.n - p) + }, + pointAddScalar: (p, tweak, isCompressed) => { + const P = Point.fromHex(p) + const t = normalizePrivateKey(tweak) + const Q = Point.BASE.multiplyAndAddUnsafe(P, t, _1n) + if (!Q) throw new Error('Tweaked point at infinity') + return Q.toRawBytes(isCompressed) + }, + pointMultiply: (p, tweak, isCompressed) => { + const P = Point.fromHex(p) + const t = bytesToNumber(ensureBytes(tweak)) + return P.multiply(t).toRawBytes(isCompressed) + }, + hashToPrivateKey: hash => { + hash = ensureBytes(hash) + if (hash.length < 40 || hash.length > 1024) + throw new Error('Expected 40-1024 bytes of private key as per FIPS 186') + const num = mod(bytesToNumber(hash), CURVE.n - _1n) + _1n + return numTo32b(num) + }, + randomBytes: (bytesLength = 32) => { + if (crypto.web) { + return crypto.web.getRandomValues(new Uint8Array(bytesLength)) + } else if (crypto.node) { + const {randomBytes} = crypto.node + return Uint8Array.from(randomBytes(bytesLength)) + } else { + throw new Error("The environment doesn't have randomBytes function") + } + }, + randomPrivateKey: () => { + return utils.hashToPrivateKey(utils.randomBytes(40)) + }, + bytesToHex, + hexToBytes, + concatBytes, + mod, + invert, + sha256: async (...messages) => { + if (crypto.web) { + const buffer = await crypto.web.subtle.digest( + 'SHA-256', + concatBytes(...messages) + ) + return new Uint8Array(buffer) + } else if (crypto.node) { + const {createHash} = crypto.node + const hash = createHash('sha256') + messages.forEach(m => hash.update(m)) + return Uint8Array.from(hash.digest()) + } else { + throw new Error("The environment doesn't have sha256 function") + } + }, + hmacSha256: async (key, ...messages) => { + if (crypto.web) { + const ckey = await crypto.web.subtle.importKey( + 'raw', + key, + {name: 'HMAC', hash: {name: 'SHA-256'}}, + false, + ['sign'] + ) + const message = concatBytes(...messages) + const buffer = await crypto.web.subtle.sign('HMAC', ckey, message) + return new Uint8Array(buffer) + } else if (crypto.node) { + const {createHmac} = crypto.node + const hash = createHmac('sha256', key) + messages.forEach(m => hash.update(m)) + return Uint8Array.from(hash.digest()) + } else { + throw new Error("The environment doesn't have hmac-sha256 function") + } + }, + sha256Sync: undefined, + hmacSha256Sync: undefined, + taggedHash: async (tag, ...messages) => { + let tagP = TAGGED_HASH_PREFIXES[tag] + if (tagP === undefined) { + const tagH = await utils.sha256( + Uint8Array.from(tag, c => c.charCodeAt(0)) + ) + tagP = concatBytes(tagH, tagH) + TAGGED_HASH_PREFIXES[tag] = tagP + } + return utils.sha256(tagP, ...messages) + }, + taggedHashSync: (tag, ...messages) => { + if (typeof utils.sha256Sync !== 'function') + throw new Error('utils.sha256Sync is undefined, you need to set it') + let tagP = TAGGED_HASH_PREFIXES[tag] + if (tagP === undefined) { + const tagH = utils.sha256Sync( + Uint8Array.from(tag, c => c.charCodeAt(0)) + ) + tagP = concatBytes(tagH, tagH) + TAGGED_HASH_PREFIXES[tag] = tagP + } + return utils.sha256Sync(tagP, ...messages) + }, + precompute(windowSize = 8, point = Point.BASE) { + const cached = point === Point.BASE ? point : new Point(point.x, point.y) + cached._setWindowSize(windowSize) + cached.multiply(_3n) + return cached + } + } + + exports.CURVE = CURVE + exports.Point = Point + exports.Signature = Signature + exports.getPublicKey = getPublicKey + exports.getSharedSecret = getSharedSecret + exports.recoverPublicKey = recoverPublicKey + exports.schnorr = schnorr + exports.sign = sign + exports.signSync = signSync + exports.utils = utils + exports.verify = verify + + Object.defineProperty(exports, '__esModule', {value: true}) +}) diff --git a/lnbits/extensions/cashu/static/js/utils.js b/lnbits/extensions/cashu/static/js/utils.js new file mode 100644 index 00000000..cf852b58 --- /dev/null +++ b/lnbits/extensions/cashu/static/js/utils.js @@ -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}`) +} diff --git a/lnbits/extensions/cashu/tasks.py b/lnbits/extensions/cashu/tasks.py new file mode 100644 index 00000000..9de17a1c --- /dev/null +++ b/lnbits/extensions/cashu/tasks.py @@ -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 diff --git a/lnbits/extensions/cashu/templates/cashu/_api_docs.html b/lnbits/extensions/cashu/templates/cashu/_api_docs.html new file mode 100644 index 00000000..f7bb19f6 --- /dev/null +++ b/lnbits/extensions/cashu/templates/cashu/_api_docs.html @@ -0,0 +1,80 @@ + + + + diff --git a/lnbits/extensions/cashu/templates/cashu/_cashu.html b/lnbits/extensions/cashu/templates/cashu/_cashu.html new file mode 100644 index 00000000..952fe7e1 --- /dev/null +++ b/lnbits/extensions/cashu/templates/cashu/_cashu.html @@ -0,0 +1,13 @@ + + + +

Create Cashu ecash mints and wallets.

+ Created by + arcbtc, + vlad, + calle. +
+
+
diff --git a/lnbits/extensions/cashu/templates/cashu/index.html b/lnbits/extensions/cashu/templates/cashu/index.html new file mode 100644 index 00000000..2599669c --- /dev/null +++ b/lnbits/extensions/cashu/templates/cashu/index.html @@ -0,0 +1,367 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + Cashu mint and wallet +

+

+ 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. +

+ Important +

+

+ 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. +

+
+
+ + + +
+
+
Mints
+
+
+ Export to CSV +
+
+ + {% raw %} + + + + {% endraw %} + + New Mint +
+
+
+ +
+ + +
{{SITE_TITLE}} Cashu extension
+
+ + + + {% include "cashu/_api_docs.html" %} + + {% include "cashu/_cashu.html" %} + + +
+
+ + + + + + + +
+ Create Mint + + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/cashu/templates/cashu/mint.html b/lnbits/extensions/cashu/templates/cashu/mint.html new file mode 100644 index 00000000..ee6ab606 --- /dev/null +++ b/lnbits/extensions/cashu/templates/cashu/mint.html @@ -0,0 +1,76 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
+ +

{{ mint_name }}

+ Open wallet +
+
+
+ + +
Read the following carefully!
+

+ This is a + Cashu + mint. Cashu is an ecash system for Bitcoin. +

+

+ Open this page in your native browser
+ 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. +

+

+ Add wallet to home screen
+ 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. +

+

+ Backup your wallet
+ 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. +

+

+ This service is in BETA
+ We hold no responsibility for people losing access to funds. Use at + your own risk! +

+
+
+
+ + {% endblock %} {% block scripts %} + + + + {% endblock %} +
diff --git a/lnbits/extensions/cashu/templates/cashu/wallet.html b/lnbits/extensions/cashu/templates/cashu/wallet.html new file mode 100644 index 00000000..88dffe7c --- /dev/null +++ b/lnbits/extensions/cashu/templates/cashu/wallet.html @@ -0,0 +1,2343 @@ +{% extends "public.html" %} {% block toolbar_title %} {% raw %} Cashu {% endraw +%} - {{mint_name}} {% endblock %} {% block footer %}{% endblock %} {% block +page_container %} + + +
+
+ + +
+
+
+ Get invoice + +
+
+

+
+ {% raw %} {{getBalance()}} + {{tickershort}}{% endraw %} +
+

+
+
+ Pay invoice + +
+
+
+
+
+
+

+
+ {% raw %} {{getBalance()}} + {{tickershort}}{% endraw %} +
+

+
+
+
+
+
+ + + +
+
+ Get Ecash +
+
+
+ + Pay Ecash +
+
+ + + + + + + + + + + + + {% raw %} + + {% endraw %} + + + + + + + + {% raw %} + + {% endraw %} + + + + + + + + {% raw %} + + {% endraw %} + + + +
+
+ +
+ + Warning + BackupDownload wallet backup +
+
+ + + + + + + + + + + + +
+
+ {% raw %} {{ + parseFloat(String(payInvoiceData.invoice.fsat).replaceAll(",", + "")) / 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %} +
+
+ {{ payInvoiceData.invoice.fsat }}{% endraw %} + {{LNBITS_DENOMINATION}} {% raw %} +
+ +

+ Description: {{ + payInvoiceData.invoice.description }}
+ Expire date: {{ payInvoiceData.invoice.expireDate + }}
+ Hash: {{ payInvoiceData.invoice.hash }} +

+ {% endraw %} +
+ Pay + Cancel +
+
+ Not enough funds! + Cancel +
+
+
+ {% raw %} + +

+ Authenticate with {{ payInvoiceData.lnurlauth.domain }}? +

+ +

+ For every website and for every LNbits wallet, a new keypair + will be deterministically generated so your identity can't be + tied to your LNbits wallet or linked across websites. No other + data will be shared with {{ payInvoiceData.lnurlauth.domain }}. +

+

+ Your public key for + {{ payInvoiceData.lnurlauth.domain }} is: +

+

+ + {{ payInvoiceData.lnurlauth.pubkey }} + +

+
+ Login + Cancel +
+
+ {% endraw %} +
+
+ {% raw %} + +

+ {{ payInvoiceData.lnurlpay.domain }} is requesting {{ + payInvoiceData.lnurlpay.maxSendable | msatoshiFormat }} + {{LNBITS_DENOMINATION}} + +
+ and a {{payInvoiceData.lnurlpay.commentAllowed}}-char comment +
+

+

+ {{ payInvoiceData.lnurlpay.targetUser || + payInvoiceData.lnurlpay.domain }} + is requesting
+ between + {{ payInvoiceData.lnurlpay.minSendable | msatoshiFormat }} + and + {{ payInvoiceData.lnurlpay.maxSendable | msatoshiFormat }} + {% endraw %} {{LNBITS_DENOMINATION}} {% raw %} + +
+ and a {{payInvoiceData.lnurlpay.commentAllowed}}-char comment +
+

+ +
+

+ {{ payInvoiceData.lnurlpay.description }} +

+

+ +

+
+
+
+ {% endraw %} + + {% raw %} +
+
+ +
+
+
+ Send {{LNBITS_DENOMINATION}} + Cancel +
+
+ {% endraw %} +
+
+ + + +
+ Enter + + + Close +
+
+
+ + + +
+ + Cancel + +
+
+
+
+
+ + + +
+ +
+
+ Cancel +
+
+
+ + + +
Warning
+

+ Bookmark this page and backup your tokens! + Ecash is a bearer asset, meaning losing access to this wallet will + mean you will lose the funds. This wallet stores ecash tokens in its + database. If you lose the link or delete your your data without + backing up, you will lose your tokens. Press the Backup button to + download a copy of your tokens. +

+

+ Add to home screen. + You can add Cashu to your home screen as a progressive web app + (PWA). On Android Chrome, click the hamburger menu at the upper + right. On iOS Safari, click the share button. Now press the Add to + Home screen button. +

+

+ This service is in BETA! We hold no responsibility + for people losing access to funds. Use at your own risk! +

+
+ Copy wallet URL + I understand +
+
+
+ + + +
+
+
+ Create a Lightning invoice +
+
+ + +
+ +
+ Copy invoice + Create Invoice + Close +
+
+
+ + + +
+
+
+ How much would you like to send? +
+
+ + +
+
+
+ + + + + + +
+ +
+
+ Send Tokens + +
+ Copy token + Copy link +
+ + Close +
+
+
+ + + +
+
+
+ Receive Cashu tokens +
+
+ +
+ +
+ Receive + + + Close +
+
+
+
+
+
+{% endblock %} {% block styles %} + +{% endblock %} {% block scripts %} + + + + + +{% endblock %} diff --git a/lnbits/extensions/cashu/views.py b/lnbits/extensions/cashu/views.py new file mode 100644 index 00000000..1aab68bd --- /dev/null +++ b/lnbits/extensions/cashu/views.py @@ -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", + }, + ], + } + ], + } diff --git a/lnbits/extensions/cashu/views_api.py b/lnbits/extensions/cashu/views_api.py new file mode 100644 index 00000000..2d10f3f0 --- /dev/null +++ b/lnbits/extensions/cashu/views_api.py @@ -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 diff --git a/lnbits/extensions/copilot/tasks.py b/lnbits/extensions/copilot/tasks.py index 48ad7813..384070cd 100644 --- a/lnbits/extensions/copilot/tasks.py +++ b/lnbits/extensions/copilot/tasks.py @@ -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: diff --git a/lnbits/extensions/copilot/templates/copilot/compose.html b/lnbits/extensions/copilot/templates/copilot/compose.html index b4022ee0..ea44114c 100644 --- a/lnbits/extensions/copilot/templates/copilot/compose.html +++ b/lnbits/extensions/copilot/templates/copilot/compose.html @@ -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) diff --git a/lnbits/extensions/copilot/views.py b/lnbits/extensions/copilot/views.py index b4a2354a..3b1ebf03 100644 --- a/lnbits/extensions/copilot/views.py +++ b/lnbits/extensions/copilot/views.py @@ -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) diff --git a/lnbits/extensions/copilot/views_api.py b/lnbits/extensions/copilot/views_api.py index 46611a2e..39d0f7fd 100644 --- a/lnbits/extensions/copilot/views_api.py +++ b/lnbits/extensions/copilot/views_api.py @@ -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 "" diff --git a/lnbits/extensions/events/templates/events/index.html b/lnbits/extensions/events/templates/events/index.html index 7f34a3e2..21258930 100644 --- a/lnbits/extensions/events/templates/events/index.html +++ b/lnbits/extensions/events/templates/events/index.html @@ -260,7 +260,7 @@ dense v-model.number="formDialog.data.price_per_ticket" type="number" - label="Price per ticket " + label="Sats per ticket " > diff --git a/lnbits/extensions/invoices/templates/invoices/index.html b/lnbits/extensions/invoices/templates/invoices/index.html index e3093e3c..4ef3b7f1 100644 --- a/lnbits/extensions/invoices/templates/invoices/index.html +++ b/lnbits/extensions/invoices/templates/invoices/index.html @@ -118,7 +118,7 @@ dense v-model.trim="formDialog.data.company_name" label="Company Name" - placeholder="LNBits Labs" + placeholder="LNbits Labs" > 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 diff --git a/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html b/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html index b0b223ff..b2165590 100644 --- a/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html +++ b/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html @@ -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 %} Click to copy URL + @click="copyText(wslocation + '/api/v1/ws/' + settingsDialog.data.id, 'Link copied to clipboard!')" + >{% raw %}{{wslocation}}/api/v1/ws/{{settingsDialog.data.id}}{% endraw + %} Click to copy URL Copy LNURL + {% raw %}{{ wsMessage }}{% endraw %} + {% raw %}{{ wsMessage }}{% endraw %}
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,)) diff --git a/lnbits/extensions/satspay/helpers.py b/lnbits/extensions/satspay/helpers.py index 2d15b557..b21a3ae2 100644 --- a/lnbits/extensions/satspay/helpers.py +++ b/lnbits/extensions/satspay/helpers.py @@ -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"] diff --git a/lnbits/extensions/satspay/migrations.py b/lnbits/extensions/satspay/migrations.py index 87446c80..e23bd413 100644 --- a/lnbits/extensions/satspay/migrations.py +++ b/lnbits/extensions/satspay/migrations.py @@ -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;") diff --git a/lnbits/extensions/satspay/models.py b/lnbits/extensions/satspay/models.py index daf63f42..0b2b1473 100644 --- a/lnbits/extensions/satspay/models.py +++ b/lnbits/extensions/satspay/models.py @@ -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)) diff --git a/lnbits/extensions/satspay/static/js/utils.js b/lnbits/extensions/satspay/static/js/utils.js index 9b4abbfc..5317673f 100644 --- a/lnbits/extensions/satspay/static/js/utils.js +++ b/lnbits/extensions/satspay/static/js/utils.js @@ -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) : '' diff --git a/lnbits/extensions/satspay/tasks.py b/lnbits/extensions/satspay/tasks.py index 46c16bbc..ce54b44a 100644 --- a/lnbits/extensions/satspay/tasks.py +++ b/lnbits/extensions/satspay/tasks.py @@ -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)) diff --git a/lnbits/extensions/satspay/templates/satspay/_api_docs.html b/lnbits/extensions/satspay/templates/satspay/_api_docs.html index ed658735..6d5ae661 100644 --- a/lnbits/extensions/satspay/templates/satspay/_api_docs.html +++ b/lnbits/extensions/satspay/templates/satspay/_api_docs.html @@ -5,7 +5,13 @@ WatchOnly extension, we highly reccomend using a fresh extended public Key specifically for SatsPayServer!
- Created by, Ben ArcBen Arc, + motorina0


diff --git a/lnbits/extensions/satspay/templates/satspay/display.html b/lnbits/extensions/satspay/templates/satspay/display.html index 12288c80..8ea218bd 100644 --- a/lnbits/extensions/satspay/templates/satspay/display.html +++ b/lnbits/extensions/satspay/templates/satspay/display.html @@ -109,7 +109,7 @@ @@ -131,7 +131,7 @@ @@ -170,13 +170,17 @@ name="check" style="color: green; font-size: 21.4em" > - +
+
+ +
+
@@ -218,7 +222,7 @@
@@ -289,7 +297,17 @@
- +{% endblock %} {% block styles %} + + {% endblock %} {% block scripts %} @@ -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() diff --git a/lnbits/extensions/satspay/templates/satspay/index.html b/lnbits/extensions/satspay/templates/satspay/index.html index 396200cf..2dda8792 100644 --- a/lnbits/extensions/satspay/templates/satspay/index.html +++ b/lnbits/extensions/satspay/templates/satspay/index.html @@ -8,6 +8,26 @@ New charge + + New CSS Theme + + New CSS Theme + For security reason, custom css is only available to server + admins. @@ -203,9 +223,19 @@ :href="props.row.webhook" target="_blank" style="color: unset; text-decoration: none" - >{{props.row.webhook || props.row.webhook}}
{{props.row.webhook}}
+
+ + {{props.row.webhook_message }} + +
ID:
@@ -254,6 +284,63 @@ + + + +
+
+
Themes
+
+
+ + {% raw %} + + + + {% endraw %} + +
+
@@ -298,32 +385,6 @@ > - - - - - - - -
@@ -372,6 +433,52 @@ label="Wallet *" > + +
+
+ + + + + + + + + +
+
+ + + + + + + +
+ Update CSS theme + Save CSS theme + Cancel +
+
+
+
+ + + + + +
+ Close +
+
+
{% endblock %} {% block scripts %} {{ window_vars(user) }}