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) {