diff --git a/app/backend/app.py b/app/backend/app.py index 93b7e50..3e67ebf 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 = APP_DATA_DIR / "bolt12pay.db" def _load_json_file(path: Path): try: @@ -1255,6 +1257,95 @@ 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?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 @@ -1264,6 +1355,115 @@ def get_lnurl_base_url(): cfg = load_config() return (cfg.get("lnurl_base_url") or "").strip().rstrip("/") or LNURL_BASE_URL +def _tx_db(): + APP_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, @@ -1923,6 +2123,23 @@ 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("/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: @@ -2141,6 +2358,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) @@ -2167,7 +2385,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: @@ -2185,6 +2402,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( { @@ -2196,12 +2432,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: @@ -2226,6 +2463,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...") @@ -4712,6 +4957,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 +4969,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: