diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 7db77a67..a00dfe88 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -22,6 +22,7 @@ from .db import ( ApiKey, CashuTransaction, CliToken, + LightningInvoice, ModelRow, UpstreamProviderRow, create_session, @@ -1477,6 +1478,52 @@ async def get_transactions_api( } +@admin_router.get( + "/api/lightning-invoices", dependencies=[Depends(require_admin_api)] +) +async def get_lightning_invoices_api( + status: str | None = None, + purpose: str | None = None, + search: str | None = None, + limit: int = 50, + offset: int = 0, +) -> dict: + async with create_session() as session: + from sqlmodel import col, func + + base = select(LightningInvoice) + if status: + base = base.where(LightningInvoice.status == status) + if purpose: + base = base.where(LightningInvoice.purpose == purpose) + if search: + pattern = f"%{search}%" + base = base.where( + (col(LightningInvoice.id).like(pattern)) + | (col(LightningInvoice.bolt11).like(pattern)) + | (col(LightningInvoice.payment_hash).like(pattern)) + | (col(LightningInvoice.api_key_hash).like(pattern)) + ) + + count_result = await session.exec( + select(func.count()).select_from(base.subquery()) + ) + total = count_result.one() + + stmt = ( + base.order_by(col(LightningInvoice.created_at).desc()) + .offset(offset) + .limit(limit) + ) + results = await session.exec(stmt) + invoices = results.all() + + return { + "invoices": [inv.dict() for inv in invoices], + "total": total, + } + + @admin_router.post( "/api/upstream-providers/{provider_id}/routstr/refund", dependencies=[Depends(require_admin_api)], diff --git a/routstr/core/main.py b/routstr/core/main.py index de21bc68..d9fb00eb 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -13,7 +13,7 @@ from starlette.types import Scope from ..auth import periodic_key_reset from ..balance import balance_router, deprecated_wallet_router -from ..lightning import lightning_router +from ..lightning import lightning_router, periodic_invoice_watcher from ..nostr import ( announce_provider, providers_cache_refresher, @@ -57,6 +57,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: auto_topup_task = None refund_sweep_task = None routstr_fee_task = None + invoice_watcher_task = None try: # Apply litellm-wide settings (drop_params, chat-completions URL, @@ -124,6 +125,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: auto_topup_task = asyncio.create_task(periodic_auto_topup()) refund_sweep_task = asyncio.create_task(periodic_refund_sweep()) routstr_fee_task = asyncio.create_task(periodic_routstr_fee_payout()) + invoice_watcher_task = asyncio.create_task(periodic_invoice_watcher()) yield @@ -163,6 +165,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: refund_sweep_task.cancel() if routstr_fee_task is not None: routstr_fee_task.cancel() + if invoice_watcher_task is not None: + invoice_watcher_task.cancel() try: tasks_to_wait = [] @@ -190,6 +194,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: tasks_to_wait.append(refund_sweep_task) if routstr_fee_task is not None: tasks_to_wait.append(routstr_fee_task) + if invoice_watcher_task is not None: + tasks_to_wait.append(invoice_watcher_task) if tasks_to_wait: await asyncio.gather(*tasks_to_wait, return_exceptions=True) diff --git a/routstr/lightning.py b/routstr/lightning.py index 870f339c..81423198 100644 --- a/routstr/lightning.py +++ b/routstr/lightning.py @@ -1,13 +1,14 @@ +import asyncio import hashlib import secrets import time from fastapi import APIRouter, Depends, Header, HTTPException from pydantic import BaseModel, Field -from sqlmodel import select +from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession -from .core.db import ApiKey, LightningInvoice, get_session +from .core.db import ApiKey, LightningInvoice, create_session, get_session from .core.logging import get_logger from .core.settings import settings from .wallet import get_wallet @@ -159,13 +160,13 @@ async def get_invoice_status( if not invoice: raise HTTPException(status_code=404, detail="Invoice not found") + if invoice.status == "pending": + await check_invoice_payment(invoice, session) + if invoice.status == "pending" and int(time.time()) > invoice.expires_at: invoice.status = "expired" await session.commit() - if invoice.status == "pending": - await check_invoice_payment(invoice, session) - api_key = None if invoice.status == "paid" and invoice.purpose == "create": if invoice.api_key_hash: @@ -291,3 +292,41 @@ async def topup_api_key_from_invoice( api_key.balance += invoice.amount_sats * 1000 # Convert to msats await session.flush() + + +INVOICE_WATCH_INTERVAL_SECONDS = 5 +INVOICE_WATCH_BATCH_LIMIT = 100 + + +async def periodic_invoice_watcher() -> None: + """Background task: detect paid Lightning invoices and credit balances. + + Removes the need for clients to poll the status endpoint after paying. + """ + while True: + try: + async with create_session() as session: + now = int(time.time()) + result = await session.exec( + select(LightningInvoice) + .where( + LightningInvoice.status == "pending", + col(LightningInvoice.expires_at) > now, + ) + .limit(INVOICE_WATCH_BATCH_LIMIT) + ) + pending = result.all() + for invoice in pending: + try: + await check_invoice_payment(invoice, session) + except Exception as e: + logger.error( + "Invoice watcher failed for invoice", + extra={"invoice_id": invoice.id, "error": str(e)}, + ) + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"Invoice watcher loop error: {e}") + + await asyncio.sleep(INVOICE_WATCH_INTERVAL_SECONDS) diff --git a/ui/app/transactions/page.tsx b/ui/app/transactions/page.tsx index 8704515f..e1812bfb 100644 --- a/ui/app/transactions/page.tsx +++ b/ui/app/transactions/page.tsx @@ -53,7 +53,11 @@ import { ChevronLeft, ChevronRight, } from 'lucide-react'; -import { AdminService, type Transaction } from '@/lib/api/services/admin'; +import { + AdminService, + type Transaction, + type LightningInvoice, +} from '@/lib/api/services/admin'; import { format } from 'date-fns'; import { toast } from 'sonner'; @@ -200,6 +204,172 @@ function TransactionTable({ ); } +function LightningInvoiceTable({ + invoices, + copiedId, + onCopy, +}: { + invoices: LightningInvoice[]; + copiedId: string | null; + onCopy: (text: string, id: string) => void; +}) { + if (invoices.length === 0) { + return ( + + + + + + No invoices found + + Lightning invoices created via /lightning/invoice will show here. + + + + ); + } + + const statusBadge = (status: LightningInvoice['status']) => { + if (status === 'paid') + return ( + + Paid + + ); + if (status === 'expired') + return ( + + Expired + + ); + if (status === 'cancelled') + return ( + + Cancelled + + ); + return ( + + Pending + + ); + }; + + return ( + +
+ + + + Purpose + Amount + Status + API Key + Payment Hash + Created + Paid + Actions + + + + {invoices.map((inv) => ( + + + {inv.purpose} + + + {inv.amount_sats} sat + + {statusBadge(inv.status)} + + {inv.api_key_hash ? ( +
+ + {inv.api_key_hash.slice(0, 12)}... + + +
+ ) : ( + + )} +
+ +
+ + {inv.payment_hash.slice(0, 14)}... + + +
+
+ + {format(inv.created_at * 1000, 'yyyy-MM-dd HH:mm:ss')} + + + {inv.paid_at + ? format(inv.paid_at * 1000, 'yyyy-MM-dd HH:mm:ss') + : '—'} + + + + +
+ ))} +
+
+
+ +
+ ); +} + export default function TransactionsPage() { const [search, setSearch] = useState(''); const [type, setType] = useState('all'); @@ -231,6 +401,7 @@ export default function TransactionsPage() { const [activeTab, setActiveTab] = useState('x-cashu'); const [xcashuPage, setXcashuPage] = useState(0); const [apikeyPage, setApikeyPage] = useState(0); + const [lightningPage, setLightningPage] = useState(0); const typeParam = type === 'all' ? undefined : type; const statusParam = status === 'all' ? undefined : status; @@ -278,12 +449,37 @@ export default function TransactionsPage() { placeholderData: keepPreviousData, }); + const LIGHTNING_STATUSES = ['pending', 'paid', 'expired', 'cancelled']; + const lightningStatusParam = LIGHTNING_STATUSES.includes(status) + ? status + : undefined; + + const lightningQuery = useQuery({ + queryKey: [ + 'lightning-invoices', + lightningStatusParam, + searchParam, + lightningPage, + ], + queryFn: () => + AdminService.getLightningInvoices( + lightningStatusParam, + undefined, + searchParam, + PAGE_SIZE, + lightningPage * PAGE_SIZE + ), + placeholderData: keepPreviousData, + refetchInterval: 10000, + }); + const handleClearFilters = () => { setSearch(''); setType('all'); setStatus('all'); setXcashuPage(0); setApikeyPage(0); + setLightningPage(0); }; const copyToClipboard = (text: string, id: string) => { @@ -337,9 +533,13 @@ export default function TransactionsPage() { useEffect(() => { setXcashuPage(0); setApikeyPage(0); + setLightningPage(0); }, [type, status, search]); - const isRefetching = xcashuQuery.isRefetching || apikeyQuery.isRefetching; + const isRefetching = + xcashuQuery.isRefetching || + apikeyQuery.isRefetching || + lightningQuery.isRefetching; const renderCardContent = ( query: typeof xcashuQuery, @@ -417,6 +617,7 @@ export default function TransactionsPage() { onClick={() => { xcashuQuery.refetch(); apikeyQuery.refetch(); + lightningQuery.refetch(); }} variant='outline' size='sm' @@ -476,6 +677,11 @@ export default function TransactionsPage() { Pending Collected Swept + Paid (Lightning) + Expired (Lightning) + + Cancelled (Lightning) + @@ -516,6 +722,15 @@ export default function TransactionsPage() { )} + + + Lightning + {lightningQuery.data && ( + + {lightningQuery.data.total} + + )} + @@ -553,6 +768,81 @@ export default function TransactionsPage() { + + + + +
+ Lightning Invoice History + + Auto-refreshing every 10s. Paid invoices credit balance + automatically. + +
+
+ + {lightningQuery.isLoading ? ( +
+ {Array.from({ length: 8 }).map((_, index) => ( + + ))} +
+ ) : ( + <> + {(() => { + const total = lightningQuery.data?.total ?? 0; + const totalPages = Math.ceil(total / PAGE_SIZE); + if (totalPages <= 1) return null; + return ( +
+ + {lightningPage * PAGE_SIZE + 1}– + {Math.min((lightningPage + 1) * PAGE_SIZE, total)}{' '} + of {total} + +
+ + + {lightningPage + 1} / {totalPages} + + +
+
+ ); + })()} + + + )} +
+
+
diff --git a/ui/lib/api/services/admin.ts b/ui/lib/api/services/admin.ts index 0f7cc3f8..82b1c5e8 100644 --- a/ui/lib/api/services/admin.ts +++ b/ui/lib/api/services/admin.ts @@ -908,6 +908,25 @@ export class AdminService { ); } + static async getLightningInvoices( + status?: string, + purpose?: string, + search?: string, + limit: number = 50, + offset: number = 0 + ): Promise { + const params = new URLSearchParams(); + if (status) params.append('status', status); + if (purpose) params.append('purpose', purpose); + if (search) params.append('search', search); + params.append('limit', limit.toString()); + params.append('offset', offset.toString()); + + return await apiClient.get( + `/admin/api/lightning-invoices?${params.toString()}` + ); + } + static async createProviderAccountByType(providerType: string): Promise<{ ok: boolean; account_data: Record; @@ -1186,3 +1205,22 @@ export interface TransactionsResponse { transactions: Transaction[]; total: number; } + +export interface LightningInvoice { + id: string; + bolt11: string; + amount_sats: number; + description: string; + payment_hash: string; + status: 'pending' | 'paid' | 'expired' | 'cancelled'; + api_key_hash: string | null; + purpose: 'create' | 'topup'; + created_at: number; + expires_at: number; + paid_at: number | null; +} + +export interface LightningInvoicesResponse { + invoices: LightningInvoice[]; + total: number; +}