From 456090f7564ea90b90987c0ef7d8e179833a89cd Mon Sep 17 00:00:00 2001 From: Ashen <310210685+ashen0x@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:29:37 +0530 Subject: [PATCH 001/120] feat: add a Withdrawals tab so issued tokens can be recovered --- ui/app/transactions/page.tsx | 119 +++++++++++++++++++--- ui/components/detailed-wallet-balance.tsx | 8 +- ui/components/withdraw-modal.tsx | 86 +++++++++++----- ui/lib/api/services/admin.ts | 2 +- ui/lib/utils.ts | 9 ++ 5 files changed, 184 insertions(+), 40 deletions(-) diff --git a/ui/app/transactions/page.tsx b/ui/app/transactions/page.tsx index 0ea721c0..5ef53ffb 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, @@ -181,19 +183,37 @@ function TransactionTable({ {format(tx.created_at * 1000, 'yyyy-MM-dd HH:mm:ss')} - + {/* The sweeper marks a withdrawal collected or swept once + it can no longer be redeemed, and the token is never + rendered anywhere. */} + {tx.source === 'admin' && !tx.swept && !tx.collected && ( + )} - + ))} @@ -402,6 +422,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 +471,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 +523,7 @@ export default function TransactionsPage() { setStatus('all'); setXcashuPage(0); setApikeyPage(0); + setWithdrawalsPage(0); setLightningPage(0); }; @@ -508,6 +552,18 @@ export default function TransactionsPage() { Collected ); + // An outstanding withdrawal ends at "issued": the node hands over a + // bearer token and never learns whether it was redeemed, so it would + // otherwise read Pending forever. + if (tx.source === 'admin') + return ( + + Issued + + ); return ( { 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 +675,7 @@ export default function TransactionsPage() { onClick={() => { xcashuQuery.refetch(); apikeyQuery.refetch(); + withdrawalsQuery.refetch(); lightningQuery.refetch(); }} variant='outline' @@ -703,7 +762,7 @@ export default function TransactionsPage() { value={activeTab} onValueChange={setActiveTab} > - + X-Cashu @@ -722,6 +781,18 @@ export default function TransactionsPage() { )} + + + Withdrawals + {withdrawalsQuery.data && ( + + {withdrawalsQuery.data.total} + + )} + Lightning @@ -769,6 +840,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. - -