Merge pull request #651 from ashen0x/ui/withdraw-and-clipboard-hardening

Make withdrawn tokens recoverable
This commit is contained in:
9qeklajc
2026-08-21 21:20:19 +02:00
committed by GitHub
7 changed files with 385 additions and 66 deletions
+33 -2
View File
@@ -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,
}
+154 -1
View File
@@ -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"}
+119 -36
View File
@@ -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')}
</TableCell>
<TableCell className='text-right'>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => onCopy(tx.token, tx.id + '-token')}
title='Copy Token'
>
{copiedId === tx.id + '-token' ? (
<Check className='h-4 w-4' />
) : (
<Copy className='h-4 w-4' />
<div className='flex items-center justify-end gap-1'>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => onCopy(tx.token, tx.id + '-token')}
title='Copy Token'
>
{copiedId === tx.id + '-token' ? (
<Check className='h-4 w-4' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
{/* The token is never rendered in the table, so the
clipboard must not be the only way to get it out. */}
{tx.status === 'issued' && (
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() =>
downloadText(`cashu-token-${tx.id}.txt`, tx.token)
}
title='Download Token'
>
<Download className='h-4 w-4' />
</Button>
)}
</Button>
</div>
</TableCell>
</TableRow>
))}
@@ -402,6 +443,7 @@ export default function TransactionsPage() {
const [activeTab, setActiveTab] = useState<string>('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 (
<Badge
variant='outline'
className='border-orange-500/20 bg-orange-500/10 text-orange-500'
>
Swept
</Badge>
);
if (tx.collected)
return (
<Badge
variant='outline'
className='border-green-500/20 bg-green-500/10 text-green-500'
>
Collected
</Badge>
);
const { label, className } = STATUS_BADGES[tx.status];
return (
<Badge
variant='outline'
className='border-blue-500/20 bg-blue-500/10 text-blue-500'
>
Pending
<Badge variant='outline' className={className}>
{label}
</Badge>
);
};
@@ -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() {
<SelectContent>
<SelectItem value='all'>All Statuses</SelectItem>
<SelectItem value='pending'>Pending</SelectItem>
<SelectItem value='issued'>Issued</SelectItem>
<SelectItem value='collected'>Collected</SelectItem>
<SelectItem value='swept'>Swept</SelectItem>
<SelectItem value='paid'>Paid (Lightning)</SelectItem>
@@ -703,7 +752,7 @@ export default function TransactionsPage() {
value={activeTab}
onValueChange={setActiveTab}
>
<TabsList className='mb-4'>
<TabsList className='mb-4 max-w-full justify-start overflow-x-auto'>
<TabsTrigger value='x-cashu' className='flex items-center gap-2'>
<Zap className='h-4 w-4' />
X-Cashu
@@ -722,6 +771,18 @@ export default function TransactionsPage() {
</Badge>
)}
</TabsTrigger>
<TabsTrigger
value='withdrawals'
className='flex items-center gap-2'
>
<ArrowUpRight className='h-4 w-4' />
Withdrawals
{withdrawalsQuery.data && (
<Badge variant='secondary' className='ml-1'>
{withdrawalsQuery.data.total}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value='lightning' className='flex items-center gap-2'>
<Zap className='h-4 w-4' />
Lightning
@@ -769,6 +830,28 @@ export default function TransactionsPage() {
</Card>
</TabsContent>
<TabsContent value='withdrawals'>
<Card>
<CardHeader>
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
<CardTitle>Withdrawal History</CardTitle>
{hasActiveFilters && (
<CardDescription>
Filtered by {activeFilterDescription}
</CardDescription>
)}
</div>
</CardHeader>
<CardContent className='overflow-hidden'>
{renderCardContent(
withdrawalsQuery,
withdrawalsPage,
setWithdrawalsPage
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value='lightning'>
<Card>
<CardHeader>
+7 -1
View File
@@ -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'],
});
}}
/>
</>
+61 -25
View File
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-xl'>
<Dialog open={open} onOpenChange={handleOpenChange}>
{/* The X would route to handleOpenChange, which refuses until the token
is confirmed saved, so it would render as a dead control. */}
<DialogContent className='sm:max-w-xl' showCloseButton={false}>
<DialogHeader>
<DialogTitle>Withdrawal Successful</DialogTitle>
<DialogTitle>Withdrawal complete</DialogTitle>
<DialogDescription>
Save this token! It represents your withdrawn balance.
This token is the money. Anyone holding it can redeem it, so save
it before closing.
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<Alert>
<CheckCircle className='h-5 w-5' />
<AlertTitle>Withdrawal Token</AlertTitle>
<AlertDescription>
Save this token now. It represents your withdrawn balance.
</AlertDescription>
</Alert>
<Textarea
readOnly
value={withdrawnToken}
onFocus={(e) => e.target.select()}
className='font-mono text-xs leading-relaxed'
rows={6}
/>
<div className='flex flex-col gap-2 sm:flex-row'>
<Button
onClick={handleCopyToken}
onClick={() => copy(withdrawnToken)}
className='w-full flex-1'
variant={copiedToken ? 'outline' : 'default'}
>
@@ -143,13 +149,41 @@ export function WithdrawModal({
)}
</Button>
<Button
onClick={() => onOpenChange(false)}
onClick={handleDownloadToken}
variant='outline'
className='w-full sm:w-auto'
className='w-full flex-1'
>
Close
<Download className='mr-2 h-4 w-4' />
Download .txt
</Button>
</div>
<p className='text-muted-foreground text-xs'>
A copy is also kept under Transactions in the Withdrawals tab,
where you can copy it again later.
</p>
<div className='flex items-center gap-2'>
<Checkbox
id='token-saved'
checked={confirmedSaved}
onCheckedChange={(checked) =>
setConfirmedSaved(checked === true)
}
/>
<Label htmlFor='token-saved' className='text-sm font-normal'>
I have saved this token
</Label>
</div>
<Button
onClick={() => handleOpenChange(false)}
variant='outline'
disabled={!confirmedSaved}
className='w-full'
>
Close
</Button>
</div>
</DialogContent>
</Dialog>
@@ -233,8 +267,10 @@ export function WithdrawModal({
<Alert variant='destructive'>
<AlertCircle className='h-5 w-5' />
<AlertDescription>
{(withdrawMutation.error as Error).message ||
'Failed to withdraw'}
{getApiErrorMessage(
withdrawMutation.error,
'Failed to withdraw'
)}
</AlertDescription>
</Alert>
)}
+2 -1
View File
@@ -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;
}
+9
View File
@@ -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);
}