From 7db43cb7ce9c04916d1266dbe15d13a824be2d38 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Thu, 5 Nov 2026 11:26:33 -0500 Subject: [PATCH 1/9] fix: show MPT transactions beyond the issuer's 550 most recent The MPT page reads the issuer's account history and filters it for the MPT, but stopped after 10 empty pages (550 transactions). MPTs whose issuer kept transacting afterwards showed "No transactions found." The MPT page now keeps reading until it reaches the issuance's creation transaction, reads from the Clio server (full history on testnet and devnet), and retries a failed page twice. The hook also stops auto-fetching after a failure, which the old attempt cap hid. Co-Authored-By: Claude Opus 5.5 --- .../Token/MPT/TablePicker/index.tsx | 4 + .../shared/hooks/useAccountTransactions.ts | 32 +++- .../hooks/useAccountTransactions.test.tsx | 162 ++++++++++++++++++ src/rippled/accountTransactions.ts | 9 +- src/rippled/test/accountTransactions.test.ts | 79 +++++++++ 5 files changed, 279 insertions(+), 7 deletions(-) diff --git a/src/containers/Token/MPT/TablePicker/index.tsx b/src/containers/Token/MPT/TablePicker/index.tsx index 1e1923bdc..05a9a7bd2 100644 --- a/src/containers/Token/MPT/TablePicker/index.tsx +++ b/src/containers/Token/MPT/TablePicker/index.tsx @@ -54,6 +54,10 @@ export const TablePicker = ({ account: issuer, tokenId: mptIssuanceId, limit: 50, + // Scan back to the issuance's creation so older transactions show up + fetchUntilEnd: true, + useP2PSocket: true, + retry: 2, }) const [tablePickerState, setTablePickerState] = useState< diff --git a/src/containers/Token/shared/hooks/useAccountTransactions.ts b/src/containers/Token/shared/hooks/useAccountTransactions.ts index 7dc4c895a..f10b0b159 100644 --- a/src/containers/Token/shared/hooks/useAccountTransactions.ts +++ b/src/containers/Token/shared/hooks/useAccountTransactions.ts @@ -2,7 +2,9 @@ import { useContext, useEffect, useMemo, useState } from 'react' import { useInfiniteQuery } from 'react-query' import { useAnalytics } from '../../../shared/analytics' -import SocketContext from '../../../shared/SocketContext' +import SocketContext, { + ExplorerXrplClient, +} from '../../../shared/SocketContext' import { getAccountTransactions } from '../../../../rippled' /** @@ -21,6 +23,12 @@ interface UseAccountTransactionsOptions { /** Token identifier: currency code for IOU, MPT issuance ID for MPT */ tokenId: string limit?: number + /** Auto-fetch every page until the history ends, not just empty ones */ + fetchUntilEnd?: boolean + /** Query the P2P (Clio) server, which keeps full history, when configured */ + useP2PSocket?: boolean + /** Number of times to retry a failed page fetch */ + retry?: number } /** @@ -29,15 +37,23 @@ interface UseAccountTransactionsOptions { * Transactions are filtered client-side after fetching from rippled. * If a fetched page contains no matching transactions but more pages exist, * automatically fetches additional pages (up to MAX_AUTO_FETCH_ATTEMPTS) - * to find matching transactions. + * to find matching transactions. With fetchUntilEnd, it keeps fetching + * until there is no next page. */ export function useAccountTransactions({ account, tokenId, limit, + fetchUntilEnd = false, + useP2PSocket = false, + retry = 0, }: UseAccountTransactionsOptions) { const { trackException } = useAnalytics() const rippledSocket = useContext(SocketContext) + // getAccountTransactions only sends a plain account_tx, so the P2P client works + const querySocket = useP2PSocket + ? ((rippledSocket?.p2pSocket ?? rippledSocket) as ExplorerXrplClient) + : rippledSocket const { data, @@ -53,7 +69,7 @@ export function useAccountTransactions({ tokenId, pageParam, limit, - rippledSocket, + querySocket, ).catch((errorResponse: Error) => { const errorLocation = `transactions ${account}.${tokenId} at ${pageParam}` trackException(`${errorLocation} --- ${JSON.stringify(errorResponse)}`) @@ -61,6 +77,7 @@ export function useAccountTransactions({ }), { getNextPageParam: (lastPage) => lastPage.marker, + retry, }, ) @@ -75,12 +92,15 @@ export function useAccountTransactions({ }, [data]) const lastPageWasEmpty = lastPageTransactionCount === 0 - const canAutoFetch = hasNextPage === true && !isLoading + // Don't auto-fetch after a failure, or a failing page is re-requested forever + const canAutoFetch = hasNextPage === true && !isLoading && !error const [autoFetchAttempts, setAutoFetchAttempts] = useState(0) const hasRemainingAttempts = autoFetchAttempts < MAX_AUTO_FETCH_ATTEMPTS - // Auto-fetch if the last page returned no matching transactions + // Auto-fetch if the last page returned no matching transactions, or every + // page when fetchUntilEnd is set const shouldAutoFetch = - lastPageWasEmpty && canAutoFetch && hasRemainingAttempts + canAutoFetch && + (fetchUntilEnd || (lastPageWasEmpty && hasRemainingAttempts)) useEffect(() => { if (!shouldAutoFetch) { diff --git a/src/containers/Token/shared/test/hooks/useAccountTransactions.test.tsx b/src/containers/Token/shared/test/hooks/useAccountTransactions.test.tsx index d8c06ef5b..389934597 100644 --- a/src/containers/Token/shared/test/hooks/useAccountTransactions.test.tsx +++ b/src/containers/Token/shared/test/hooks/useAccountTransactions.test.tsx @@ -154,4 +154,166 @@ describe('useAccountTransactions', () => { { hash: 'tx1' }, ]) }) + + it('auto-fetches every page until the end when fetchUntilEnd is set', async () => { + // Matches on pages 0 and 12, empty pages in between: more than the cap of 10 + mockedGetAccountTransactions.mockImplementation( + async (_account: string, _tokenId: string, marker: string) => { + const page = marker ? Number(marker) : 0 + return { + transactions: + page === 0 || page === 12 ? [{ hash: `tx${page}` }] : [], + marker: page < 12 ? String(page + 1) : undefined, + } + }, + ) + + const { result } = renderHook( + () => + useAccountTransactions({ + account: 'rAccount123', + tokenId: 'MPT', + fetchUntilEnd: true, + }), + { wrapper: createWrapper() }, + ) + + for (let page = 1; page <= 13; page += 1) { + // eslint-disable-next-line no-await-in-loop + await waitFor(() => + expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(page), + ) + jest.advanceTimersByTime(200) + } + + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(13) + expect(result.current.hasNextPage).toBe(false) + expect( + result.current.data?.pages.flatMap((page: any) => page.transactions), + ).toEqual([{ hash: 'tx0' }, { hash: 'tx12' }]) + }) + + it('stops auto-fetching at the attempt cap by default', async () => { + mockedGetAccountTransactions.mockImplementation( + async (_account: string, _tokenId: string, marker: string) => ({ + transactions: [], + marker: String((marker ? Number(marker) : 0) + 1), + }), + ) + + const { result } = renderHook( + () => + useAccountTransactions({ + account: 'rAccount123', + tokenId: 'USD', + }), + { wrapper: createWrapper() }, + ) + + for (let page = 1; page <= 11; page += 1) { + // eslint-disable-next-line no-await-in-loop + await waitFor(() => + expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(page), + ) + jest.advanceTimersByTime(200) + } + + await waitFor(() => expect(result.current.loading).toBe(false)) + jest.advanceTimersByTime(1000) + expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(11) + expect(result.current.hasNextPage).toBe(true) + }) + + it('stops auto-fetching after a page fails', async () => { + jest.useRealTimers() + mockedGetAccountTransactions + .mockResolvedValueOnce({ transactions: [], marker: 'page-2-marker' }) + .mockRejectedValue(new Error('slowDown')) + + const { result } = renderHook( + () => + useAccountTransactions({ + account: 'rAccount123', + tokenId: 'MPT', + fetchUntilEnd: true, + }), + { wrapper: createWrapper() }, + ) + + await waitFor(() => expect(result.current.error).not.toBeNull()) + await new Promise((resolve) => { + setTimeout(resolve, 1000) + }) + expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(2) + expect(result.current.loading).toBe(false) + }) + + it('retries a failed page when retry is set', async () => { + jest.useRealTimers() + mockedGetAccountTransactions + .mockRejectedValueOnce(new Error('slowDown')) + .mockResolvedValueOnce({ transactions: [{ hash: 'tx1' }] }) + + const { result } = renderHook( + () => + useAccountTransactions({ + account: 'rAccount123', + tokenId: 'MPT', + retry: 1, + }), + { wrapper: createWrapper() }, + ) + + await waitFor( + () => + expect(result.current.data?.pages[0].transactions).toEqual([ + { hash: 'tx1' }, + ]), + { timeout: 3000 }, + ) + expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(2) + expect(result.current.error).toBeNull() + }) + + it('queries the P2P socket when useP2PSocket is set', async () => { + const p2pSocket = { name: 'p2p' } + const socket = { name: 'main', p2pSocket } + mockedGetAccountTransactions.mockResolvedValue({ transactions: [] }) + + renderHook( + () => + useAccountTransactions({ + account: 'rAccount123', + tokenId: 'MPT', + useP2PSocket: true, + }), + { wrapper: createWrapper(socket) }, + ) + + await waitFor(() => + expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(1), + ) + expect(mockedGetAccountTransactions.mock.calls[0][4]).toBe(p2pSocket) + }) + + it('falls back to the main socket when no P2P socket is configured', async () => { + const socket = { name: 'main', p2pSocket: undefined } + mockedGetAccountTransactions.mockResolvedValue({ transactions: [] }) + + renderHook( + () => + useAccountTransactions({ + account: 'rAccount123', + tokenId: 'MPT', + useP2PSocket: true, + }), + { wrapper: createWrapper(socket) }, + ) + + await waitFor(() => + expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(1), + ) + expect(mockedGetAccountTransactions.mock.calls[0][4]).toBe(socket) + }) }) diff --git a/src/rippled/accountTransactions.ts b/src/rippled/accountTransactions.ts index efc122a8c..9dc7bde6d 100644 --- a/src/rippled/accountTransactions.ts +++ b/src/rippled/accountTransactions.ts @@ -69,6 +69,13 @@ const getAccountTransactions = async ( limit, marker, ) + // Nothing older than an MPT's creation can reference it, so stop paging there + const reachedMPTCreation = + !!currency && + data.transactions.some( + (tx: any) => + tx.meta?.mpt_issuance_id?.toUpperCase() === currency.toUpperCase(), + ) const transactions = data.transactions .map((tx: any) => { const txn = formatTransaction(tx) @@ -89,7 +96,7 @@ const getAccountTransactions = async ( }) return { transactions, - marker: data.marker, + marker: reachedMPTCreation ? undefined : data.marker, } } catch (error: any) { log.error(error.toString()) diff --git a/src/rippled/test/accountTransactions.test.ts b/src/rippled/test/accountTransactions.test.ts index f19c573f7..f12ba3705 100644 --- a/src/rippled/test/accountTransactions.test.ts +++ b/src/rippled/test/accountTransactions.test.ts @@ -114,4 +114,83 @@ describe('accountTransactions', () => { expect(result.transactions[0].hash).toBe('tx3') expect(result.transactions[1].hash).toBe('tx4') }) + + describe('MPT creation', () => { + const mptIssuanceId = '00000001ABC123DEF456' + const createTx = (id: string) => ({ + hash: 'create', + type: 'MPTokenIssuanceCreate', + meta: { TransactionResult: 'tesSUCCESS', mpt_issuance_id: id }, + }) + + it("stops paging at the MPT's creation", async () => { + mockGetAccountTxs.mockResolvedValue({ + transactions: [createTx(mptIssuanceId)], + marker: '12345.1', + }) + + const result = await getAccountTransactions( + validAccount, + mptIssuanceId, + undefined, + 20, + mockSocket, + ) + + expect(result.transactions).toHaveLength(1) + expect(result.marker).toBeUndefined() + }) + + it('matches the creation regardless of ID case', async () => { + mockGetAccountTxs.mockResolvedValue({ + transactions: [createTx(mptIssuanceId)], + marker: '12345.1', + }) + + const result = await getAccountTransactions( + validAccount, + mptIssuanceId.toLowerCase(), + undefined, + 20, + mockSocket, + ) + + expect(result.marker).toBeUndefined() + }) + + it("keeps paging past another MPT's creation", async () => { + mockGetAccountTxs.mockResolvedValue({ + transactions: [createTx('00000002FFFFFFFFFFFF')], + marker: '12345.1', + }) + + const result = await getAccountTransactions( + validAccount, + mptIssuanceId, + undefined, + 20, + mockSocket, + ) + + expect(result.transactions).toHaveLength(0) + expect(result.marker).toBe('12345.1') + }) + + it('keeps paging when no token filter is applied', async () => { + mockGetAccountTxs.mockResolvedValue({ + transactions: [createTx(mptIssuanceId)], + marker: '12345.1', + }) + + const result = await getAccountTransactions( + validAccount, + undefined, + undefined, + 20, + mockSocket, + ) + + expect(result.marker).toBe('12345.1') + }) + }) }) From 64d5b10596c964809a10d70ab05a40b92e24ec23 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Thu, 5 Nov 2026 11:41:01 -0500 Subject: [PATCH 2/9] fix: keep auto-fetching after each page even when loading state stays set The auto-fetch effect only re-ran when shouldAutoFetch changed. When a page's fetching state and data land in the same render, shouldAutoFetch stays true, the effect doesn't re-run, and the scan stalls. It showed up under @testing-library/react 16 (the locked version), where the scan stopped after two pages. Re-running the effect per loaded page fixes it. Co-Authored-By: Claude Opus 5.5 --- src/containers/Token/shared/hooks/useAccountTransactions.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/containers/Token/shared/hooks/useAccountTransactions.ts b/src/containers/Token/shared/hooks/useAccountTransactions.ts index f10b0b159..bed06e2bc 100644 --- a/src/containers/Token/shared/hooks/useAccountTransactions.ts +++ b/src/containers/Token/shared/hooks/useAccountTransactions.ts @@ -102,6 +102,8 @@ export function useAccountTransactions({ canAutoFetch && (fetchUntilEnd || (lastPageWasEmpty && hasRemainingAttempts)) + // Re-run per loaded page: shouldAutoFetch can stay true across a fetch + const pageCount = data?.pages.length ?? 0 useEffect(() => { if (!shouldAutoFetch) { return undefined @@ -113,7 +115,7 @@ export function useAccountTransactions({ }, AUTO_FETCH_DELAY_MS) return () => clearTimeout(timer) - }, [shouldAutoFetch, fetchNextPage]) + }, [shouldAutoFetch, fetchNextPage, pageCount]) // Keep loading true while auto-fetch is pending to prevent UI flickering // between "Load More" button and loading spinner From e81bca6aa8ac0eee3844ad1a74eb5aaa60831f4d Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Thu, 5 Nov 2026 12:21:40 -0500 Subject: [PATCH 3/9] fix: load a screenful of MPT transactions instead of the whole history fetchUntilEnd read every page back to the issuance's creation on page load, so a busy MPT downloaded its whole history on every visit. Replace it with minResults: auto-fetch, uncapped, until 20 matches are loaded or the creation is reached, and have "Load more" fetch the next 20, skipping empty pages. Reset the target when the token changes, since the hook stays mounted across MPT pages. Make the fake-timer tests let waitFor drive the clock, and add a real-timer test for the per-page effect re-run. Co-Authored-By: Claude Opus 5.5 --- .../Token/MPT/TablePicker/index.tsx | 5 +- .../shared/hooks/useAccountTransactions.ts | 42 +++-- .../hooks/useAccountTransactions.test.tsx | 166 +++++++++++++----- 3 files changed, 158 insertions(+), 55 deletions(-) diff --git a/src/containers/Token/MPT/TablePicker/index.tsx b/src/containers/Token/MPT/TablePicker/index.tsx index 05a9a7bd2..83bdc15f2 100644 --- a/src/containers/Token/MPT/TablePicker/index.tsx +++ b/src/containers/Token/MPT/TablePicker/index.tsx @@ -54,8 +54,9 @@ export const TablePicker = ({ account: issuer, tokenId: mptIssuanceId, limit: 50, - // Scan back to the issuance's creation so older transactions show up - fetchUntilEnd: true, + // Skip non-matching pages until a screenful is found or the issuance's + // creation is reached, so older transactions show up + minResults: 20, useP2PSocket: true, retry: 2, }) diff --git a/src/containers/Token/shared/hooks/useAccountTransactions.ts b/src/containers/Token/shared/hooks/useAccountTransactions.ts index bed06e2bc..3688532f2 100644 --- a/src/containers/Token/shared/hooks/useAccountTransactions.ts +++ b/src/containers/Token/shared/hooks/useAccountTransactions.ts @@ -1,4 +1,4 @@ -import { useContext, useEffect, useMemo, useState } from 'react' +import { useCallback, useContext, useEffect, useMemo, useState } from 'react' import { useInfiniteQuery } from 'react-query' import { useAnalytics } from '../../../shared/analytics' @@ -23,8 +23,8 @@ interface UseAccountTransactionsOptions { /** Token identifier: currency code for IOU, MPT issuance ID for MPT */ tokenId: string limit?: number - /** Auto-fetch every page until the history ends, not just empty ones */ - fetchUntilEnd?: boolean + /** Auto-fetch, uncapped, until this many matches load; "Load more" adds as many again */ + minResults?: number /** Query the P2P (Clio) server, which keeps full history, when configured */ useP2PSocket?: boolean /** Number of times to retry a failed page fetch */ @@ -37,14 +37,14 @@ interface UseAccountTransactionsOptions { * Transactions are filtered client-side after fetching from rippled. * If a fetched page contains no matching transactions but more pages exist, * automatically fetches additional pages (up to MAX_AUTO_FETCH_ATTEMPTS) - * to find matching transactions. With fetchUntilEnd, it keeps fetching - * until there is no next page. + * to find matching transactions. With minResults, it keeps fetching until + * that many matches are loaded or there is no next page. */ export function useAccountTransactions({ account, tokenId, limit, - fetchUntilEnd = false, + minResults = 0, useP2PSocket = false, retry = 0, }: UseAccountTransactionsOptions) { @@ -91,16 +91,32 @@ export function useAccountTransactions({ return pages[pages.length - 1]?.transactions?.length || 0 }, [data]) + const loadedCount = useMemo( + () => + data?.pages.reduce( + (count, page) => count + (page.transactions?.length || 0), + 0, + ) ?? 0, + [data], + ) + const [targetCount, setTargetCount] = useState(minResults) + // The hook stays mounted when navigating between tokens + useEffect(() => { + setTargetCount(minResults) + }, [account, tokenId, minResults]) + const lastPageWasEmpty = lastPageTransactionCount === 0 // Don't auto-fetch after a failure, or a failing page is re-requested forever const canAutoFetch = hasNextPage === true && !isLoading && !error const [autoFetchAttempts, setAutoFetchAttempts] = useState(0) const hasRemainingAttempts = autoFetchAttempts < MAX_AUTO_FETCH_ATTEMPTS - // Auto-fetch if the last page returned no matching transactions, or every - // page when fetchUntilEnd is set + // Auto-fetch if the last page returned no matching transactions, or until + // minResults matches are loaded when set const shouldAutoFetch = canAutoFetch && - (fetchUntilEnd || (lastPageWasEmpty && hasRemainingAttempts)) + (minResults + ? loadedCount < targetCount + : lastPageWasEmpty && hasRemainingAttempts) // Re-run per loaded page: shouldAutoFetch can stay true across a fetch const pageCount = data?.pages.length ?? 0 @@ -121,11 +137,17 @@ export function useAccountTransactions({ // between "Load More" button and loading spinner const loading = isLoading || shouldAutoFetch + // With minResults, "Load more" raises the target so empty pages are skipped + const loadMore = useCallback(() => { + setTargetCount(loadedCount + minResults) + return fetchNextPage() + }, [loadedCount, minResults, fetchNextPage]) + return { data, error, loading, - fetchNextPage, + fetchNextPage: minResults ? loadMore : fetchNextPage, hasNextPage: hasNextPage ?? false, } } diff --git a/src/containers/Token/shared/test/hooks/useAccountTransactions.test.tsx b/src/containers/Token/shared/test/hooks/useAccountTransactions.test.tsx index 389934597..6bdd1f0d6 100644 --- a/src/containers/Token/shared/test/hooks/useAccountTransactions.test.tsx +++ b/src/containers/Token/shared/test/hooks/useAccountTransactions.test.tsx @@ -1,4 +1,4 @@ -import { renderHook, waitFor } from '@testing-library/react' +import { act, renderHook, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from 'react-query' import { useAccountTransactions } from '../../hooks/useAccountTransactions' import SocketContext from '../../../../shared/SocketContext' @@ -13,6 +13,14 @@ jest.mock('../../../../shared/analytics', () => ({ const mockedGetAccountTransactions = rippled.getAccountTransactions as jest.Mock +// With fake timers, waitFor advances the clock itself and flushes pending +// promises between steps, so each 200ms auto-fetch delay elapses in order +const advanceThroughPages = (calls: number) => + waitFor( + () => expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(calls), + { timeout: calls * 1000 }, + ) + const createWrapper = (socket: any = {}, queryClient = new QueryClient()) => ({ children }: { children: React.ReactNode }) => ( @@ -155,43 +163,122 @@ describe('useAccountTransactions', () => { ]) }) - it('auto-fetches every page until the end when fetchUntilEnd is set', async () => { - // Matches on pages 0 and 12, empty pages in between: more than the cap of 10 - mockedGetAccountTransactions.mockImplementation( - async (_account: string, _tokenId: string, marker: string) => { - const page = marker ? Number(marker) : 0 - return { - transactions: - page === 0 || page === 12 ? [{ hash: `tx${page}` }] : [], - marker: page < 12 ? String(page + 1) : undefined, - } - }, - ) + describe('with minResults', () => { + // Pages with matches at the given indexes; every other page is empty + const mockPages = (matchPages: number[], lastPage: number) => + mockedGetAccountTransactions.mockImplementation( + async (_account: string, _tokenId: string, marker: string) => { + const page = marker ? Number(marker) : 0 + return { + transactions: matchPages.includes(page) + ? [{ hash: `tx${page}a` }, { hash: `tx${page}b` }] + : [], + marker: page < lastPage ? String(page + 1) : undefined, + } + }, + ) - const { result } = renderHook( - () => - useAccountTransactions({ - account: 'rAccount123', - tokenId: 'MPT', - fetchUntilEnd: true, - }), - { wrapper: createWrapper() }, - ) + const renderMPTHook = () => + renderHook( + () => + useAccountTransactions({ + account: 'rAccount123', + tokenId: 'MPT', + minResults: 4, + }), + { wrapper: createWrapper() }, + ) + + it('auto-fetches past the attempt cap until minResults matches load', async () => { + // 2 matches on page 0, 2 on page 12: the 4th match is past the cap of 10 + mockPages([0, 12, 20], 30) + const { result } = renderMPTHook() + + await advanceThroughPages(13) + await waitFor(() => expect(result.current.loading).toBe(false)) + jest.advanceTimersByTime(1000) + + expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(13) + expect(result.current.hasNextPage).toBe(true) + }) + + it('loads the next minResults matches on load more', async () => { + mockPages([0, 1, 15, 25], 30) + const { result } = renderMPTHook() + + await advanceThroughPages(2) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(2) - for (let page = 1; page <= 13; page += 1) { - // eslint-disable-next-line no-await-in-loop - await waitFor(() => - expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(page), + act(() => { + result.current.fetchNextPage() + }) + await advanceThroughPages(26) + await waitFor(() => expect(result.current.loading).toBe(false)) + jest.advanceTimersByTime(1000) + + // Skips the empty pages up to page 25, where the 8th match is + expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(26) + expect( + result.current.data?.pages.flatMap((page: any) => page.transactions), + ).toHaveLength(8) + }) + + it('resets the target when the token changes', async () => { + mockPages([0, 1, 2, 3], 30) + const { result, rerender } = renderHook( + ({ tokenId }) => + useAccountTransactions({ + account: 'rAccount123', + tokenId, + minResults: 4, + }), + { wrapper: createWrapper(), initialProps: { tokenId: 'MPT1' } }, ) - jest.advanceTimersByTime(200) - } - await waitFor(() => expect(result.current.loading).toBe(false)) - expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(13) - expect(result.current.hasNextPage).toBe(false) - expect( - result.current.data?.pages.flatMap((page: any) => page.transactions), - ).toEqual([{ hash: 'tx0' }, { hash: 'tx12' }]) + await advanceThroughPages(2) + await waitFor(() => expect(result.current.loading).toBe(false)) + act(() => { + result.current.fetchNextPage() + }) + await advanceThroughPages(4) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(4) + + rerender({ tokenId: 'MPT2' }) + await advanceThroughPages(6) + await waitFor(() => expect(result.current.loading).toBe(false)) + jest.advanceTimersByTime(1000) + + // The new token loads 4 matches (2 pages), not the previous target of 8 + expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(6) + }) + + it('stops at the end of history with fewer than minResults matches', async () => { + mockPages([3], 5) + const { result } = renderMPTHook() + + await advanceThroughPages(6) + await waitFor(() => expect(result.current.loading).toBe(false)) + + expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(6) + expect(result.current.hasNextPage).toBe(false) + }) + + it('keeps auto-fetching when loading state stays set across pages', async () => { + // Real timers: a fetch's state and data can land in one render here, + // which stalled the scan before the effect re-ran per page + jest.useRealTimers() + mockPages([], 5) + const { result } = renderMPTHook() + + await waitFor( + () => expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(6), + { timeout: 4000 }, + ) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.hasNextPage).toBe(false) + }) }) it('stops auto-fetching at the attempt cap by default', async () => { @@ -211,14 +298,7 @@ describe('useAccountTransactions', () => { { wrapper: createWrapper() }, ) - for (let page = 1; page <= 11; page += 1) { - // eslint-disable-next-line no-await-in-loop - await waitFor(() => - expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(page), - ) - jest.advanceTimersByTime(200) - } - + await advanceThroughPages(11) await waitFor(() => expect(result.current.loading).toBe(false)) jest.advanceTimersByTime(1000) expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(11) @@ -236,7 +316,7 @@ describe('useAccountTransactions', () => { useAccountTransactions({ account: 'rAccount123', tokenId: 'MPT', - fetchUntilEnd: true, + minResults: 20, }), { wrapper: createWrapper() }, ) From 9d57f1f159b963dde5dc69bf0075973fdca8df06 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Thu, 5 Nov 2026 15:54:04 -0500 Subject: [PATCH 4/9] fix: cap the MPT scan at 50 pages per round minResults kept auto-fetching until 20 matches loaded, with no limit on non-matching pages. An issuer with a long run of unrelated transactions (or anyone sending it many tiny payments) made every visit to an older MPT read thousands of pages. Each round, the initial load or one "Load more", now reads at most 50 pages; the budget resets on "Load more" and when the token changes. When the budget runs out before any match, the MPT page shows "Try loading more transactions" instead of "No transactions found.", since more history is left. Co-Authored-By: Claude Opus 5.5 --- .../Token/MPT/TablePicker/index.tsx | 29 +++++++---- .../MPT/test/TablePicker/TablePicker.test.tsx | 41 +++++++++++++++- .../shared/hooks/useAccountTransactions.ts | 25 +++++++--- .../hooks/useAccountTransactions.test.tsx | 49 +++++++++++++++++++ 4 files changed, 128 insertions(+), 16 deletions(-) diff --git a/src/containers/Token/MPT/TablePicker/index.tsx b/src/containers/Token/MPT/TablePicker/index.tsx index 83bdc15f2..9537abca7 100644 --- a/src/containers/Token/MPT/TablePicker/index.tsx +++ b/src/containers/Token/MPT/TablePicker/index.tsx @@ -76,15 +76,26 @@ export const TablePicker = ({ } // Helper to render transaction table - const renderTransactionTable = () => ( - page.transactions).flat()} - loading={loading} - emptyMessage={error?.message ? t(error.message as any) : ''} - onLoadMore={() => fetchNextPage()} - hasAdditionalResults={hasNextPage} - /> - ) + const renderTransactionTable = () => { + const transactions = data?.pages + ?.map((page: any) => page.transactions) + .flat() + // The page budget ran out before any match: more history is left, so + // "No transactions found." would be wrong + const tryLoading = !error && hasNextPage && transactions?.length === 0 + const emptyMessage = tryLoading + ? 'get_account_transactions_try' + : error?.message + return ( + fetchNextPage()} + hasAdditionalResults={hasNextPage} + /> + ) + } // Transform transfer amount.value using assetScale const transfersFormatted: LOSTransfer[] = useMemo(() => { diff --git a/src/containers/Token/MPT/test/TablePicker/TablePicker.test.tsx b/src/containers/Token/MPT/test/TablePicker/TablePicker.test.tsx index b42cbbd71..6c6302a35 100644 --- a/src/containers/Token/MPT/test/TablePicker/TablePicker.test.tsx +++ b/src/containers/Token/MPT/test/TablePicker/TablePicker.test.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react' +import { render, waitFor } from '@testing-library/react' import { I18nextProvider } from 'react-i18next' import { BrowserRouter as Router } from 'react-router' import { QueryClientProvider } from 'react-query' @@ -168,4 +168,43 @@ describe('MPT TablePicker container', () => { container.querySelectorAll('.token-transaction-table-container'), ).toHaveLength(1) }) + + describe('empty transactions message', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('asks to load more when the page budget runs out with history left', async () => { + const { container } = renderComponent(async () => ({ + transactions: [], + marker: 'more', + })) + + await waitFor( + () => + expect( + container.querySelector('.empty-transactions-message'), + ).toHaveTextContent('get_account_transactions_try'), + { timeout: 20000 }, + ) + expect(getAccountTransactions).toHaveBeenCalledTimes(50) + }) + + it('reports no transactions at the end of history', async () => { + const { container } = renderComponent(async () => ({ + transactions: [], + marker: undefined, + })) + + await waitFor(() => + expect( + container.querySelector('.empty-transactions-message'), + ).toHaveTextContent('no_transactions_message'), + ) + }) + }) }) diff --git a/src/containers/Token/shared/hooks/useAccountTransactions.ts b/src/containers/Token/shared/hooks/useAccountTransactions.ts index 3688532f2..d62945401 100644 --- a/src/containers/Token/shared/hooks/useAccountTransactions.ts +++ b/src/containers/Token/shared/hooks/useAccountTransactions.ts @@ -18,12 +18,18 @@ const MAX_AUTO_FETCH_ATTEMPTS = 10 */ const AUTO_FETCH_DELAY_MS = 200 +/** + * Maximum pages auto-fetched per round (initial load or "Load more") with + * minResults, so a long run of non-matching history can't scan unbounded. + */ +const MAX_PAGES_PER_ROUND = 50 + interface UseAccountTransactionsOptions { account: string /** Token identifier: currency code for IOU, MPT issuance ID for MPT */ tokenId: string limit?: number - /** Auto-fetch, uncapped, until this many matches load; "Load more" adds as many again */ + /** Auto-fetch until this many matches load (or the page budget runs out); "Load more" adds as many again */ minResults?: number /** Query the P2P (Clio) server, which keeps full history, when configured */ useP2PSocket?: boolean @@ -38,7 +44,8 @@ interface UseAccountTransactionsOptions { * If a fetched page contains no matching transactions but more pages exist, * automatically fetches additional pages (up to MAX_AUTO_FETCH_ATTEMPTS) * to find matching transactions. With minResults, it keeps fetching until - * that many matches are loaded or there is no next page. + * that many matches are loaded, there is no next page, or + * MAX_PAGES_PER_ROUND pages have been fetched this round. */ export function useAccountTransactions({ account, @@ -99,11 +106,16 @@ export function useAccountTransactions({ ) ?? 0, [data], ) + const pageCount = data?.pages.length ?? 0 const [targetCount, setTargetCount] = useState(minResults) + // Pages loaded when the current round started + const [roundStartPage, setRoundStartPage] = useState(0) // The hook stays mounted when navigating between tokens useEffect(() => { setTargetCount(minResults) + setRoundStartPage(0) }, [account, tokenId, minResults]) + const hasRoundBudget = pageCount - roundStartPage < MAX_PAGES_PER_ROUND const lastPageWasEmpty = lastPageTransactionCount === 0 // Don't auto-fetch after a failure, or a failing page is re-requested forever @@ -115,11 +127,10 @@ export function useAccountTransactions({ const shouldAutoFetch = canAutoFetch && (minResults - ? loadedCount < targetCount + ? loadedCount < targetCount && hasRoundBudget : lastPageWasEmpty && hasRemainingAttempts) // Re-run per loaded page: shouldAutoFetch can stay true across a fetch - const pageCount = data?.pages.length ?? 0 useEffect(() => { if (!shouldAutoFetch) { return undefined @@ -137,11 +148,13 @@ export function useAccountTransactions({ // between "Load More" button and loading spinner const loading = isLoading || shouldAutoFetch - // With minResults, "Load more" raises the target so empty pages are skipped + // With minResults, "Load more" starts a new round: it raises the target so + // empty pages are skipped, and gets a fresh page budget const loadMore = useCallback(() => { setTargetCount(loadedCount + minResults) + setRoundStartPage(pageCount) return fetchNextPage() - }, [loadedCount, minResults, fetchNextPage]) + }, [loadedCount, minResults, pageCount, fetchNextPage]) return { data, diff --git a/src/containers/Token/shared/test/hooks/useAccountTransactions.test.tsx b/src/containers/Token/shared/test/hooks/useAccountTransactions.test.tsx index 6bdd1f0d6..f7eeb4dde 100644 --- a/src/containers/Token/shared/test/hooks/useAccountTransactions.test.tsx +++ b/src/containers/Token/shared/test/hooks/useAccountTransactions.test.tsx @@ -265,6 +265,55 @@ describe('useAccountTransactions', () => { expect(result.current.hasNextPage).toBe(false) }) + it('stops after 50 pages per round, and load more gets 50 more', async () => { + mockPages([], 200) + const { result } = renderMPTHook() + + await advanceThroughPages(50) + await waitFor(() => expect(result.current.loading).toBe(false)) + jest.advanceTimersByTime(1000) + expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(50) + expect(result.current.hasNextPage).toBe(true) + + act(() => { + result.current.fetchNextPage() + }) + await advanceThroughPages(100) + await waitFor(() => expect(result.current.loading).toBe(false)) + jest.advanceTimersByTime(1000) + expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(100) + }) + + it('resets the page budget when the token changes', async () => { + mockPages([], 200) + const { result, rerender } = renderHook( + ({ tokenId }) => + useAccountTransactions({ + account: 'rAccount123', + tokenId, + minResults: 4, + }), + { wrapper: createWrapper(), initialProps: { tokenId: 'MPT1' } }, + ) + + // Start a second round on MPT1, so its round starts at page 50 + await advanceThroughPages(50) + await waitFor(() => expect(result.current.loading).toBe(false)) + act(() => { + result.current.fetchNextPage() + }) + await advanceThroughPages(60) + rerender({ tokenId: 'MPT2' }) + await advanceThroughPages(110) + + // MPT2 gets a full round of 50 and then settles; a stale round start + // would leave it reporting loading with no fetch scheduled + await waitFor(() => expect(result.current.loading).toBe(false)) + jest.advanceTimersByTime(1000) + expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(110) + expect(result.current.hasNextPage).toBe(true) + }) + it('keeps auto-fetching when loading state stays set across pages', async () => { // Real timers: a fetch's state and data can land in one render here, // which stalled the scan before the effect re-ran per page From ebb63be3f983fdad496e75a745f4882c49565cf8 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Fri, 6 Nov 2026 09:57:32 -0500 Subject: [PATCH 5/9] fix: reuse cached MPT pages on return and route P2P account_tx via lib Going back to an MPT already viewed refetched every cached page at once: TablePicker stayed mounted, so react-query refetched the stale infinite query on the key change, bypassing the 200 ms pacing. Key TablePicker by MPT so it remounts and the cache is reused (refetchOnMount is off). Count pages already cached as a finished round, so a remount onto a round left unfinished waits for "Load more" instead of fetching. Choose the P2P (Clio) socket in lib/rippled.ts via a useP2P flag, like the other P2P commands, instead of in the hook with a cast. Co-Authored-By: Claude Opus 5.5 --- .../IOU/test/TablePicker/TablePicker.test.tsx | 1 + src/containers/Token/MPT/index.tsx | 3 + .../MPT/test/TablePicker/TablePicker.test.tsx | 110 +++++++++++++++++- .../shared/hooks/useAccountTransactions.ts | 19 ++- .../hooks/useAccountTransactions.test.tsx | 97 +++++++++------ src/rippled/accountTransactions.ts | 2 + src/rippled/lib/rippled.ts | 4 +- src/rippled/lib/test/rippled.test.ts | 36 ++++++ src/rippled/test/accountTransactions.test.ts | 24 ++++ 9 files changed, 246 insertions(+), 50 deletions(-) diff --git a/src/containers/Token/IOU/test/TablePicker/TablePicker.test.tsx b/src/containers/Token/IOU/test/TablePicker/TablePicker.test.tsx index 4af99ec46..68db5017a 100644 --- a/src/containers/Token/IOU/test/TablePicker/TablePicker.test.tsx +++ b/src/containers/Token/IOU/test/TablePicker/TablePicker.test.tsx @@ -189,6 +189,7 @@ describe('TablePicker container', () => { '44922483.5', undefined, undefined, + false, ) }) diff --git a/src/containers/Token/MPT/index.tsx b/src/containers/Token/MPT/index.tsx index 9025915a4..a8e5425bf 100644 --- a/src/containers/Token/MPT/index.tsx +++ b/src/containers/Token/MPT/index.tsx @@ -224,6 +224,9 @@ export const MPT = () => { {mptIssuanceId && mptokenIssuance && (
{ ) }) }) + + describe('returning to an MPT already viewed', () => { + const MPT_A = '00000001A407AF5856CCF3C42619DAA925813FC955C72983' + const MPT_B = '00000002A407AF5856CCF3C42619DAA925813FC955C72983' + + beforeEach(() => { + jest.useFakeTimers() + ;(getAccountTransactions as jest.Mock).mockImplementation( + async (_account: string, _tokenId: string, marker: string) => ({ + transactions: [], + marker: String((marker ? Number(marker) : 0) + 1), + }), + ) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + // The app's query defaults, with react-query's default 5-minute cache, so + // MPT A's pages are still cached when the visitor comes back to it + const appQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + retry: false, + }, + }, + }) + + const renderPicker = ( + queryClient: QueryClient, + id: string, + key: string, + ) => ( + + + + + + + + ) + + // View A for a full round, then B, then return to A, keying the table by + // MPT as MPT/index.tsx does. Returns the requests made after returning to A. + const visitAThenBThenA = async () => { + const queryClient = appQueryClient() + const ui = (id: string) => renderPicker(queryClient, id, id) + const { rerender } = render(ui(MPT_A)) + await waitFor( + () => expect(getAccountTransactions).toHaveBeenCalledTimes(50), + { + timeout: 60000, + }, + ) + rerender(ui(MPT_B)) + await waitFor( + () => expect(getAccountTransactions).toHaveBeenCalledTimes(100), + { + timeout: 60000, + }, + ) + rerender(ui(MPT_A)) + await waitFor( + () => + expect( + (getAccountTransactions as jest.Mock).mock.calls.length, + ).toBeGreaterThan(149), + { timeout: 3000 }, + ).catch(() => {}) + return (getAccountTransactions as jest.Mock).mock.calls.length - 100 + } + + it('reuses the cached pages instead of refetching them', async () => { + expect(await visitAThenBThenA()).toBe(0) + }) + }) }) diff --git a/src/containers/Token/shared/hooks/useAccountTransactions.ts b/src/containers/Token/shared/hooks/useAccountTransactions.ts index d62945401..fa0a4545c 100644 --- a/src/containers/Token/shared/hooks/useAccountTransactions.ts +++ b/src/containers/Token/shared/hooks/useAccountTransactions.ts @@ -2,9 +2,7 @@ import { useCallback, useContext, useEffect, useMemo, useState } from 'react' import { useInfiniteQuery } from 'react-query' import { useAnalytics } from '../../../shared/analytics' -import SocketContext, { - ExplorerXrplClient, -} from '../../../shared/SocketContext' +import SocketContext from '../../../shared/SocketContext' import { getAccountTransactions } from '../../../../rippled' /** @@ -57,10 +55,6 @@ export function useAccountTransactions({ }: UseAccountTransactionsOptions) { const { trackException } = useAnalytics() const rippledSocket = useContext(SocketContext) - // getAccountTransactions only sends a plain account_tx, so the P2P client works - const querySocket = useP2PSocket - ? ((rippledSocket?.p2pSocket ?? rippledSocket) as ExplorerXrplClient) - : rippledSocket const { data, @@ -76,7 +70,8 @@ export function useAccountTransactions({ tokenId, pageParam, limit, - querySocket, + rippledSocket, + useP2PSocket, ).catch((errorResponse: Error) => { const errorLocation = `transactions ${account}.${tokenId} at ${pageParam}` trackException(`${errorLocation} --- ${JSON.stringify(errorResponse)}`) @@ -107,13 +102,17 @@ export function useAccountTransactions({ [data], ) const pageCount = data?.pages.length ?? 0 - const [targetCount, setTargetCount] = useState(minResults) + // Already-cached pages count as a finished round, so showing them again + // (e.g. going back to an MPT) waits for "Load more" instead of fetching + const initialTarget = pageCount > 0 ? loadedCount : minResults + const [targetCount, setTargetCount] = useState(initialTarget) // Pages loaded when the current round started const [roundStartPage, setRoundStartPage] = useState(0) // The hook stays mounted when navigating between tokens useEffect(() => { - setTargetCount(minResults) + setTargetCount(initialTarget) setRoundStartPage(0) + // eslint-disable-next-line react-hooks/exhaustive-deps -- only when the token changes, not as pages load }, [account, tokenId, minResults]) const hasRoundBudget = pageCount - roundStartPage < MAX_PAGES_PER_ROUND diff --git a/src/containers/Token/shared/test/hooks/useAccountTransactions.test.tsx b/src/containers/Token/shared/test/hooks/useAccountTransactions.test.tsx index f7eeb4dde..9348f1610 100644 --- a/src/containers/Token/shared/test/hooks/useAccountTransactions.test.tsx +++ b/src/containers/Token/shared/test/hooks/useAccountTransactions.test.tsx @@ -314,6 +314,46 @@ describe('useAccountTransactions', () => { expect(result.current.hasNextPage).toBe(true) }) + it('waits for load more when remounting onto a round left unfinished', async () => { + // Every page has 2 matches, so a round of minResults=4 is 2 pages + mockPages([...Array(30).keys()], 30) + const queryClient = new QueryClient({ + defaultOptions: { queries: { refetchOnMount: false, retry: false } }, + }) + const wrapper = createWrapper({}, queryClient) + const renderMPT = () => + renderHook( + () => + useAccountTransactions({ + account: 'rAccount123', + tokenId: 'MPT', + minResults: 4, + }), + { wrapper }, + ) + + // Leave after the first page, with 2 of the 4 matches loaded + const first = renderMPT() + await waitFor(() => + expect(first.result.current.data?.pages).toHaveLength(1), + ) + first.unmount() + + const { result } = renderMPT() + jest.advanceTimersByTime(1000) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(1) + expect(result.current.hasNextPage).toBe(true) + + act(() => { + result.current.fetchNextPage() + }) + await advanceThroughPages(3) + await waitFor(() => expect(result.current.loading).toBe(false)) + // "Load more" fetches the next 4 matches: 2 more pages + expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(3) + }) + it('keeps auto-fetching when loading state stays set across pages', async () => { // Real timers: a fetch's state and data can land in one render here, // which stalled the scan before the effect re-ran per page @@ -405,44 +445,25 @@ describe('useAccountTransactions', () => { expect(result.current.error).toBeNull() }) - it('queries the P2P socket when useP2PSocket is set', async () => { - const p2pSocket = { name: 'p2p' } - const socket = { name: 'main', p2pSocket } - mockedGetAccountTransactions.mockResolvedValue({ transactions: [] }) - - renderHook( - () => - useAccountTransactions({ - account: 'rAccount123', - tokenId: 'MPT', - useP2PSocket: true, - }), - { wrapper: createWrapper(socket) }, - ) - - await waitFor(() => - expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(1), - ) - expect(mockedGetAccountTransactions.mock.calls[0][4]).toBe(p2pSocket) - }) - - it('falls back to the main socket when no P2P socket is configured', async () => { - const socket = { name: 'main', p2pSocket: undefined } - mockedGetAccountTransactions.mockResolvedValue({ transactions: [] }) + it.each([true, false])( + 'passes useP2PSocket=%s through to getAccountTransactions', + async (useP2PSocket) => { + mockedGetAccountTransactions.mockResolvedValue({ transactions: [] }) - renderHook( - () => - useAccountTransactions({ - account: 'rAccount123', - tokenId: 'MPT', - useP2PSocket: true, - }), - { wrapper: createWrapper(socket) }, - ) + renderHook( + () => + useAccountTransactions({ + account: 'rAccount123', + tokenId: 'MPT', + useP2PSocket, + }), + { wrapper: createWrapper() }, + ) - await waitFor(() => - expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(1), - ) - expect(mockedGetAccountTransactions.mock.calls[0][4]).toBe(socket) - }) + await waitFor(() => + expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(1), + ) + expect(mockedGetAccountTransactions.mock.calls[0][5]).toBe(useP2PSocket) + }, + ) }) diff --git a/src/rippled/accountTransactions.ts b/src/rippled/accountTransactions.ts index 9dc7bde6d..5093be4d2 100644 --- a/src/rippled/accountTransactions.ts +++ b/src/rippled/accountTransactions.ts @@ -23,6 +23,7 @@ const getAccountTransactions = async ( marker: any, limit: number | undefined, rippledSocket: ExplorerXrplClient, + useP2P: boolean = false, ): Promise => { // TODO: Retrieve txs for untagged X-address only? @@ -68,6 +69,7 @@ const getAccountTransactions = async ( classicAddress, limit, marker, + useP2P, ) // Nothing older than an MPT's creation can reference it, so stop paging there const reachedMPTCreation = diff --git a/src/rippled/lib/rippled.ts b/src/rippled/lib/rippled.ts index 2df1f16c0..e0121b4a9 100644 --- a/src/rippled/lib/rippled.ts +++ b/src/rippled/lib/rippled.ts @@ -504,11 +504,13 @@ const getAccountTransactions = async ( account: string, limit: number = 20, marker: string = '', + // The P2P (Clio) server keeps full history; the main one may not + useP2P: boolean = false, ): Promise => { const markerComponents = marker.split('.') const ledger = parseInt(markerComponents[0], 10) const seq = parseInt(markerComponents[1], 10) - const resp = await query(rippledSocket, { + const resp = await (useP2P ? queryP2P : query)(rippledSocket, { command: 'account_tx', account, limit, diff --git a/src/rippled/lib/test/rippled.test.ts b/src/rippled/lib/test/rippled.test.ts index e720bb740..f26da8fd9 100644 --- a/src/rippled/lib/test/rippled.test.ts +++ b/src/rippled/lib/test/rippled.test.ts @@ -5,6 +5,7 @@ import { getNegativeUNL, getAccountSponsorship, getAccountInfo, + getAccountTransactions, } from '../rippled' const VAULT_INDEX = @@ -481,3 +482,38 @@ describe('getAccountInfo', () => { }) }) }) + +describe('getAccountTransactions', () => { + const ACCOUNT = 'rMgkHzJ6UpnhxmemPxVMi8ob63kY57JH4h' + const response = { transactions: [] } + + it('queries the main socket by default', async () => { + const p2pSocket = makeSocket(response) + const socket = { ...makeSocket(response), p2pSocket } + + await getAccountTransactions(socket, ACCOUNT) + + expect(socket.send).toHaveBeenCalledTimes(1) + expect(p2pSocket.send).not.toHaveBeenCalled() + }) + + it('queries the P2P socket when useP2P is set', async () => { + const p2pSocket = makeSocket(response) + const socket = { ...makeSocket(response), p2pSocket } + + await getAccountTransactions(socket, ACCOUNT, 50, '', true) + + expect(p2pSocket.send).toHaveBeenCalledWith( + expect.objectContaining({ command: 'account_tx', account: ACCOUNT }), + ) + expect(socket.send).not.toHaveBeenCalled() + }) + + it('falls back to the main socket when no P2P socket is configured', async () => { + const socket = makeSocket(response) + + await getAccountTransactions(socket, ACCOUNT, 50, '', true) + + expect(socket.send).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/rippled/test/accountTransactions.test.ts b/src/rippled/test/accountTransactions.test.ts index f12ba3705..6b936ad0c 100644 --- a/src/rippled/test/accountTransactions.test.ts +++ b/src/rippled/test/accountTransactions.test.ts @@ -193,4 +193,28 @@ describe('accountTransactions', () => { expect(result.marker).toBe('12345.1') }) }) + + it.each([true, false])( + 'passes useP2P=%s to the account_tx request', + async (useP2P) => { + mockGetAccountTxs.mockResolvedValue({ transactions: [] }) + + await getAccountTransactions( + validAccount, + undefined, + '12345.1', + 50, + mockSocket, + useP2P, + ) + + expect(mockGetAccountTxs).toHaveBeenCalledWith( + mockSocket, + validAccount, + 50, + '12345.1', + useP2P, + ) + }, + ) }) From c8ecb08a2e4239a8b9c820ffda01e2e37bc812fa Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Fri, 6 Nov 2026 10:43:27 -0500 Subject: [PATCH 6/9] fix: apply the MPT transaction scan to the IOU page's All tab The IOU page read 20 issuer transactions per page and stopped after 10 empty pages (~220 transactions), so older IOU transactions were missed. Use the same hook options as the MPT page: 50 per page, scan until 20 matches with a 50-page budget per round, read from the Clio host, retry twice, and show "Try loading more transactions" when the budget runs out. There is no stop at creation, since an IOU has no creation tx. Key TablePicker by token, as on the MPT page. Without it, switching to another currency got 1 request instead of a full round (the attempt counter wasn't reset), and going back refetched every cached page. Co-Authored-By: Claude Opus 5.5 --- .../Token/IOU/TablePicker/index.tsx | 35 +++- src/containers/Token/IOU/index.tsx | 3 + .../IOU/test/TablePicker/TablePicker.test.tsx | 178 +++++++++++++++++- 3 files changed, 198 insertions(+), 18 deletions(-) diff --git a/src/containers/Token/IOU/TablePicker/index.tsx b/src/containers/Token/IOU/TablePicker/index.tsx index 3df098400..d5a9fa9d0 100644 --- a/src/containers/Token/IOU/TablePicker/index.tsx +++ b/src/containers/Token/IOU/TablePicker/index.tsx @@ -83,6 +83,12 @@ export const TablePicker = ({ useAccountTransactions({ account: accountId, tokenId: currency, + limit: 50, + // Skip non-matching pages until a screenful is found, so older + // transactions show up + minResults: 20, + useP2PSocket: true, + retry: 2, }) const [tablePickerState, setTablePickerState] = useState< @@ -128,15 +134,26 @@ export const TablePicker = ({ } // Helper to render transaction table - const renderTransactionTable = () => ( - page.transactions).flat()} - loading={loading} - emptyMessage={error?.message ? t(error.message as any) : ''} - onLoadMore={() => fetchNextPage()} - hasAdditionalResults={hasNextPage} - /> - ) + const renderTransactionTable = () => { + const transactions = data?.pages + ?.map((page: any) => page.transactions) + .flat() + // The page budget ran out before any match: more history is left, so + // "No transactions found." would be wrong + const tryLoading = !error && hasNextPage && transactions?.length === 0 + const emptyMessage = tryLoading + ? 'get_account_transactions_try' + : error?.message + return ( + fetchNextPage()} + hasAdditionalResults={hasNextPage} + /> + ) + } // Helper to render dex trades table const renderDexTradesTable = () => ( diff --git a/src/containers/Token/IOU/index.tsx b/src/containers/Token/IOU/index.tsx index 085d6c3af..5171258c7 100644 --- a/src/containers/Token/IOU/index.tsx +++ b/src/containers/Token/IOU/index.tsx @@ -181,6 +181,9 @@ export const IOU = () => { {accountId && tokenData && (
{ TEST_ACCOUNT_ID, TEST_CURRENCY, '44922483.5', + 50, undefined, - undefined, - false, + true, ) }) @@ -197,11 +197,15 @@ describe('TablePicker container', () => { const { container } = renderTablePicker(() => Promise.reject()) await flushPromises() - await waitFor(() => { - expect( - container.querySelector('.empty-transactions-message'), - ).toBeInTheDocument() - }) + // Two retries, 1 s and 2 s apart, before the error shows + await waitFor( + () => { + expect( + container.querySelector('.empty-transactions-message'), + ).toBeInTheDocument() + }, + { timeout: 5000 }, + ) expect(container.querySelector('.load-more-btn')).not.toBeInTheDocument() expect(container.querySelector('.transaction-table')).toBeInTheDocument() @@ -209,7 +213,7 @@ describe('TablePicker container', () => { container.querySelector('.empty-transactions-message'), ).toHaveTextContent('get_account_transactions_failed') expect(container.querySelectorAll('a').length).toBe(0) - }) + }, 10000) it('renders with holders data', () => { const setCurrentPage = jest.fn() @@ -320,4 +324,160 @@ describe('TablePicker container', () => { }) expect(container.querySelectorAll('.transaction-table').length).toBe(1) }) + + describe('scanning past non-matching history', () => { + // Pages 0..lastPage with no matching transactions; the marker is the third + // argument to getAccountTransactions + const emptyPages = + (lastPage: number) => + async (...args: any[]) => { + const page = args[2] ? Number(args[2]) : 0 + return { + transactions: [], + marker: page < lastPage ? String(page + 1) : undefined, + } + } + + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('asks to load more when the page budget runs out with history left', async () => { + const { container } = renderTablePicker(emptyPages(1000)) + + await waitFor( + () => + expect( + container.querySelector('.empty-transactions-message'), + ).toHaveTextContent('get_account_transactions_try'), + { timeout: 20000 }, + ) + expect(getAccountTransactions).toHaveBeenCalledTimes(50) + }) + + it('reports no transactions at the end of history', async () => { + const { container } = renderTablePicker(emptyPages(3)) + + await waitFor(() => + expect( + container.querySelector('.empty-transactions-message'), + ).toHaveTextContent('no_transactions_message'), + ) + expect(getAccountTransactions).toHaveBeenCalledTimes(4) + }) + }) + + describe('switching currency and back', () => { + beforeEach(() => { + jest.useFakeTimers() + ;(getAccountTransactions as Mock).mockImplementation( + async (_account: string, _currency: string, marker: string) => ({ + transactions: [], + marker: String((marker ? Number(marker) : 0) + 1), + }), + ) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + // The app's query defaults, with react-query's default 5-minute cache, so + // the first currency's pages are still cached on the way back + const appQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + retry: false, + }, + }, + }) + + // Keyed by token, as IOU/index.tsx does + const renderPicker = (queryClient: QueryClient, currency: string) => ( + + + + + + + + ) + + const callsFor = (currency: string) => + (getAccountTransactions as Mock).mock.calls.filter( + (call: any[]) => call[1] === currency, + ).length + + it('scans each currency in full and reuses the cache on return', async () => { + const queryClient = appQueryClient() + const { rerender } = render(renderPicker(queryClient, 'USD')) + await waitFor(() => expect(callsFor('USD')).toBe(50), { + timeout: 60000, + }) + + // A second currency gets its own full round + rerender(renderPicker(queryClient, 'EUR')) + await waitFor(() => expect(callsFor('EUR')).toBe(50), { + timeout: 60000, + }) + + // Going back reuses the cached pages instead of refetching them + rerender(renderPicker(queryClient, 'USD')) + await waitFor(() => expect(callsFor('USD')).toBeGreaterThan(50), { + timeout: 3000, + }).catch(() => {}) + expect(callsFor('USD')).toBe(50) + }) + }) }) From 01562582256721c063104054e60796c9138c5259 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Fri, 6 Nov 2026 12:54:39 -0500 Subject: [PATCH 7/9] fix: filter MPT transactions with Clio's mpt_issuance_id and show Batches Ask the Clio (P2P) host to filter account_tx by the MPT. Only matching rows are sent, and Clio matches through metadata too, so Vault and LoanBroker transactions that change the MPT's balances now show. Rows the server filtered aren't filtered again in the browser, which dropped them. Hosts without a configured Clio connection (custom networks) get no filter and keep the browser-side filter. Clio returns a Batch's inner transactions but not the Batch, so add each parent Batch (via ParentBatchID) after its last inner transaction, as the account page lists them. Without the server filter, keep a Batch whose inner transactions mention the token; its summary drops them. Revert the IOU page to main: the filter is MPT-only. Co-Authored-By: Claude Opus 5.5 --- .../Token/IOU/TablePicker/index.tsx | 35 +--- src/containers/Token/IOU/index.tsx | 3 - .../IOU/test/TablePicker/TablePicker.test.tsx | 178 +----------------- .../Batch/test/mock_data/BatchMPT.json | 72 +++++++ src/rippled/accountTransactions.ts | 66 ++++++- src/rippled/lib/rippled.ts | 6 + src/rippled/lib/test/rippled.test.ts | 61 ++++++ src/rippled/test/accountTransactions.test.ts | 174 ++++++++++++++++- .../test/accountTransactionsBatch.test.ts | 52 +++++ 9 files changed, 436 insertions(+), 211 deletions(-) create mode 100644 src/containers/shared/components/Transaction/Batch/test/mock_data/BatchMPT.json create mode 100644 src/rippled/test/accountTransactionsBatch.test.ts diff --git a/src/containers/Token/IOU/TablePicker/index.tsx b/src/containers/Token/IOU/TablePicker/index.tsx index d5a9fa9d0..3df098400 100644 --- a/src/containers/Token/IOU/TablePicker/index.tsx +++ b/src/containers/Token/IOU/TablePicker/index.tsx @@ -83,12 +83,6 @@ export const TablePicker = ({ useAccountTransactions({ account: accountId, tokenId: currency, - limit: 50, - // Skip non-matching pages until a screenful is found, so older - // transactions show up - minResults: 20, - useP2PSocket: true, - retry: 2, }) const [tablePickerState, setTablePickerState] = useState< @@ -134,26 +128,15 @@ export const TablePicker = ({ } // Helper to render transaction table - const renderTransactionTable = () => { - const transactions = data?.pages - ?.map((page: any) => page.transactions) - .flat() - // The page budget ran out before any match: more history is left, so - // "No transactions found." would be wrong - const tryLoading = !error && hasNextPage && transactions?.length === 0 - const emptyMessage = tryLoading - ? 'get_account_transactions_try' - : error?.message - return ( - fetchNextPage()} - hasAdditionalResults={hasNextPage} - /> - ) - } + const renderTransactionTable = () => ( + page.transactions).flat()} + loading={loading} + emptyMessage={error?.message ? t(error.message as any) : ''} + onLoadMore={() => fetchNextPage()} + hasAdditionalResults={hasNextPage} + /> + ) // Helper to render dex trades table const renderDexTradesTable = () => ( diff --git a/src/containers/Token/IOU/index.tsx b/src/containers/Token/IOU/index.tsx index 5171258c7..085d6c3af 100644 --- a/src/containers/Token/IOU/index.tsx +++ b/src/containers/Token/IOU/index.tsx @@ -181,9 +181,6 @@ export const IOU = () => { {accountId && tokenData && (
{ TEST_ACCOUNT_ID, TEST_CURRENCY, '44922483.5', - 50, undefined, - true, + undefined, + false, ) }) @@ -197,15 +197,11 @@ describe('TablePicker container', () => { const { container } = renderTablePicker(() => Promise.reject()) await flushPromises() - // Two retries, 1 s and 2 s apart, before the error shows - await waitFor( - () => { - expect( - container.querySelector('.empty-transactions-message'), - ).toBeInTheDocument() - }, - { timeout: 5000 }, - ) + await waitFor(() => { + expect( + container.querySelector('.empty-transactions-message'), + ).toBeInTheDocument() + }) expect(container.querySelector('.load-more-btn')).not.toBeInTheDocument() expect(container.querySelector('.transaction-table')).toBeInTheDocument() @@ -213,7 +209,7 @@ describe('TablePicker container', () => { container.querySelector('.empty-transactions-message'), ).toHaveTextContent('get_account_transactions_failed') expect(container.querySelectorAll('a').length).toBe(0) - }, 10000) + }) it('renders with holders data', () => { const setCurrentPage = jest.fn() @@ -324,160 +320,4 @@ describe('TablePicker container', () => { }) expect(container.querySelectorAll('.transaction-table').length).toBe(1) }) - - describe('scanning past non-matching history', () => { - // Pages 0..lastPage with no matching transactions; the marker is the third - // argument to getAccountTransactions - const emptyPages = - (lastPage: number) => - async (...args: any[]) => { - const page = args[2] ? Number(args[2]) : 0 - return { - transactions: [], - marker: page < lastPage ? String(page + 1) : undefined, - } - } - - beforeEach(() => { - jest.useFakeTimers() - }) - - afterEach(() => { - jest.useRealTimers() - }) - - it('asks to load more when the page budget runs out with history left', async () => { - const { container } = renderTablePicker(emptyPages(1000)) - - await waitFor( - () => - expect( - container.querySelector('.empty-transactions-message'), - ).toHaveTextContent('get_account_transactions_try'), - { timeout: 20000 }, - ) - expect(getAccountTransactions).toHaveBeenCalledTimes(50) - }) - - it('reports no transactions at the end of history', async () => { - const { container } = renderTablePicker(emptyPages(3)) - - await waitFor(() => - expect( - container.querySelector('.empty-transactions-message'), - ).toHaveTextContent('no_transactions_message'), - ) - expect(getAccountTransactions).toHaveBeenCalledTimes(4) - }) - }) - - describe('switching currency and back', () => { - beforeEach(() => { - jest.useFakeTimers() - ;(getAccountTransactions as Mock).mockImplementation( - async (_account: string, _currency: string, marker: string) => ({ - transactions: [], - marker: String((marker ? Number(marker) : 0) + 1), - }), - ) - }) - - afterEach(() => { - jest.useRealTimers() - }) - - // The app's query defaults, with react-query's default 5-minute cache, so - // the first currency's pages are still cached on the way back - const appQueryClient = () => - new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - refetchOnReconnect: false, - refetchOnMount: false, - retry: false, - }, - }, - }) - - // Keyed by token, as IOU/index.tsx does - const renderPicker = (queryClient: QueryClient, currency: string) => ( - - - - - - - - ) - - const callsFor = (currency: string) => - (getAccountTransactions as Mock).mock.calls.filter( - (call: any[]) => call[1] === currency, - ).length - - it('scans each currency in full and reuses the cache on return', async () => { - const queryClient = appQueryClient() - const { rerender } = render(renderPicker(queryClient, 'USD')) - await waitFor(() => expect(callsFor('USD')).toBe(50), { - timeout: 60000, - }) - - // A second currency gets its own full round - rerender(renderPicker(queryClient, 'EUR')) - await waitFor(() => expect(callsFor('EUR')).toBe(50), { - timeout: 60000, - }) - - // Going back reuses the cached pages instead of refetching them - rerender(renderPicker(queryClient, 'USD')) - await waitFor(() => expect(callsFor('USD')).toBeGreaterThan(50), { - timeout: 3000, - }).catch(() => {}) - expect(callsFor('USD')).toBe(50) - }) - }) }) diff --git a/src/containers/shared/components/Transaction/Batch/test/mock_data/BatchMPT.json b/src/containers/shared/components/Transaction/Batch/test/mock_data/BatchMPT.json new file mode 100644 index 000000000..34b658e2b --- /dev/null +++ b/src/containers/shared/components/Transaction/Batch/test/mock_data/BatchMPT.json @@ -0,0 +1,72 @@ +{ + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rMgkHzJ6UpnhxmemPxVMi8ob63kY57JH4h", + "Balance": "189999448", + "Flags": 0, + "OwnerCount": 10, + "Sequence": 21737342 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "A744447F4F454C7768DCE1D15C88C9CB84F4ADF79E577B7D4E335F0BC75D214F", + "PreviousFields": { + "Balance": "189999496", + "Sequence": 21737341 + }, + "PreviousTxnID": "108AD32044DB9FF4776AE573CE70A95D30853E56D44227A98B5256114DBB8620", + "PreviousTxnLgrSeq": 21848848 + } + } + ], + "TransactionIndex": 17, + "TransactionResult": "tesSUCCESS" + }, + "tx": { + "Account": "rMgkHzJ6UpnhxmemPxVMi8ob63kY57JH4h", + "Fee": "48", + "Flags": 65536, + "LastLedgerSequence": 21848876, + "RawTransactions": [ + { + "RawTransaction": { + "Account": "rMgkHzJ6UpnhxmemPxVMi8ob63kY57JH4h", + "Fee": "0", + "Flags": 1073741824, + "Holder": "r1mpyFA6cdnyKQUXG7iXTvcHPKAkHQcBS", + "MPTokenIssuanceID": "014BAF7CE2E6E09523A4EE29CED6EBF52DF4E31BD21E562D", + "Sequence": 21737342, + "SigningPubKey": "", + "TransactionType": "MPTokenAuthorize" + } + }, + { + "RawTransaction": { + "Account": "rMgkHzJ6UpnhxmemPxVMi8ob63kY57JH4h", + "Amount": { + "mpt_issuance_id": "014BAF7CE2E6E09523A4EE29CED6EBF52DF4E31BD21E562D", + "value": "105000000000" + }, + "Destination": "r1mpyFA6cdnyKQUXG7iXTvcHPKAkHQcBS", + "Fee": "0", + "Flags": 1073741824, + "Sequence": 21737343, + "SigningPubKey": "", + "TransactionType": "Payment" + } + } + ], + "Sequence": 21737341, + "SigningPubKey": "ED77E19396FBEE000E2446195BBC21BD9A9407E2369DD01395CEEE1469C20EB346", + "TransactionType": "Batch", + "TxnSignature": "04DD6FD47303C34207F5DC1563299342B07508DC38F93D68333876A5A0D3CC5223D1D2B1D817300AFCD5B0134046246E0DED180244AA023088B139540161AF00", + "hash": "DAE907A4E339A5CE356493E1ABEC177AC8F66B01FAA632E349922F3AF057D316", + "ctid": "C14D631A00110001", + "date": 846367451, + "ledger_index": 21848858, + "inLedger": 21848858 + }, + "validated": true +} diff --git a/src/rippled/accountTransactions.ts b/src/rippled/accountTransactions.ts index 5093be4d2..5bed3e863 100644 --- a/src/rippled/accountTransactions.ts +++ b/src/rippled/accountTransactions.ts @@ -5,7 +5,10 @@ import { } from 'ripple-address-codec' import { formatTransaction } from './lib/utils' -import { getAccountTransactions as getAccountTxs } from './lib/rippled' +import { + getAccountTransactions as getAccountTxs, + getTransaction, +} from './lib/rippled' import summarize from './lib/txSummary' import logger from './lib/logger' import type { ExplorerXrplClient } from '../containers/shared/SocketContext' @@ -17,6 +20,34 @@ export interface AccountTransactionsResult { marker?: any } +// Clio's MPT filter returns a Batch's inner transactions but not the Batch +// itself, so add each parent Batch after its last inner transaction +const withParentBatches = async ( + rippledSocket: ExplorerXrplClient, + transactions: any[], +): Promise => { + const parentIds = [ + ...new Set( + transactions.map((tx) => tx.meta?.ParentBatchID).filter(Boolean), + ), + ] + const parents = new Map( + await Promise.all( + parentIds.map(async (id): Promise<[string, any]> => [ + id, + await getTransaction(rippledSocket, id).catch(() => undefined), + ]), + ), + ) + return transactions.flatMap((tx, index) => { + const parentId = tx.meta?.ParentBatchID + const isLastInner = + parentId && transactions[index + 1]?.meta?.ParentBatchID !== parentId + const parent = isLastInner && parents.get(parentId) + return parent ? [tx, parent] : [tx] + }) +} + const getAccountTransactions = async ( account: string, currency: string | undefined, @@ -70,6 +101,8 @@ const getAccountTransactions = async ( limit, marker, useP2P, + // Only the MPT page reads from the P2P host, so currency is an MPT ID + useP2P ? currency : undefined, ) // Nothing older than an MPT's creation can reference it, so stop paging there const reachedMPTCreation = @@ -78,24 +111,39 @@ const getAccountTransactions = async ( (tx: any) => tx.meta?.mpt_issuance_id?.toUpperCase() === currency.toUpperCase(), ) - const transactions = data.transactions + // Clio also matches through metadata (e.g. Vault and LoanBroker + // transactions), which the summary doesn't keep, so don't filter again + const rows = data.filtered + ? await withParentBatches(rippledSocket, data.transactions) + : data.transactions + // Filter by currency (IOU) or MPT issuance ID (passed as currency) + const mentionsToken = (value: any) => { + const text = JSON.stringify(value) + return ( + text.includes(`"currency":"${currency?.toUpperCase()}"`) || + text.includes(`"${currency}"`) + ) + } + const transactions = rows .map((tx: any) => { const txn = formatTransaction(tx) - return summarize(txn, true) + return { txn, summary: summarize(txn, true) } }) - .filter((tx: any) => { + .filter(({ txn, summary }: any) => { // No filter - return all transactions - if (!currency) { + if (!currency || data.filtered) { return true } - // Filter by currency (IOU) or MPT issuance ID (passed as currency) - const txString = JSON.stringify(tx) + // A Batch's summary keeps only its inner hashes, so check the inner + // transactions themselves return ( - txString.includes(`"currency":"${currency.toUpperCase()}"`) || - txString.includes(`"${currency}"`) + mentionsToken(summary) || + (txn.tx?.TransactionType === 'Batch' && + mentionsToken(txn.tx.RawTransactions)) ) }) + .map(({ summary }: any) => summary) return { transactions, marker: reachedMPTCreation ? undefined : data.marker, diff --git a/src/rippled/lib/rippled.ts b/src/rippled/lib/rippled.ts index e0121b4a9..387ba2e4d 100644 --- a/src/rippled/lib/rippled.ts +++ b/src/rippled/lib/rippled.ts @@ -506,10 +506,14 @@ const getAccountTransactions = async ( marker: string = '', // The P2P (Clio) server keeps full history; the main one may not useP2P: boolean = false, + // Clio filters by MPT server-side; rippled ignores the field + mptIssuanceId: string | undefined = undefined, ): Promise => { const markerComponents = marker.split('.') const ledger = parseInt(markerComponents[0], 10) const seq = parseInt(markerComponents[1], 10) + // Only the configured P2P host is known to be Clio + const filtered = useP2P && !!mptIssuanceId && !!rippledSocket.p2pSocket const resp = await (useP2P ? queryP2P : query)(rippledSocket, { command: 'account_tx', account, @@ -522,6 +526,7 @@ const getAccountTransactions = async ( seq, } : undefined, + mpt_issuance_id: filtered ? mptIssuanceId : undefined, }) if (resp.error === 'actNotFound') { throw new Error('account not found', 404) @@ -535,6 +540,7 @@ const getAccountTransactions = async ( marker: resp.marker ? `${resp.marker.ledger}.${resp.marker.seq}` : undefined, + filtered, } } diff --git a/src/rippled/lib/test/rippled.test.ts b/src/rippled/lib/test/rippled.test.ts index f26da8fd9..fa8628b4b 100644 --- a/src/rippled/lib/test/rippled.test.ts +++ b/src/rippled/lib/test/rippled.test.ts @@ -516,4 +516,65 @@ describe('getAccountTransactions', () => { expect(socket.send).toHaveBeenCalledTimes(1) }) + + describe('MPT filter', () => { + const MPT_ID = '014BAF7CE2E6E09523A4EE29CED6EBF52DF4E31BD21E562D' + + it('asks the P2P (Clio) host to filter by MPT', async () => { + const p2pSocket = makeSocket(response) + const socket = { ...makeSocket(response), p2pSocket } + + const result = await getAccountTransactions( + socket, + ACCOUNT, + 50, + '', + true, + MPT_ID, + ) + + expect(p2pSocket.send).toHaveBeenCalledWith( + expect.objectContaining({ mpt_issuance_id: MPT_ID }), + ) + expect(result.filtered).toBe(true) + }) + + it('does not filter when no P2P host is configured', async () => { + // e.g. a custom network, whose server may be rippled + const socket = makeSocket(response) + + const result = await getAccountTransactions( + socket, + ACCOUNT, + 50, + '', + true, + MPT_ID, + ) + + expect(socket.send).toHaveBeenCalledWith( + expect.objectContaining({ mpt_issuance_id: undefined }), + ) + expect(result.filtered).toBe(false) + }) + + it('does not filter without useP2P', async () => { + const p2pSocket = makeSocket(response) + const socket = { ...makeSocket(response), p2pSocket } + + const result = await getAccountTransactions( + socket, + ACCOUNT, + 50, + '', + false, + MPT_ID, + ) + + expect(socket.send).toHaveBeenCalledWith( + expect.objectContaining({ mpt_issuance_id: undefined }), + ) + expect(result.filtered).toBe(false) + }) + }) }) diff --git a/src/rippled/test/accountTransactions.test.ts b/src/rippled/test/accountTransactions.test.ts index 6b936ad0c..332df0a39 100644 --- a/src/rippled/test/accountTransactions.test.ts +++ b/src/rippled/test/accountTransactions.test.ts @@ -3,6 +3,7 @@ import getAccountTransactions from '../accountTransactions' // Mock dependencies jest.mock('../lib/rippled', () => ({ getAccountTransactions: jest.fn(), + getTransaction: jest.fn(), })) jest.mock('../lib/utils', () => ({ @@ -18,6 +19,8 @@ jest.mock('../lib/logger', () => () => ({ // eslint-disable-next-line @typescript-eslint/no-require-imports const mockGetAccountTxs = require('../lib/rippled').getAccountTransactions +// eslint-disable-next-line @typescript-eslint/no-require-imports +const mockGetTransaction = require('../lib/rippled').getTransaction describe('accountTransactions', () => { const mockSocket = {} as any @@ -194,14 +197,17 @@ describe('accountTransactions', () => { }) }) - it.each([true, false])( - 'passes useP2P=%s to the account_tx request', - async (useP2P) => { + it.each([ + [true, '00000001ABC123DEF456'], + [false, undefined], + ])( + 'passes useP2P=%s and the MPT filter %s to the account_tx request', + async (useP2P, mptFilter) => { mockGetAccountTxs.mockResolvedValue({ transactions: [] }) await getAccountTransactions( validAccount, - undefined, + '00000001ABC123DEF456', '12345.1', 50, mockSocket, @@ -214,7 +220,167 @@ describe('accountTransactions', () => { 50, '12345.1', useP2P, + mptFilter, ) }, ) + + describe('rows filtered by the server', () => { + const mptIssuanceId = '00000001ABC123DEF456' + + it('keeps every row without filtering again', async () => { + // A VaultDeposit only references the MPT in its metadata + mockGetAccountTxs.mockResolvedValue({ + transactions: [{ hash: 'vault-deposit', type: 'VaultDeposit' }], + filtered: true, + }) + + const result = await getAccountTransactions( + validAccount, + mptIssuanceId, + undefined, + 20, + mockSocket, + true, + ) + + expect(result.transactions.map((tx: any) => tx.hash)).toEqual([ + 'vault-deposit', + ]) + }) + + it('adds each parent Batch after its last inner transaction', async () => { + const batch = { hash: 'batch', type: 'Batch' } + mockGetTransaction.mockResolvedValue(batch) + mockGetAccountTxs.mockResolvedValue({ + transactions: [ + { hash: 'inner-2', meta: { ParentBatchID: 'batch' } }, + { hash: 'inner-1', meta: { ParentBatchID: 'batch' } }, + { hash: 'other' }, + ], + filtered: true, + }) + + const result = await getAccountTransactions( + validAccount, + mptIssuanceId, + undefined, + 20, + mockSocket, + true, + ) + + expect(mockGetTransaction).toHaveBeenCalledTimes(1) + expect(result.transactions.map((tx: any) => tx.hash)).toEqual([ + 'inner-2', + 'inner-1', + 'batch', + 'other', + ]) + }) + + it('keeps the inner transactions when the Batch lookup fails', async () => { + mockGetTransaction.mockRejectedValue(new Error('txnNotFound')) + mockGetAccountTxs.mockResolvedValue({ + transactions: [{ hash: 'inner', meta: { ParentBatchID: 'batch' } }], + filtered: true, + }) + + const result = await getAccountTransactions( + validAccount, + mptIssuanceId, + undefined, + 20, + mockSocket, + true, + ) + + expect(result.transactions.map((tx: any) => tx.hash)).toEqual(['inner']) + }) + }) + + describe('Batch', () => { + const mptIssuanceId = '00000001ABC123DEF456' + // A Batch's summary keeps only its inner hashes, so its RawTransactions are + // the only place the token appears + const batchTx = (innerTxs: any[]) => ({ + hash: 'batch', + summary: { batchTransactions: [{ hash: 'inner' }] }, + tx: { + TransactionType: 'Batch', + RawTransactions: innerTxs.map((RawTransaction) => ({ + RawTransaction, + })), + }, + }) + + it('keeps a Batch whose inner transaction references the MPT', async () => { + mockGetAccountTxs.mockResolvedValue({ + transactions: [ + batchTx([ + { + TransactionType: 'MPTokenAuthorize', + MPTokenIssuanceID: mptIssuanceId, + }, + ]), + ], + }) + + const result = await getAccountTransactions( + validAccount, + mptIssuanceId, + undefined, + 20, + mockSocket, + ) + + expect(result.transactions.map((tx: any) => tx.hash)).toEqual(['batch']) + }) + + it('keeps a Batch whose inner transaction uses the IOU', async () => { + mockGetAccountTxs.mockResolvedValue({ + transactions: [ + batchTx([ + { + TransactionType: 'Payment', + Amount: { currency: 'USD', issuer: 'rIssuer1', value: '1' }, + }, + ]), + ], + }) + + const result = await getAccountTransactions( + validAccount, + 'usd', + undefined, + 20, + mockSocket, + ) + + expect(result.transactions.map((tx: any) => tx.hash)).toEqual(['batch']) + }) + + it('drops a Batch whose inner transactions are unrelated', async () => { + mockGetAccountTxs.mockResolvedValue({ + transactions: [ + batchTx([ + { + TransactionType: 'MPTokenAuthorize', + MPTokenIssuanceID: '00000002FFFFFFFFFFFF', + }, + ]), + ], + }) + + const result = await getAccountTransactions( + validAccount, + mptIssuanceId, + undefined, + 20, + mockSocket, + ) + + expect(result.transactions).toHaveLength(0) + }) + }) }) diff --git a/src/rippled/test/accountTransactionsBatch.test.ts b/src/rippled/test/accountTransactionsBatch.test.ts new file mode 100644 index 000000000..28a2ef3c7 --- /dev/null +++ b/src/rippled/test/accountTransactionsBatch.test.ts @@ -0,0 +1,52 @@ +import getAccountTransactions from '../accountTransactions' +import BatchMPT from '../../containers/shared/components/Transaction/Batch/test/mock_data/BatchMPT.json' + +// Uses the real formatter and summarizer: a Batch's summary drops its inner +// transactions, which is what the filter has to work around +jest.mock('../lib/rippled', () => ({ + getAccountTransactions: jest.fn(), +})) + +jest.mock('../lib/logger', () => () => ({ + info: jest.fn(), + error: jest.fn(), +})) + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const mockGetAccountTxs = require('../lib/rippled').getAccountTransactions + +describe('accountTransactions with a real Batch', () => { + const ISSUER = 'rMgkHzJ6UpnhxmemPxVMi8ob63kY57JH4h' + const MPT_ID = '014BAF7CE2E6E09523A4EE29CED6EBF52DF4E31BD21E562D' + + beforeEach(() => { + mockGetAccountTxs.mockResolvedValue({ transactions: [BatchMPT] }) + }) + + it('keeps a Batch whose inner transactions reference the MPT', async () => { + const result = await getAccountTransactions( + ISSUER, + MPT_ID, + undefined, + 20, + {} as any, + ) + + expect(result.transactions.map((tx: any) => tx.hash)).toEqual([ + BatchMPT.tx.hash, + ]) + expect(result.transactions[0].type).toBe('Batch') + }) + + it('drops the Batch for an unrelated MPT', async () => { + const result = await getAccountTransactions( + ISSUER, + '00000001FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF', + undefined, + 20, + {} as any, + ) + + expect(result.transactions).toHaveLength(0) + }) +}) From d260ffef4bdd83725a469d1593c2f980d5c0c9d1 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Fri, 6 Nov 2026 13:33:47 -0500 Subject: [PATCH 8/9] fix: look up parent Batches on the Clio host and trim duplicate tests The parent Batch was looked up on the main host. On testnet and devnet that is rippled with short history, so a Batch older than it was silently dropped while its inner rows, from Clio, still showed. Give getTransaction the same useP2P flag as getAccountTxs and look the Batch up on the Clio host the rows came from. Drop three tests that duplicate stronger ones: two mocked Batch filter tests (covered by the real-Batch test) and a main-socket default test. Co-Authored-By: Claude Opus 5.5 --- src/rippled/accountTransactions.ts | 3 +- src/rippled/lib/rippled.ts | 4 +- src/rippled/lib/test/rippled.test.ts | 34 ++++++++++---- src/rippled/test/accountTransactions.test.ts | 49 +------------------- 4 files changed, 31 insertions(+), 59 deletions(-) diff --git a/src/rippled/accountTransactions.ts b/src/rippled/accountTransactions.ts index 5bed3e863..c70c57bcf 100644 --- a/src/rippled/accountTransactions.ts +++ b/src/rippled/accountTransactions.ts @@ -35,7 +35,8 @@ const withParentBatches = async ( await Promise.all( parentIds.map(async (id): Promise<[string, any]> => [ id, - await getTransaction(rippledSocket, id).catch(() => undefined), + // Same Clio host as the inner rows: the main host may lack old history + await getTransaction(rippledSocket, id, true).catch(() => undefined), ]), ), ) diff --git a/src/rippled/lib/rippled.ts b/src/rippled/lib/rippled.ts index 387ba2e4d..0930c7fb4 100644 --- a/src/rippled/lib/rippled.ts +++ b/src/rippled/lib/rippled.ts @@ -171,6 +171,8 @@ const getLedgerEntry = async ( const getTransaction = async ( rippledSocket: ExplorerXrplClient, txId: string, + // The P2P (Clio) server keeps full history; the main one may not + useP2P: boolean = false, ): Promise => { const params: any = { command: 'tx', @@ -183,7 +185,7 @@ const getTransaction = async ( throw new Error(`${txId} not a ctid or hash`, 404) } - const resp = await query(rippledSocket, params) + const resp = await (useP2P ? queryP2P : query)(rippledSocket, params) if (resp.error === 'txnNotFound') { throw new Error('transaction not found', 404) } diff --git a/src/rippled/lib/test/rippled.test.ts b/src/rippled/lib/test/rippled.test.ts index fa8628b4b..f129abcbc 100644 --- a/src/rippled/lib/test/rippled.test.ts +++ b/src/rippled/lib/test/rippled.test.ts @@ -6,6 +6,7 @@ import { getAccountSponsorship, getAccountInfo, getAccountTransactions, + getTransaction, } from '../rippled' const VAULT_INDEX = @@ -487,16 +488,6 @@ describe('getAccountTransactions', () => { const ACCOUNT = 'rMgkHzJ6UpnhxmemPxVMi8ob63kY57JH4h' const response = { transactions: [] } - it('queries the main socket by default', async () => { - const p2pSocket = makeSocket(response) - const socket = { ...makeSocket(response), p2pSocket } - - await getAccountTransactions(socket, ACCOUNT) - - expect(socket.send).toHaveBeenCalledTimes(1) - expect(p2pSocket.send).not.toHaveBeenCalled() - }) - it('queries the P2P socket when useP2P is set', async () => { const p2pSocket = makeSocket(response) const socket = { ...makeSocket(response), p2pSocket } @@ -578,3 +569,26 @@ describe('getAccountTransactions', () => { }) }) }) + +describe('getTransaction', () => { + const HASH = + 'DAE907A4E339A5CE356493E1ABEC177AC8F66B01FAA632E349922F3AF057D316' + const response = { hash: HASH, meta: {}, validated: true } + + it.each([ + [false, 'main'], + [true, 'P2P'], + ])('with useP2P=%s queries the %s socket', async (useP2P, which) => { + const p2pSocket = makeSocket(response) + const socket = { ...makeSocket(response), p2pSocket } + + await getTransaction(socket, HASH, useP2P) + + const used = which === 'P2P' ? p2pSocket : socket + const unused = which === 'P2P' ? socket : p2pSocket + expect(used.send).toHaveBeenCalledWith( + expect.objectContaining({ command: 'tx', transaction: HASH }), + ) + expect(unused.send).not.toHaveBeenCalled() + }) +}) diff --git a/src/rippled/test/accountTransactions.test.ts b/src/rippled/test/accountTransactions.test.ts index 332df0a39..72ee1afdf 100644 --- a/src/rippled/test/accountTransactions.test.ts +++ b/src/rippled/test/accountTransactions.test.ts @@ -270,7 +270,9 @@ describe('accountTransactions', () => { true, ) + // On the P2P (Clio) host, like the inner rows expect(mockGetTransaction).toHaveBeenCalledTimes(1) + expect(mockGetTransaction).toHaveBeenCalledWith(mockSocket, 'batch', true) expect(result.transactions.map((tx: any) => tx.hash)).toEqual([ 'inner-2', 'inner-1', @@ -300,7 +302,6 @@ describe('accountTransactions', () => { }) describe('Batch', () => { - const mptIssuanceId = '00000001ABC123DEF456' // A Batch's summary keeps only its inner hashes, so its RawTransactions are // the only place the token appears const batchTx = (innerTxs: any[]) => ({ @@ -314,29 +315,6 @@ describe('accountTransactions', () => { }, }) - it('keeps a Batch whose inner transaction references the MPT', async () => { - mockGetAccountTxs.mockResolvedValue({ - transactions: [ - batchTx([ - { - TransactionType: 'MPTokenAuthorize', - MPTokenIssuanceID: mptIssuanceId, - }, - ]), - ], - }) - - const result = await getAccountTransactions( - validAccount, - mptIssuanceId, - undefined, - 20, - mockSocket, - ) - - expect(result.transactions.map((tx: any) => tx.hash)).toEqual(['batch']) - }) - it('keeps a Batch whose inner transaction uses the IOU', async () => { mockGetAccountTxs.mockResolvedValue({ transactions: [ @@ -359,28 +337,5 @@ describe('accountTransactions', () => { expect(result.transactions.map((tx: any) => tx.hash)).toEqual(['batch']) }) - - it('drops a Batch whose inner transactions are unrelated', async () => { - mockGetAccountTxs.mockResolvedValue({ - transactions: [ - batchTx([ - { - TransactionType: 'MPTokenAuthorize', - MPTokenIssuanceID: '00000002FFFFFFFFFFFF', - }, - ]), - ], - }) - - const result = await getAccountTransactions( - validAccount, - mptIssuanceId, - undefined, - 20, - mockSocket, - ) - - expect(result.transactions).toHaveLength(0) - }) }) }) From 4b850b032c5c768825ec4fb7ba50c2ecf7e502ea Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Fri, 6 Nov 2026 15:30:12 -0500 Subject: [PATCH 9/9] fix: read 250 issuer transactions per MPT request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Clio filtering by MPT, a response holds only matching rows, so a larger page mostly saves requests. Measured: the reported testnet MPT needs 7 requests instead of 33, and the densest mainnet MPT found (0627B685…, 182 of 200 issuer transactions match) peaks at 623 KB per response, under s2.ripple.com's 1 MB per 0.5 s limit. 400 reached 953 KB. Co-Authored-By: Claude Opus 5.5 --- src/containers/Token/MPT/TablePicker/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/containers/Token/MPT/TablePicker/index.tsx b/src/containers/Token/MPT/TablePicker/index.tsx index 9537abca7..2923e5f1d 100644 --- a/src/containers/Token/MPT/TablePicker/index.tsx +++ b/src/containers/Token/MPT/TablePicker/index.tsx @@ -53,7 +53,9 @@ export const TablePicker = ({ useAccountTransactions({ account: issuer, tokenId: mptIssuanceId, - limit: 50, + // Clio returns only matching rows; larger pages risk s2.ripple.com's + // 1 MB per 0.5 s limit when nearly every transaction matches + limit: 250, // Skip non-matching pages until a screenful is found or the issuance's // creation is reached, so older transactions show up minResults: 20,