diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index 0d87a2246..01a01fccb 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -338,37 +338,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 @@ -395,12 +372,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, @@ -416,6 +400,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 e4fee2ae1..9d771315b 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( @@ -190,7 +195,71 @@ async def m005_balance_check_balance_notify(db): ) -async def m006_create_admin_settings_table(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 + +async def m008_create_admin_settings_table(db): await db.execute( """ CREATE TABLE IF NOT EXISTS settings ( diff --git a/lnbits/core/models.py b/lnbits/core/models.py index c7d8e5986..65c72b419 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 @@ -85,6 +87,7 @@ class Payment(BaseModel): bolt11: str preimage: str payment_hash: str + expiry: Optional[float] extra: Optional[Dict] = {} wallet_id: str webhook: Optional[str] @@ -103,6 +106,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"], @@ -130,6 +134,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_") @@ -173,7 +181,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/db.py b/lnbits/db.py index 00bf849bb..1bef7bf21 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 a133f5920..88dffe7c7 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 +
-
Date:
+
Created:
{{ payment.date }} ({{ payment.dateFrom }})
+
+
Expiry:
+
{{ payment.expirydate }} ({{ payment.expirydateFrom }})
+
Description:
{{ payment.memo }}
diff --git a/lnbits/tasks.py b/lnbits/tasks.py index 769df7990..00d367255 100644 --- a/lnbits/tasks.py +++ b/lnbits/tasks.py @@ -147,7 +147,7 @@ async def check_pending_payments(): ) # we delete expired invoices once upon the first pending check if incoming: - logger.info("Task: deleting all expired invoices") + logger.debug("Task: deleting all expired invoices") start_time: float = time.time() await delete_expired_invoices(conn=conn) logger.info( diff --git a/tests/data/mock_data.zip b/tests/data/mock_data.zip index e992b9887..01d4a13ee 100644 Binary files a/tests/data/mock_data.zip and b/tests/data/mock_data.zip differ