diff --git a/src/components/MainWalletPage.test.tsx b/src/components/MainWalletPage.test.tsx
index e4661cee8..3f2da3734 100644
--- a/src/components/MainWalletPage.test.tsx
+++ b/src/components/MainWalletPage.test.tsx
@@ -53,6 +53,10 @@ vi.mock('./wallet/WalletJarsDetailsOverlay', () => ({
),
}))
+vi.mock('./wallet/TxHistoryContent', () => ({
+ TxHistoryContent: () =>
,
+}))
+
vi.mock('./ui/jam/Balance', () => ({
Balance: ({ valueString, onClick }: { valueString: string; onClick?: () => void }) => (
{valueString}
diff --git a/src/components/MainWalletPage.tsx b/src/components/MainWalletPage.tsx
index 3dbcdc4a5..c8e4fbd84 100644
--- a/src/components/MainWalletPage.tsx
+++ b/src/components/MainWalletPage.tsx
@@ -19,6 +19,8 @@ import type { WalletFileName } from '@/lib/utils'
import { cn, shortenStringMiddle, walletDisplayName } from '@/lib/utils'
import { Balance } from './ui/jam/Balance'
import { Spinner } from './ui/spinner'
+import { TxHistoryContent } from './wallet/TxHistoryContent'
+import { TxHistoryOverlay } from './wallet/TxHistoryOverlay'
import { WalletJarsDetailsOverlay } from './wallet/WalletJarsDetailsOverlay'
interface MainWalletPageProps {
@@ -30,6 +32,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
const navigate = useNavigate()
const [selectedJar, setSelectedJar] = useState()
const [isWalletJarsDetailsOpen, setIsWalletJarsDetailsOpen] = useState(false)
+ const [isTxHistoryOpen, setIsTxHistoryOpen] = useState(false)
const { toggleDisplayMode } = useJamDisplayContext()
const { isLoading, isFetching, error, refetch: refetchWalletData } = useJamWalletInfoContext()
@@ -55,6 +58,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
walletFileName={walletFileName}
selectedJarIndex={selectedJar?.jarIndex}
/>
+
@@ -152,6 +156,13 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
{t('global.refresh')}
+
setIsTxHistoryOpen(true)}
+ className="w-full max-w-6xl"
+ />
>
)
diff --git a/src/components/wallet/TxHistoryContent.test.tsx b/src/components/wallet/TxHistoryContent.test.tsx
new file mode 100644
index 000000000..b22a61a99
--- /dev/null
+++ b/src/components/wallet/TxHistoryContent.test.tsx
@@ -0,0 +1,113 @@
+import type { WalletHistoryResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
+import type { UseQueryResult } from '@tanstack/react-query'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { useQueryWalletHistory } from '@/hooks/useQueryWalletHistory'
+import { TxHistoryContent } from './TxHistoryContent'
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('@/hooks/useQueryWalletHistory', () => ({
+ useQueryWalletHistory: vi.fn(),
+}))
+
+vi.mock('./TxHistoryTable', () => ({
+ TxHistoryTable: ({ history, compact }: { history: unknown[]; compact: boolean }) => (
+
+ Mock Table (Rows: {history.length})
+
+ ),
+}))
+
+vi.mock('@/components/ui/alert', () => ({
+ Alert: ({ children, variant }: { children: React.ReactNode; variant?: string }) => (
+
+ {children}
+
+ ),
+ AlertDescription: ({ children }: { children: React.ReactNode }) => {children}
,
+}))
+
+vi.mock('@/components/ui/spinner', () => ({
+ Spinner: () => ,
+}))
+
+const mockUseQuery = useQueryWalletHistory as unknown as ReturnType
+
+const mockQueryResult = (overrides: Partial>) =>
+ ({
+ isLoading: false,
+ error: null,
+ isFetching: false,
+ refetch: vi.fn(),
+ ...overrides,
+ }) as UseQueryResult
+
+describe('TxHistoryContent', () => {
+ it('renders loading state', () => {
+ mockUseQuery.mockReturnValue({
+ history: [],
+ queryResult: mockQueryResult({ isLoading: true }),
+ })
+
+ render()
+ expect(screen.getByTestId('mock-loading')).toBeInTheDocument()
+ })
+
+ it('renders error state', () => {
+ mockUseQuery.mockReturnValue({
+ history: [],
+ queryResult: mockQueryResult({ error: new Error('failed') }),
+ })
+
+ render()
+ expect(screen.getByTestId('mock-alert')).toBeInTheDocument()
+ expect(screen.getByText('tx_history.error_loading')).toBeInTheDocument()
+ })
+
+ it('renders the table with data', () => {
+ const mockHistory = [{ txid: '1' }, { txid: '2' }]
+ mockUseQuery.mockReturnValue({
+ history: mockHistory,
+ queryResult: mockQueryResult({}),
+ })
+
+ render()
+ expect(screen.getByTestId('mock-table')).toHaveAttribute('data-compact', 'true')
+ expect(screen.getByText('Mock Table (Rows: 2)')).toBeInTheDocument()
+ })
+
+ it('renders Load More button when not compact and handles clicks', () => {
+ const mockHistory = Array.from({ length: 10 }, () => ({ txid: 'mock' }))
+ const refetch = vi.fn()
+
+ mockUseQuery.mockReturnValue({
+ history: mockHistory,
+ queryResult: mockQueryResult({ refetch, isFetching: false }),
+ })
+
+ render()
+
+ const loadMoreButton = screen.getByRole('button', { name: 'tx_history.button_load_more' })
+ expect(loadMoreButton).toBeInTheDocument()
+
+ fireEvent.click(loadMoreButton)
+ expect(mockUseQuery).toHaveBeenLastCalledWith(expect.objectContaining({ limit: 20 }))
+ })
+
+ it('hides Load More button if history length is less than limit', () => {
+ const mockHistory = Array.from({ length: 5 }, () => ({ txid: 'mock' }))
+
+ mockUseQuery.mockReturnValue({
+ history: mockHistory,
+ queryResult: mockQueryResult({}),
+ })
+
+ render()
+ expect(screen.queryByRole('button', { name: 'tx_history.button_load_more' })).not.toBeInTheDocument()
+ })
+})
diff --git a/src/components/wallet/TxHistoryContent.tsx b/src/components/wallet/TxHistoryContent.tsx
new file mode 100644
index 000000000..aa64a5c26
--- /dev/null
+++ b/src/components/wallet/TxHistoryContent.tsx
@@ -0,0 +1,72 @@
+import { useState } from 'react'
+import { AlertTriangleIcon, ListIcon } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Button } from '@/components/ui/button'
+import { Spinner } from '@/components/ui/spinner'
+import { useQueryWalletHistory } from '@/hooks/useQueryWalletHistory'
+import { cn, type WalletFileName } from '@/lib/utils'
+import { TxHistoryTable } from './TxHistoryTable'
+
+interface TxHistoryContentProps {
+ walletFileName: WalletFileName
+ className?: string
+ initialLimit?: number
+ compact?: boolean
+ enabled?: boolean
+ onViewAll?: () => void
+}
+
+export const TxHistoryContent = ({
+ walletFileName,
+ className,
+ initialLimit = 10,
+ compact = false,
+ enabled = true,
+ onViewAll,
+}: TxHistoryContentProps) => {
+ const { t } = useTranslation()
+ const [limit, setLimit] = useState(initialLimit)
+ const { history, queryResult } = useQueryWalletHistory({ walletFileName, limit, enabled })
+
+ return (
+
+
+
{t('tx_history.section_title')}
+ {compact && onViewAll ? (
+
+ ) : null}
+
+
+ {queryResult.isLoading ? (
+
+
+ {t('global.loading')}
+
+ ) : queryResult.error ? (
+
+
+ {t('tx_history.error_loading')}
+
+ ) : (
+
+ )}
+
+ {!compact && history.length >= limit ? (
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/components/wallet/TxHistoryOverlay.test.tsx b/src/components/wallet/TxHistoryOverlay.test.tsx
new file mode 100644
index 000000000..f2f441949
--- /dev/null
+++ b/src/components/wallet/TxHistoryOverlay.test.tsx
@@ -0,0 +1,66 @@
+import type { ReactNode } from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { TxHistoryOverlay } from './TxHistoryOverlay'
+
+type ChildrenProps = { children: ReactNode }
+type DialogProps = ChildrenProps & { open?: boolean; onOpenChange?: (open: boolean) => void }
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('./TxHistoryContent', () => ({
+ TxHistoryContent: ({ walletFileName }: { walletFileName: string }) => (
+
+ Content
+
+ ),
+}))
+
+vi.mock('../ui/jam/PageTitle', () => ({
+ default: ({ title }: { title: string }) => {title}
,
+}))
+
+// Mock Dialog to avoid dealing with portals and radix UI internals
+vi.mock('@/components/ui/dialog', () => ({
+ Dialog: ({ children, open, onOpenChange }: DialogProps) =>
+ open ? (
+
+
+ {children}
+
+ ) : null,
+ DialogContent: ({ children }: ChildrenProps) => {children}
,
+ DialogHeader: ({ children }: ChildrenProps) => {children}
,
+ DialogTitle: ({ children }: ChildrenProps) => {children}
,
+}))
+
+describe('TxHistoryOverlay', () => {
+ it('renders dialog content when open', () => {
+ render()
+
+ expect(screen.getByTestId('page-title')).toHaveTextContent('tx_history.overlay_title')
+
+ const content = screen.getByTestId('tx-history-content')
+ expect(content).toBeInTheDocument()
+ expect(content).toHaveAttribute('data-wallet', 'test-wallet.jmdat')
+ })
+
+ it('calls onOpenChange when closed', () => {
+ const onOpenChange = vi.fn()
+ render()
+
+ const closeButton = screen.getByText('Close')
+ fireEvent.click(closeButton)
+
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+
+ it('does not render when closed', () => {
+ render()
+ expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
+ })
+})
diff --git a/src/components/wallet/TxHistoryOverlay.tsx b/src/components/wallet/TxHistoryOverlay.tsx
new file mode 100644
index 000000000..155c6fd09
--- /dev/null
+++ b/src/components/wallet/TxHistoryOverlay.tsx
@@ -0,0 +1,36 @@
+import type { ComponentProps } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import type { WithRequiredProperty } from '@/types/global'
+import PageTitle from '../ui/jam/PageTitle'
+import { TxHistoryContent } from './TxHistoryContent'
+
+type TxHistoryOverlayProps = WithRequiredProperty<
+ Omit, 'children'>,
+ 'open' | 'onOpenChange'
+> &
+ Pick, 'walletFileName'>
+
+export function TxHistoryOverlay({ open, onOpenChange, walletFileName, ...dialogProps }: TxHistoryOverlayProps) {
+ const { t } = useTranslation()
+
+ return (
+
+ )
+}
diff --git a/src/components/wallet/TxHistoryTable.test.tsx b/src/components/wallet/TxHistoryTable.test.tsx
new file mode 100644
index 000000000..6973ca82d
--- /dev/null
+++ b/src/components/wallet/TxHistoryTable.test.tsx
@@ -0,0 +1,108 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { HistoryEntry } from '@/hooks/useQueryWalletHistory'
+import { TxHistoryTable } from './TxHistoryTable'
+
+const mocks = vi.hoisted(() => ({
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+}))
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: mocks.toastSuccess,
+ error: mocks.toastError,
+ },
+}))
+
+vi.mock('../ui/jam/Address', () => ({
+ Address: ({ value }: { value: string }) => {value},
+}))
+
+vi.mock('../ui/jam/Balance', () => ({
+ Balance: ({ valueString }: { valueString: string }) => {valueString},
+}))
+
+const mockDeposit: HistoryEntry = {
+ timestamp: '2026-07-28T12:00:00Z',
+ role: 'deposit',
+ txid: 'tx1',
+ cj_amount: 5000,
+ net_fee: 0,
+ confirmations: 10,
+ success: true,
+ source_mixdepth: 0,
+}
+
+const mockSend: HistoryEntry = {
+ timestamp: '2026-07-28T13:00:00Z',
+ role: 'send',
+ txid: 'tx2',
+ cj_amount: 10000,
+ net_fee: -100,
+ confirmations: 0,
+ success: true,
+ source_mixdepth: 0,
+}
+
+const mockFailed: HistoryEntry = {
+ timestamp: '2026-07-28T14:00:00Z',
+ role: 'taker',
+ txid: 'tx3',
+ cj_amount: 0,
+ net_fee: 0,
+ confirmations: 0,
+ success: false,
+ failure_reason: 'aborted',
+ source_mixdepth: 1,
+}
+
+describe('TxHistoryTable', () => {
+ beforeEach(() => {
+ mocks.toastSuccess.mockReset()
+ mocks.toastError.mockReset()
+ })
+
+ it('renders history rows and sorts by date', () => {
+ render()
+
+ expect(screen.getByText('tx_history.role_deposit')).toBeInTheDocument()
+ expect(screen.getByText('tx_history.role_send')).toBeInTheDocument()
+
+ const dateHeader = screen.getByText('tx_history.column_date')
+ fireEvent.click(dateHeader)
+ expect(dateHeader).toBeInTheDocument()
+ })
+
+ it('renders empty history alert', () => {
+ render()
+ expect(screen.getByText('tx_history.empty_history')).toBeInTheDocument()
+ })
+
+ it('expands row details to show raw JSON', () => {
+ render()
+
+ const detailsButton = screen.getByRole('button', { name: 'jar_details.utxo_list.row_button_details' })
+ fireEvent.click(detailsButton)
+
+ // Check if the JSON is rendered
+ expect(screen.getByText(/"txid": "tx1"/u)).toBeInTheDocument()
+ })
+
+ it('sorts columns correctly', () => {
+ render()
+
+ fireEvent.click(screen.getByText('tx_history.column_amount'))
+ fireEvent.click(screen.getByText('tx_history.column_net_fee'))
+ fireEvent.click(screen.getByText('tx_history.column_confirmations'))
+ fireEvent.click(screen.getByText('tx_history.column_role'))
+
+ expect(screen.getByText('tx_history.role_taker')).toBeInTheDocument()
+ })
+})
diff --git a/src/components/wallet/TxHistoryTable.tsx b/src/components/wallet/TxHistoryTable.tsx
new file mode 100644
index 000000000..fefe61f1d
--- /dev/null
+++ b/src/components/wallet/TxHistoryTable.tsx
@@ -0,0 +1,300 @@
+import { useMemo, useState } from 'react'
+import {
+ createColumnHelper,
+ flexRender,
+ getCoreRowModel,
+ getExpandedRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ type CellContext,
+ type PaginationState,
+ type Row,
+ type SortingState,
+ useReactTable,
+} from '@tanstack/react-table'
+import type { TFunction } from 'i18next'
+import { CheckIcon, ChevronDownIcon, CopyIcon } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+import { TablePagination } from '@/components/ui/jam/TablePagination'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
+import type { HistoryEntry } from '@/hooks/useQueryWalletHistory'
+import { cn, shortenStringMiddle } from '@/lib/utils'
+import { Alert, AlertDescription } from '../ui/alert'
+import { Button } from '../ui/button'
+import { buttonVariants } from '../ui/button-variants'
+import { Card, CardContent } from '../ui/card'
+import { Balance } from '../ui/jam/Balance'
+import { CopyButton } from '../ui/jam/CopyButton'
+import { SortIcon } from '../ui/jam/SortIcon'
+import { StatusBadge } from '../ui/jam/StatusBadge'
+
+const ITEMS_PER_PAGE = 25
+
+type KnownHistoryRole = 'maker' | 'taker' | 'send' | 'deposit'
+type StatusBadgeVariant = NonNullable[0]['variant']>
+
+const ROLE_VARIANT: Record = {
+ maker: 'cj-out',
+ taker: 'default',
+ send: 'used-empty',
+ deposit: 'deposit',
+}
+
+const columnHelper = createColumnHelper()
+
+const historyRole = (entry: HistoryEntry): KnownHistoryRole | undefined => {
+ return ['maker', 'taker', 'send', 'deposit'].includes(entry.role ?? '') ? (entry.role as KnownHistoryRole) : undefined
+}
+
+const roleLabel = (entry: HistoryEntry, t: TFunction) => {
+ const role = historyRole(entry)
+ return role ? t(`tx_history.role_${role}`) : t('tx_history.role_unknown')
+}
+
+const dateTimeValue = (value: string) => {
+ const date = new Date(value)
+ return Number.isNaN(date.getTime()) ? 0 : date.getTime()
+}
+
+const formatDateTime = (value: string) => {
+ const date = new Date(value)
+ return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
+}
+
+const TxHistoryDetails = ({ entry }: { entry: HistoryEntry }) => {
+ return (
+
+
+ {JSON.stringify(entry, null, 2)}
+
+
+ )
+}
+
+const TxHistoryTableRow = ({ row }: { row: Row }) => {
+ return (
+ <>
+
+ {row.getVisibleCells().map((cell) => {
+ const alignCenter = cell.column.columnDef.meta?.align === 'center'
+ const alignRight = cell.column.columnDef.meta?.align === 'right'
+ return (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ )
+ })}
+
+ {row.getIsExpanded() && (
+
+
+
+
+
+ )}
+ >
+ )
+}
+
+const txHistoryTableColumns = (t: TFunction) => {
+ return [
+ columnHelper.accessor('timestamp', {
+ header: () => t('tx_history.column_date'),
+ sortingFn: (a, b) => dateTimeValue(a.original.timestamp) - dateTimeValue(b.original.timestamp),
+ cell: (info) => {formatDateTime(info.getValue())},
+ meta: {
+ alphabetic: true,
+ },
+ }),
+ columnHelper.accessor('role', {
+ header: () => t('tx_history.column_role'),
+ sortingFn: (a, b) => roleLabel(a.original, t).localeCompare(roleLabel(b.original, t)),
+ cell: (info) => {
+ const role = historyRole(info.row.original)
+ return (
+ {roleLabel(info.row.original, t)}
+ )
+ },
+ }),
+ columnHelper.accessor('cj_amount', {
+ header: () => t('tx_history.column_amount'),
+ sortingFn: (a, b) => (a.original.cj_amount ?? 0) - (b.original.cj_amount ?? 0),
+ cell: (info) => ,
+ meta: {
+ numeric: true,
+ align: 'right',
+ },
+ }),
+ columnHelper.accessor('net_fee', {
+ header: () => t('tx_history.column_net_fee'),
+ sortingFn: (a, b) => (a.original.net_fee ?? 0) - (b.original.net_fee ?? 0),
+ cell: (info) => ,
+ meta: {
+ numeric: true,
+ align: 'right',
+ },
+ }),
+ columnHelper.accessor('confirmations', {
+ header: () => t('tx_history.column_confirmations'),
+ sortingFn: (a, b) => (a.original.confirmations ?? 0) - (b.original.confirmations ?? 0),
+ cell: (info) => <>{info.getValue() ?? 0}>,
+ meta: {
+ numeric: true,
+ align: 'center',
+ },
+ }),
+ columnHelper.accessor('txid', {
+ header: () => t('tx_history.column_txid'),
+ cell: (info) => {
+ const txid = info.getValue() ?? ''
+ return txid ? (
+
+ {shortenStringMiddle(txid, 16)}
+ }
+ successText={}
+ className={cn(buttonVariants({ variant: 'outline', size: 'icon-xs' }), 'shrink-0')}
+ onSuccess={() => toast.success(t('tx_history.copy_txid_success'))}
+ onError={() => toast.error(t('tx_history.copy_txid_error'))}
+ />
+
+ ) : (
+ ''
+ )
+ },
+ enableSorting: false,
+ }),
+ {
+ id: 'expand-col',
+ cell: ({ row }: CellContext) => {
+ return row.getCanExpand() ? (
+
+ ) : (
+ ''
+ )
+ },
+ enableSorting: false,
+ },
+ ]
+}
+
+interface TxHistoryTableProps {
+ history: HistoryEntry[]
+ compact?: boolean
+}
+
+export const TxHistoryTable = ({ history, compact = false }: TxHistoryTableProps) => {
+ const { t } = useTranslation()
+ const [sorting, setSorting] = useState([{ id: 'timestamp', desc: true }])
+ const [pagination, setPagination] = useState({
+ pageIndex: 0,
+ pageSize: compact ? 5 : ITEMS_PER_PAGE,
+ })
+
+ const columns = useMemo(() => txHistoryTableColumns(t), [t])
+
+ const table = useReactTable({
+ data: history,
+ columns,
+ state: {
+ sorting,
+ pagination,
+ },
+ autoResetPageIndex: true,
+ getRowId: (row, index) => `${row.txid || 'notxid'}-${row.source_mixdepth ?? 0}-${row.timestamp}-${index}`,
+ onSortingChange: setSorting,
+ onPaginationChange: setPagination,
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getRowCanExpand: () => true,
+ getExpandedRowModel: getExpandedRowModel(),
+ paginateExpandedRows: false,
+ })
+
+ if (history.length === 0) {
+ return (
+
+ {t('tx_history.empty_history')}
+
+ )
+ }
+
+ return (
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ const canSort = header.column.getCanSort()
+ const alignCenter = header.column.columnDef.meta?.align === 'center'
+ const alignRight = header.column.columnDef.meta?.align === 'right'
+ return (
+ header.column.toggleSorting() : undefined}
+ >
+ 0 && !header.column.getIsSorted(),
+ })}
+ >
+ {flexRender(header.column.columnDef.header, header.getContext())}
+ {canSort ? : undefined}
+
+
+ )
+ })}
+
+ ))}
+
+
+ {table.getRowModel().rows.map((row) => (
+
+ ))}
+
+
+
+
+ {!compact && (
+
table.setPageIndex(page - 1)}
+ onItemsPerPageChange={(newItemsPerPage) => {
+ table.setPageSize(newItemsPerPage === -1 ? history.length || 1 : newItemsPerPage)
+ table.setPageIndex(0)
+ }}
+ />
+ )}
+
+ )
+}
diff --git a/src/hooks/useQueryWalletHistory.test.ts b/src/hooks/useQueryWalletHistory.test.ts
new file mode 100644
index 000000000..440e5fa8a
--- /dev/null
+++ b/src/hooks/useQueryWalletHistory.test.ts
@@ -0,0 +1,72 @@
+import { renderHook } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import type { WalletFileName } from '@/lib/utils'
+import { useQueryWalletHistory } from './useQueryWalletHistory'
+
+const queryMock = vi.fn<(options: unknown) => unknown>()
+
+vi.mock('@tanstack/react-query', () => ({
+ useQuery: (options: unknown) => queryMock(options),
+}))
+
+vi.mock('zustand', () => ({
+ useStore: () => ({ session: 'mock-session' }),
+}))
+
+vi.mock('@/hooks/useApiClient', () => ({
+ useApiClient: () => ({}),
+}))
+
+vi.mock('@/constants/debugFeatures', () => ({
+ isDevMode: () => false,
+}))
+
+vi.mock('@/lib/queryClient', () => ({
+ withQueryDelay: (function_: unknown) => function_,
+}))
+
+vi.mock('@/store/jmSessionStore', () => ({
+ jmSessionStore: {},
+}))
+
+vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
+ wallethistoryOptions: () => ({ queryKey: ['history'], queryFn: vi.fn() }),
+}))
+
+describe('useQueryWalletHistory', () => {
+ it('returns history and queryResult when data exists', () => {
+ const mockHistory = [{ txid: 'tx1', timestamp: '2026-07-28' }]
+ queryMock.mockReturnValue({ data: { history: mockHistory } })
+
+ const { result } = renderHook(() => useQueryWalletHistory({ walletFileName: 'wallet.jmdat' }))
+
+ expect(result.current.history).toEqual(mockHistory)
+ expect(result.current.queryResult.data?.history).toEqual(mockHistory)
+ })
+
+ it('returns empty array when data does not exist', () => {
+ queryMock.mockReturnValue({ data: null })
+
+ const { result } = renderHook(() => useQueryWalletHistory({ walletFileName: 'wallet.jmdat' }))
+
+ expect(result.current.history).toEqual([])
+ })
+
+ it('is not enabled if walletFileName is empty', () => {
+ queryMock.mockImplementation((options) => options)
+
+ const { result } = renderHook(() =>
+ useQueryWalletHistory({ walletFileName: undefined as unknown as WalletFileName }),
+ )
+
+ expect((result.current.queryResult as { enabled?: boolean }).enabled).toBe(false)
+ })
+
+ it('is not enabled if explicitly disabled', () => {
+ queryMock.mockImplementation((options) => options)
+
+ const { result } = renderHook(() => useQueryWalletHistory({ walletFileName: 'wallet.jmdat', enabled: false }))
+
+ expect((result.current.queryResult as { enabled?: boolean }).enabled).toBe(false)
+ })
+})
diff --git a/src/hooks/useQueryWalletHistory.ts b/src/hooks/useQueryWalletHistory.ts
new file mode 100644
index 000000000..119741ed6
--- /dev/null
+++ b/src/hooks/useQueryWalletHistory.ts
@@ -0,0 +1,53 @@
+import { wallethistoryOptions } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
+import type { HistoryEntry, WalletHistoryResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
+import { useQuery, type UseQueryResult } from '@tanstack/react-query'
+import { useStore } from 'zustand'
+import { isDevMode } from '@/constants/debugFeatures'
+import { useApiClient } from '@/hooks/useApiClient'
+import { withQueryDelay } from '@/lib/queryClient'
+import type { WalletFileName } from '@/lib/utils'
+import { jmSessionStore } from '@/store/jmSessionStore'
+
+export type { HistoryEntry } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
+
+export type UseQueryWalletHistoryResult = {
+ history: HistoryEntry[]
+ queryResult: UseQueryResult
+}
+
+interface UseQueryWalletHistoryProps {
+ walletFileName: WalletFileName
+ limit?: number
+ enabled?: boolean
+}
+
+const EMPTY_HISTORY: HistoryEntry[] = []
+
+export function useQueryWalletHistory({
+ walletFileName,
+ limit,
+ enabled = true,
+}: UseQueryWalletHistoryProps): UseQueryWalletHistoryResult {
+ const client = useApiClient()
+ const jmSession = useStore(jmSessionStore, (state) => state.state?.session)
+
+ const queryOptions = wallethistoryOptions({
+ client,
+ path: { walletname: walletFileName || '' },
+ query: { limit },
+ })
+
+ const queryResult = useQuery({
+ ...queryOptions,
+ queryFn: withQueryDelay(queryOptions.queryFn, {
+ // simulate slow mainnet responses in dev mode
+ throttle: isDevMode() ? 210 : 0,
+ }),
+ enabled: enabled && !!walletFileName && !!jmSession,
+ })
+
+ return {
+ history: queryResult.data?.history ?? EMPTY_HISTORY,
+ queryResult,
+ }
+}
diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json
index d9bf7c885..9b7e960ab 100644
--- a/src/i18n/locales/en/translation.json
+++ b/src/i18n/locales/en/translation.json
@@ -887,5 +887,26 @@
"subtitle_one": "The selected UTXO is eligible for inclusion in the transaction.",
"subtitle_other": "The selected UTXOs are eligible for inclusion in the transaction.",
"text_subtitle_addon": "Unselected UTXOs will be frozen and can be unfrozen later anytime."
+ },
+ "tx_history": {
+ "section_title": "Recent Activity",
+ "overlay_title": "Transaction History",
+ "button_view_all": "View All",
+ "button_load_more": "Load More",
+ "empty_history": "No transactions found.",
+ "error_loading": "Failed to load transaction history.",
+ "copy_txid_success": "Transaction ID copied.",
+ "copy_txid_error": "Failed to copy transaction ID.",
+ "column_date": "Date",
+ "column_role": "Type",
+ "column_amount": "Amount",
+ "column_net_fee": "Net Fee",
+ "column_confirmations": "Confs.",
+ "column_txid": "Txid",
+ "role_maker": "Maker",
+ "role_taker": "CoinJoin",
+ "role_send": "Send",
+ "role_deposit": "Deposit",
+ "role_unknown": "Unknown"
}
}