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/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/views/api.py b/lnbits/core/views/api.py index 995cf9e7..21342d68 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -702,9 +702,9 @@ 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()), } diff --git a/lnbits/db.py b/lnbits/db.py index e83b4bf8..7d294197 100644 --- a/lnbits/db.py +++ b/lnbits/db.py @@ -29,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}: @@ -125,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" @@ -149,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/cashu/templates/cashu/wallet.html b/lnbits/extensions/cashu/templates/cashu/wallet.html index a133f592..88dffe7c 100644 --- a/lnbits/extensions/cashu/templates/cashu/wallet.html +++ b/lnbits/extensions/cashu/templates/cashu/wallet.html @@ -1,5 +1,5 @@ -{% extends "public.html" %} {% block toolbar_title %} {% raw %} {{name}} Cashu -{% endraw %} {% endblock %} {% block footer %}{% endblock %} {% block +{% extends "public.html" %} {% block toolbar_title %} {% raw %} Cashu {% endraw +%} - {{mint_name}} {% endblock %} {% block footer %}{% endblock %} {% block page_container %} @@ -752,7 +752,13 @@ page_container %}
- Receive Tokens + Receive + = amount + fees_msat / 1000, Exception( - f"Provided proofs ({total_provided} sats) not enough for Lightning payment ({amount + fees_msat} sats)." - ) + internal_checking_id = await check_internal(invoice_obj.payment_hash) - await pay_invoice( - wallet_id=cashu.wallet, - payment_request=invoice, - description=f"pay cashu invoice", - extra={"tag": "cashu", "cahsu_name": cashu.name}, - ) + if not internal_checking_id: + fees_msat = fee_reserve(invoice_obj.amount_msat) + else: + fees_msat = 0 + assert total_provided >= amount + math.ceil(fees_msat / 1000), Exception( + f"Provided proofs ({total_provided} sats) not enough for Lightning payment ({amount + fees_msat} sats)." + ) + logger.debug(f"Cashu: Initiating payment of {total_provided} sats") + 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) - status: PaymentStatus = await check_transaction_status( - cashu.wallet, invoice_obj.payment_hash - ) - if status.paid == True: - await ledger._invalidate_proofs(proofs) return GetMeltResponse(paid=status.paid, preimage=status.preimage) @@ -333,7 +347,7 @@ async def check_fees( fees_msat = fee_reserve(invoice_obj.amount_msat) else: fees_msat = 0 - return CheckFeesResponse(fee=fees_msat / 1000) + return CheckFeesResponse(fee=math.ceil(fees_msat / 1000)) @cashu_ext.post("/api/v1/{cashu_id}/split") diff --git a/lnbits/extensions/satspay/helpers.py b/lnbits/extensions/satspay/helpers.py index 60c5ba4a..b21a3ae2 100644 --- a/lnbits/extensions/satspay/helpers.py +++ b/lnbits/extensions/satspay/helpers.py @@ -37,7 +37,11 @@ async def call_webhook(charge: Charges): json=public_charge(charge), timeout=40, ) - return {"webhook_success": r.is_success, "webhook_message": r.reason_phrase} + 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) diff --git a/lnbits/extensions/satspay/static/js/utils.js b/lnbits/extensions/satspay/static/js/utils.js index 2b1be8bd..5317673f 100644 --- a/lnbits/extensions/satspay/static/js/utils.js +++ b/lnbits/extensions/satspay/static/js/utils.js @@ -23,6 +23,7 @@ const mapCharge = (obj, oldObj = {}) => { charge.displayUrl = ['/satspay/', obj.id].join('') charge.expanded = oldObj.expanded || false charge.pendingBalance = oldObj.pendingBalance || 0 + charge.extra = charge.extra ? JSON.parse(charge.extra) : charge.extra return charge } diff --git a/lnbits/extensions/satspay/templates/satspay/index.html b/lnbits/extensions/satspay/templates/satspay/index.html index 602b1a28..2dda8792 100644 --- a/lnbits/extensions/satspay/templates/satspay/index.html +++ b/lnbits/extensions/satspay/templates/satspay/index.html @@ -227,7 +227,12 @@ >
- + {{props.row.webhook_message }}
@@ -528,6 +533,23 @@ + + + + + +
+ Close +
+
+
{% endblock %} {% block scripts %} {{ window_vars(user) }}