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/TablePicker/index.tsx b/src/containers/Token/MPT/TablePicker/index.tsx index 1e1923bdc..2923e5f1d 100644 --- a/src/containers/Token/MPT/TablePicker/index.tsx +++ b/src/containers/Token/MPT/TablePicker/index.tsx @@ -53,7 +53,14 @@ 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, + useP2PSocket: true, + retry: 2, }) const [tablePickerState, setTablePickerState] = useState< @@ -71,15 +78,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/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 && (
{ 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'), + ) + }) + }) + + 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 7dc4c895a..fa0a4545c 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' @@ -16,11 +16,23 @@ 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 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 + /** Number of times to retry a failed page fetch */ + retry?: number } /** @@ -29,12 +41,17 @@ 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 minResults, it keeps fetching until + * 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, tokenId, limit, + minResults = 0, + useP2PSocket = false, + retry = 0, }: UseAccountTransactionsOptions) { const { trackException } = useAnalytics() const rippledSocket = useContext(SocketContext) @@ -54,6 +71,7 @@ export function useAccountTransactions({ pageParam, limit, rippledSocket, + useP2PSocket, ).catch((errorResponse: Error) => { const errorLocation = `transactions ${account}.${tokenId} at ${pageParam}` trackException(`${errorLocation} --- ${JSON.stringify(errorResponse)}`) @@ -61,6 +79,7 @@ export function useAccountTransactions({ }), { getNextPageParam: (lastPage) => lastPage.marker, + retry, }, ) @@ -74,14 +93,43 @@ 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 pageCount = data?.pages.length ?? 0 + // 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(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 + 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 until + // minResults matches are loaded when set const shouldAutoFetch = - lastPageWasEmpty && canAutoFetch && hasRemainingAttempts + canAutoFetch && + (minResults + ? loadedCount < targetCount && hasRoundBudget + : lastPageWasEmpty && hasRemainingAttempts) + // Re-run per loaded page: shouldAutoFetch can stay true across a fetch useEffect(() => { if (!shouldAutoFetch) { return undefined @@ -93,17 +141,25 @@ 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 const loading = isLoading || shouldAutoFetch + // 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, pageCount, 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 d8c06ef5b..9348f1610 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 }) => ( @@ -154,4 +162,308 @@ describe('useAccountTransactions', () => { { hash: 'tx1' }, ]) }) + + 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 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) + + 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' } }, + ) + + 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('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('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 + 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 () => { + 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() }, + ) + + await advanceThroughPages(11) + 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', + minResults: 20, + }), + { 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.each([true, false])( + 'passes useP2PSocket=%s through to getAccountTransactions', + async (useP2PSocket) => { + mockedGetAccountTransactions.mockResolvedValue({ transactions: [] }) + + renderHook( + () => + useAccountTransactions({ + account: 'rAccount123', + tokenId: 'MPT', + useP2PSocket, + }), + { wrapper: createWrapper() }, + ) + + await waitFor(() => + expect(mockedGetAccountTransactions).toHaveBeenCalledTimes(1), + ) + expect(mockedGetAccountTransactions.mock.calls[0][5]).toBe(useP2PSocket) + }, + ) }) 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 efc122a8c..c70c57bcf 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,12 +20,42 @@ 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, + // Same Clio host as the inner rows: the main host may lack old history + await getTransaction(rippledSocket, id, true).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, marker: any, limit: number | undefined, rippledSocket: ExplorerXrplClient, + useP2P: boolean = false, ): Promise => { // TODO: Retrieve txs for untagged X-address only? @@ -68,28 +101,53 @@ const getAccountTransactions = async ( classicAddress, limit, marker, + useP2P, + // Only the MPT page reads from the P2P host, so currency is an MPT ID + useP2P ? currency : undefined, ) - const transactions = data.transactions + // 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(), + ) + // 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: data.marker, + marker: reachedMPTCreation ? undefined : data.marker, } } catch (error: any) { log.error(error.toString()) diff --git a/src/rippled/lib/rippled.ts b/src/rippled/lib/rippled.ts index 2df1f16c0..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) } @@ -504,11 +506,17 @@ 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, + // 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) - const resp = await query(rippledSocket, { + // 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, limit, @@ -520,6 +528,7 @@ const getAccountTransactions = async ( seq, } : undefined, + mpt_issuance_id: filtered ? mptIssuanceId : undefined, }) if (resp.error === 'actNotFound') { throw new Error('account not found', 404) @@ -533,6 +542,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 e720bb740..f129abcbc 100644 --- a/src/rippled/lib/test/rippled.test.ts +++ b/src/rippled/lib/test/rippled.test.ts @@ -5,6 +5,8 @@ import { getNegativeUNL, getAccountSponsorship, getAccountInfo, + getAccountTransactions, + getTransaction, } from '../rippled' const VAULT_INDEX = @@ -481,3 +483,112 @@ describe('getAccountInfo', () => { }) }) }) + +describe('getAccountTransactions', () => { + const ACCOUNT = 'rMgkHzJ6UpnhxmemPxVMi8ob63kY57JH4h' + const response = { transactions: [] } + + 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) + }) + + 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) + }) + }) +}) + +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 f19c573f7..72ee1afdf 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 @@ -114,4 +117,225 @@ 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') + }) + }) + + 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, + '00000001ABC123DEF456', + '12345.1', + 50, + mockSocket, + useP2P, + ) + + expect(mockGetAccountTxs).toHaveBeenCalledWith( + mockSocket, + validAccount, + 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, + ) + + // 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', + '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', () => { + // 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 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']) + }) + }) }) 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) + }) +})