diff --git a/migrations/versions/a776ca70e5fe_add_cashu_refunds_table.py b/migrations/versions/a776ca70e5fe_add_cashu_refunds_table.py index 5b595cb0..6342f25c 100644 --- a/migrations/versions/a776ca70e5fe_add_cashu_refunds_table.py +++ b/migrations/versions/a776ca70e5fe_add_cashu_refunds_table.py @@ -1,4 +1,4 @@ -"""add cashu_refunds table +"""add cashu_transactions table Revision ID: a776ca70e5fe Revises: 614c0a740e68 @@ -10,25 +10,33 @@ import sqlmodel from alembic import op # revision identifiers, used by Alembic. -revision = 'a776ca70e5fe' -down_revision = '614c0a740e68' +revision = "a776ca70e5fe" +down_revision = "614c0a740e68" branch_labels = None depends_on = None + def upgrade() -> None: op.create_table( - 'cashu_refunds', - sa.Column('payment_token_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('refund_token', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('amount', sa.Integer(), nullable=False), - sa.Column('unit', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('mint_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column('created_at', sa.Integer(), nullable=False), - sa.Column('collected', sa.Boolean(), nullable=False), - sa.Column('swept', sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint('payment_token_hash'), + "cashu_transactions", + sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("token", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("amount", sa.Integer(), nullable=False), + sa.Column("unit", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("mint_url", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column( + "type", + sqlmodel.sql.sqltypes.AutoString(), + nullable=False, + server_default="out", + ), + sa.Column("request_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("created_at", sa.Integer(), nullable=False), + sa.Column("collected", sa.Boolean(), nullable=False), + sa.Column("swept", sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint("id"), ) def downgrade() -> None: - op.drop_table('cashu_refunds') + op.drop_table("cashu_transactions") diff --git a/routstr/balance.py b/routstr/balance.py index fa8c580f..7edede9b 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -9,7 +9,7 @@ from pydantic import BaseModel from sqlmodel import select from .auth import get_billing_key, validate_bearer_key -from .core.db import ApiKey, AsyncSession, CashuRefund, get_session +from .core.db import ApiKey, AsyncSession, CashuTransaction, get_session from .core.logging import get_logger from .core.settings import settings from .lightning import lightning_router @@ -407,7 +407,7 @@ async def get_cashu_refund( session: AsyncSession = Depends(get_session), ) -> dict: """Retrieve a stored Cashu refund token by the hash of the original payment token.""" - result = await session.get(CashuRefund, payment_token_hash) + result = await session.get(CashuTransaction, payment_token_hash) if result is None: raise HTTPException(status_code=404, detail="Refund not found") if result.swept: @@ -416,7 +416,7 @@ async def get_cashu_refund( session.add(result) await session.commit() return { - "refund_token": result.refund_token, + "refund_token": result.token, "amount": result.amount, "unit": result.unit, } diff --git a/routstr/core/admin.py b/routstr/core/admin.py index c3160c9e..6884a959 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -16,7 +16,7 @@ from ..wallet import ( send_token, slow_filter_spend_proofs, ) -from .db import ApiKey, ModelRow, UpstreamProviderRow, create_session +from .db import ApiKey, CashuTransaction, ModelRow, UpstreamProviderRow, create_session from .log_manager import log_manager from .logging import get_logger from .settings import SettingsService, settings @@ -747,7 +747,9 @@ async def get_provider_models(provider_id: int) -> dict[str, object]: ) db_model_ids = {model.id for model in db_models} - filtered_remote_models = [m for m in upstream_models if m.id not in db_model_ids] + filtered_remote_models = [ + m for m in upstream_models if m.id not in db_model_ids + ] return { "provider": { @@ -893,7 +895,9 @@ async def initiate_provider_topup( "purpose": "topup", "api_key": provider.api_key, }, - headers={"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {}, + headers={"Authorization": f"Bearer {provider.api_key}"} + if provider.api_key + else {}, ) if resp.status_code == 200: @@ -977,7 +981,9 @@ async def check_topup_status(provider_id: int, invoice_id: str) -> dict[str, obj clean_url = provider.base_url.rstrip("/") resp = await client.get( f"{clean_url}/v1/balance/lightning/invoice/{invoice_id}/status", - headers={"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {}, + headers={"Authorization": f"Bearer {provider.api_key}"} + if provider.api_key + else {}, ) if resp.status_code == 200: status_data = resp.json() @@ -1208,6 +1214,49 @@ async def get_log_dates_api(request: Request) -> dict[str, object]: return {"dates": dates} +@admin_router.get("/api/transactions", dependencies=[Depends(require_admin_api)]) +async def get_transactions_api( + type: str | None = None, + status: str | None = None, + search: str | None = None, + limit: int = 100, +) -> dict: + async with create_session() as session: + from sqlmodel import col + + stmt = select(CashuTransaction) + if type: + stmt = stmt.where(CashuTransaction.type == type) + if status: + if status == "collected": + stmt = stmt.where(CashuTransaction.collected == True) # noqa: E712 + elif status == "swept": + stmt = stmt.where(CashuTransaction.swept == True) # noqa: E712 + elif status == "pending": + stmt = stmt.where( + not CashuTransaction.collected, + CashuTransaction.swept == False, # noqa: E712 + ) + + if search: + search_pattern = f"%{search}%" + stmt = stmt.where( + (col(CashuTransaction.id).like(search_pattern)) + | (col(CashuTransaction.token).like(search_pattern)) + | (col(CashuTransaction.request_id).like(search_pattern)) + ) + + stmt = stmt.order_by(col(CashuTransaction.created_at).desc()).limit(limit) + + results = await session.exec(stmt) + transactions = results.all() + + return { + "transactions": [tx.dict() for tx in transactions], + "total": len(transactions), + } + + @admin_router.post( "/api/upstream-providers/{provider_id}/routstr/refund", dependencies=[Depends(require_admin_api)], diff --git a/routstr/core/db.py b/routstr/core/db.py index 556d5c5e..50f935b8 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -7,6 +7,7 @@ from typing import AsyncGenerator from alembic import command from alembic.config import Config +from alembic.util.exc import CommandError from sqlalchemy import UniqueConstraint from sqlalchemy.ext.asyncio.engine import create_async_engine from sqlmodel import Field, Relationship, SQLModel, func, select, update @@ -128,19 +129,19 @@ class LightningInvoice(SQLModel, table=True): # type: ignore paid_at: int | None = Field(default=None, description="Unix timestamp when paid") -class CashuRefund(SQLModel, table=True): # type: ignore - __tablename__ = "cashu_refunds" +class CashuTransaction(SQLModel, table=True): # type: ignore + __tablename__ = "cashu_transactions" - payment_token_hash: str = Field( + id: str = Field( primary_key=True, - description="SHA-256 hash of the original x-cashu payment token", + description="SHA-256 hash of the x-cashu token or unique identifier", ) - refund_token: str = Field(description="Serialized Cashu refund token") - amount: int = Field(description="Refund amount in the token's unit") + token: str = Field(description="Serialized Cashu token") + amount: int = Field(description="Amount in the token's unit") unit: str = Field(description="Token unit (sat or msat)") - mint_url: str | None = Field( - default=None, description="Mint URL for the refund token" - ) + mint_url: str | None = Field(default=None, description="Mint URL for the token") + type: str = Field(default="out", description="Transaction type: in or out") + request_id: str | None = Field(default=None, description="Associated request ID") created_at: int = Field( default_factory=lambda: int(time.time()), description="Unix timestamp", @@ -149,28 +150,32 @@ class CashuRefund(SQLModel, table=True): # type: ignore swept: bool = Field(default=False) -async def store_cashu_refund( - payment_token_hash: str, - refund_token: str, +async def store_cashu_transaction( + id: str, + token: str, amount: int, unit: str, mint_url: str | None = None, + type: str = "out", + request_id: str | None = None, ) -> None: try: async with create_session() as session: - refund = CashuRefund( - payment_token_hash=payment_token_hash, - refund_token=refund_token, + tx = CashuTransaction( + id=id, + token=token, amount=amount, unit=unit, mint_url=mint_url, + type=type, + request_id=request_id, ) - session.add(refund) + session.add(tx) await session.commit() except Exception as e: logger.warning( - "Failed to store cashu refund", - extra={"error": str(e), "payment_token_hash": payment_token_hash}, + "Failed to store cashu transaction", + extra={"error": str(e), "id": id, "type": type}, ) @@ -273,6 +278,17 @@ def fix_cashu_migrations() -> None: logger.warning(f"Could not check/fix Cashu database {db_file}: {e}") +def _clear_alembic_version() -> None: + """Clear the alembic_version table so stamp/upgrade can proceed.""" + sync_url = DATABASE_URL.replace("+aiosqlite", "") + from sqlalchemy import create_engine, text + + eng = create_engine(sync_url) + with eng.begin() as conn: + conn.execute(text("DELETE FROM alembic_version")) + eng.dispose() + + def run_migrations() -> None: """Run Alembic migrations programmatically.""" try: @@ -294,8 +310,19 @@ def run_migrations() -> None: # Set the database URL in the config alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL) - # Run migrations to the latest revision - command.upgrade(alembic_cfg, "head") + try: + command.upgrade(alembic_cfg, "head") + except CommandError as e: + if "Can't locate revision" in str(e): + logger.warning( + "Database stamped with unknown revision (likely from another branch). " + "Re-stamping to current head.", + extra={"error": str(e)}, + ) + _clear_alembic_version() + command.stamp(alembic_cfg, "head") + else: + raise logger.info("Database migrations completed successfully") diff --git a/routstr/core/settings.py b/routstr/core/settings.py index f52f8b04..fba0383c 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -238,9 +238,10 @@ class SettingsService: except Exception: db_json = {} + valid_fields = set(env_resolved.dict().keys()) merged_dict: dict[str, Any] = dict(env_resolved.dict()) merged_dict.update( - {k: v for k, v in db_json.items() if v not in (None, "", [], {})} + {k: v for k, v in db_json.items() if v not in (None, "", [], {}) and k in valid_fields} ) # Ensure primary_mint is consistent with cashu_mints if not explicitly set @@ -306,8 +307,10 @@ class SettingsService: raise RuntimeError("Settings row missing") (data_str,) = row data = json.loads(data_str) if isinstance(data_str, str) else dict(data_str) + valid_fields = set(settings.dict().keys()) # Update in-place for k, v in data.items(): - setattr(settings, k, v) + if k in valid_fields: + setattr(settings, k, v) cls._current = settings return settings diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index faa89871..cf2a2fe3 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -21,7 +21,7 @@ from ..core.db import ( AsyncSession, UpstreamProviderRow, create_session, - store_cashu_refund, + store_cashu_transaction, ) from ..core.exceptions import UpstreamError from ..payment.cost_calculation import ( @@ -1725,15 +1725,16 @@ class BaseUpstreamProvider: if payment_token_hash: try: - await store_cashu_refund( - payment_token_hash=payment_token_hash, - refund_token=refund_token, + await store_cashu_transaction( + id=payment_token_hash, + token=refund_token, amount=amount, unit=unit, mint_url=mint, + type="out", ) except Exception: - pass # store_cashu_refund already logs + pass # store_cashu_transaction already logs return refund_token except Exception as e: @@ -2033,8 +2034,13 @@ class BaseUpstreamProvider: response.headers["X-Cashu"] = refund_token if payment_token_hash: try: - await store_cashu_refund( - payment_token_hash, refund_token, emergency_refund, unit, mint + await store_cashu_transaction( + id=payment_token_hash, + token=refund_token, + amount=emergency_refund, + unit=unit, + mint_url=mint, + type="out", ) except Exception: pass @@ -2899,8 +2905,13 @@ class BaseUpstreamProvider: response.headers["X-Cashu"] = refund_token if payment_token_hash: try: - await store_cashu_refund( - payment_token_hash, refund_token, emergency_refund, unit, mint + await store_cashu_transaction( + id=payment_token_hash, + token=refund_token, + amount=emergency_refund, + unit=unit, + mint_url=mint, + type="out", ) except Exception: pass diff --git a/routstr/wallet.py b/routstr/wallet.py index f7bbcd73..6bdcf0e5 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -1,4 +1,5 @@ import asyncio +import hashlib import math import time from typing import TypedDict @@ -35,6 +36,21 @@ async def recieve_token( wallet.verify_proofs_dleq(token_obj.proofs) await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True) + + # Store incoming transaction + try: + token_hash = hashlib.sha256(token.encode()).hexdigest() + await db.store_cashu_transaction( + id=token_hash, + token=token, + amount=token_obj.amount, + unit=token_obj.unit, + mint_url=token_obj.mint, + type="in", + ) + except Exception: + pass + return token_obj.amount, token_obj.unit, token_obj.mint @@ -359,23 +375,24 @@ async def periodic_refund_sweep() -> None: try: cutoff = int(time.time()) - settings.refund_sweep_ttl_seconds async with db.create_session() as session: - stmt = select(db.CashuRefund).where( - db.CashuRefund.collected == False, # noqa: E712 - db.CashuRefund.swept == False, # noqa: E712 - db.CashuRefund.created_at < cutoff, + stmt = select(db.CashuTransaction).where( + db.CashuTransaction.type == "out", + db.CashuTransaction.collected == False, # noqa: E712 + db.CashuTransaction.swept == False, # noqa: E712 + db.CashuTransaction.created_at < cutoff, ) results = await session.exec(stmt) refunds = results.all() for refund in refunds: try: - await recieve_token(refund.refund_token) + await recieve_token(refund.token) refund.swept = True session.add(refund) logger.info( "Swept uncollected refund", extra={ - "payment_token_hash": refund.payment_token_hash, + "id": refund.id, "amount": refund.amount, "unit": refund.unit, }, @@ -388,14 +405,14 @@ async def periodic_refund_sweep() -> None: logger.info( "Refund already spent (client collected), marking swept", extra={ - "payment_token_hash": refund.payment_token_hash, + "id": refund.id, }, ) else: logger.warning( "Failed to sweep refund", extra={ - "payment_token_hash": refund.payment_token_hash, + "id": refund.id, "error": str(e), }, ) diff --git a/ui/app/transactions/page.tsx b/ui/app/transactions/page.tsx new file mode 100644 index 00000000..256cf7d6 --- /dev/null +++ b/ui/app/transactions/page.tsx @@ -0,0 +1,285 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { AppPageShell } from '@/components/app-page-shell'; +import { PageHeader } from '@/components/page-header'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Badge } from '@/components/ui/badge'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + RefreshCw, + Search, + ArrowDownLeft, + ArrowUpRight, + Copy, + Check, +} from 'lucide-react'; +import { AdminService, type Transaction } from '@/lib/api/services/admin'; +import { formatDistanceToNow } from 'date-fns'; +import { toast } from 'sonner'; + +export default function TransactionsPage() { + const [search, setSearch] = useState(''); + const [type, setType] = useState('all'); + const [status, setStatus] = useState('all'); + const [copiedId, setCopiedId] = useState(null); + + const { data, isLoading, refetch, isRefetching } = useQuery({ + queryKey: ['transactions', type, status, search], + queryFn: () => + AdminService.getTransactions( + type === 'all' ? undefined : type, + status === 'all' ? undefined : status, + search || undefined, + 100 + ), + }); + + const copyToClipboard = (text: string, id: string) => { + navigator.clipboard.writeText(text); + setCopiedId(id); + toast.success('Copied to clipboard'); + setTimeout(() => setCopiedId(null), 2000); + }; + + const getStatusBadge = (tx: Transaction) => { + if (tx.swept) + return ( + + Swept + + ); + if (tx.collected) + return ( + + Collected + + ); + return ( + + Pending + + ); + }; + + return ( + +
+ refetch()} + variant='outline' + size='sm' + disabled={isRefetching} + > + + Refresh + + } + /> + +
+
+
+ + setSearch(e.target.value)} + /> +
+
+ + +
+ + + + Transaction History + + + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : ( + + + + + Type + Amount + Status + ID / Request ID + Mint + Date + Actions + + + + {data?.transactions.map((tx) => ( + + +
+ {tx.type === 'in' ? ( + + ) : ( + + )} + {tx.type} +
+
+ + {tx.amount} {tx.unit} + + {getStatusBadge(tx)} + +
+
+ + {tx.id} + + +
+ {tx.request_id && ( +
+ + {tx.request_id} + + +
+ )} +
+
+ +
+ {tx.mint_url} +
+
+ + {formatDistanceToNow(tx.created_at * 1000, { + addSuffix: true, + })} + + + + +
+ ))} + {data?.transactions.length === 0 && ( + + + No transactions found + + + )} +
+
+
+ )} +
+
+
+
+ ); +} diff --git a/ui/components/app-page-shell.tsx b/ui/components/app-page-shell.tsx index ad2fdd31..6b613862 100644 --- a/ui/components/app-page-shell.tsx +++ b/ui/components/app-page-shell.tsx @@ -13,6 +13,7 @@ import { ServerIcon, SettingsIcon, WalletIcon, + ArrowRightLeftIcon, } from 'lucide-react'; import Image from 'next/image'; import { toast } from 'sonner'; @@ -41,6 +42,7 @@ const NAV_ITEMS = [ { title: 'Logs', url: '/logs', icon: FileTextIcon }, { title: 'Models', url: '/models', icon: DatabaseIcon }, { title: 'Providers', url: '/providers', icon: ServerIcon }, + { title: 'Transactions', url: '/transactions', icon: ArrowRightLeftIcon }, { title: 'Settings', url: '/settings', icon: SettingsIcon }, ] as const; diff --git a/ui/components/app-sidebar.tsx b/ui/components/app-sidebar.tsx index 76179f1c..7c777240 100644 --- a/ui/components/app-sidebar.tsx +++ b/ui/components/app-sidebar.tsx @@ -9,6 +9,7 @@ import { ServerIcon, SettingsIcon, WalletIcon, + ArrowRightLeftIcon, } from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; @@ -45,6 +46,11 @@ const data = { url: '/balances', icon: WalletIcon, }, + { + title: 'Transactions', + url: '/transactions', + icon: ArrowRightLeftIcon, + }, { title: 'Logs', url: '/logs', diff --git a/ui/lib/api/services/admin.ts b/ui/lib/api/services/admin.ts index d91e24cb..8ed8e3d7 100644 --- a/ui/lib/api/services/admin.ts +++ b/ui/lib/api/services/admin.ts @@ -869,6 +869,23 @@ export class AdminService { ); } + static async getTransactions( + type?: string, + status?: string, + search?: string, + limit: number = 100 + ): Promise { + const params = new URLSearchParams(); + if (type) params.append('type', type); + if (status) params.append('status', status); + if (search) params.append('search', search); + params.append('limit', limit.toString()); + + return await apiClient.get( + `/admin/api/transactions?${params.toString()}` + ); + } + static async createProviderAccountByType(providerType: string): Promise<{ ok: boolean; account_data: Record; @@ -1042,3 +1059,21 @@ export interface LogResponse { search: string | null; limit: number; } + +export interface Transaction { + id: string; + token: string; + amount: number; + unit: string; + mint_url: string; + type: 'in' | 'out'; + request_id?: string; + created_at: number; + collected: boolean; + swept: boolean; +} + +export interface TransactionsResponse { + transactions: Transaction[]; + total: number; +}