diff --git a/app/components/UI/Perps/Perps.testIds.ts b/app/components/UI/Perps/Perps.testIds.ts index 6da45ce91c21..52f232fb76f4 100644 --- a/app/components/UI/Perps/Perps.testIds.ts +++ b/app/components/UI/Perps/Perps.testIds.ts @@ -759,6 +759,7 @@ export const PerpsTransactionsViewSelectorsIDs = { TAB_ORDERS: 'perps-transactions-tab-orders', TAB_FUNDING: 'perps-transactions-tab-funding', TAB_DEPOSITS: 'perps-transactions-tab-deposits', + FUNDING_LOAD_MORE_SPINNER: 'perps-transactions-funding-load-more-spinner', } as const; // ======================================== diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.styles.ts b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.styles.ts index de9f2a783d57..878c05855bd3 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.styles.ts +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.styles.ts @@ -89,5 +89,9 @@ export const styleSheet = (params: { theme: Theme }) => { paddingVertical: 12, paddingHorizontal: 16, }, + loadMoreContainer: { + paddingVertical: 16, + alignItems: 'center' as const, + }, }; }; diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.test.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.test.tsx index e718f3427077..a4ea86bc9193 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.test.tsx @@ -175,6 +175,9 @@ describe('PerpsTransactionsView', () => { isLoading: false, error: null, refetch: mockRefetchTransactions, + loadMoreFunding: jest.fn().mockResolvedValue(undefined), + hasFundingMore: true, + isFetchingMoreFunding: false, }); mockUsePerpsEventTracking.mockReturnValue({ @@ -299,6 +302,9 @@ describe('PerpsTransactionsView', () => { isLoading: false, error: null, refetch: mockRefetch, + loadMoreFunding: jest.fn().mockResolvedValue(undefined), + hasFundingMore: true, + isFetchingMoreFunding: false, }); renderWithProvider(, { @@ -318,6 +324,9 @@ describe('PerpsTransactionsView', () => { isLoading: false, error: null, refetch: jest.fn(), + loadMoreFunding: jest.fn().mockResolvedValue(undefined), + hasFundingMore: true, + isFetchingMoreFunding: false, }); const component = renderWithProvider(, { @@ -350,6 +359,9 @@ describe('PerpsTransactionsView', () => { isLoading: false, error: null, refetch: mockRefetchTransactions, + loadMoreFunding: jest.fn().mockResolvedValue(undefined), + hasFundingMore: true, + isFetchingMoreFunding: false, }); const component = renderWithProvider(, { @@ -368,6 +380,9 @@ describe('PerpsTransactionsView', () => { isLoading: false, error: 'API Error', refetch: jest.fn(), + loadMoreFunding: jest.fn().mockResolvedValue(undefined), + hasFundingMore: true, + isFetchingMoreFunding: false, }); const component = renderWithProvider(, { @@ -426,6 +441,9 @@ describe('PerpsTransactionsView', () => { isLoading: false, error: null, refetch: jest.fn(), + loadMoreFunding: jest.fn().mockResolvedValue(undefined), + hasFundingMore: true, + isFetchingMoreFunding: false, }); renderWithProvider(, { @@ -459,6 +477,9 @@ describe('PerpsTransactionsView', () => { isLoading: false, error: null, refetch: jest.fn(), + loadMoreFunding: jest.fn().mockResolvedValue(undefined), + hasFundingMore: true, + isFetchingMoreFunding: false, }); renderWithProvider(, { @@ -477,6 +498,9 @@ describe('PerpsTransactionsView', () => { isLoading: false, error: 'Network error', refetch: jest.fn(), + loadMoreFunding: jest.fn().mockResolvedValue(undefined), + hasFundingMore: true, + isFetchingMoreFunding: false, }); const component = renderWithProvider(, { @@ -499,6 +523,9 @@ describe('PerpsTransactionsView', () => { isLoading: false, error: null, refetch: jest.fn(), + loadMoreFunding: jest.fn().mockResolvedValue(undefined), + hasFundingMore: true, + isFetchingMoreFunding: false, }); const component = renderWithProvider(, { @@ -614,6 +641,9 @@ describe('PerpsTransactionsView', () => { isLoading: false, error: null, refetch: jest.fn(), + loadMoreFunding: jest.fn().mockResolvedValue(undefined), + hasFundingMore: true, + isFetchingMoreFunding: false, }); const component = renderWithProvider(, { diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx index 8abce05f38ee..e8b6d4f84e9a 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx @@ -1,7 +1,18 @@ import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { FlashList } from '@shopify/flash-list'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { RefreshControl, ScrollView, View } from 'react-native'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + ActivityIndicator, + RefreshControl, + ScrollView, + View, +} from 'react-native'; import { useSelector } from 'react-redux'; import { strings } from '../../../../../../locales/i18n'; import { @@ -77,6 +88,9 @@ const PerpsTransactionsView: React.FC = () => { transactions: allTransactions, isLoading: transactionsLoading, refetch: refreshTransactions, + loadMoreFunding, + hasFundingMore, + isFetchingMoreFunding, } = usePerpsTransactionHistory({ skipInitialFetch: !isConnected, accountId, @@ -194,6 +208,28 @@ const PerpsTransactionsView: React.FC = () => { } }, [isConnected, refreshTransactions]); + // Auto-advance funding cursor when the Funding tab is empty but more data + // exists. FlashList does not reliably call onEndReached on empty lists, so + // we trigger loadMoreFunding directly when the tab shows no results. + useEffect(() => { + if ( + activeFilter === 'Funding' && + !transactionsLoading && + !isFetchingMoreFunding && + hasFundingMore && + fundingTransactions.length === 0 + ) { + loadMoreFunding(); + } + }, [ + activeFilter, + transactionsLoading, + isFetchingMoreFunding, + hasFundingMore, + fundingTransactions, + loadMoreFunding, + ]); + useFocusEffect( useCallback(() => { if (!isConnected) { @@ -490,9 +526,26 @@ const PerpsTransactionsView: React.FC = () => { item.type === 'header' ? 'header' : 'transaction' } ListEmptyComponent={shouldShowEmptyState ? renderEmptyState : null} + ListFooterComponent={ + activeFilter === 'Funding' && isFetchingMoreFunding ? ( + + + + ) : null + } refreshControl={ } + onEndReached={ + activeFilter === 'Funding' && hasFundingMore + ? loadMoreFunding + : undefined + } + onEndReachedThreshold={0.5} showsVerticalScrollIndicator={false} drawDistance={ PERPS_TRANSACTIONS_HISTORY_CONSTANTS.FLASH_LIST_DRAW_DISTANCE diff --git a/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts b/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts index b2d026d9622b..7593d8597717 100644 --- a/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts @@ -302,6 +302,9 @@ describe('usePerpsHomeData', () => { isLoading: false, error: null, refetch: jest.fn().mockResolvedValue(undefined), + loadMoreFunding: jest.fn().mockResolvedValue(undefined), + hasFundingMore: true, + isFetchingMoreFunding: false, }); // Mock sortMarkets to return markets as-is by default @@ -897,6 +900,9 @@ describe('usePerpsHomeData', () => { isLoading: false, error: null, refetch: jest.fn().mockResolvedValue(undefined), + loadMoreFunding: jest.fn().mockResolvedValue(undefined), + hasFundingMore: true, + isFetchingMoreFunding: false, }); const { result } = renderHook(() => usePerpsHomeData()); diff --git a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts index 7fb779c179b8..cbe1828ce57a 100644 --- a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts @@ -588,8 +588,6 @@ describe('usePerpsTransactionHistory', () => { it('uses provided parameters', async () => { const params = { - startTime: 1640995200000, - endTime: 1640995300000, accountId: 'eip155:1:0x1234567890123456789012345678901234567890' as CaipAccountId, }; @@ -608,10 +606,9 @@ describe('usePerpsTransactionHistory', () => { expect(mockProvider.getOrders).toHaveBeenCalledWith({ accountId: 'eip155:1:0x1234567890123456789012345678901234567890', }); + // startTime/endTime defaults are handled in HyperLiquidProvider via 30-day window expect(mockProvider.getFunding).toHaveBeenCalledWith({ accountId: 'eip155:1:0x1234567890123456789012345678901234567890', - startTime: 1640995200000, - endTime: 1640995300000, }); }); @@ -1369,6 +1366,137 @@ describe('usePerpsTransactionHistory', () => { }); }); + describe('loadMoreFunding', () => { + const olderFundingRaw = [ + { + symbol: 'ETH', + amountUsd: '-1.00', + rate: '0.0001', + timestamp: 1638403200000, + }, + ]; + const olderFundingTx = { + id: 'funding-1638403200000-ETH', + type: 'funding' as const, + category: 'funding_fee' as const, + title: 'Paid funding fee', + subtitle: 'ETH', + timestamp: 1638403200000, + asset: 'ETH', + fundingAmount: { + isPositive: false, + fee: '-$1.00', + feeNumber: -1, + rate: '0.01%', + }, + }; + + async function renderAndWaitForInitialFetch() { + const hook = renderHook(() => + usePerpsTransactionHistory({ skipInitialFetch: false }), + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + return hook; + } + + it('fetches older funding and appends to transactions', async () => { + // Arrange + const { result } = await renderAndWaitForInitialFetch(); + mockProvider.getFunding.mockResolvedValueOnce(olderFundingRaw); + mockTransformFundingToTransactions.mockReturnValueOnce([olderFundingTx]); + + // Act + await act(async () => { + await result.current.loadMoreFunding(); + }); + + // Assert + expect(mockProvider.getFunding).toHaveBeenLastCalledWith( + expect.objectContaining({ + startTime: expect.any(Number), + endTime: expect.any(Number), + }), + ); + expect(result.current.hasFundingMore).toBe(true); + expect(result.current.isFetchingMoreFunding).toBe(false); + }); + + it('skips empty windows and keeps hasFundingMore true', async () => { + // Arrange + const { result } = await renderAndWaitForInitialFetch(); + mockProvider.getFunding.mockResolvedValueOnce([]); + + // Act + await act(async () => { + await result.current.loadMoreFunding(); + }); + + // Assert — cursor advances but pagination continues + expect(result.current.hasFundingMore).toBe(true); + }); + + it('sets hasFundingMore to false on fetch error', async () => { + // Arrange + const { result } = await renderAndWaitForInitialFetch(); + mockProvider.getFunding.mockRejectedValueOnce(new Error('API error')); + + // Act + await act(async () => { + await result.current.loadMoreFunding(); + }); + + // Assert + expect(result.current.hasFundingMore).toBe(false); + expect(result.current.isFetchingMoreFunding).toBe(false); + }); + + it('does not fetch when hasFundingMore is false', async () => { + // Arrange + const { result } = await renderAndWaitForInitialFetch(); + // Force hasFundingMore to false via error (errors still stop pagination) + mockProvider.getFunding.mockRejectedValueOnce(new Error('API error')); + await act(async () => { + await result.current.loadMoreFunding(); + }); + expect(result.current.hasFundingMore).toBe(false); + mockProvider.getFunding.mockClear(); + + // Act + await act(async () => { + await result.current.loadMoreFunding(); + }); + + // Assert — no new call + expect(mockProvider.getFunding).not.toHaveBeenCalled(); + }); + + it('deduplicates transactions when loadMore returns overlapping ids', async () => { + // Arrange + const { result } = await renderAndWaitForInitialFetch(); + const dupFundingTx = { + ...olderFundingTx, + id: 'funding-1638403200000-ETH', + }; + mockProvider.getFunding.mockResolvedValueOnce(olderFundingRaw); + mockTransformFundingToTransactions.mockReturnValueOnce([ + dupFundingTx, + dupFundingTx, + ]); + + // Act + await act(async () => { + await result.current.loadMoreFunding(); + }); + + // Assert — no duplicates in result + const ids = result.current.transactions.map((tx) => tx.id); + const uniqueIds = new Set(ids); + expect(ids.length).toBe(uniqueIds.size); + }); + }); + describe('connection state transitions', () => { it('triggers fetch when skipInitialFetch transitions from true to false', async () => { // Reset mocks to track calls clearly diff --git a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts index 0eb2e6528f06..ea1270020445 100644 --- a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts +++ b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts @@ -6,7 +6,23 @@ import { TransactionMeta, TransactionType, } from '@metamask/transaction-controller'; -import type { OrderFill } from '@metamask/perps-controller'; +import { + PERPS_TRANSACTIONS_HISTORY_CONSTANTS, + type OrderFill, +} from '@metamask/perps-controller'; + +const PAGE_WINDOW_MS = + PERPS_TRANSACTIONS_HISTORY_CONSTANTS.FUNDING_HISTORY_PAGE_WINDOW_DAYS * + 24 * + 60 * + 60 * + 1000; +const MAX_LOOKBACK_MS = + PERPS_TRANSACTIONS_HISTORY_CONSTANTS.DEFAULT_FUNDING_HISTORY_DAYS * + 24 * + 60 * + 60 * + 1000; import Engine from '../../../../core/Engine'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import type { CaipAccountId } from '@metamask/utils'; @@ -31,6 +47,15 @@ import { mergeOrderFills, } from '../utils/transactionTransforms'; +function deduplicateById(transactions: PerpsTransaction[]): PerpsTransaction[] { + const seen = new Set(); + return transactions.filter((tx) => { + if (seen.has(tx.id)) return false; + seen.add(tx.id); + return true; + }); +} + function deduplicateByTxHash( walletTxs: PerpsTransaction[], restHashes: Set, @@ -43,8 +68,6 @@ function deduplicateByTxHash( } interface UsePerpsTransactionHistoryParams { - startTime?: number; - endTime?: number; accountId?: CaipAccountId; skipInitialFetch?: boolean; } @@ -54,6 +77,9 @@ interface UsePerpsTransactionHistoryResult { isLoading: boolean; error: string | null; refetch: () => Promise; + loadMoreFunding: () => Promise; + hasFundingMore: boolean; + isFetchingMoreFunding: boolean; } /** @@ -62,8 +88,6 @@ interface UsePerpsTransactionHistoryResult { * Uses HyperLiquid user history as the single source of truth for withdrawals */ export const usePerpsTransactionHistory = ({ - startTime, - endTime, accountId, skipInitialFetch = false, }: UsePerpsTransactionHistoryParams = {}): UsePerpsTransactionHistoryResult => { @@ -72,13 +96,23 @@ export const usePerpsTransactionHistory = ({ const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + // Cursor tracks the startTime of the oldest funding window already fetched. + // null = initial fetch not done yet. + const fundingCursorRef = useRef(null); + // Bumped on every fetchAllTransactions so in-flight loadMoreFunding calls + // can detect a concurrent refresh and discard stale results. + const fetchGenerationRef = useRef(0); + const [hasFundingMore, setHasFundingMore] = useState(true); + const [isFetchingMoreFunding, setIsFetchingMoreFunding] = useState(false); + const isFetchingMoreFundingRef = useRef(false); + // Get user history (includes deposits/withdrawals) - single source of truth const { userHistory, isLoading: userHistoryLoading, error: userHistoryError, refetch: refetchUserHistory, - } = useUserHistory({ startTime, endTime, accountId }); + } = useUserHistory({ accountId }); // Subscribe to live WebSocket fills for instant trade updates // This ensures new trades appear immediately without waiting for REST refetch @@ -154,6 +188,8 @@ export const usePerpsTransactionHistory = ({ DevLogger.log('Fetching comprehensive transaction history...'); + const fetchEndTime = Date.now(); + // Fetch all transaction data in parallel const [fills, orders, funding] = await Promise.all([ provider.getOrderFills({ @@ -161,11 +197,7 @@ export const usePerpsTransactionHistory = ({ aggregateByTime: false, }), provider.getOrders({ accountId }), - provider.getFunding({ - accountId, - startTime, - endTime, - }), + provider.getFunding({ accountId }), ]); DevLogger.log('Transaction data fetched:', { fills, orders, funding }); @@ -212,33 +244,31 @@ export const usePerpsTransactionHistory = ({ ...userHistoryTransactions, ]; - // Sort by timestamp descending (newest first) - allTransactions.sort((a, b) => b.timestamp - a.timestamp); - - // Remove duplicates based on ID - const uniqueTransactions = allTransactions.reduce((acc, transaction) => { - const existingIndex = acc.findIndex((t) => t.id === transaction.id); - if (existingIndex === -1) { - acc.push(transaction); - } - return acc; - }, [] as PerpsTransaction[]); + // Dedup only — final sort happens in mergedTransactions memo + const uniqueTransactions = deduplicateById(allTransactions); DevLogger.log('Combined transactions:', uniqueTransactions); setTransactions(uniqueTransactions); + + // Reset funding pagination cursor and bump generation to invalidate + // any in-flight loadMoreFunding calls from a previous scroll. + fundingCursorRef.current = fetchEndTime - PAGE_WINDOW_MS; + fetchGenerationRef.current += 1; + setHasFundingMore(true); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to fetch transaction history'; DevLogger.log('Error fetching transaction history:', errorMessage); + // Preserve existing transactions on error so a transient API failure + // (rate limit, network hiccup) during pull-to-refresh does not wipe + // the user's already-loaded funding history with an empty state. setError(errorMessage); - setTransactions([]); - setRestFills([]); } finally { setIsLoading(false); } - }, [startTime, endTime, accountId]); + }, [accountId]); const refetch = useCallback(async () => { // Fetch user history first, then fetch all transactions @@ -247,6 +277,78 @@ export const usePerpsTransactionHistory = ({ await fetchAllTransactions(); }, [fetchAllTransactions, refetchUserHistory]); + const loadMoreFunding = useCallback(async () => { + if (!hasFundingMore || isFetchingMoreFundingRef.current) return; + + const controller = Engine.context.PerpsController; + if (!controller) return; + + const provider = controller.getActiveProviderOrNull(); + if (!provider) return; + + const cursorEndTime = fundingCursorRef.current; + if (cursorEndTime === null) return; + + const cursorStartTime = cursorEndTime - PAGE_WINDOW_MS; + const maxStartTime = Date.now() - MAX_LOOKBACK_MS; + + if (cursorEndTime <= maxStartTime) { + setHasFundingMore(false); + return; + } + + DevLogger.log('[PERPS-FUNDING] loadMoreFunding: fetching older window', { + cursorStartTime, + cursorEndTime, + windowDays: Math.round(PAGE_WINDOW_MS / (24 * 60 * 60 * 1000)), + }); + + const generation = fetchGenerationRef.current; + isFetchingMoreFundingRef.current = true; + setIsFetchingMoreFunding(true); + try { + const olderFunding = await provider.getFunding({ + accountId, + startTime: Math.max(cursorStartTime, maxStartTime), + endTime: cursorEndTime, + }); + + // A refresh fired while we were awaiting — discard stale results + if (fetchGenerationRef.current !== generation) return; + + DevLogger.log('[PERPS-FUNDING] loadMoreFunding: older records loaded', { + count: olderFunding.length, + newCursor: cursorStartTime, + hasMore: olderFunding.length > 0, + }); + + // Advance cursor even when the window is empty so pagination skips + // gaps in activity (e.g. no open positions for 30+ days) instead of + // stopping permanently. + fundingCursorRef.current = cursorStartTime; + + if (olderFunding.length === 0) { + return; + } + + const olderFundingTxs = transformFundingToTransactions(olderFunding); + // Dedup only — final sort happens in mergedTransactions memo + setTransactions((prev) => deduplicateById([...prev, ...olderFundingTxs])); + + if (Math.max(cursorStartTime, maxStartTime) <= maxStartTime) { + setHasFundingMore(false); + } + } catch (err) { + DevLogger.log('[PERPS-FUNDING] loadMoreFunding error:', err); + // Stop pagination so the auto-advance effect does not retry in a loop. + // Pull-to-refresh resets hasFundingMore, allowing the user to retry. + setHasFundingMore(false); + } finally { + isFetchingMoreFundingRef.current = false; + setIsFetchingMoreFunding(false); + } + }, [accountId, hasFundingMore]); + useEffect(() => { // Detect transition from skipping (not connected) to not skipping (connected) // This fixes the case where the component mounts before connection is established @@ -324,7 +426,15 @@ export const usePerpsTransactionHistory = ({ ...walletWithdrawalsDeduplicated, ]; - return allTransactions.sort((a, b) => b.timestamp - a.timestamp); + return allTransactions.sort( + (a, b) => + b.timestamp - a.timestamp || + ((a.asset ?? '') < (b.asset ?? '') + ? -1 + : (a.asset ?? '') > (b.asset ?? '') + ? 1 + : 0), + ); }, [ liveFills, restFills, @@ -338,5 +448,8 @@ export const usePerpsTransactionHistory = ({ isLoading: combinedIsLoading, error: combinedError, refetch, + loadMoreFunding, + hasFundingMore, + isFetchingMoreFunding, }; }; diff --git a/app/components/UI/Perps/utils/transactionTransforms.test.ts b/app/components/UI/Perps/utils/transactionTransforms.test.ts index fac4d23f6a7e..39d770c7eb12 100644 --- a/app/components/UI/Perps/utils/transactionTransforms.test.ts +++ b/app/components/UI/Perps/utils/transactionTransforms.test.ts @@ -1467,7 +1467,7 @@ describe('transactionTransforms', () => { expect(result[0].fundingAmount.feeNumber).toBe(-3.5); }); - it('sorts funding by timestamp descending', () => { + it('preserves input order (sorting is handled by the consumer)', () => { const funding1 = { ...mockFunding, timestamp: 1000 }; const funding2 = { ...mockFunding, timestamp: 2000 }; const funding3 = { ...mockFunding, timestamp: 1500 }; @@ -1478,9 +1478,9 @@ describe('transactionTransforms', () => { funding3, ]); - expect(result[0].timestamp).toBe(2000); - expect(result[1].timestamp).toBe(1500); - expect(result[2].timestamp).toBe(1000); + expect(result[0].timestamp).toBe(1000); + expect(result[1].timestamp).toBe(2000); + expect(result[2].timestamp).toBe(1500); }); it('strips hip3 prefix from symbol in subtitle', () => { diff --git a/app/components/UI/Perps/utils/transactionTransforms.ts b/app/components/UI/Perps/utils/transactionTransforms.ts index 7957dd2053a6..359ba4040b08 100644 --- a/app/components/UI/Perps/utils/transactionTransforms.ts +++ b/app/components/UI/Perps/utils/transactionTransforms.ts @@ -558,10 +558,7 @@ export function transformOrdersToTransactions( export function transformFundingToTransactions( funding: Funding[], ): PerpsTransaction[] { - // Sort funding by timestamp in descending order (newest first) to match Orders and Trades - const sortedFunding = [...funding].sort((a, b) => b.timestamp - a.timestamp); - - return sortedFunding.map((fundingItem) => { + return funding.map((fundingItem) => { const { symbol, amountUsd, rate, timestamp } = fundingItem; // Create safe amount strings diff --git a/app/controllers/perps/constants/transactionsHistoryConfig.ts b/app/controllers/perps/constants/transactionsHistoryConfig.ts index b4436451b636..c646156a3204 100644 --- a/app/controllers/perps/constants/transactionsHistoryConfig.ts +++ b/app/controllers/perps/constants/transactionsHistoryConfig.ts @@ -6,10 +6,29 @@ export const PERPS_TRANSACTIONS_HISTORY_CONSTANTS = { FLASH_LIST_SCROLL_EVENT_THROTTLE: 16, LIST_ITEM_SELECTOR_OPACITY: 0.7, /** - * Default number of days to look back for funding history. - * HyperLiquid API requires a startTime and returns max 500 records. - * Using 365 days ensures most users see their complete recent history. - * Can be increased if users need older funding data. + * Maximum number of days to look back for funding history. + * Only the most recent 30-day window is fetched on initial load; + * older windows are fetched on-demand as the user scrolls. + * Empty windows (gaps in activity) are skipped automatically. */ DEFAULT_FUNDING_HISTORY_DAYS: 365, + /** + * Number of days per pagination window when fetching funding history. + * Each window is fetched via fetchWindowWithAutoSplit, which recursively + * halves any window that hits FUNDING_HISTORY_API_LIMIT, guaranteeing + * complete results regardless of position count or trading activity. + */ + FUNDING_HISTORY_PAGE_WINDOW_DAYS: 30, + /** + * HyperLiquid API returns at most this many records per userFunding call. + * When a single window exceeds this, only the oldest records are returned — + * the pagination strategy avoids this by using small enough windows. + */ + FUNDING_HISTORY_API_LIMIT: 500, + /** + * Minimum window size (ms) for the auto-split recursion in getFunding. + * HyperLiquid's funding interval is 8 h, so a 1-hour window holds at most + * a fraction of one event per position — well under the 500-record cap. + */ + MIN_SPLIT_WINDOW_MS: 60 * 60 * 1000, } as const; diff --git a/app/controllers/perps/providers/HyperLiquidProvider.test.ts b/app/controllers/perps/providers/HyperLiquidProvider.test.ts index 1f595a5645af..4fc8d016eb4c 100644 --- a/app/controllers/perps/providers/HyperLiquidProvider.test.ts +++ b/app/controllers/perps/providers/HyperLiquidProvider.test.ts @@ -5,6 +5,7 @@ import { createMockMessenger, } from '../../../components/UI/Perps/__mocks__/serviceMocks'; import { CandlePeriod } from '../constants/chartConfig'; +import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../constants/transactionsHistoryConfig'; import { BUILDER_FEE_CONFIG, REFERRAL_CONFIG, @@ -268,6 +269,7 @@ const createMockInfoClient = (overrides: Record = {}) => ({ historicalOrders: jest.fn().mockResolvedValue([]), userFills: jest.fn().mockResolvedValue([]), userFillsByTime: jest.fn().mockResolvedValue([]), + userFunding: jest.fn().mockResolvedValue([]), ...overrides, }); @@ -6503,6 +6505,148 @@ describe('HyperLiquidProvider', () => { expect(result).toEqual([]); }); + it('fetches funding across multiple page windows to include latest records', async () => { + const NOW = 1735689600000; // fixed timestamp for determinism + const DAY_MS = 24 * 60 * 60 * 1000; + + const oldRecord = { + time: NOW - 40 * DAY_MS, + hash: '0x' + 'a'.repeat(64), + delta: { + type: 'funding', + coin: 'BTC', + usdc: '-1.0', + szi: '0.1', + fundingRate: '0.0001', + nSamples: null, + }, + }; + const recentRecord = { + time: NOW - 5 * DAY_MS, + hash: '0x' + 'b'.repeat(64), + delta: { + type: 'funding', + coin: 'BTC', + usdc: '-2.0', + szi: '0.1', + fundingRate: '0.0001', + nSamples: null, + }, + }; + + const userFundingMock = jest + .fn() + .mockImplementation( + (params: { startTime: number; endTime: number }) => { + const records = [oldRecord, recentRecord].filter( + (r) => r.time >= params.startTime && r.time <= params.endTime, + ); + return Promise.resolve(records); + }, + ); + + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + userFunding: userFundingMock, + }); + + // Time range spans 60 days → 2 page windows of 30 days each + const result = await provider.getFunding({ + startTime: NOW - 60 * DAY_MS, + endTime: NOW, + }); + + expect(userFundingMock).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(2); + // Results sorted ascending: older first, recent last + expect(result[0].amountUsd).toBe('-1.0'); + expect(result[1].amountUsd).toBe('-2.0'); + // Most recent record is present — this would fail with the old single-call approach + // when total records exceeded the 500-record API cap + expect(result[1].timestamp).toBe(recentRecord.time); + }); + + it('includes records from the most recent page window when history is long', async () => { + const NOW = 1735689600000; + const DAY_MS = 24 * 60 * 60 * 1000; + const recentTs = NOW - 2 * DAY_MS; + + const userFundingMock = jest + .fn() + .mockImplementation( + (params: { startTime: number; endTime: number }) => { + if (params.endTime >= recentTs && params.startTime <= recentTs) { + return Promise.resolve([ + { + time: recentTs, + hash: '0x' + 'f'.repeat(64), + delta: { + type: 'funding', + coin: 'ETH', + usdc: '-0.5', + szi: '1.0', + fundingRate: '0.00005', + nSamples: null, + }, + }, + ]); + } + return Promise.resolve([]); + }, + ); + + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + userFunding: userFundingMock, + }); + + // Pass explicit 365-day range to trigger multi-page behavior. + // The default is now 30 days (1 call); callers must pass startTime to paginate further. + const result = await provider.getFunding({ + startTime: NOW - 365 * DAY_MS, + endTime: NOW, + }); + + // Multiple page windows must be created for a 365-day explicit range + expect(userFundingMock.mock.calls.length).toBeGreaterThan(1); + // The most recent record is present — proves pagination reaches the latest window + expect(result.some((r) => r.timestamp === recentTs)).toBe(true); + }); + + it('handles null response from one page window without losing other pages', async () => { + const NOW = 1735689600000; + const DAY_MS = 24 * 60 * 60 * 1000; + const validRecord = { + time: NOW - 10 * DAY_MS, + hash: '0x' + 'c'.repeat(64), + delta: { + type: 'funding', + coin: 'BTC', + usdc: '-3.0', + szi: '0.2', + fundingRate: '0.0002', + nSamples: null, + }, + }; + + let callCount = 0; + const userFundingMock = jest.fn().mockImplementation(() => { + callCount += 1; + // First call returns null, subsequent calls return data + return Promise.resolve(callCount === 1 ? null : [validRecord]); + }); + + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + userFunding: userFundingMock, + }); + + const result = await provider.getFunding({ + startTime: NOW - 60 * DAY_MS, + endTime: NOW, + }); + + // Null page is gracefully skipped; valid records from other pages survive + expect(result.some((r) => r.amountUsd === '-3.0')).toBe(true); + }); + it('handles validateWithdrawal returning true', async () => { const params = { amount: '100', @@ -9452,6 +9596,123 @@ describe('HyperLiquidProvider', () => { }); }); + describe('getFunding', () => { + const makeFundingRecord = (time: number, coin = 'BTC') => ({ + delta: { coin, usdc: '0.001', fundingRate: '0.0001' }, + hash: `0x${time.toString(16)}`, + time, + }); + + it('returns funding records for the default 30-day window with a single API call', async () => { + // Arrange + const records = [ + makeFundingRecord(Date.now() - 2000, 'ETH'), + makeFundingRecord(Date.now() - 1000, 'BTC'), + ]; + const mockUserFunding = jest.fn().mockResolvedValue(records); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue( + createMockInfoClient({ userFunding: mockUserFunding }), + ); + + // Act + const result = await provider.getFunding(); + + // Assert — exactly one API call for the default 30-day window + expect(mockUserFunding).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(2); + expect(result[0].symbol).toBe('ETH'); + expect(result[1].symbol).toBe('BTC'); + }); + + it('auto-splits window when API returns the record cap and recovers all records from sub-windows', async () => { + // Arrange — first call hits the cap (500 records); the function discards + // those and refetches the two halves. Each half is under the cap. + const apiLimit = + PERPS_TRANSACTIONS_HISTORY_CONSTANTS.FUNDING_HISTORY_API_LIMIT; + const capRecords = Array.from({ length: apiLimit }, (_, i) => + makeFundingRecord(1_700_000_000_000 + i * 1000), + ); + const leftHalfRecords = [makeFundingRecord(1_700_000_001_000)]; + const rightHalfRecords = [ + makeFundingRecord(1_700_000_002_000), + makeFundingRecord(1_700_000_003_000), + ]; + + const mockUserFunding = jest + .fn() + .mockResolvedValueOnce(capRecords) + .mockResolvedValueOnce(leftHalfRecords) + .mockResolvedValueOnce(rightHalfRecords); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue( + createMockInfoClient({ userFunding: mockUserFunding }), + ); + + // Act — 2-day explicit window (> 1 h minimum) so splitting is allowed + const endTime = 1_700_000_100_000; + const twoDaysMs = 2 * 24 * 60 * 60 * 1000; + const result = await provider.getFunding({ + startTime: endTime - twoDaysMs, + endTime, + }); + + // Assert — 3 calls total: original window + left half + right half + expect(mockUserFunding).toHaveBeenCalledTimes(3); + // Combined result comes from the sub-windows (not the capped initial call) + expect(result).toHaveLength( + leftHalfRecords.length + rightHalfRecords.length, + ); + }); + + it('does not split when window is at or below the minimum split size', async () => { + // Arrange — even with a full 500-record response the 1-hour window must + // not recurse (prevents infinite recursion at the minimum boundary) + const apiLimit = + PERPS_TRANSACTIONS_HISTORY_CONSTANTS.FUNDING_HISTORY_API_LIMIT; + const capRecords = Array.from({ length: apiLimit }, (_, i) => + makeFundingRecord(Date.now() - i * 1000), + ); + const mockUserFunding = jest.fn().mockResolvedValue(capRecords); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue( + createMockInfoClient({ userFunding: mockUserFunding }), + ); + + // Act — 1-hour window equals minSplitWindowMs; no split should occur + const oneHourMs = 60 * 60 * 1000; + const endTime = Date.now(); + await provider.getFunding({ startTime: endTime - oneHourMs, endTime }); + + // Assert — exactly one call, no recursive splitting + expect(mockUserFunding).toHaveBeenCalledTimes(1); + }); + + it('passes explicit startTime and endTime directly to the API', async () => { + // Arrange + const mockUserFunding = jest.fn().mockResolvedValue([]); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue( + createMockInfoClient({ userFunding: mockUserFunding }), + ); + + // Act + const startTime = 1_700_000_000_000; + const endTime = 1_702_592_000_000; // startTime + 30 days + await provider.getFunding({ startTime, endTime }); + + // Assert — explicit bounds forwarded verbatim to the API + expect(mockUserFunding).toHaveBeenCalledWith( + expect.objectContaining({ startTime, endTime }), + ); + }); + }); + describe('buildAssetMapping with perpDexs network failure', () => { it('completes asset mapping using fallback when perpDexs throws', async () => { // Arrange — perpDexs throws, so getValidatedDexs falls back to [null] diff --git a/app/controllers/perps/providers/HyperLiquidProvider.ts b/app/controllers/perps/providers/HyperLiquidProvider.ts index c714aa8a6f7f..b87b34cd2a57 100644 --- a/app/controllers/perps/providers/HyperLiquidProvider.ts +++ b/app/controllers/perps/providers/HyperLiquidProvider.ts @@ -5429,38 +5429,99 @@ export class HyperLiquidProvider implements PerpsProvider { params?.accountId, ); - // HyperLiquid API requires startTime to be a number (not undefined) - // Default to configured days ago to get recent funding payments - // Using 0 (epoch) would return oldest 500 records, missing latest payments - const defaultStartTime = - Date.now() - - PERPS_TRANSACTIONS_HISTORY_CONSTANTS.DEFAULT_FUNDING_HISTORY_DAYS * - 24 * - 60 * - 60 * - 1000; - const rawFunding = await infoClient.userFunding({ - user: userAddress, - startTime: params?.startTime ?? defaultStartTime, - endTime: params?.endTime, + // On-demand loading: the default window is one 30-day page so the + // initial fetch costs exactly 1 API call (~24 weight vs 312 previously). + // When loadMoreFunding in usePerpsTransactionHistory passes explicit + // startTime/endTime for an older 30-day page the while-loop below still + // produces exactly 1 chunk. The 365-day max lookback is enforced by the + // caller. + // + // Each chunk is fetched via fetchWindowWithAutoSplit: if a call returns + // FUNDING_HISTORY_API_LIMIT records the window has hit the API cap and + // the oldest records would be silently dropped. The function splits the + // window in half and recurses until every sub-window is under the cap, + // guaranteeing complete results regardless of position count or activity. + const finalEndTime = params?.endTime ?? Date.now(); + const pageWindowMs = + PERPS_TRANSACTIONS_HISTORY_CONSTANTS.FUNDING_HISTORY_PAGE_WINDOW_DAYS * + 24 * + 60 * + 60 * + 1000; + const finalStartTime = params?.startTime ?? finalEndTime - pageWindowMs; // Default: most recent 30-day window only + + const minSplitWindowMs = + PERPS_TRANSACTIONS_HISTORY_CONSTANTS.MIN_SPLIT_WINDOW_MS; + const apiLimit = + PERPS_TRANSACTIONS_HISTORY_CONSTANTS.FUNDING_HISTORY_API_LIMIT; + + // Fetches a single window. If the result hits the API cap the window is + // split in half and both halves are fetched in parallel, recursively, + // until every sub-window is under the cap. + const fetchWindowWithAutoSplit = async ( + windowStart: number, + windowEnd: number, + ): Promise>> => { + const result = await infoClient.userFunding({ + user: userAddress, + startTime: windowStart, + endTime: windowEnd, + }); + const records = result ?? []; + if ( + records.length >= apiLimit && + windowEnd - windowStart > minSplitWindowMs + ) { + const mid = windowStart + Math.floor((windowEnd - windowStart) / 2); + const [left, right] = await Promise.all([ + fetchWindowWithAutoSplit(windowStart, mid), + fetchWindowWithAutoSplit(mid, windowEnd), + ]); + return [...(left ?? []), ...(right ?? [])]; + } + return records; + }; + + const chunks: { start: number; end: number }[] = []; + let chunkEnd = finalEndTime; + while (chunkEnd > finalStartTime) { + const chunkStart = Math.max(finalStartTime, chunkEnd - pageWindowMs); + chunks.push({ start: chunkStart, end: chunkEnd }); + chunkEnd = chunkStart; + } + + const pages = await Promise.all( + chunks.map((chunk) => fetchWindowWithAutoSplit(chunk.start, chunk.end)), + ); + + // Deduplicate at chunk boundaries — adjacent windows share their boundary + // timestamp (chunkEnd of N === chunkStart of N+1) and the API is + // inclusive on both sides, so a record can appear in both adjacent calls. + // Funding records share a zero hash, so we key on time + coin instead. + const seen = new Set(); + const allRaw = pages.flat().filter((record) => { + const key = `${record.time}-${record.delta.coin}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; }); + allRaw.sort((a, b) => a.time - b.time); this.#deps.debugLogger.log('User funding received:', { - count: rawFunding?.length ?? 0, + count: allRaw.length, + chunks: chunks.length, }); // Transform HyperLiquid funding to abstract Funding type - const funding: Funding[] = (rawFunding || []).map((rawFundingItem) => { - const { delta, hash, time } = rawFundingItem; - - return { - symbol: delta.coin, - amountUsd: delta.usdc, - rate: delta.fundingRate, - timestamp: time, - transactionHash: hash, - }; - }); + const funding: Funding[] = allRaw.map(({ delta, hash, time }) => ({ + symbol: delta.coin, + amountUsd: delta.usdc, + rate: delta.fundingRate, + timestamp: time, + transactionHash: hash, + })); return funding; } catch (error) {