diff --git a/routstr/core/admin.py b/routstr/core/admin.py index c314ae14..5f6c8604 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -1811,6 +1811,21 @@ async def release_ppq_auto_topup_api( return {"ok": True, "released": True} +def _transaction_status(tx: CashuTransaction) -> str: + """An outgoing admin withdrawal ends at "issued": the node hands the bearer + token over and never learns whether it was redeemed, so its flags stay false + and it would otherwise read pending forever. Incoming admin rows are redeemed + by the node itself and keep the normal collected/swept lifecycle. + """ + if tx.swept: + return "swept" + if tx.collected: + return "collected" + if tx.source == "admin" and tx.type == "out": + return "issued" + return "pending" + + @admin_router.get("/api/transactions", dependencies=[Depends(require_admin_api)]) async def get_transactions_api( type: str | None = None, @@ -1840,13 +1855,25 @@ async def get_transactions_api( base = base.where(CashuTransaction.source == source) if status: if status == "collected": - base = base.where(CashuTransaction.collected == True) # noqa: E712 + base = base.where( + CashuTransaction.collected == True, # noqa: E712 + CashuTransaction.swept == False, # noqa: E712 + ) elif status == "swept": base = base.where(CashuTransaction.swept == True) # noqa: E712 + elif status == "issued": + base = base.where( + CashuTransaction.source == "admin", + CashuTransaction.type == "out", + CashuTransaction.collected == False, # noqa: E712 + CashuTransaction.swept == False, # noqa: E712 + ) elif status == "pending": base = base.where( CashuTransaction.collected == False, # noqa: E712 CashuTransaction.swept == False, # noqa: E712 + (CashuTransaction.source != "admin") + | (CashuTransaction.type != "out"), ) if search: @@ -1873,7 +1900,11 @@ async def get_transactions_api( return { "transactions": [ - tx.dict(exclude={"sweep_started_at"}) for tx in transactions + { + **tx.dict(exclude={"sweep_started_at"}), + "status": _transaction_status(tx), + } + for tx in transactions ], "total": total, } diff --git a/tests/unit/test_admin_transactions.py b/tests/unit/test_admin_transactions.py index 22508fd6..4ce6ffa0 100644 --- a/tests/unit/test_admin_transactions.py +++ b/tests/unit/test_admin_transactions.py @@ -1,9 +1,14 @@ +from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlmodel import SQLModel +from sqlmodel.ext.asyncio.session import AsyncSession -from routstr.core.admin import get_transactions_api +from routstr.core.admin import _transaction_status, get_transactions_api from routstr.core.db import CashuTransaction @@ -33,3 +38,151 @@ async def test_transactions_api_excludes_internal_sweep_claim_timestamp() -> Non assert response["total"] == 1 assert response["transactions"][0]["token"] == "cashu-token" assert "sweep_started_at" not in response["transactions"][0] + + +@pytest.mark.parametrize( + ("source", "typ", "collected", "swept", "expected"), + [ + ("admin", "out", False, False, "issued"), + ("admin", "out", True, False, "collected"), + ("admin", "out", False, True, "swept"), + # The node redeems an incoming top-up itself, so it is never "issued". + ("admin", "in", False, False, "pending"), + ("admin", "in", True, False, "collected"), + ("x-cashu", "out", False, False, "pending"), + ("x-cashu", "out", True, False, "collected"), + ("apikey", "out", False, True, "swept"), + ], +) +def test_transaction_status( + source: str, typ: str, collected: bool, swept: bool, expected: str +) -> None: + transaction = CashuTransaction( + token="t", + amount=1, + unit="sat", + type=typ, + source=source, + collected=collected, + swept=swept, + ) + assert _transaction_status(transaction) == expected + + +@pytest.fixture +async def session_factory( + tmp_path: Path, +) -> AsyncIterator[async_sessionmaker[AsyncSession]]: + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'admin.db'}") + async with engine.begin() as connection: + await connection.run_sync(SQLModel.metadata.create_all) + factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + yield factory + await engine.dispose() + + +ROWS = [ + ("issued-withdrawal", "admin", "out", False, False), + ("collected-withdrawal", "admin", "out", True, False), + ("swept-withdrawal", "admin", "out", False, True), + ("incoming-topup", "admin", "in", False, False), + ("pending-xcashu", "x-cashu", "out", False, False), + ("collected-apikey", "apikey", "out", True, False), + # Nothing stops both terminal flags being set, and the sweeper can reach + # this state when a token it claimed turns out to be already spent. + ("collected-and-swept", "x-cashu", "out", True, True), +] + + +async def _seed(factory: async_sessionmaker[AsyncSession]) -> None: + async with factory() as session: + session.add_all( + [ + CashuTransaction( + id=row_id, + token=row_id, + amount=1, + unit="sat", + source=source, + type=typ, + collected=collected, + swept=swept, + ) + for row_id, source, typ, collected, swept in ROWS + ] + ) + await session.commit() + + +async def _query( + factory: async_sessionmaker[AsyncSession], **kwargs: object +) -> list[dict]: + @asynccontextmanager + async def create_session(): # type: ignore[no-untyped-def] + async with factory() as session: + yield session + + with patch("routstr.core.admin.create_session", create_session): + response = await get_transactions_api(**kwargs) # type: ignore[arg-type] + return response["transactions"] + + +@pytest.mark.asyncio +async def test_response_carries_status_for_every_row( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed(session_factory) + + by_id = {tx["id"]: tx["status"] for tx in await _query(session_factory)} + + assert by_id == { + "issued-withdrawal": "issued", + "collected-withdrawal": "collected", + "swept-withdrawal": "swept", + "incoming-topup": "pending", + "pending-xcashu": "pending", + "collected-apikey": "collected", + "collected-and-swept": "swept", + } + + +@pytest.mark.asyncio +async def test_status_filters_partition_rows_by_reported_status( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed(session_factory) + unfiltered = {tx["id"]: tx["status"] for tx in await _query(session_factory)} + + filtered: dict[str, str] = {} + for status in ("issued", "collected", "swept", "pending"): + for tx in await _query(session_factory, status=status): + assert tx["status"] == status + filtered[tx["id"]] = status + + assert filtered == unfiltered + + +@pytest.mark.asyncio +async def test_issued_filter_returns_only_outstanding_withdrawals( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed(session_factory) + + rows = await _query(session_factory, status="issued") + + assert [tx["id"] for tx in rows] == ["issued-withdrawal"] + + +@pytest.mark.asyncio +async def test_pending_filter_excludes_issued_withdrawals( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + await _seed(session_factory) + + rows = await _query(session_factory, status="pending") + ids = {tx["id"] for tx in rows} + + # It is uncollected and unswept, so it matched "pending" before it had a + # status of its own. + assert "issued-withdrawal" not in ids + assert ids == {"incoming-topup", "pending-xcashu"} diff --git a/ui/app/transactions/page.tsx b/ui/app/transactions/page.tsx index 0ea721c0..56bc1b74 100644 --- a/ui/app/transactions/page.tsx +++ b/ui/app/transactions/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'; import { useQuery, keepPreviousData } from '@tanstack/react-query'; import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; +import { downloadText } from '@/lib/utils'; import { AppPageShell } from '@/components/app-page-shell'; import { PageHeader } from '@/components/page-header'; import { @@ -53,6 +54,7 @@ import { Zap, ChevronLeft, ChevronRight, + Download, } from 'lucide-react'; import { AdminService, @@ -64,6 +66,28 @@ import { toast } from 'sonner'; const STORAGE_KEY = 'routstr-transaction-filters'; +const STATUS_BADGES: Record< + Transaction['status'], + { label: string; className: string } +> = { + issued: { + label: 'Issued', + className: 'border-gray-500/20 bg-gray-500/10 text-gray-500', + }, + collected: { + label: 'Collected', + className: 'border-green-500/20 bg-green-500/10 text-green-500', + }, + swept: { + label: 'Swept', + className: 'border-orange-500/20 bg-orange-500/10 text-orange-500', + }, + pending: { + label: 'Pending', + className: 'border-blue-500/20 bg-blue-500/10 text-blue-500', + }, +}; + function TransactionTable({ transactions, copiedId, @@ -181,19 +205,36 @@ function TransactionTable({ {format(tx.created_at * 1000, 'yyyy-MM-dd HH:mm:ss')} - onCopy(tx.token, tx.id + '-token')} - title='Copy Token' - > - {copiedId === tx.id + '-token' ? ( - - ) : ( - + + onCopy(tx.token, tx.id + '-token')} + title='Copy Token' + > + {copiedId === tx.id + '-token' ? ( + + ) : ( + + )} + + {/* The token is never rendered in the table, so the + clipboard must not be the only way to get it out. */} + {tx.status === 'issued' && ( + + downloadText(`cashu-token-${tx.id}.txt`, tx.token) + } + title='Download Token' + > + + )} - + ))} @@ -402,6 +443,7 @@ export default function TransactionsPage() { const [activeTab, setActiveTab] = useState('x-cashu'); const [xcashuPage, setXcashuPage] = useState(0); const [apikeyPage, setApikeyPage] = useState(0); + const [withdrawalsPage, setWithdrawalsPage] = useState(0); const [lightningPage, setLightningPage] = useState(0); const typeParam = type === 'all' ? undefined : type; @@ -450,6 +492,28 @@ export default function TransactionsPage() { placeholderData: keepPreviousData, }); + // Withdrawals are stored with source "admin" and keep their one-time token. + const withdrawalsQuery = useQuery({ + queryKey: [ + 'transactions', + 'admin', + typeParam, + statusParam, + searchParam, + withdrawalsPage, + ], + queryFn: () => + AdminService.getTransactions( + typeParam, + statusParam, + searchParam, + 'admin', + PAGE_SIZE, + withdrawalsPage * PAGE_SIZE + ), + placeholderData: keepPreviousData, + }); + const LIGHTNING_STATUSES = ['pending', 'paid', 'expired', 'cancelled']; const lightningStatusParam = LIGHTNING_STATUSES.includes(status) ? status @@ -480,6 +544,7 @@ export default function TransactionsPage() { setStatus('all'); setXcashuPage(0); setApikeyPage(0); + setWithdrawalsPage(0); setLightningPage(0); }; @@ -490,30 +555,10 @@ export default function TransactionsPage() { }; const getStatusBadge = (tx: Transaction) => { - if (tx.swept) - return ( - - Swept - - ); - if (tx.collected) - return ( - - Collected - - ); + const { label, className } = STATUS_BADGES[tx.status]; return ( - - Pending + + {label} ); }; @@ -533,12 +578,14 @@ export default function TransactionsPage() { useEffect(() => { setXcashuPage(0); setApikeyPage(0); + setWithdrawalsPage(0); setLightningPage(0); }, [type, status, search]); const isRefetching = xcashuQuery.isRefetching || apikeyQuery.isRefetching || + withdrawalsQuery.isRefetching || lightningQuery.isRefetching; const renderCardContent = ( @@ -617,6 +664,7 @@ export default function TransactionsPage() { onClick={() => { xcashuQuery.refetch(); apikeyQuery.refetch(); + withdrawalsQuery.refetch(); lightningQuery.refetch(); }} variant='outline' @@ -675,6 +723,7 @@ export default function TransactionsPage() { All Statuses Pending + Issued Collected Swept Paid (Lightning) @@ -703,7 +752,7 @@ export default function TransactionsPage() { value={activeTab} onValueChange={setActiveTab} > - + X-Cashu @@ -722,6 +771,18 @@ export default function TransactionsPage() { )} + + + Withdrawals + {withdrawalsQuery.data && ( + + {withdrawalsQuery.data.total} + + )} + Lightning @@ -769,6 +830,28 @@ export default function TransactionsPage() { + + + + + Withdrawal History + {hasActiveFilters && ( + + Filtered by {activeFilterDescription} + + )} + + + + {renderCardContent( + withdrawalsQuery, + withdrawalsPage, + setWithdrawalsPage + )} + + + + diff --git a/ui/components/detailed-wallet-balance.tsx b/ui/components/detailed-wallet-balance.tsx index 794d0a34..22a51ed4 100644 --- a/ui/components/detailed-wallet-balance.tsx +++ b/ui/components/detailed-wallet-balance.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { RefreshCw, AlertCircle, Wallet, User, Coins } from 'lucide-react'; import { WalletService, BalanceDetail } from '@/lib/api/services/wallet'; import { getApiErrorMessage } from '@/lib/api/errors'; @@ -45,6 +45,7 @@ export function DetailedWalletBalance({ usdPerSat: number | null; }) { const [withdrawModalOpen, setWithdrawModalOpen] = useState(false); + const queryClient = useQueryClient(); const { data, isLoading, isError, error, isFetching, refetch } = useQuery({ queryKey: ['detailed-wallet-balance'], @@ -391,6 +392,11 @@ export function DetailedWalletBalance({ balances={data || []} onSuccess={() => { refetch(); + // The success dialog tells the operator the token is in the + // Withdrawals tab; without this the cached tab stays stale for 5 min. + queryClient.invalidateQueries({ + queryKey: ['transactions', 'admin'], + }); }} /> > diff --git a/ui/components/withdraw-modal.tsx b/ui/components/withdraw-modal.tsx index cb5dffef..020b1532 100644 --- a/ui/components/withdraw-modal.tsx +++ b/ui/components/withdraw-modal.tsx @@ -11,10 +11,11 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { Select, SelectContent, @@ -22,7 +23,10 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { AlertCircle, Copy, CheckCircle } from 'lucide-react'; +import { AlertCircle, Copy, CheckCircle, Download } from 'lucide-react'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; +import { getApiErrorMessage } from '@/lib/api/errors'; +import { downloadText } from '@/lib/utils'; interface WithdrawModalProps { open: boolean; @@ -40,7 +44,8 @@ export function WithdrawModal({ const [selectedMintUnit, setSelectedMintUnit] = useState(''); const [amount, setAmount] = useState(''); const [withdrawnToken, setWithdrawnToken] = useState(''); - const [copiedToken, setCopiedToken] = useState(false); + const [confirmedSaved, setConfirmedSaved] = useState(false); + const { copied: copiedToken, copy } = useCopyToClipboard(); const availableBalances = balances.filter( (b) => !b.error && b.wallet_balance > 0 @@ -82,16 +87,20 @@ export function WithdrawModal({ setWithdrawnToken(''); setAmount(''); setSelectedMintUnit(''); - setCopiedToken(false); + setConfirmedSaved(false); } }, [open]); - const handleCopyToken = () => { - navigator.clipboard.writeText(withdrawnToken); - setCopiedToken(true); - setTimeout(() => setCopiedToken(false), 2000); + // The token is a bearer instrument. Escape, backdrop, X and drag-dismiss all + // route through here; none of them may destroy it before the user confirms. + const handleOpenChange = (next: boolean) => { + if (!next && withdrawnToken && !confirmedSaved) return; + onOpenChange(next); }; + const handleDownloadToken = () => + downloadText(`cashu-token-${Date.now()}.txt`, withdrawnToken); + const amountNum = parseInt(amount) || 0; const showWarning = selectedBalance && @@ -100,33 +109,30 @@ export function WithdrawModal({ if (withdrawnToken) { return ( - - + + {/* The X would route to handleOpenChange, which refuses until the token + is confirmed saved, so it would render as a dead control. */} + - Withdrawal Successful + Withdrawal complete - Save this token! It represents your withdrawn balance. + This token is the money. Anyone holding it can redeem it, so save + it before closing. - - - Withdrawal Token - - Save this token now. It represents your withdrawn balance. - - e.target.select()} className='font-mono text-xs leading-relaxed' rows={6} /> copy(withdrawnToken)} className='w-full flex-1' variant={copiedToken ? 'outline' : 'default'} > @@ -143,13 +149,41 @@ export function WithdrawModal({ )} onOpenChange(false)} + onClick={handleDownloadToken} variant='outline' - className='w-full sm:w-auto' + className='w-full flex-1' > - Close + + Download .txt + + + A copy is also kept under Transactions in the Withdrawals tab, + where you can copy it again later. + + + + + setConfirmedSaved(checked === true) + } + /> + + I have saved this token + + + + handleOpenChange(false)} + variant='outline' + disabled={!confirmedSaved} + className='w-full' + > + Close + @@ -233,8 +267,10 @@ export function WithdrawModal({ - {(withdrawMutation.error as Error).message || - 'Failed to withdraw'} + {getApiErrorMessage( + withdrawMutation.error, + 'Failed to withdraw' + )} )} diff --git a/ui/lib/api/services/admin.ts b/ui/lib/api/services/admin.ts index bdc700cd..7067627b 100644 --- a/ui/lib/api/services/admin.ts +++ b/ui/lib/api/services/admin.ts @@ -1261,7 +1261,8 @@ export interface Transaction { created_at: number; collected: boolean; swept: boolean; - source: 'x-cashu' | 'apikey'; + status: 'issued' | 'collected' | 'swept' | 'pending'; + source: 'x-cashu' | 'apikey' | 'admin'; api_key_hashed_key?: string; } diff --git a/ui/lib/utils.ts b/ui/lib/utils.ts index 2819a830..8d88fd33 100644 --- a/ui/lib/utils.ts +++ b/ui/lib/utils.ts @@ -4,3 +4,12 @@ import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function downloadText(filename: string, text: string) { + const url = URL.createObjectURL(new Blob([text], { type: 'text/plain' })); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +}
+ A copy is also kept under Transactions in the Withdrawals tab, + where you can copy it again later. +