diff --git a/app/components/account/history/HistoryFilterBar.tsx b/app/components/account/history/HistoryFilterBar.tsx new file mode 100644 index 000000000..d99eab78c --- /dev/null +++ b/app/components/account/history/HistoryFilterBar.tsx @@ -0,0 +1,380 @@ +'use client'; + +import { Badge } from '@components/shared/ui/badge'; +import { Button } from '@components/shared/ui/button'; +import { Input } from '@components/shared/ui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@components/shared/ui/popover'; +import { HistoryFilters, useHistoryFiltersSupported } from '@providers/accounts/history'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import React from 'react'; +import { Filter, X } from 'react-feather'; + +// URL params map one-to-one onto the Triton `getTransactionsForAddress` filter paths. +export const SLOT_GTE_PARAM = 'slot.gte'; +export const SLOT_LTE_PARAM = 'slot.lte'; +export const BLOCK_TIME_GTE_PARAM = 'blockTime.gte'; +export const BLOCK_TIME_LTE_PARAM = 'blockTime.lte'; +export const STATUS_PARAM = 'status'; + +const STATUS_VALUES = ['succeeded', 'failed'] as const; + +const STATUS_LABELS: Record<(typeof STATUS_VALUES)[number], string> = { + failed: 'Failed', + succeeded: 'Succeeded', +}; + +function parseSlotParam(raw: string | null | undefined): number | undefined { + if (!raw) return undefined; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : undefined; +} + +function parseEnumParam(raw: string | null | undefined, allowed: readonly T[]): T | undefined { + return raw && (allowed as readonly string[]).includes(raw) ? (raw as T) : undefined; +} + +// Collapses an undefined-only range back to `undefined` so consumers can treat a +// present range object as "this filter is active". +function toRange(gte: number | undefined, lte: number | undefined) { + return gte === undefined && lte === undefined ? undefined : { gte, lte }; +} + +export function useHistoryFilters(): HistoryFilters { + const searchParams = useSearchParams(); + return { + blockTime: toRange( + parseSlotParam(searchParams?.get(BLOCK_TIME_GTE_PARAM)), + parseSlotParam(searchParams?.get(BLOCK_TIME_LTE_PARAM)), + ), + slot: toRange( + parseSlotParam(searchParams?.get(SLOT_GTE_PARAM)), + parseSlotParam(searchParams?.get(SLOT_LTE_PARAM)), + ), + status: parseEnumParam(searchParams?.get(STATUS_PARAM), STATUS_VALUES), + }; +} + +// Update operates directly on URL param names so callers reference the same gTFA paths. +type ParamUpdate = Record; + +function useUpdateHistoryFilters() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + return React.useCallback( + (next: ParamUpdate) => { + const params = new URLSearchParams(searchParams?.toString() ?? ''); + Object.entries(next).forEach(([key, value]) => { + if (value === undefined) { + params.delete(key); + } else { + params.set(key, String(value)); + } + }); + const qs = params.toString(); + router.replace(`${pathname}${qs ? `?${qs}` : ''}`, { scroll: false }); + }, + [router, pathname, searchParams], + ); +} + +// Clears every filter param from the URL in one update. +export function useClearHistoryFilters() { + const updateFilters = useUpdateHistoryFilters(); + return React.useCallback( + () => + updateFilters({ + [BLOCK_TIME_GTE_PARAM]: undefined, + [BLOCK_TIME_LTE_PARAM]: undefined, + [SLOT_GTE_PARAM]: undefined, + [SLOT_LTE_PARAM]: undefined, + [STATUS_PARAM]: undefined, + }), + [updateFilters], + ); +} + +function slotDraftToValue(raw: string): number | undefined | 'invalid' { + const trimmed = raw.trim(); + if (!trimmed) return undefined; + const parsed = Number(trimmed); + if (!Number.isFinite(parsed) || parsed < 0) return 'invalid'; + return Math.floor(parsed); +} + +// datetime-local <-> unix seconds (local time, matching what the input displays). +function unixToLocalInput(sec: number | undefined): string { + if (sec === undefined) return ''; + const d = new Date(sec * 1000); + if (Number.isNaN(d.getTime())) return ''; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad( + d.getMinutes(), + )}`; +} + +function localInputToUnix(raw: string): number | undefined { + if (!raw) return undefined; + const ms = new Date(raw).getTime(); + return Number.isFinite(ms) ? Math.floor(ms / 1000) : undefined; +} + +function FilterChip({ label, value, onClear }: { label: string; value: string; onClear: () => void }) { + return ( + + + {label}: {value} + + + + ); +} + +export function HistoryFilterChips(filters: HistoryFilters) { + const { slot, blockTime, status } = filters; + const updateFilters = useUpdateHistoryFilters(); + const hasAny = slot !== undefined || blockTime !== undefined || status !== undefined; + if (!hasAny) return undefined; + return ( + <> + {slot?.gte !== undefined && ( + updateFilters({ [SLOT_GTE_PARAM]: undefined })} + /> + )} + {slot?.lte !== undefined && ( + updateFilters({ [SLOT_LTE_PARAM]: undefined })} + /> + )} + {status !== undefined && ( + updateFilters({ [STATUS_PARAM]: undefined })} + /> + )} + {blockTime?.gte !== undefined && ( + updateFilters({ [BLOCK_TIME_GTE_PARAM]: undefined })} + /> + )} + {blockTime?.lte !== undefined && ( + updateFilters({ [BLOCK_TIME_LTE_PARAM]: undefined })} + /> + )} + + ); +} + +const SELECT_CLASS = + 'w-full rounded-md border border-neutral-700 bg-neutral-900 px-2 py-1.5 text-sm text-neutral-100'; + +export function HistoryFilterTrigger(filters: HistoryFilters) { + const { slot, blockTime, status } = filters; + const updateFilters = useUpdateHistoryFilters(); + const supported = useHistoryFiltersSupported(); + const [open, setOpen] = React.useState(false); + + const [slotGteDraft, setSlotGteDraft] = React.useState(''); + const [slotLteDraft, setSlotLteDraft] = React.useState(''); + const [statusDraft, setStatusDraft] = React.useState(''); + const [blockTimeGteDraft, setBlockTimeGteDraft] = React.useState(''); + const [blockTimeLteDraft, setBlockTimeLteDraft] = React.useState(''); + + React.useEffect(() => { + setSlotGteDraft(slot?.gte !== undefined ? String(slot.gte) : ''); + setSlotLteDraft(slot?.lte !== undefined ? String(slot.lte) : ''); + setStatusDraft(status ?? ''); + setBlockTimeGteDraft(unixToLocalInput(blockTime?.gte)); + setBlockTimeLteDraft(unixToLocalInput(blockTime?.lte)); + }, [slot, blockTime, status, open]); + + const slotGteValue = slotDraftToValue(slotGteDraft); + const slotLteValue = slotDraftToValue(slotLteDraft); + const blockTimeGteValue = localInputToUnix(blockTimeGteDraft); + const blockTimeLteValue = localInputToUnix(blockTimeLteDraft); + + const slotGteInvalid = slotGteValue === 'invalid'; + const slotLteInvalid = slotLteValue === 'invalid'; + const slotRangeInvalid = + typeof slotGteValue === 'number' && typeof slotLteValue === 'number' && slotGteValue > slotLteValue; + const timeRangeInvalid = + blockTimeGteValue !== undefined && blockTimeLteValue !== undefined && blockTimeGteValue > blockTimeLteValue; + const hasError = slotGteInvalid || slotLteInvalid || slotRangeInvalid || timeRangeInvalid; + + const apply = () => { + if (hasError) return; + // The hasError guard above rules out the 'invalid' sentinel for both slots. + updateFilters({ + [BLOCK_TIME_GTE_PARAM]: blockTimeGteValue, + [BLOCK_TIME_LTE_PARAM]: blockTimeLteValue, + [SLOT_GTE_PARAM]: slotGteValue as number | undefined, + [SLOT_LTE_PARAM]: slotLteValue as number | undefined, + [STATUS_PARAM]: parseEnumParam(statusDraft, STATUS_VALUES), + }); + setOpen(false); + }; + + const clearAll = () => { + updateFilters({ + [BLOCK_TIME_GTE_PARAM]: undefined, + [BLOCK_TIME_LTE_PARAM]: undefined, + [SLOT_GTE_PARAM]: undefined, + [SLOT_LTE_PARAM]: undefined, + [STATUS_PARAM]: undefined, + }); + setOpen(false); + }; + + const activeCount = + (slot?.gte !== undefined ? 1 : 0) + + (slot?.lte !== undefined ? 1 : 0) + + (status !== undefined ? 1 : 0) + + (blockTime?.gte !== undefined ? 1 : 0) + + (blockTime?.lte !== undefined ? 1 : 0); + const triggerLabel = activeCount === 0 ? 'Filters' : 'Edit filters'; + + // The endpoint doesn't support getTransactionsForAddress, so filtering can't be + // applied server-side; show a disabled control rather than a misleading filter UI. + if (!supported) { + return ( + + ); + } + + return ( + + + + + +
{ + e.preventDefault(); + apply(); + }} + className="flex flex-col gap-3" + > +
+ + setSlotGteDraft(e.target.value)} + /> +
+
+ + setSlotLteDraft(e.target.value)} + /> +
+ {slotRangeInvalid &&
Slot ≥ must be ≤ slot ≤.
} + +
+ + +
+ +
+ + setBlockTimeGteDraft(e.target.value)} + /> +
+
+ + setBlockTimeLteDraft(e.target.value)} + /> +
+ {timeRangeInvalid && ( +
Block time ≥ must be on or before block time ≤.
+ )} + +
+ {activeCount > 0 && ( + + )} + +
+
+
+
+ ); +} + +// Combined bar kept for tests / any consumer that wants chips + trigger inline. +export function HistoryFilterBar(props: HistoryFilters) { + return ( +
+ + +
+ ); +} diff --git a/app/components/account/history/__tests__/HistoryFilterBar.spec.tsx b/app/components/account/history/__tests__/HistoryFilterBar.spec.tsx new file mode 100644 index 000000000..1c9030b03 --- /dev/null +++ b/app/components/account/history/__tests__/HistoryFilterBar.spec.tsx @@ -0,0 +1,174 @@ +/* eslint-disable no-restricted-syntax -- test assertions use RegExp for pattern matching */ +import { fireEvent, render, renderHook, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const replaceMock = vi.fn(); +let mockSearchString = ''; + +vi.mock('next/navigation', () => ({ + usePathname: () => '/address/testAddress', + useRouter: () => ({ replace: replaceMock }), + useSearchParams: () => new URLSearchParams(mockSearchString), +})); + +// Must import after mocks +import { HistoryFilterBar, useHistoryFilters } from '../HistoryFilterBar'; + +function setSearch(search: string) { + mockSearchString = search; +} + +beforeEach(() => { + replaceMock.mockClear(); + setSearch(''); +}); + +describe('useHistoryFilters', () => { + it('should return all-undefined when no params are set', () => { + setSearch(''); + const { result } = renderHook(() => useHistoryFilters()); + expect(result.current).toEqual({ + blockTime: undefined, + slot: undefined, + status: undefined, + }); + }); + + it('should parse both slot bounds from the gTFA filter paths', () => { + setSearch('slot.gte=100&slot.lte=200'); + const { result } = renderHook(() => useHistoryFilters()); + expect(result.current.slot).toEqual({ gte: 100, lte: 200 }); + }); + + it('should ignore negative and non-numeric values', () => { + setSearch('slot.gte=-5&slot.lte=abc'); + const { result } = renderHook(() => useHistoryFilters()); + expect(result.current.slot).toBeUndefined(); + }); + + it('should floor fractional values', () => { + setSearch('slot.gte=100.9'); + const { result } = renderHook(() => useHistoryFilters()); + expect(result.current.slot).toEqual({ gte: 100, lte: undefined }); + }); + + it('should parse the status enum, rejecting unknown values', () => { + setSearch('status=failed'); + const { result } = renderHook(() => useHistoryFilters()); + expect(result.current).toMatchObject({ status: 'failed' }); + + setSearch('status=bogus'); + const { result: rejected } = renderHook(() => useHistoryFilters()); + expect(rejected.current).toMatchObject({ status: undefined }); + }); + + it('should parse block-time bounds from the gTFA filter paths', () => { + setSearch('blockTime.gte=1700000000&blockTime.lte=1700100000'); + const { result } = renderHook(() => useHistoryFilters()); + expect(result.current.blockTime).toEqual({ gte: 1_700_000_000, lte: 1_700_100_000 }); + }); +}); + +describe('HistoryFilterBar', () => { + it('should show a single Filters button when no filter is active', () => { + render(); + expect(screen.getByRole('button', { name: /^Filters$/ })).toBeInTheDocument(); + expect(screen.queryByText(/Slot ≥:/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Slot ≤:/)).not.toBeInTheDocument(); + }); + + it('should render a chip per active slot bound', () => { + render(); + expect(screen.getByText(/Slot ≥:\s*100/)).toBeInTheDocument(); + // Use a regex since jsdom's toLocaleString thousand separator varies by ICU build. + expect(screen.getByText(/Slot ≤:\s*2[,.\s ]?000[,.\s ]?000/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Edit filters/ })).toBeInTheDocument(); + }); + + it('should clear a single slot bound when its chip × is clicked', () => { + setSearch('slot.gte=100&slot.lte=200'); + render(); + + fireEvent.click(screen.getByRole('button', { name: /Clear slot ≥ filter/ })); + + expect(replaceMock).toHaveBeenCalledTimes(1); + const [href] = replaceMock.mock.calls[0]; + expect(href).toBe('/address/testAddress?slot.lte=200'); + }); + + it('should apply both slot bounds from the popover', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /^Filters$/ })); + fireEvent.change(screen.getByPlaceholderText(/lower bound/), { target: { value: '100' } }); + fireEvent.change(screen.getByPlaceholderText(/upper bound/), { target: { value: '500' } }); + fireEvent.click(screen.getByRole('button', { name: /^Apply$/ })); + + expect(replaceMock).toHaveBeenCalledTimes(1); + const [href] = replaceMock.mock.calls[0]; + expect(href).toMatch(/slot\.gte=100/); + expect(href).toMatch(/slot\.lte=500/); + }); + + it('should preserve unrelated query params when updating the URL', () => { + setSearch('cluster=devnet&slot.gte=100'); + render(); + + fireEvent.click(screen.getByRole('button', { name: /Clear slot ≥ filter/ })); + + const [href] = replaceMock.mock.calls[0]; + expect(href).toBe('/address/testAddress?cluster=devnet'); + }); + + it('should disable Apply and show an error when slot ≥ exceeds slot ≤', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /^Filters$/ })); + fireEvent.change(screen.getByPlaceholderText(/lower bound/), { target: { value: '500' } }); + fireEvent.change(screen.getByPlaceholderText(/upper bound/), { target: { value: '100' } }); + + const apply = screen.getByRole('button', { name: /^Apply$/ }); + expect(apply).toBeDisabled(); + expect(screen.getByText(/Slot ≥ must be/)).toBeInTheDocument(); + + fireEvent.click(apply); + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it('should apply the status select', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /^Filters$/ })); + fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'succeeded' } }); + fireEvent.click(screen.getByRole('button', { name: /^Apply$/ })); + + const [href] = replaceMock.mock.calls[0]; + expect(href).toMatch(/status=succeeded/); + }); + + it('should render a chip for the status filter', () => { + render(); + expect(screen.getByText(/Status:\s*Failed/)).toBeInTheDocument(); + }); + + it('should clear a single non-slot filter via its chip', () => { + setSearch('status=failed&slot.lte=200'); + render(); + + fireEvent.click(screen.getByRole('button', { name: /Clear status filter/ })); + + const [href] = replaceMock.mock.calls[0]; + expect(href).toBe('/address/testAddress?slot.lte=200'); + }); + + it('should clear all filters via Clear all', () => { + setSearch('slot.gte=100&slot.lte=500'); + render(); + + fireEvent.click(screen.getByRole('button', { name: /Edit filters/ })); + fireEvent.click(screen.getByRole('button', { name: /Clear all/ })); + + const [href] = replaceMock.mock.calls[0]; + expect(href).toBe('/address/testAddress'); + }); +}); diff --git a/app/components/shared/ui/input.tsx b/app/components/shared/ui/input.tsx index 73fb8f0d9..5c6c7631c 100644 --- a/app/components/shared/ui/input.tsx +++ b/app/components/shared/ui/input.tsx @@ -10,6 +10,9 @@ const inputVariants = cva( 'font-normal font-mono', 'flex h-9 w-full rounded border', 'px-4 py-2.5 text-xs', + // Native date/time pickers render a dark calendar glyph that's invisible on + // our dark input backgrounds; invert it so it matches the light input text. + '[&::-webkit-calendar-picker-indicator]:invert', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900', 'disabled:cursor-not-allowed disabled:opacity-50', 'aria-[invalid="true"]:!border-destructive aria-[invalid="true"]:focus-visible:ring-destructive', diff --git a/app/features/transaction-history/ui/BaseTransactionHistoryCard.tsx b/app/features/transaction-history/ui/BaseTransactionHistoryCard.tsx index d00995748..286d842a7 100644 --- a/app/features/transaction-history/ui/BaseTransactionHistoryCard.tsx +++ b/app/features/transaction-history/ui/BaseTransactionHistoryCard.tsx @@ -28,6 +28,8 @@ export type BaseTransactionHistoryCardProps = { foundOldest: boolean; onRefresh: () => void; onLoadMore: () => void; + headerActions?: ReactNode; + headerSubRow?: ReactNode; }; export function BaseTransactionHistoryCard({ @@ -36,6 +38,8 @@ export function BaseTransactionHistoryCard({ foundOldest, onRefresh, onLoadMore, + headerActions, + headerSubRow, }: BaseTransactionHistoryCardProps) { const hasTimestamps = rows.some(row => row.blockTime); @@ -46,6 +50,8 @@ export function BaseTransactionHistoryCard({ analyticsSection="transaction_history_header" refresh={onRefresh} fetching={fetching} + actions={headerActions} + subHeader={headerSubRow} /> diff --git a/app/features/transaction-history/ui/TransactionHistoryCard.tsx b/app/features/transaction-history/ui/TransactionHistoryCard.tsx index e2cc32f33..56fdc3a66 100644 --- a/app/features/transaction-history/ui/TransactionHistoryCard.tsx +++ b/app/features/transaction-history/ui/TransactionHistoryCard.tsx @@ -1,12 +1,23 @@ 'use client'; +import { + HistoryFilterChips, + HistoryFilterTrigger, + useClearHistoryFilters, + useHistoryFilters, +} from '@components/account/history/HistoryFilterBar'; import { getTransactionRows } from '@components/account/HistoryCardComponents'; import { ErrorCard } from '@components/common/ErrorCard'; import { LoadingCard } from '@components/common/LoadingCard'; -import { useAccountHistory, useFetchAccountHistory } from '@providers/accounts/history'; +import { + useAccountHistory, + useFetchAccountHistory, + useHistoryFiltersSupported, + useResetAccountHistory, +} from '@providers/accounts/history'; import { FetchStatus } from '@providers/cache'; import { PublicKey } from '@solana/web3.js'; -import { useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { BaseTransactionHistoryCard, type TransactionHistoryRowView } from './BaseTransactionHistoryCard'; import { InstructionsCell } from './InstructionsCell'; @@ -14,12 +25,18 @@ import { TransactionRawDataCell } from './TransactionRawDataCell'; export function TransactionHistoryCard({ address }: { address: string }) { const pubkey = useMemo(() => new PublicKey(address), [address]); + const filters = useHistoryFilters(); + const hasActiveFilters = Object.values(filters).some(value => value !== undefined); + const filtersKey = JSON.stringify(filters); const history = useAccountHistory(address); - const fetchAccountHistory = useFetchAccountHistory(); + const fetchAccountHistory = useFetchAccountHistory(25, filters); + const resetHistory = useResetAccountHistory(); + const filtersSupported = useHistoryFiltersSupported(); + const clearFilters = useClearHistoryFilters(); // Signatures only — the parsed transactions for instruction names are fetched lazily per row, one at a // time (see InstructionsCell), so the page never batch-hammers the RPC into 429s. - const refresh = () => fetchAccountHistory(pubkey, false, true); + const refresh = useCallback(() => fetchAccountHistory(pubkey, false, true), [fetchAccountHistory, pubkey]); const loadMore = () => fetchAccountHistory(pubkey, false); const rows: TransactionHistoryRowView[] = history?.data?.fetched @@ -39,6 +56,27 @@ export function TransactionHistoryCard({ address }: { address: string }) { } }, [address]); // eslint-disable-line react-hooks/exhaustive-deps + // Refetch from scratch when any filter changes. The cache is keyed by address + // only, so we reset this address's entry (which also supersedes any in-flight + // request for it) before refetching to avoid mixing pre- and post-filter results + // in combineFetched. + const previousFiltersKey = useRef(filtersKey); + useEffect(() => { + if (previousFiltersKey.current !== filtersKey) { + previousFiltersKey.current = filtersKey; + resetHistory(address); + refresh(); + } + }, [filtersKey, address, resetHistory, refresh]); + + // If the endpoint turns out not to support filtering, drop any active filters so the + // (unfiltered) results aren't shown alongside misleading filter chips. + useEffect(() => { + if (!filtersSupported && hasActiveFilters) { + clearFilters(); + } + }, [filtersSupported, hasActiveFilters, clearFilters]); + if (!history?.data) { return !history || history.status === FetchStatus.Fetching ? ( @@ -54,6 +92,8 @@ export function TransactionHistoryCard({ address }: { address: string }) { foundOldest={history.data.foundOldest} onRefresh={refresh} onLoadMore={loadMore} + headerActions={} + headerSubRow={hasActiveFilters ? : undefined} /> ); } diff --git a/app/providers/accounts/__tests__/history.spec.ts b/app/providers/accounts/__tests__/history.spec.ts index d7912c7e0..ae3e4d11c 100644 --- a/app/providers/accounts/__tests__/history.spec.ts +++ b/app/providers/accounts/__tests__/history.spec.ts @@ -9,13 +9,13 @@ describe('reconcile', () => { it('should ignore an empty refresh so a flaky RPC response cannot wipe loaded history or flip foundOldest', () => { const history = { fetched: [sig('a'), sig('b')], foundOldest: false }; - const result = reconcile(history, { before: undefined, history: { fetched: [], foundOldest: true } }); + const result = reconcile(history, { append: false, history: { fetched: [], foundOldest: true } }); expect(result).toBe(history); }); it('should still record an empty result on the first load (a genuinely empty account)', () => { - const result = reconcile(undefined, { before: undefined, history: { fetched: [], foundOldest: true } }); + const result = reconcile(undefined, { append: false, history: { fetched: [], foundOldest: true } }); expect(result?.fetched).toEqual([]); expect(result?.foundOldest).toBe(true); @@ -25,17 +25,17 @@ describe('reconcile', () => { const history = { fetched: [sig('a')], foundOldest: false }; const result = reconcile(history, { - before: undefined, + append: false, history: { fetched: [sig('b'), sig('a')], foundOldest: false }, }); expect(result?.fetched.map(s => s.signature)).toEqual(['b', 'a']); }); - it('should keep the end-of-history signal when load-more (before set) returns empty', () => { + it('should keep the end-of-history signal when load-more (append) returns empty', () => { const history = { fetched: [sig('a')], foundOldest: false }; - const result = reconcile(history, { before: 'a', history: { fetched: [], foundOldest: true } }); + const result = reconcile(history, { append: true, history: { fetched: [], foundOldest: true } }); expect(result?.fetched.map(s => s.signature)).toEqual(['a']); expect(result?.foundOldest).toBe(true); diff --git a/app/providers/accounts/__tests__/history.spec.tsx b/app/providers/accounts/__tests__/history.spec.tsx new file mode 100644 index 000000000..e54048f6e --- /dev/null +++ b/app/providers/accounts/__tests__/history.spec.tsx @@ -0,0 +1,385 @@ +import { Connection, PublicKey } from '@solana/web3.js'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@providers/cluster', () => ({ + useCluster: vi.fn(() => ({ + cluster: 0, + url: 'https://mock.rpc', + })), +})); + +vi.mock('@solana/web3.js', async () => { + const actual = await vi.importActual('@solana/web3.js'); + return { + ...actual, + Connection: vi.fn(), + PublicKey: actual.PublicKey, + }; +}); + +vi.mock('@/app/shared/lib/logger', () => ({ Logger: { error: vi.fn() } })); + +// Must import after mocks +import { FetchStatus } from '@providers/cache'; + +import { + HistoryProvider, + useAccountHistory, + useFetchAccountHistory, + useHistoryFiltersSupported, + useResetAccountHistory, +} from '../history'; + +const ADDRESS = 'rexav5eNTUSNT1K2N7cfRjnthwhcP5BC25v2tA4rW4h'; +const ADDRESS_B = '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d'; + +function sig(signature: string, slot: number) { + return { blockTime: null, confirmationStatus: 'finalized', err: null, memo: null, signature, slot }; +} + +function envelope(data: ReturnType[], paginationToken: string | null) { + return { json: async () => ({ id: 1, jsonrpc: '2.0', result: { data, paginationToken } }), ok: true, status: 200 }; +} + +// A promise we resolve by hand, to model a request that is still in flight. +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(r => { + resolve = r; + }); + return { promise, resolve }; +} + +const fetchMock = vi.fn(); +const mockConnection = { getSignaturesForAddress: vi.fn() }; + +// Resolve the next fetch call with a getTransactionsForAddress result envelope. +function mockResult(data: ReturnType[], paginationToken: string | null) { + fetchMock.mockResolvedValueOnce(envelope(data, paginationToken)); +} + +// Resolve the next fetch call with a JSON-RPC error (e.g. method-not-found). +function mockRpcError(code: number, message: string) { + // Standard RPC nodes return JSON-RPC errors (including method-not-found) with HTTP 200. + fetchMock.mockResolvedValueOnce({ + json: async () => ({ error: { code, message }, id: 1, jsonrpc: '2.0' }), + ok: true, + status: 200, + }); +} + +// Parse the JSON body of the Nth fetch call into [address, options]. +function requestParams(call = 0): [string, Record] { + const body = JSON.parse(fetchMock.mock.calls[call][1].body); + expect(body.method).toBe('getTransactionsForAddress'); + return body.params; +} + +function wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(Connection).mockImplementation(() => mockConnection as unknown as Connection); + vi.stubGlobal('fetch', fetchMock); + // Fallback response; tests queue page-specific results with mockResult (once). + fetchMock.mockResolvedValue({ + json: async () => ({ id: 1, jsonrpc: '2.0', result: { data: [], paginationToken: null } }), + ok: true, + status: 200, + }); +}); + +describe('useFetchAccountHistory — getTransactionsForAddress', () => { + it('should map slot filters onto the filters object on the initial fetch', async () => { + const { result } = renderHook(() => useFetchAccountHistory(25, { slot: { gte: 100, lte: 500 } }), { + wrapper, + }); + + await act(async () => { + result.current(new PublicKey(ADDRESS)); + }); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + + const [address, options] = requestParams(); + expect(address).toBe(ADDRESS); + expect(options).toMatchObject({ + filters: { slot: { gte: 100, lte: 500 } }, + limit: 25, + paginationToken: null, + sortOrder: 'desc', + transactionDetails: 'signatures', + }); + }); + + it('should map status and block time filters', async () => { + const { result } = renderHook( + () => + useFetchAccountHistory(25, { + blockTime: { gte: 1_700_000_000, lte: 1_700_100_000 }, + status: 'failed', + }), + { wrapper }, + ); + + await act(async () => { + result.current(new PublicKey(ADDRESS)); + }); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + const [, options] = requestParams(); + expect(options.filters).toEqual({ + blockTime: { gte: 1_700_000_000, lte: 1_700_100_000 }, + status: 'failed', + }); + }); + + it('should omit the filters key when no filter is provided', async () => { + const { result } = renderHook(() => useFetchAccountHistory(25, {}), { wrapper }); + + await act(async () => { + result.current(new PublicKey(ADDRESS)); + }); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + const [, options] = requestParams(); + expect(options).toMatchObject({ limit: 25, paginationToken: null }); + expect('filters' in options).toBe(false); + }); + + it('should thread the paginationToken from the previous page when loading more', async () => { + mockResult( + Array.from({ length: 25 }, (_, i) => sig(`sig${i}`, 1000 - i)), + 'token-page-2', + ); + + const { result } = renderHook( + () => ({ + fetch: useFetchAccountHistory(25, { slot: { gte: 100 } }), + history: useAccountHistory(ADDRESS), + }), + { wrapper }, + ); + + await act(async () => { + result.current.fetch(new PublicKey(ADDRESS)); + }); + + await waitFor(() => expect(result.current.history?.data?.fetched?.length).toBe(25)); + + fetchMock.mockClear(); + mockResult([], null); + + await act(async () => { + result.current.fetch(new PublicKey(ADDRESS)); + }); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + const [, options] = requestParams(); + expect(options).toMatchObject({ + filters: { slot: { gte: 100 } }, + limit: 25, + paginationToken: 'token-page-2', + }); + }); + + it('should keep loading more on a short page that still carries a token', async () => { + // Fewer items than the limit, but a non-null token means more data exists. + mockResult([sig('partial', 10)], 'token-page-2'); + + const { result } = renderHook( + () => ({ + fetch: useFetchAccountHistory(25, {}), + history: useAccountHistory(ADDRESS), + }), + { wrapper }, + ); + + await act(async () => { + result.current.fetch(new PublicKey(ADDRESS)); + }); + + await waitFor(() => expect(result.current.history?.data?.fetched?.length).toBe(1)); + expect(result.current.history?.data?.foundOldest).toBe(false); + + // Load More should issue another request, threading the token forward. + fetchMock.mockClear(); + mockResult([], null); + await act(async () => { + result.current.fetch(new PublicKey(ADDRESS)); + }); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + expect(requestParams()[1]).toMatchObject({ paginationToken: 'token-page-2' }); + }); + + it('should stop paginating once a page returns a null token', async () => { + mockResult([sig('only', 10)], null); + + const { result } = renderHook( + () => ({ + fetch: useFetchAccountHistory(25, {}), + history: useAccountHistory(ADDRESS), + }), + { wrapper }, + ); + + await act(async () => { + result.current.fetch(new PublicKey(ADDRESS)); + }); + + await waitFor(() => expect(result.current.history?.data?.foundOldest).toBe(true)); + + fetchMock.mockClear(); + + await act(async () => { + result.current.fetch(new PublicKey(ADDRESS)); + }); + + // foundOldest short-circuits the load-more, so no further request is made. + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('useResetAccountHistory', () => { + it('should discard an in-flight response that resolves after a reset (no stale write)', async () => { + // First request is left pending to model a page-load still in flight. + const pending = deferred>(); + fetchMock.mockReturnValueOnce(pending.promise); + + const { result } = renderHook( + () => ({ + fetch: useFetchAccountHistory(25, {}), + history: useAccountHistory(ADDRESS), + reset: useResetAccountHistory(), + }), + { wrapper }, + ); + + // Kick off the initial (unfiltered) fetch; it does not resolve yet. + act(() => { + result.current.fetch(new PublicKey(ADDRESS)); + }); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + + // User applies a filter: reset supersedes the in-flight request, then refetch. + mockResult([sig('filtered', 200)], null); + act(() => { + result.current.reset(ADDRESS); + result.current.fetch(new PublicKey(ADDRESS), false, true); + }); + + await waitFor(() => expect(result.current.history?.data?.fetched?.[0]?.signature).toBe('filtered')); + + // Now the original request resolves with unfiltered data — it must be dropped. + await act(async () => { + pending.resolve(envelope([sig('stale', 1)], null)); + await pending.promise; + }); + + expect(result.current.history?.data?.fetched).toHaveLength(1); + expect(result.current.history?.data?.fetched[0].signature).toBe('filtered'); + }); + + it('should clear only the target address, leaving other addresses intact', async () => { + const { result } = renderHook( + () => ({ + fetch: useFetchAccountHistory(25, {}), + historyA: useAccountHistory(ADDRESS), + historyB: useAccountHistory(ADDRESS_B), + reset: useResetAccountHistory(), + }), + { wrapper }, + ); + + mockResult([sig('a', 10)], null); + await act(async () => { + result.current.fetch(new PublicKey(ADDRESS)); + }); + mockResult([sig('b', 20)], null); + await act(async () => { + result.current.fetch(new PublicKey(ADDRESS_B)); + }); + + await waitFor(() => expect(result.current.historyA?.data?.fetched?.length).toBe(1)); + await waitFor(() => expect(result.current.historyB?.data?.fetched?.length).toBe(1)); + + act(() => { + result.current.reset(ADDRESS); + }); + + expect(result.current.historyA).toBeUndefined(); + expect(result.current.historyB?.data?.fetched?.[0]?.signature).toBe('b'); + }); +}); + +describe('getSignaturesForAddress fallback', () => { + it('should fall back when getTransactionsForAddress is not found, applying no filters', async () => { + mockRpcError(-32601, 'Method not found'); + mockConnection.getSignaturesForAddress.mockResolvedValueOnce([sig('legacy', 5)]); + + const { result } = renderHook( + () => ({ + fetch: useFetchAccountHistory(25, { slot: { gte: 10, lte: 99 } }), + history: useAccountHistory(ADDRESS), + }), + { wrapper }, + ); + + await act(async () => { + result.current.fetch(new PublicKey(ADDRESS)); + }); + + await waitFor(() => expect(result.current.history?.data?.fetched?.[0]?.signature).toBe('legacy')); + expect(mockConnection.getSignaturesForAddress).toHaveBeenCalledTimes(1); + const [pubkey, opts] = mockConnection.getSignaturesForAddress.mock.calls[0]; + expect(pubkey.toBase58()).toBe(ADDRESS); + // Standard RPCs support none of the filters, so slot bounds are not forwarded. + expect(opts).toEqual({ limit: 25 }); + }); + + it('should not fall back on a generic RPC error', async () => { + mockRpcError(-32000, 'boom'); + + const { result } = renderHook( + () => ({ + fetch: useFetchAccountHistory(25, {}), + history: useAccountHistory(ADDRESS), + }), + { wrapper }, + ); + + await act(async () => { + result.current.fetch(new PublicKey(ADDRESS)); + }); + + await waitFor(() => expect(result.current.history?.status).toBe(FetchStatus.FetchFailed)); + expect(mockConnection.getSignaturesForAddress).not.toHaveBeenCalled(); + }); + + it('should mark filtering unsupported after a method-not-found, and stay supported otherwise', async () => { + mockRpcError(-32601, 'Method not found'); + mockConnection.getSignaturesForAddress.mockResolvedValueOnce([sig('legacy', 5)]); + + const { result } = renderHook( + () => ({ + fetch: useFetchAccountHistory(25, {}), + supported: useHistoryFiltersSupported(), + }), + { wrapper }, + ); + + // Optimistically supported until the first request reveals otherwise. + expect(result.current.supported).toBe(true); + + await act(async () => { + result.current.fetch(new PublicKey(ADDRESS)); + }); + + await waitFor(() => expect(result.current.supported).toBe(false)); + }); +}); diff --git a/app/providers/accounts/history.tsx b/app/providers/accounts/history.tsx index 5da3c805a..f7eaab9d4 100644 --- a/app/providers/accounts/history.tsx +++ b/app/providers/accounts/history.tsx @@ -3,13 +3,7 @@ import * as Cache from '@providers/cache'; import { ActionType, FetchStatus } from '@providers/cache'; import { useCluster } from '@providers/cluster'; -import { - ConfirmedSignatureInfo, - Connection, - ParsedTransactionWithMeta, - PublicKey, - TransactionSignature, -} from '@solana/web3.js'; +import { ConfirmedSignatureInfo, Connection, ParsedTransactionWithMeta, PublicKey, TransactionSignature } from '@solana/web3.js'; import { Cluster } from '@utils/cluster'; import { fetchAll } from '@utils/fetch-all'; import { fetchOnce } from '@utils/fetch-once'; @@ -22,18 +16,31 @@ import { Logger } from '@/app/shared/lib/logger'; export type TransactionMap = Map; export type FailedTransactionSignatures = Set; +// Mirrors the Triton `getTransactionsForAddress` `filters` object one-to-one, so the +// UI/URL layer and the RPC payload share the same shape and field names. The UI only +// surfaces range bounds (gte/lte); the RPC also accepts gt/lt/eq which we don't use yet. +export type RangeFilter = { gte?: number; lte?: number }; +export type HistoryFilters = { + slot?: RangeFilter; // filters.slot + blockTime?: RangeFilter; // filters.blockTime (unix seconds) + status?: 'succeeded' | 'failed'; // filters.status (omit for "any") +}; + export type AccountHistory = { fetched: ConfirmedSignatureInfo[]; transactionMap?: TransactionMap; failedTransactionSignatures?: FailedTransactionSignatures; foundOldest: boolean; + // Opaque cursor returned by the RPC; threaded back to load the next page. + paginationToken?: string | null; }; type HistoryUpdate = { history?: AccountHistory; transactionMap?: TransactionMap; failedTransactionSignatures?: FailedTransactionSignatures; - before?: TransactionSignature; + // true when this page extends the tail (Load More); false on a refresh. + append?: boolean; }; type State = Cache.State; @@ -42,25 +49,23 @@ type Dispatch = Cache.Dispatch; function combineFetched( fetched: ConfirmedSignatureInfo[], current: ConfirmedSignatureInfo[] | undefined, - before: TransactionSignature | undefined, -) { + append: boolean, +): { combined: ConfirmedSignatureInfo[]; replaced: boolean } { if (current === undefined || current.length === 0) { - return fetched; - } - - // History was refreshed, fetch results should be prepended if contiguous - if (before === undefined) { - const end = fetched.findIndex(f => f.signature === current[0].signature); - if (end < 0) return fetched; - return fetched.slice(0, end).concat(current); + return { combined: fetched, replaced: true }; } - // More history was loaded, fetch results should be appended - if (current[current.length - 1].signature === before) { - return current.concat(fetched); + // More history was loaded: append, dropping any signatures we already hold. + if (append) { + const seen = new Set(current.map(c => c.signature)); + return { combined: current.concat(fetched.filter(f => !seen.has(f.signature))), replaced: false }; } - return fetched; + // History was refreshed: prepend the newly-seen prefix if the page overlaps + // what we already have, otherwise treat it as a full replacement. + const end = fetched.findIndex(f => f.signature === current[0].signature); + if (end < 0) return { combined: fetched, replaced: true }; + return { combined: fetched.slice(0, end).concat(current), replaced: false }; } function mergeFailedTransactionSignatures( @@ -88,24 +93,34 @@ export function reconcile(history: AccountHistory | undefined, update: HistoryUp return history; } - // A refresh (before === undefined) that came back empty is almost always a transient RPC blip, not a - // real transition to "zero history" (signatures are immutable). Drop it so a flaky empty can't wipe - // already-loaded rows or flip foundOldest into a false "Fetched full history". The first-ever fetch - // (no existing history) isn't covered here — fetchSignatures retries that case before it lands. - if (update.before === undefined && update.history.fetched.length === 0 && history?.fetched.length) { + const append = update.append ?? false; + + // A refresh that came back empty is almost always a transient RPC blip, not a real transition to + // "zero history" (signatures are immutable). Drop it so a flaky empty can't wipe already-loaded rows + // or flip foundOldest into a false "Fetched full history". The first-ever fetch (no existing history) + // isn't covered here — fetchSignatures retries that case before it lands. + if (!append && update.history.fetched.length === 0 && history?.fetched.length) { return history; } + const { combined, replaced } = combineFetched(update.history.fetched, history?.fetched, append); + + // The tail cursor only changes when we extended the tail (append) or replaced + // the whole list; a refresh that merely prepends new items keeps the old tail. + const tailFromUpdate = append || replaced; + + const transactionMap = mergeTransactionMap(history?.transactionMap, update.transactionMap); const failedTransactionSignatures = mergeFailedTransactionSignatures( - update.before === undefined ? undefined : history?.failedTransactionSignatures, + append ? history?.failedTransactionSignatures : undefined, update.failedTransactionSignatures, ); return { failedTransactionSignatures, - fetched: combineFetched(update.history.fetched, history?.fetched, update?.before), - foundOldest: update?.history?.foundOldest || history?.foundOldest || false, + fetched: combined, + foundOldest: tailFromUpdate ? update.history.foundOldest : (history?.foundOldest ?? false), + paginationToken: tailFromUpdate ? update.history.paginationToken : history?.paginationToken, transactionMap, }; } @@ -117,22 +132,43 @@ export const DispatchContext: React.Context = React.create export const InFlightContext: React.Context | undefined> = React.createContext | undefined>( undefined, ); +// Monotonic per-address counter. Bumped whenever a request is superseded (e.g. a +// filter change) so the in-flight response can be discarded instead of overwriting +// the freshly-cleared cache. See `useResetAccountHistory`. +const GenerationContext = React.createContext | undefined>(undefined); + +// Whether the current endpoint supports getTransactionsForAddress. Flips to false the +// first time the method is not found, so the UI can disable filtering (the +// getSignaturesForAddress fallback can't honour any of the filters). +type MethodSupport = { supported: boolean; markUnsupported: () => void }; +const MethodSupportContext = React.createContext(undefined); type HistoryProviderProps = { children: React.ReactNode }; export function HistoryProvider({ children }: HistoryProviderProps) { const { url } = useCluster(); const [state, dispatch] = Cache.useCustomReducer(url, reconcile); const inFlightRef = React.useRef(new Set()); + const generationRef = React.useRef(new Map()); + const [supported, setSupported] = React.useState(true); React.useEffect(() => { dispatch({ type: ActionType.Clear, url }); inFlightRef.current.clear(); + generationRef.current.clear(); + setSupported(true); }, [dispatch, url]); + const markUnsupported = React.useCallback(() => setSupported(false), []); + const methodSupport = React.useMemo(() => ({ markUnsupported, supported }), [markUnsupported, supported]); + return ( - {children} + + + {children} + + ); @@ -195,17 +231,133 @@ export async function fetchSignatures( } } +// Prunes undefined leaves so the RPC never receives an empty range like `{ slot: {} }`. +function pruneRange(range: RangeFilter | undefined): RangeFilter | undefined { + if (!range) return undefined; + const out: RangeFilter = {}; + if (range.gte !== undefined) out.gte = range.gte; + if (range.lte !== undefined) out.lte = range.lte; + return Object.keys(out).length > 0 ? out : undefined; +} + +// `HistoryFilters` already mirrors the RPC `filters` shape, so this just drops empty +// entries. Returns undefined when no filter is active so the key is omitted entirely. +function buildRpcFilters(filters: HistoryFilters): Record | undefined { + const out: Record = {}; + const slot = pruneRange(filters.slot); + if (slot) out.slot = slot; + const blockTime = pruneRange(filters.blockTime); + if (blockTime) out.blockTime = blockTime; + if (filters.status) out.status = filters.status; + return Object.keys(out).length > 0 ? out : undefined; +} + +type RpcHistoryItem = ConfirmedSignatureInfo & { transactionIndex?: number }; +type GetTransactionsForAddressResult = { + data: RpcHistoryItem[]; + paginationToken: string | null; +}; + +// Calls the Triton `getTransactionsForAddress` method directly (it is not part of +// web3.js). `signatures` detail level keeps the response shape compatible with the +// existing `ConfirmedSignatureInfo`-based table. +async function getTransactionsForAddress( + url: string, + address: string, + options: { limit: number; paginationToken?: string | null; filters: HistoryFilters }, +): Promise { + const params: Record = { + limit: options.limit, + paginationToken: options.paginationToken ?? null, + sortOrder: 'desc', + transactionDetails: 'signatures', + }; + const filters = buildRpcFilters(options.filters); + if (filters) params.filters = filters; + + const response = await fetch(url, { + body: JSON.stringify({ + id: 1, + jsonrpc: '2.0', + method: 'getTransactionsForAddress', + params: [address, params], + }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }); + // Parse the body before consulting the HTTP status: standard RPC nodes return the + // JSON-RPC "method not found" error with HTTP 200, so checking response.ok first + // would mask the -32601 code that drives the getSignaturesForAddress fallback. + const json = await response.json().catch(() => null); + if (json?.error) { + const error = new Error(json.error.message ?? 'getTransactionsForAddress failed') as Error & { + code?: number; + }; + error.code = json.error.code; + throw error; + } + if (!response.ok) { + throw new Error(`getTransactionsForAddress HTTP ${response.status}`); + } + if (!json?.result) { + throw new Error('getTransactionsForAddress: malformed response'); + } + return json.result as GetTransactionsForAddressResult; +} + +// getTransactionsForAddress is a Triton extension; standard RPC nodes answer with a +// JSON-RPC "method not found" (-32601). We use that to fall back to getSignaturesForAddress. +function isMethodNotFound(error: unknown): boolean { + const e = error as { code?: number; message?: string }; + if (e?.code === -32601) return true; + // A structured JSON-RPC code is authoritative: any other numeric code means this is + // not a method-not-found, so don't let a coincidental message substring (e.g. a proxy + // error page) downgrade the endpoint and permanently disable filtering for the session. + if (typeof e?.code === 'number') return false; + const message = typeof e?.message === 'string' ? e.message.toLowerCase() : ''; + return message.includes('method not found') || message.includes('unsupported method'); +} + +// Legacy fallback path for endpoints without getTransactionsForAddress. Uses +// getSignaturesForAddress, whose pagination cursor is the trailing `before` signature +// rather than a paginationToken. Standard RPCs support none of the filters, so slot, +// block time and status are all left unapplied here — the caller marks filtering +// unsupported and clears them. +async function fetchViaSignatures( + url: string, + pubkey: PublicKey, + options: { limit: number; before?: string }, +): Promise { + const connection = new Connection(url); + const fetched = await fetchSignatures(connection, pubkey, { before: options.before, limit: options.limit }); + return { + fetched, + foundOldest: fetched.length < options.limit, + paginationToken: null, + }; +} + async function fetchAccountHistory( dispatch: Dispatch, pubkey: PublicKey, cluster: Cluster, url: string, options: { - before?: TransactionSignature; limit: number; + paginationToken?: string | null; + // Trailing-signature cursor used only by the getSignaturesForAddress fallback. + before?: string; + filters: HistoryFilters; + append: boolean; }, fetchTransactions?: boolean, additionalSignatures?: string[], + // Returns false once this request has been superseded (e.g. by a filter change), + // in which case its result is dropped rather than written into the cache. + isCurrent: () => boolean = () => true, + // Called when the endpoint reports getTransactionsForAddress as unavailable, so the + // UI can disable filtering before the request falls back to getSignaturesForAddress. + onMethodNotFound?: () => void, ) { dispatch({ key: pubkey.toBase58(), @@ -217,18 +369,42 @@ async function fetchAccountHistory( let status; let history; try { - const connection = new Connection(url); - const fetched = await fetchSignatures(connection, pubkey, options); + const result = await getTransactionsForAddress(url, pubkey.toBase58(), { + filters: options.filters, + limit: options.limit, + paginationToken: options.paginationToken, + }); history = { - fetched, - foundOldest: fetched.length < options.limit, + fetched: result.data, + // A null/absent paginationToken is the canonical end-of-stream signal; a + // short page is not, since the server may still hand back a token for more. + foundOldest: !result.paginationToken, + paginationToken: result.paginationToken, }; status = FetchStatus.Fetched; } catch (error) { - if (cluster !== Cluster.Custom) { - Logger.error(error, { url }); + if (isMethodNotFound(error)) { + // Endpoint doesn't implement getTransactionsForAddress: disable filtering + // and fall back to the standard getSignaturesForAddress path. + onMethodNotFound?.(); + try { + history = await fetchViaSignatures(url, pubkey, { + before: options.before, + limit: options.limit, + }); + status = FetchStatus.Fetched; + } catch (fallbackError) { + if (cluster !== Cluster.Custom) { + Logger.error(fallbackError, { url }); + } + status = FetchStatus.FetchFailed; + } + } else { + if (cluster !== Cluster.Custom) { + Logger.error(error, { url }); + } + status = FetchStatus.FetchFailed; } - status = FetchStatus.FetchFailed; } let failedTransactionSignatures; @@ -245,9 +421,13 @@ async function fetchAccountHistory( } } + // A newer request (e.g. triggered by a filter change) has taken over for this + // address; discard this stale result so it can't overwrite the fresh cache. + if (!isCurrent()) return; + dispatch({ data: { - before: options?.before, + append: options.append, failedTransactionSignatures, history, transactionMap, @@ -259,6 +439,28 @@ async function fetchAccountHistory( }); } +// Resets a single address's history so the next fetch starts from a clean slate. +// Bumps the address generation (so any in-flight request for it is discarded) and +// evicts its in-flight marker (so the immediately-following refetch isn't deduped), +// then clears only that address's cache entry. +export function useResetAccountHistory() { + const { url } = useCluster(); + const dispatch = React.useContext(DispatchContext); + const inFlight = React.useContext(InFlightContext); + const generations = React.useContext(GenerationContext); + if (!dispatch || !inFlight || !generations) { + throw new Error(`useResetAccountHistory must be used within a HistoryProvider`); + } + return React.useCallback( + (address: string) => { + generations.set(address, (generations.get(address) ?? 0) + 1); + inFlight.delete(address); + dispatch({ key: address, type: ActionType.Clear, url }); + }, + [dispatch, inFlight, generations, url], + ); +} + export function useAccountHistories() { const context = React.useContext(StateContext); @@ -269,6 +471,12 @@ export function useAccountHistories() { return context.entries; } +// Whether the current endpoint supports getTransactionsForAddress, and therefore +// filtering. Defaults to true outside a HistoryProvider (e.g. isolated component tests). +export function useHistoryFiltersSupported(): boolean { + return React.useContext(MethodSupportContext)?.supported ?? true; +} + export function useAccountHistory(address: string): Cache.CacheEntry | undefined { const context = React.useContext(StateContext); @@ -332,18 +540,39 @@ export function useFetchTransactionsForHistory() { ); } -export function useFetchAccountHistory(limit = 25) { +export function useFetchAccountHistory(limit = 25, filters: HistoryFilters = {}) { const { cluster, url } = useCluster(); const state = React.useContext(StateContext); const dispatch = React.useContext(DispatchContext); const inFlight = React.useContext(InFlightContext); - if (!state || !dispatch || !inFlight) { + const generations = React.useContext(GenerationContext); + const methodSupport = React.useContext(MethodSupportContext); + if (!state || !dispatch || !inFlight || !generations || !methodSupport) { throw new Error(`useFetchAccountHistory must be used within a HistoryProvider`); } + const { markUnsupported } = methodSupport; + + // Destructure into primitives so the callback identity tracks filter changes. + const { slot, blockTime, status } = filters; + const slotGte = slot?.gte; + const slotLte = slot?.lte; + const blockTimeGte = blockTime?.gte; + const blockTimeLte = blockTime?.lte; return React.useCallback( (pubkey: PublicKey, fetchTransactions?: boolean, refresh?: boolean) => { - const before = state.entries[pubkey.toBase58()]; + const activeFilters: HistoryFilters = { + blockTime: { gte: blockTimeGte, lte: blockTimeLte }, + slot: { gte: slotGte, lte: slotLte }, + status, + }; + const key = pubkey.toBase58(); + // Snapshot the generation at dispatch time; if it advances before the + // response lands (a filter change), the result is treated as stale. + const generation = generations.get(key) ?? 0; + const isCurrent = () => (generations.get(key) ?? 0) === generation; + + const before = state.entries[key]; if (!refresh && before?.data?.fetched && before.data.fetched.length > 0) { if (before.data.foundOldest) return; @@ -352,24 +581,58 @@ export function useFetchAccountHistory(limit = 25) { additionalSignatures = getUnfetchedSignatures(before); } + // Cursor for the next page: paginationToken drives getTransactionsForAddress, + // the trailing signature drives the getSignaturesForAddress fallback. const oldest = before.data.fetched[before.data.fetched.length - 1].signature; - fetchOnce(pubkey.toBase58(), inFlight, () => + fetchOnce(key, inFlight, () => fetchAccountHistory( dispatch, pubkey, cluster, url, - { before: oldest, limit }, + { + append: true, + before: oldest, + filters: activeFilters, + limit, + paginationToken: before.data?.paginationToken, + }, fetchTransactions, additionalSignatures, + isCurrent, + markUnsupported, ), ).catch(e => Logger.error(e)); } else { - fetchOnce(pubkey.toBase58(), inFlight, () => - fetchAccountHistory(dispatch, pubkey, cluster, url, { limit }, fetchTransactions), + fetchOnce(key, inFlight, () => + fetchAccountHistory( + dispatch, + pubkey, + cluster, + url, + { append: false, filters: activeFilters, limit }, + fetchTransactions, + undefined, + isCurrent, + markUnsupported, + ), ).catch(e => Logger.error(e)); } }, - [limit, state, dispatch, cluster, url, inFlight], + [ + limit, + slotGte, + slotLte, + blockTimeGte, + blockTimeLte, + status, + state, + dispatch, + cluster, + url, + inFlight, + generations, + markUnsupported, + ], ); } diff --git a/app/providers/cache.tsx b/app/providers/cache.tsx index 40a3eab5d..5506f73c9 100644 --- a/app/providers/cache.tsx +++ b/app/providers/cache.tsx @@ -34,6 +34,8 @@ export type Update = { export type Clear = { type: ActionType.Clear; url: string; + // When provided, clears only this entry; otherwise clears the whole cache. + key?: string | number; }; export type Action = Update | Clear; @@ -74,6 +76,13 @@ export function useCustomReducer(url: string, reconciler: Reconciler export function reducer(state: State, action: Action, reconciler: Reconciler): State { if (action.type === ActionType.Clear) { + // Per-key clear: drop just one entry, leaving sibling addresses intact. + if (action.key !== undefined) { + if (action.url !== state.url) return state; + const entries = { ...state.entries }; + delete entries[action.key]; + return { ...state, entries }; + } return { entries: {}, url: action.url }; } else if (action.url !== state.url) { return state; diff --git a/app/shared/ui/HistoryCard/HistoryCardHeader.tsx b/app/shared/ui/HistoryCard/HistoryCardHeader.tsx index 6a1f7fe81..821857c7e 100644 --- a/app/shared/ui/HistoryCard/HistoryCardHeader.tsx +++ b/app/shared/ui/HistoryCard/HistoryCardHeader.tsx @@ -1,4 +1,5 @@ import { RefreshButton } from '@components/shared/ui/refresh-button'; +import { type ReactNode } from 'react'; import { CardHeader, CardTitle } from '@/app/shared/ui/Card'; @@ -7,15 +8,28 @@ export type HistoryCardHeaderProps = { analyticsSection: string; refresh: () => void; fetching: boolean; + actions?: ReactNode; + subHeader?: ReactNode; }; -export function HistoryCardHeader({ title, analyticsSection, refresh, fetching }: HistoryCardHeaderProps) { +export function HistoryCardHeader({ + title, + analyticsSection, + refresh, + fetching, + actions, + subHeader, +}: HistoryCardHeaderProps) { return ( - - - {title} - - + +
+ + {title} + + {actions} + +
+ {subHeader &&
{subHeader}
}
); }