From ec231a9154a6a941e6cdcb2e2ba4676a3c2ca30b Mon Sep 17 00:00:00 2001 From: Alexander Klemmstein Date: Sun, 7 Jun 2026 18:22:41 +0200 Subject: [PATCH 1/4] Add transaction history SQLite foundation --- app/backend/app.py | 117 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/app/backend/app.py b/app/backend/app.py index 93b7e50..4e1bac8 100644 --- a/app/backend/app.py +++ b/app/backend/app.py @@ -14,6 +14,8 @@ from typing import Any, Optional from urllib.parse import parse_qs, urlparse, quote +import sqlite3 +from datetime import datetime, timezone import dns.exception import dns.resolver import httpx @@ -312,7 +314,7 @@ def get_cloudflare_config(): CONFIG_DIR = APP_DATA_DIR / "config" CONFIG_JSON_PATH = Path(os.getenv("CONFIG_JSON_PATH", str(APP_DATA_DIR / "config.json"))) SECRETS_JSON_PATH = Path(os.getenv("SECRETS_JSON_PATH", str(CONFIG_DIR / "secrets.json"))) - +TX_DB_PATH = DATA_DIR / "bolt12pay.db" def _load_json_file(path: Path): try: @@ -1264,6 +1266,113 @@ def get_lnurl_base_url(): cfg = load_config() return (cfg.get("lnurl_base_url") or "").strip().rstrip("/") or LNURL_BASE_URL +def _tx_db(): + DATA_DIR.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(TX_DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def _init_tx_db(): + with _tx_db() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS transactions ( + payment_hash TEXT PRIMARY KEY, + timestamp INTEGER NOT NULL, + direction TEXT NOT NULL, + payment_type TEXT, + amount_sat INTEGER, + fee_sat INTEGER, + alias TEXT, + destination TEXT, + memo TEXT, + status TEXT, + source TEXT DEFAULT 'lnd', + raw_json TEXT, + updated_at INTEGER NOT NULL + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_transactions_timestamp + ON transactions(timestamp) + """) + + +def _utc_now_ts() -> int: + return int(datetime.now(timezone.utc).timestamp()) + + +def _upsert_transaction( + *, + payment_hash: str, + timestamp: int | None, + direction: str, + payment_type: str | None = None, + amount_sat: int | None = None, + fee_sat: int | None = None, + alias: str | None = None, + destination: str | None = None, + memo: str | None = None, + status: str | None = None, + source: str = "lnd", + raw_json: dict | None = None, +): + payment_hash = (payment_hash or "").strip() + if not payment_hash: + return + + now = _utc_now_ts() + ts = int(timestamp or now) + + with _tx_db() as conn: + conn.execute(""" + INSERT INTO transactions ( + payment_hash, timestamp, direction, payment_type, + amount_sat, fee_sat, alias, destination, memo, + status, source, raw_json, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(payment_hash) DO UPDATE SET + timestamp = COALESCE(excluded.timestamp, transactions.timestamp), + direction = COALESCE(excluded.direction, transactions.direction), + payment_type = COALESCE(excluded.payment_type, transactions.payment_type), + amount_sat = COALESCE(excluded.amount_sat, transactions.amount_sat), + fee_sat = COALESCE(excluded.fee_sat, transactions.fee_sat), + alias = COALESCE(excluded.alias, transactions.alias), + destination = COALESCE(excluded.destination, transactions.destination), + memo = COALESCE(excluded.memo, transactions.memo), + status = COALESCE(excluded.status, transactions.status), + source = COALESCE(excluded.source, transactions.source), + raw_json = COALESCE(excluded.raw_json, transactions.raw_json), + updated_at = excluded.updated_at + """, ( + payment_hash, + ts, + direction, + payment_type, + amount_sat, + fee_sat, + alias, + destination, + memo, + status, + source, + json.dumps(raw_json, separators=(",", ":"), ensure_ascii=False) if raw_json else None, + now, + )) + + +def _list_transactions(limit: int = 100): + safe_limit = max(1, min(int(limit or 100), 500)) + with _tx_db() as conn: + rows = conn.execute(""" + SELECT * + FROM transactions + ORDER BY timestamp DESC + LIMIT ? + """, (safe_limit,)).fetchall() + return [dict(row) for row in rows] + async def _create_bolt11_invoice( *, amount_sat: int, @@ -4712,6 +4821,7 @@ async def _zap_publisher_loop(): @app.on_event("startup") async def startup_background_tasks(): + _init_tx_db() app.state.zap_task = asyncio.create_task(_zap_publisher_loop()) app.state.nwc_task = asyncio.create_task(start_nwc_runtime()) print("zap publisher loop started", flush=True) @@ -4723,6 +4833,11 @@ async def api_admin_nostr_status(request: StarletteRequest): raise HTTPException(status_code=401, detail="Authentication required") return _get_nostr_admin_status() +@app.get("/api/tx-history") +async def api_tx_history(request: StarletteRequest, limit: int = 100): + if _is_pay_ui_enabled() and not _is_pay_session_valid(request): + raise HTTPException(status_code=401, detail="Authentication required") + return {"transactions": _list_transactions(limit)} def load_secrets() -> dict: From 66100f10c31accc57a50984564687ac5d11fdad8 Mon Sep 17 00:00:00 2001 From: Alexander Klemmstein Date: Sun, 7 Jun 2026 18:41:33 +0200 Subject: [PATCH 2/4] Add SQLite transaction history foundation --- app/backend/app.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/backend/app.py b/app/backend/app.py index 4e1bac8..394451f 100644 --- a/app/backend/app.py +++ b/app/backend/app.py @@ -314,7 +314,7 @@ def get_cloudflare_config(): CONFIG_DIR = APP_DATA_DIR / "config" CONFIG_JSON_PATH = Path(os.getenv("CONFIG_JSON_PATH", str(APP_DATA_DIR / "config.json"))) SECRETS_JSON_PATH = Path(os.getenv("SECRETS_JSON_PATH", str(CONFIG_DIR / "secrets.json"))) -TX_DB_PATH = DATA_DIR / "bolt12pay.db" +TX_DB_PATH = APP_DATA_DIR / "bolt12pay.db" def _load_json_file(path: Path): try: @@ -1267,7 +1267,7 @@ def get_lnurl_base_url(): return (cfg.get("lnurl_base_url") or "").strip().rstrip("/") or LNURL_BASE_URL def _tx_db(): - DATA_DIR.mkdir(parents=True, exist_ok=True) + APP_DATA_DIR.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(TX_DB_PATH) conn.row_factory = sqlite3.Row return conn @@ -1363,7 +1363,9 @@ def _upsert_transaction( def _list_transactions(limit: int = 100): + safe_limit = max(1, min(int(limit or 100), 500)) + with _tx_db() as conn: rows = conn.execute(""" SELECT * @@ -1371,8 +1373,8 @@ def _list_transactions(limit: int = 100): ORDER BY timestamp DESC LIMIT ? """, (safe_limit,)).fetchall() - return [dict(row) for row in rows] + return [dict(row) for row in rows] async def _create_bolt11_invoice( *, amount_sat: int, From 9e5f1c0645ff5c86493c2aa9495a949de4f6eed9 Mon Sep 17 00:00:00 2001 From: Alexander Klemmstein Date: Sun, 7 Jun 2026 20:47:17 +0200 Subject: [PATCH 3/4] Store LNURL payment metadata in transaction history --- app/backend/app.py | 94 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 2 deletions(-) diff --git a/app/backend/app.py b/app/backend/app.py index 394451f..85e90e7 100644 --- a/app/backend/app.py +++ b/app/backend/app.py @@ -1257,6 +1257,56 @@ def _read_macaroon_hex(path: str) -> str: raise HTTPException(status_code=500, detail=f"macaroon file not found: {path}") from exc except Exception as exc: raise HTTPException(status_code=500, detail=f"failed to read macaroon file: {exc}") from exc + +async def _fetch_lnd_payments(): + headers = { + "Grpc-Metadata-macaroon": _read_macaroon_hex(LND_MACAROON_PATH) + } + + verify = False if LND_REST_INSECURE else LND_TLS_CERT_PATH + + async with httpx.AsyncClient(timeout=30, verify=verify) as client: + response = await client.get( + f"{LND_REST_URL}/v1/payments?reversed=true&max_payments=50&include_incomplete=true", + headers=headers, + ) + + response.raise_for_status() + return response.json() + +async def _sync_lnd_payments(): + data = await _fetch_lnd_payments() + + for payment in data.get("payments", []): + if payment.get("status") != "SUCCEEDED": + continue + + _upsert_transaction( + payment_hash=payment["payment_hash"], + timestamp=int(payment.get("creation_date") or 0), + direction="outgoing", + payment_type="bolt11", + amount_sat=int(payment.get("value_sat") or 0), + fee_sat=int(payment.get("fee_sat") or 0), + status="settled", + ) + +async def _fetch_lnd_invoices(): + headers = { + "Grpc-Metadata-macaroon": _read_macaroon_hex(LND_MACAROON_PATH) + } + + verify = False if LND_REST_INSECURE else LND_TLS_CERT_PATH + + async with httpx.AsyncClient(timeout=30, verify=verify) as client: + response = await client.get( + f"{LND_REST_URL}/v1/invoices", + headers=headers, + ) + + response.raise_for_status() + return response.json() + def get_lnurl_base_domain(): cfg = load_config() return (cfg.get("lnurl_base_domain") or "").strip().lower() or LNURL_BASE_DOMAIN @@ -2034,6 +2084,18 @@ def lnurl_for_address(address: str) -> LnurlInfoResponse: lnurlp_url=lnurlp_url, ) +@app.get("/api/debug/lnd-payments") +async def debug_lnd_payments(request: StarletteRequest): + if _is_pay_ui_enabled() and not _is_pay_session_valid(request): + raise HTTPException(status_code=401, detail="Authentication required") + return await _fetch_lnd_payments() + + +@app.get("/api/debug/lnd-invoices") +async def debug_lnd_invoices(request: StarletteRequest): + if _is_pay_ui_enabled() and not _is_pay_session_valid(request): + raise HTTPException(status_code=401, detail="Authentication required") + return await _fetch_lnd_invoices() @app.get("/.well-known/lnurlp/{username}", response_model=LnurlPayMetadataResponse) def lnurl_pay_metadata(username: str) -> LnurlPayMetadataResponse: @@ -2252,6 +2314,7 @@ def pay_offer(payload: PayOfferRequest, request: StarletteRequest) -> PayOfferRe raw_output = _run_command(args) return PayOfferResponse(resolved_offer=normalized_offer, raw_output=raw_output) + @app.post("/api/pay-address", response_model=PayOfferResponse) async def pay_address(payload: PayAddressRequest, request: StarletteRequest) -> PayOfferResponse: _require_csrf(request) @@ -2278,7 +2341,6 @@ async def pay_address(payload: PayAddressRequest, request: StarletteRequest) -> indent=2, ensure_ascii=False, ) - return PayOfferResponse(resolved_offer=normalized_offer, raw_output=raw_output) except HTTPException as exc: @@ -2296,6 +2358,25 @@ async def pay_address(payload: PayAddressRequest, request: StarletteRequest) -> pay_result = await _pay_bolt11_invoice( payment_request=lnurl_result["payment_request"], ) + payment_hash = str(pay_result.get("payment_hash") or "").strip() + + if payment_hash: + _upsert_transaction( + payment_hash=payment_hash, + timestamp=_utc_now_ts(), + direction="outgoing", + payment_type="lnurl", + amount_sat=payload.amount_sat, + destination=target, + memo=payload.payer_note, + status="settled", + source="bolt12pay", + raw_json={ + "mode": "lnurl", + "target": target, + "payment_result": pay_result, + }, + ) raw_output = json.dumps( { @@ -2307,12 +2388,13 @@ async def pay_address(payload: PayAddressRequest, request: StarletteRequest) -> }, indent=2, ensure_ascii=False, - ) + ) return PayOfferResponse( resolved_offer=target, raw_output=raw_output, ) + @app.post("/api/pay-bolt11", response_model=PayOfferResponse) async def pay_bolt11(payload: PayBolt11Request) -> PayOfferResponse: @@ -2337,6 +2419,14 @@ async def pay_bolt11(payload: PayBolt11Request) -> PayOfferResponse: raw_output=raw_output, ) +@app.get("/api/debug/sync-payments") +async def api_debug_sync_payments(request: StarletteRequest): + if _is_pay_ui_enabled() and not _is_pay_session_valid(request): + raise HTTPException(status_code=401, detail="Authentication required") + + await _sync_lnd_payments() + return {"ok": True} + class DecodeBolt11Request(BaseModel): invoice: str = Field(min_length=10, description="BOLT11 invoice like lnbc...") From 5038971dbc2d811327a86c0917401d76b17f4359 Mon Sep 17 00:00:00 2001 From: Alexander Klemmstein Date: Sun, 7 Jun 2026 21:15:17 +0200 Subject: [PATCH 4/4] Add LND invoice sync for incoming transaction history --- app/backend/app.py | 46 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/app/backend/app.py b/app/backend/app.py index 85e90e7..3e67ebf 100644 --- a/app/backend/app.py +++ b/app/backend/app.py @@ -1300,13 +1300,52 @@ async def _fetch_lnd_invoices(): async with httpx.AsyncClient(timeout=30, verify=verify) as client: response = await client.get( - f"{LND_REST_URL}/v1/invoices", + f"{LND_REST_URL}/v1/invoices?num_max_invoices=1000&reversed=true", headers=headers, ) response.raise_for_status() return response.json() +async def _sync_lnd_invoices(): + data = await _fetch_lnd_invoices() + + for inv in data.get("invoices", []): + if not inv.get("settled"): + continue + print( + "SYNC INVOICE", + inv.get("memo"), + inv.get("settled"), + inv.get("r_hash"), + inv.get("amt_paid_sat"), + flush=True, + ) + + payment_hash = str(inv.get("r_hash") or "").strip() + + if not payment_hash: + continue + + print( + "UPSERT INVOICE", + payment_hash, + inv.get("amt_paid_sat"), + flush=True, + ) + _upsert_transaction( + payment_hash=payment_hash, + timestamp=int(inv.get("settle_date") or inv.get("creation_date") or 0), + direction="incoming", + payment_type="bolt11", + amount_sat=int(inv.get("amt_paid_sat") or inv.get("value") or 0), + memo=inv.get("memo"), + status="settled", + source="lnd", + raw_json=inv, + ) + + def get_lnurl_base_domain(): cfg = load_config() return (cfg.get("lnurl_base_domain") or "").strip().lower() or LNURL_BASE_DOMAIN @@ -2097,6 +2136,11 @@ async def debug_lnd_invoices(request: StarletteRequest): raise HTTPException(status_code=401, detail="Authentication required") return await _fetch_lnd_invoices() +@app.get("/api/debug/sync-invoices") +async def api_debug_sync_invoices(): + await _sync_lnd_invoices() + return {"ok": True} + @app.get("/.well-known/lnurlp/{username}", response_model=LnurlPayMetadataResponse) def lnurl_pay_metadata(username: str) -> LnurlPayMetadataResponse: alias = _resolve_lnurl_alias(username)