diff --git a/app/components/UI/Perps/PERPS_ARCH.md b/app/components/UI/Perps/PERPS_ARCH.md index 45c8ffaf9668..0419bf8a0ef8 100644 --- a/app/components/UI/Perps/PERPS_ARCH.md +++ b/app/components/UI/Perps/PERPS_ARCH.md @@ -78,11 +78,32 @@ Before creating a new hook: Single WebSocket subscriptions shared across all components with component-level debouncing. This prevents subscription interference and reduces WebSocket connections by 90%. +### WebSocket Pre-warming (Persistent Connections) + +Pre-warming creates persistent subscriptions that stay alive throughout the Perps session: + +- **Problem**: WebSocket subscriptions start on-demand, causing ~10 second delays before data arrives +- **Solution**: Create persistent subscriptions with no-op callbacks when entering Perps environment +- **Implementation**: + - `prewarm()` creates actual subscriptions that keep connections alive + - `PerpsConnectionManager` stores cleanup functions and only calls them when leaving Perps +- **Result**: Connections stay alive, cache continuously populated, instant data for all components + +### Single WebSocket Connection Architecture + +To minimize network overhead and ensure data consistency: + +- **Shared webData2**: Single subscription provides both positions (with TP/SL) and orders data +- **Reference Counting**: Tracks subscriber count to maintain connection while needed +- **Automatic Cleanup**: Disconnects when last subscriber unsubscribes +- **Result**: One WebSocket connection per data type instead of per component + ### Provider Setup - `PerpsStreamProvider` wraps all routes in `/routes/index.tsx` - Provides access to stream channels without holding state - No re-renders propagated to parent components +- `PerpsConnectionManager` pre-loads critical subscriptions on connection ### Stream Hooks @@ -92,13 +113,13 @@ Located in `/hooks/stream/`: // Each component sets its own update rate const prices = useLivePrices({ symbols: ['BTC', 'ETH'], - debounceMs: 10000, // 10s for order view + throttleMs: 10000, // 10s for order view }); ``` Available hooks: -- `useLivePrices(options)` - Real-time prices with custom debounce +- `useLivePrices(options)` - Real-time prices with custom throttle - `useLiveOrders(options)` - Order updates (future) - `useLivePositions(options)` - Position updates (future) - `useLiveFills(options)` - Fill notifications (future) @@ -110,11 +131,12 @@ Available hooks: - **Component-level control** - Different rates for different views - **Instant first render** - Cached data available immediately - **Zero parent re-renders** - Updates go directly to subscribers +- **No empty initial states** - Pre-warmed subscriptions provide data immediately ### Migration Path 1. Replace `usePerpsPrices` with `useLivePrices` -2. Set appropriate debounce for each view: +2. Set appropriate throttle for each view: - Order entry: 10000ms (stable prices) - Market list: 2000ms (responsive updates) - Market details: 500ms (near real-time) @@ -130,6 +152,8 @@ Available hooks: ├─────────────────────────────────────┤ │ Stream Manager (WebSocket) │ <- NEW LAYER ├─────────────────────────────────────┤ +│ Connection Manager (Pre-warming) │ <- NEW LAYER +├─────────────────────────────────────┤ │ Controller (Business) │ ├─────────────────────────────────────┤ │ Provider (Protocol) │ diff --git a/app/components/UI/Perps/PERPS_NAVIGATION_ARCHITECTURE.md b/app/components/UI/Perps/PERPS_NAVIGATION_ARCHITECTURE.md new file mode 100644 index 000000000000..74194bc3a573 --- /dev/null +++ b/app/components/UI/Perps/PERPS_NAVIGATION_ARCHITECTURE.md @@ -0,0 +1,250 @@ +# Perps Navigation Architecture + +> Visual documentation of the MetaMask Mobile Perps feature navigation flow and screen relationships + +## 📊 Navigation Flow Diagram + +```mermaid +graph TB + %% Entry Points + Start[App Start] --> MainTab[Main Tab Navigation] + MainTab --> PerpsRoot[PERPS.ROOT] + + %% Main Hub - PerpsView (Trading View) + PerpsRoot --> TradingView[PERPS.TRADING_VIEW
PerpsView] + + %% Primary Navigation from Trading View + TradingView --> Markets[PERPS.MARKETS
PerpsMarketListView] + TradingView --> Positions[PERPS.POSITIONS
PerpsPositionsView] + TradingView --> Withdraw[PERPS.WITHDRAW
PerpsWithdrawView] + TradingView --> Order[PERPS.ORDER
PerpsOrderView] + + %% Market Flow + Markets --> MarketDetails[PERPS.MARKET_DETAILS
PerpsMarketDetailsView] + Markets --> Tutorial[PERPS.TUTORIAL
PerpsTutorialCarousel] + + %% Market Details Actions + MarketDetails --> Order + MarketDetails --> DepositFlow[Deposit Flow
via Confirmations] + + %% Order Flow + Order --> QuoteExpired[PERPS.MODALS.QUOTE_EXPIRED_MODAL
PerpsQuoteExpiredModal] + Order --> BackToMarketDetails[Back to Market Details] + + %% Transaction History (Not directly linked in navigation) + Transactions[Transaction Views
Not in main flow] -.-> PositionTx[PERPS.POSITION_TRANSACTION] + Transactions -.-> OrderTx[PERPS.ORDER_TRANSACTION] + Transactions -.-> FundingTx[PERPS.FUNDING_TRANSACTION] + + %% Styling + classDef mainView fill:#e1f5e1,stroke:#4caf50,stroke-width:3px + classDef secondaryView fill:#e3f2fd,stroke:#2196f3,stroke-width:2px + classDef modalView fill:#fff3e0,stroke:#ff9800,stroke-width:2px + classDef unusedView fill:#ffebee,stroke:#f44336,stroke-width:2px,stroke-dasharray: 5 5 + + class TradingView mainView + class Markets,Positions,MarketDetails secondaryView + class QuoteExpired,Tutorial modalView + class Transactions,PositionTx,OrderTx,FundingTx unusedView +``` + +## 🏗️ Screen Hierarchy + +### Main Stack + +``` +Routes.PERPS.ROOT +├── Routes.PERPS.TRADING_VIEW (PerpsView) - Main Hub +│ ├── → Routes.PERPS.MARKETS +│ ├── → Routes.PERPS.POSITIONS +│ ├── → Routes.PERPS.WITHDRAW +│ └── → Routes.PERPS.ORDER +│ +├── Routes.PERPS.MARKETS (PerpsMarketListView) +│ ├── → Routes.PERPS.MARKET_DETAILS +│ └── → Routes.PERPS.TUTORIAL +│ +├── Routes.PERPS.MARKET_DETAILS (PerpsMarketDetailsView) +│ ├── → Routes.PERPS.ORDER (Long/Short) +│ └── → Deposit Flow (via Confirmations) +│ +├── Routes.PERPS.ORDER (PerpsOrderView) +│ └── → Routes.PERPS.MODALS.QUOTE_EXPIRED_MODAL +│ +└── Routes.PERPS.WITHDRAW (PerpsWithdrawView) +``` + +### Modal Stack + +``` +Routes.PERPS.MODALS.ROOT +└── Routes.PERPS.MODALS.QUOTE_EXPIRED_MODAL (PerpsQuoteExpiredModal) +``` + +## 📋 Route Usage Analysis + +| Route | Component | Used In | Status | +| ---------------------------------- | ---------------------------- | ---------------- | ----------- | +| `PERPS.ROOT` | Navigation Root | App entry | ✅ Active | +| `PERPS.TRADING_VIEW` | PerpsView | Initial route | ✅ Active | +| `PERPS.MARKETS` | PerpsMarketListView | PerpsView | ✅ Active | +| `PERPS.MARKET_DETAILS` | PerpsMarketDetailsView | MarketListView | ✅ Active | +| `PERPS.POSITIONS` | PerpsPositionsView | PerpsView | ✅ Active | +| `PERPS.ORDER` | PerpsOrderView | Multiple screens | ✅ Active | +| `PERPS.WITHDRAW` | PerpsWithdrawView | PerpsView | ✅ Active | +| `PERPS.TUTORIAL` | PerpsTutorialCarousel | MarketListView | ✅ Active | +| `PERPS.MODALS.QUOTE_EXPIRED_MODAL` | PerpsQuoteExpiredModal | OrderView | ✅ Active | +| `PERPS.DEPOSIT` | - | Routes only | ⚠️ Unused | +| `PERPS.POSITION_DETAILS` | - | Routes only | ⚠️ Unused | +| `PERPS.ORDER_HISTORY` | - | Routes only | ⚠️ Unused | +| `PERPS.ORDER_DETAILS` | - | Routes only | ⚠️ Unused | +| `PERPS.POSITION_TRANSACTION` | PerpsPositionTransactionView | TransactionsView | ❓ Orphaned | +| `PERPS.ORDER_TRANSACTION` | PerpsOrderTransactionView | TransactionsView | ❓ Orphaned | +| `PERPS.FUNDING_TRANSACTION` | PerpsFundingTransactionView | TransactionsView | ❓ Orphaned | + +## 🔄 Navigation Patterns + +### 1. **Main Trading Hub Pattern** + +``` +PerpsView (Trading View) + ├── View Markets → PerpsMarketListView + ├── View Positions → PerpsPositionsView + ├── Withdraw → PerpsWithdrawView + └── Quick Trade → PerpsOrderView +``` + +### 2. **Market Discovery Pattern** + +``` +PerpsMarketListView + ├── Select Market → PerpsMarketDetailsView + └── Tutorial → PerpsTutorialCarousel +``` + +### 3. **Trading Execution Pattern** + +``` +PerpsMarketDetailsView + ├── Long → PerpsOrderView (direction: 'long') + ├── Short → PerpsOrderView (direction: 'short') + └── Add Funds → Confirmations Screen +``` + +## 🧩 Key Components Usage + +### Tab Components (PerpsTabView) + +- **Location**: Embedded in PerpsView +- **Purpose**: Main navigation hub with tabs +- **Tabs**: Portfolio, Markets, Orders, Transactions + +### Market Components + +- **PerpsMarketCard**: Used in MarketListView +- **PerpsMarketHeader**: Used in MarketDetailsView +- **PerpsMarketTabs**: Used in MarketDetailsView (Position/Orders/Stats) + +### Position Components + +- **PerpsPositionCard**: Used in PositionsView, MarketTabs +- **PerpsPositionSummary**: Used in PerpsView + +### Order Components + +- **PerpsOpenOrderCard**: Used in MarketTabs, OrdersView +- **PerpsOrderConfirmation**: Used in OrderView + +## 🔍 Potential Cleanup Opportunities + +### 1. **Unused Routes** (Can be removed from Routes.ts) + +- `PERPS.DEPOSIT` - No implementation found +- `PERPS.POSITION_DETAILS` - No implementation found +- `PERPS.ORDER_HISTORY` - No implementation found +- `PERPS.ORDER_DETAILS` - No implementation found + +### 2. **Orphaned Transaction Views** + +- `PerpsTransactionsView` - Parent component exists but not navigated to +- `PerpsPositionTransactionView` - Child view not accessible +- `PerpsOrderTransactionView` - Child view not accessible +- `PerpsFundingTransactionView` - Child view not accessible + +**Note**: These transaction views might be intended for future use or are accessed through a different flow not visible in the main navigation. + +### 3. **Refactoring Opportunities** + +- **PerpsTabView**: Consider if this needs to be a separate view or can be integrated +- **Transaction Views**: Either implement navigation or remove if not needed + +## 📱 Screen Flow Examples + +### Example 1: Opening a Position + +``` +1. PerpsView (Trading View) +2. → PerpsMarketListView (Browse Markets) +3. → PerpsMarketDetailsView (Select SOL) +4. → PerpsOrderView (Long/Short) +5. → Confirm → Back to PerpsMarketDetailsView +``` + +### Example 2: Managing Positions + +``` +1. PerpsView (Trading View) +2. → PerpsPositionsView (View All Positions) +3. → Select Position → Actions (Close/Edit) +``` + +### Example 3: First Time User + +``` +1. PerpsView (Trading View) +2. → PerpsMarketListView +3. → PerpsTutorialCarousel (Tutorial) +4. → Back to Markets +``` + +## 🎯 Recommendations + +1. **Remove unused routes** from `Routes.ts` to clean up the codebase +2. **Investigate transaction views** - Either implement proper navigation or remove if deprecated +3. **Consider consolidating** PerpsTabView functionality if it's only used in one place +4. **Document intended use** for transaction views if they're for future features +5. **Add navigation tests** to ensure all routes are accessible and working + +## 📊 Component Dependencies + +```mermaid +graph LR + subgraph Providers + ConnectionProvider[PerpsConnectionProvider] + StreamProvider[PerpsStreamProvider] + end + + subgraph Views + PerpsView + MarketListView + MarketDetailsView + PositionsView + OrderView + end + + subgraph Components + MarketCard + PositionCard + OrderCard + MarketTabs + end + + ConnectionProvider --> Views + StreamProvider --> Views + Views --> Components +``` + +--- + +_Last Updated: January 2025_ +_Note: This documentation reflects the current state of the codebase. Some routes exist in Routes.ts but have no corresponding implementation._ diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx index 3483ababf230..1a6d112a3f0d 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx @@ -11,6 +11,9 @@ import { import { PerpsConnectionProvider } from '../../providers/PerpsConnectionProvider'; import Routes from '../../../../../constants/navigation/Routes'; +// Mock PerpsStreamManager +jest.mock('../../providers/PerpsStreamManager'); + // Create mock functions that can be modified during tests const mockUsePerpsAccount = jest.fn(); const mockUseHasExistingPosition = jest.fn(); @@ -691,9 +694,9 @@ describe('PerpsMarketDetailsView', () => { // Trigger the refresh await refreshControl.props.onRefresh(); - // Should refresh candle data and orders data + // Should refresh candle data + // Note: Orders refresh automatically via WebSocket, no manual refresh needed expect(mockRefreshCandleData).toHaveBeenCalledTimes(1); - expect(mockRefreshOrders).toHaveBeenCalledTimes(1); // Should not refresh position data when orders tab is active expect(mockRefreshPosition).not.toHaveBeenCalled(); }); diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index e8cef238b6b8..6c477d81fdf0 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -59,10 +59,10 @@ import { capitalize } from '../../../../../util/general'; import { usePerpsAccount, usePerpsConnection, - usePerpsOpenOrders, usePerpsPerformance, usePerpsTrading, } from '../../hooks'; +import { usePerpsLiveOrders } from '../../hooks/stream'; import PerpsMarketTabs from '../../components/PerpsMarketTabs/PerpsMarketTabs'; import PerpsNotificationTooltip from '../../components/PerpsNotificationTooltip'; import { isNotificationsFeatureEnabled } from '../../../../../util/notifications'; @@ -108,15 +108,11 @@ const PerpsMarketDetailsView: React.FC = () => { const account = usePerpsAccount(); - const { isConnected } = usePerpsConnection(); + usePerpsConnection(); const { depositWithConfirmation } = usePerpsTrading(); - // Get currently open orders for this market - const { orders: ordersData, refresh: refreshOrders } = usePerpsOpenOrders({ - skipInitialFetch: !isConnected, - enablePolling: true, - pollingInterval: 5000, // Poll every 5 seconds for real-time updates - }); + // Get real-time open orders via WebSocket + const ordersData = usePerpsLiveOrders(); // Instant updates (no debouncing) // Filter orders for the current market const openOrders = useMemo(() => { @@ -133,7 +129,7 @@ const PerpsMarketDetailsView: React.FC = () => { const marketStats = usePerpsMarketStats(market?.symbol || ''); // Get candlestick data - const { candleData, isLoadingHistory, priceData, refreshCandleData } = + const { candleData, isLoadingHistory, refreshCandleData } = usePerpsPositionData({ coin: market?.symbol || '', selectedDuration, // Time duration (1hr, 1D, 1W, etc.) @@ -228,8 +224,7 @@ const PerpsMarketDetailsView: React.FC = () => { break; case 'orders': - // Refresh orders data - await refreshOrders(); + // Orders update automatically via WebSocket, no refresh needed break; case 'statistics': @@ -251,7 +246,6 @@ const PerpsMarketDetailsView: React.FC = () => { }, [ activeTabId, refreshPosition, - refreshOrders, marketStats, candleData, refreshCandleData, @@ -328,8 +322,6 @@ const PerpsMarketDetailsView: React.FC = () => { @@ -383,7 +375,8 @@ const PerpsMarketDetailsView: React.FC = () => { unfilledOrders={openOrders} onPositionUpdate={refreshPosition} onActiveTabChange={setActiveTabId} - priceData={priceData} + nextFundingTime={market?.nextFundingTime} + fundingIntervalHours={market?.fundingIntervalHours} /> @@ -471,4 +464,18 @@ const PerpsMarketDetailsView: React.FC = () => { ); }; +// Enable Why Did You Render in development +// Uncomment to enable WDYR for debugging re-renders +// if (__DEV__) { +// // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports +// const { shouldEnableWhyDidYouRender } = require('../../../../../../wdyr'); +// if (shouldEnableWhyDidYouRender()) { +// // @ts-expect-error - whyDidYouRender is added by the WDYR library +// PerpsMarketDetailsView.whyDidYouRender = { +// logOnDifferentValues: true, +// customName: 'PerpsMarketDetailsView', +// }; +// } +// } + export default PerpsMarketDetailsView; diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 6c868d41d12c..3bcc30789484 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -82,7 +82,7 @@ import { usePerpsOrderValidation, usePerpsPerformance, } from '../../hooks'; -import { useLivePrices } from '../../hooks/stream'; +import { usePerpsLivePrices } from '../../hooks/stream'; import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; import { usePerpsScreenTracking } from '../../hooks/usePerpsScreenTracking'; import { formatPrice } from '../../utils/formatUtils'; @@ -314,9 +314,9 @@ const PerpsOrderViewContentBase: React.FC = () => { // Get real-time price data using new stream architecture // Uses single WebSocket subscription with component-level debouncing - const prices = useLivePrices({ + const prices = usePerpsLivePrices({ symbols: [orderForm.asset], - debounceMs: 10000, // 10 seconds for testing the architecture + throttleMs: 10000, // 10 seconds for testing the architecture }); const currentPrice = prices[orderForm.asset]; diff --git a/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.test.tsx b/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.test.tsx index a840ee503001..3c53ce661017 100644 --- a/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.test.tsx @@ -10,9 +10,9 @@ import PerpsPositionsView from './PerpsPositionsView'; import { usePerpsAccount, usePerpsTrading, - usePerpsPositions, usePerpsTPSLUpdate, usePerpsClosePosition, + usePerpsLivePositions, } from '../../hooks'; import type { Position } from '../../controllers/types'; @@ -29,6 +29,9 @@ jest.mock('@react-navigation/native', () => ({ useFocusEffect: jest.fn(), })); +// Mock PerpsStreamManager +jest.mock('../../providers/PerpsStreamManager'); + jest.mock('../../hooks', () => ({ usePerpsAccount: jest.fn(), usePerpsTrading: jest.fn(), @@ -36,7 +39,6 @@ jest.mock('../../hooks', () => ({ handleUpdateTPSL: jest.fn(), isUpdating: false, })), - usePerpsPositions: jest.fn(), usePerpsClosePosition: jest.fn(() => ({ handleClosePosition: jest.fn(), isClosing: false, @@ -46,8 +48,11 @@ jest.mock('../../hooks', () => ({ { name: 'ETH', symbol: 'ETH' }, { name: 'BTC', symbol: 'BTC' }, ], - error: null, - isLoading: false, + isInitialLoading: false, + })), + usePerpsLivePositions: jest.fn(() => ({ + positions: [], + isInitialLoading: false, })), })); @@ -156,12 +161,9 @@ describe('PerpsPositionsView', () => { }); // Mock usePerpsPositions hook - (usePerpsPositions as jest.Mock).mockReturnValue({ + (usePerpsLivePositions as jest.Mock).mockReturnValue({ positions: mockPositions, - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); // Using real implementations of utility functions (calculateTotalPnL, formatPrice, formatPnl) to test actual behavior @@ -212,12 +214,9 @@ describe('PerpsPositionsView', () => { it('displays correct position count for single position', async () => { // Arrange - (usePerpsPositions as jest.Mock).mockReturnValue({ + (usePerpsLivePositions as jest.Mock).mockReturnValue({ positions: [mockPositions[0]], - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); // Act @@ -233,12 +232,9 @@ describe('PerpsPositionsView', () => { describe('Loading States', () => { it('displays loading state initially', () => { // Arrange - (usePerpsPositions as jest.Mock).mockReturnValue({ + (usePerpsLivePositions as jest.Mock).mockReturnValue({ positions: [], - isLoading: true, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: true, }); // Act @@ -249,54 +245,20 @@ describe('PerpsPositionsView', () => { }); }); - describe('Error States', () => { - it('displays error message when positions fail to load', async () => { - // Arrange - const errorMessage = 'Network error'; - (usePerpsPositions as jest.Mock).mockReturnValue({ - positions: [], - isLoading: false, - isRefreshing: false, - error: errorMessage, - loadPositions: jest.fn(), - }); - - // Act - render(); - - // Assert - expect(screen.getByText('Error Loading Positions')).toBeOnTheScreen(); - expect(screen.getByText(errorMessage)).toBeOnTheScreen(); - }); - - it('displays generic error message for non-Error objects', async () => { - // Arrange - (usePerpsPositions as jest.Mock).mockReturnValue({ - positions: [], - isLoading: false, - isRefreshing: false, - error: 'Failed to load positions', - loadPositions: jest.fn(), - }); - - // Act - render(); - - // Assert - expect(screen.getByText('Error Loading Positions')).toBeOnTheScreen(); - expect(screen.getByText('Failed to load positions')).toBeOnTheScreen(); - }); - }); + // Error States tests are commented out as the new usePerpsLivePositions + // hook doesn't return error state - errors are handled internally + // describe('Error States', () => { + // it('displays error message when positions fail to load', async () => { + // // Test removed - new hook doesn't expose error state + // }); + // }); describe('Empty State', () => { it('displays empty state when no positions are available', async () => { // Arrange - (usePerpsPositions as jest.Mock).mockReturnValue({ + (usePerpsLivePositions as jest.Mock).mockReturnValue({ positions: [], - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); // Act @@ -316,12 +278,9 @@ describe('PerpsPositionsView', () => { it('displays empty state when positions is null', async () => { // Arrange - (usePerpsPositions as jest.Mock).mockReturnValue({ + (usePerpsLivePositions as jest.Mock).mockReturnValue({ positions: [], - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); // Act @@ -430,12 +389,9 @@ describe('PerpsPositionsView', () => { { ...mockPositions[0], coin: 'ETH' }, { ...mockPositions[0], coin: 'ETH' }, ]; - (usePerpsPositions as jest.Mock).mockReturnValue({ + (usePerpsLivePositions as jest.Mock).mockReturnValue({ positions: duplicatePositions, - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); // Act @@ -465,12 +421,9 @@ describe('PerpsPositionsView', () => { isClosing: false, }); - (usePerpsPositions as jest.Mock).mockReturnValue({ + (usePerpsLivePositions as jest.Mock).mockReturnValue({ positions: mockPositions, - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: mockLoadPositions, + isInitialLoading: false, }); }); @@ -547,12 +500,9 @@ describe('PerpsPositionsView', () => { isUpdating: false, }); - (usePerpsPositions as jest.Mock).mockReturnValue({ + (usePerpsLivePositions as jest.Mock).mockReturnValue({ positions: mockPositions, - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: mockLoadPositions, + isInitialLoading: false, }); }); diff --git a/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.tsx b/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.tsx index ec09bd1f0e1c..14db2db90506 100644 --- a/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.tsx +++ b/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.tsx @@ -4,7 +4,7 @@ import { type ParamListBase, } from '@react-navigation/native'; import React, { useMemo, useState } from 'react'; -import { RefreshControl, SafeAreaView, ScrollView, View } from 'react-native'; +import { SafeAreaView, ScrollView, View } from 'react-native'; import { strings } from '../../../../../../locales/i18n'; import ButtonIcon, { ButtonIconSizes, @@ -23,7 +23,7 @@ import PerpsTPSLBottomSheet from '../../components/PerpsTPSLBottomSheet'; import type { Position } from '../../controllers/types'; import { usePerpsAccount, - usePerpsPositions, + usePerpsLivePositions, usePerpsTPSLUpdate, } from '../../hooks'; import { formatPnl, formatPrice } from '../../utils/formatUtils'; @@ -32,7 +32,7 @@ import { createStyles } from './PerpsPositionsView.styles'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; const PerpsPositionsView: React.FC = () => { - const { styles, theme } = useStyles(createStyles, {}); + const { styles } = useStyles(createStyles, {}); const navigation = useNavigation>(); const cachedAccountState = usePerpsAccount(); @@ -42,16 +42,18 @@ const PerpsPositionsView: React.FC = () => { ); const [isTPSLVisible, setIsTPSLVisible] = useState(false); - const { positions, isLoading, isRefreshing, error, loadPositions } = - usePerpsPositions({ - loadOnMount: true, - refreshOnFocus: true, - }); + // Get real-time positions via WebSocket + const { positions, isInitialLoading } = usePerpsLivePositions({ + throttleMs: 1000, // Update every second + }); + + const error = null; const { handleUpdateTPSL, isUpdating } = usePerpsTPSLUpdate({ onSuccess: () => { - // Refresh positions to show updated data - loadPositions({ isRefresh: true }); + // Positions update automatically via WebSocket + setIsTPSLVisible(false); + setSelectedPosition(null); }, }); @@ -74,12 +76,8 @@ const PerpsPositionsView: React.FC = () => { navigation.goBack(); }; - const handleRefresh = () => { - loadPositions({ isRefresh: true }); - }; - const renderContent = () => { - if (isLoading) { + if (isInitialLoading) { return ( @@ -153,16 +151,7 @@ const PerpsPositionsView: React.FC = () => { - - } - > + {/* Account Summary */} diff --git a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx index fe8b8ee02ff0..2b841856c7ff 100644 --- a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx @@ -13,6 +13,9 @@ jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn(), })); +// Mock PerpsStreamManager +jest.mock('../../providers/PerpsStreamManager'); + // Mock PerpsConnectionProvider jest.mock('../../providers/PerpsConnectionProvider', () => ({ PerpsConnectionProvider: ({ children }: { children: React.ReactNode }) => @@ -31,7 +34,6 @@ jest.mock('../../providers/PerpsConnectionProvider', () => ({ // Mock hooks jest.mock('../../hooks', () => ({ usePerpsConnection: jest.fn(), - usePerpsPositions: jest.fn(), usePerpsTrading: jest.fn(), usePerpsFirstTimeUser: jest.fn(), usePerpsAccount: jest.fn(), @@ -46,6 +48,14 @@ jest.mock('../../hooks', () => ({ })), })); +// Mock stream hooks +jest.mock('../../hooks/stream', () => ({ + usePerpsLivePositions: jest.fn(() => ({ + positions: [], + isInitialLoading: false, + })), +})); + // Mock components jest.mock('../../components/PerpsTabControlBar', () => ({ PerpsTabControlBar: ({ @@ -150,8 +160,8 @@ describe('PerpsTabView', () => { const mockUsePerpsConnection = jest.requireMock('../../hooks').usePerpsConnection; - const mockUsePerpsPositions = - jest.requireMock('../../hooks').usePerpsPositions; + const mockUsePerpsLivePositions = + jest.requireMock('../../hooks/stream').usePerpsLivePositions; const mockUsePerpsTrading = jest.requireMock('../../hooks').usePerpsTrading; const mockUsePerpsFirstTimeUser = jest.requireMock('../../hooks').usePerpsFirstTimeUser; @@ -188,11 +198,9 @@ describe('PerpsTabView', () => { isInitialized: true, }); - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [], - isLoading: false, - isRefreshing: false, - loadPositions: jest.fn(), + isInitialLoading: false, }); mockUsePerpsTrading.mockReturnValue({ @@ -248,11 +256,9 @@ describe('PerpsTabView', () => { }); it('should render loading state when positions are loading', () => { - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [], - isLoading: true, - isRefreshing: false, - loadPositions: jest.fn(), + isInitialLoading: true, }); render(); @@ -263,11 +269,9 @@ describe('PerpsTabView', () => { }); it('should render empty state when no positions exist', () => { - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [], - isLoading: false, - isRefreshing: false, - loadPositions: jest.fn(), + isInitialLoading: false, }); render(); @@ -281,11 +285,9 @@ describe('PerpsTabView', () => { }); it('should render positions when they exist', () => { - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [mockPosition], - isLoading: false, - isRefreshing: false, - loadPositions: jest.fn(), + isInitialLoading: false, }); render(); @@ -305,11 +307,9 @@ describe('PerpsTabView', () => { { ...mockPosition, coin: 'SOL', size: '50.0' }, ]; - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions, - isLoading: false, - isRefreshing: false, - loadPositions: jest.fn(), + isInitialLoading: false, }); render(); @@ -420,7 +420,7 @@ describe('PerpsTabView', () => { it('should have pull-to-refresh functionality configured', async () => { const mockLoadPositions = jest.fn(); - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [], isLoading: false, isRefreshing: false, @@ -510,11 +510,9 @@ describe('PerpsTabView', () => { describe('State Management', () => { it('should handle refresh state correctly', () => { - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [], - isLoading: false, - isRefreshing: true, - loadPositions: jest.fn(), + isInitialLoading: false, }); render(); @@ -561,11 +559,9 @@ describe('PerpsTabView', () => { stopLossPrice: undefined, }; - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [incompletePosition], - isLoading: false, - isRefreshing: false, - loadPositions: jest.fn(), + isInitialLoading: false, }); expect(() => render()).not.toThrow(); @@ -573,11 +569,9 @@ describe('PerpsTabView', () => { }); it('should handle empty positions array correctly', () => { - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [], - isLoading: false, - isRefreshing: false, - loadPositions: jest.fn(), + isInitialLoading: false, }); render(); @@ -625,7 +619,7 @@ describe('PerpsTabView', () => { // Mock console.error to avoid noise in tests const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); - mockUsePerpsPositions.mockImplementation(() => { + mockUsePerpsLivePositions.mockImplementation(() => { throw new Error('Hook error'); }); @@ -644,11 +638,9 @@ describe('PerpsTabView', () => { }); it('should render text with proper variants and colors', () => { - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [mockPosition], - isLoading: false, - isRefreshing: false, - loadPositions: jest.fn(), + isInitialLoading: false, }); render(); @@ -667,11 +659,9 @@ describe('PerpsTabView', () => { size: `${i + 1}.0`, })); - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: manyPositions, - isLoading: false, - isRefreshing: false, - loadPositions: jest.fn(), + isInitialLoading: false, }); const startTime = performance.now(); @@ -689,7 +679,7 @@ describe('PerpsTabView', () => { // Simulate rapid state changes for (let i = 0; i < 5; i++) { - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: i % 2 === 0 ? [] : [mockPosition], isLoading: i % 3 === 0, isRefreshing: i % 4 === 0, @@ -710,8 +700,8 @@ describe('PerpsTabViewWithProvider', () => { // Setup mocks for wrapped component tests const mockUsePerpsConnection = jest.requireMock('../../hooks') .usePerpsConnection as jest.Mock; - const mockUsePerpsPositions = jest.requireMock('../../hooks') - .usePerpsPositions as jest.Mock; + const mockUsePerpsLivePositions = jest.requireMock('../../hooks/stream') + .usePerpsLivePositions as jest.Mock; const mockUsePerpsTrading = jest.requireMock('../../hooks') .usePerpsTrading as jest.Mock; const mockUsePerpsFirstTimeUser = jest.requireMock('../../hooks') @@ -725,11 +715,9 @@ describe('PerpsTabViewWithProvider', () => { isInitialized: true, }); - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [], - isLoading: false, - isRefreshing: false, - loadPositions: jest.fn(), + isInitialLoading: false, }); mockUsePerpsTrading.mockReturnValue({ diff --git a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx index 42a166d1d070..dd189d9adb7e 100644 --- a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx +++ b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx @@ -1,6 +1,6 @@ import { useNavigation, type NavigationProp } from '@react-navigation/native'; import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Modal, RefreshControl, ScrollView, View } from 'react-native'; +import { Modal, ScrollView, View } from 'react-native'; import { strings } from '../../../../../../locales/i18n'; import BottomSheet, { BottomSheetRef, @@ -36,10 +36,10 @@ import { usePerpsConnection, usePerpsEventTracking, usePerpsFirstTimeUser, - usePerpsPositions, usePerpsTrading, usePerpsPerformance, } from '../../hooks'; +import { usePerpsLivePositions } from '../../hooks/stream'; import styleSheet from './PerpsTabView.styles'; interface PerpsTabViewProps {} @@ -58,15 +58,12 @@ const PerpsTabView: React.FC = () => { const bottomSheetRef = useRef(null); - const { - positions, - isLoading: isPositionsLoading, - isRefreshing, - loadPositions, - } = usePerpsPositions(); + // Get real-time positions via WebSocket + const { positions, isInitialLoading } = usePerpsLivePositions({ + throttleMs: 1000, // Update positions every second + }); const { isFirstTimeUser } = usePerpsFirstTimeUser(); - const isLoading = isPositionsLoading; const firstTimeUserIconSize = 48 as unknown as IconSize; // Start measuring position data load time on mount @@ -88,7 +85,7 @@ const PerpsTabView: React.FC = () => { useEffect(() => { if ( !hasTrackedHomescreen.current && - !isLoading && + !isInitialLoading && positions && cachedAccountState?.totalBalance !== undefined ) { @@ -113,17 +110,13 @@ const PerpsTabView: React.FC = () => { hasTrackedHomescreen.current = true; } }, [ - isLoading, + isInitialLoading, positions, cachedAccountState?.totalBalance, track, endMeasure, ]); - const handleRefresh = useCallback(() => { - loadPositions(); - }, [loadPositions]); - const handleManageBalancePress = useCallback(() => { setIsBottomSheetVisible(true); }, []); @@ -161,7 +154,7 @@ const PerpsTabView: React.FC = () => { }, [navigation]); const renderPositionsSection = () => { - if (isLoading) { + if (isInitialLoading) { return ( @@ -254,15 +247,7 @@ const PerpsTabView: React.FC = () => { ) : ( <> - - } - > + {renderPositionsSection()} @@ -308,12 +293,13 @@ const PerpsTabView: React.FC = () => { }; // Enable WDYR tracking in development -if (__DEV__) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (PerpsTabView as any).whyDidYouRender = { - logOnDifferentValues: true, - customName: 'PerpsTabView', - }; -} +// Uncomment to enable WDYR for debugging re-renders +// if (__DEV__) { +// // eslint-disable-next-line @typescript-eslint/no-explicit-any +// (PerpsTabView as any).whyDidYouRender = { +// logOnDifferentValues: true, +// customName: 'PerpsTabView', +// }; +// } export default PerpsTabView; diff --git a/app/components/UI/Perps/Views/PerpsView.test.tsx b/app/components/UI/Perps/Views/PerpsView.test.tsx index 115a9a92d7fb..2e7e231d298c 100644 --- a/app/components/UI/Perps/Views/PerpsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsView.test.tsx @@ -7,9 +7,12 @@ jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn(() => ({ navigate: jest.fn() })), })); +// Mock PerpsStreamManager +jest.mock('../providers/PerpsStreamManager'); + // Mock stream hooks jest.mock('../hooks/stream', () => ({ - useLivePrices: jest.fn(() => ({ + usePerpsLivePrices: jest.fn(() => ({ 'BTC-PERP': { price: '50000', percentChange24h: '2.5' }, 'ETH-PERP': { price: '3000', percentChange24h: '-1.2' }, 'SOL-PERP': { price: '100', percentChange24h: '5.0' }, diff --git a/app/components/UI/Perps/Views/PerpsView.tsx b/app/components/UI/Perps/Views/PerpsView.tsx index 61c38c6db985..bd5abd929236 100644 --- a/app/components/UI/Perps/Views/PerpsView.tsx +++ b/app/components/UI/Perps/Views/PerpsView.tsx @@ -39,7 +39,7 @@ import { usePerpsNetworkConfig, usePerpsTrading, } from '../hooks'; -import { useLivePrices } from '../hooks/stream'; +import { usePerpsLivePrices } from '../hooks/stream'; // Import connection components import PerpsConnectionErrorView from '../components/PerpsConnectionErrorView'; @@ -173,10 +173,10 @@ const PerpsView: React.FC = () => { resetError, } = usePerpsConnection(); - // Get real-time prices for popular assets with 5s debounce for portfolio view - const priceData = useLivePrices({ + // Get real-time prices for popular assets with 5s throttle for portfolio view + const priceData = usePerpsLivePrices({ symbols: POPULAR_ASSETS, - debounceMs: 5000, + throttleMs: 5000, }); // Parse available balance to check if withdrawal should be enabled diff --git a/app/components/UI/Perps/components/FundingCountdown/FundingCountdown.test.tsx b/app/components/UI/Perps/components/FundingCountdown/FundingCountdown.test.tsx new file mode 100644 index 000000000000..3d554726e534 --- /dev/null +++ b/app/components/UI/Perps/components/FundingCountdown/FundingCountdown.test.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import FundingCountdown from './FundingCountdown'; +import { calculateFundingCountdown } from '../../utils/marketUtils'; + +jest.mock('../../utils/marketUtils', () => ({ + calculateFundingCountdown: jest.fn(), +})); + +describe('FundingCountdown', () => { + const mockCalculateFundingCountdown = calculateFundingCountdown as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render with market-specific funding time', () => { + const nextFundingTime = Date.now() + 3600000; // 1 hour from now + const fundingIntervalHours = 4; + + mockCalculateFundingCountdown.mockReturnValue('00:59:59'); + + const { getByText } = render( + , + ); + + expect(mockCalculateFundingCountdown).toHaveBeenCalledWith({ + nextFundingTime, + fundingIntervalHours, + }); + expect(getByText('(00:59:59)')).toBeTruthy(); + }); + + it('should render with default 8-hour intervals when no specific time provided', () => { + mockCalculateFundingCountdown.mockReturnValue('07:59:59'); + + const { getByText } = render(); + + expect(mockCalculateFundingCountdown).toHaveBeenCalledWith({ + nextFundingTime: undefined, + fundingIntervalHours: undefined, + }); + expect(getByText('(07:59:59)')).toBeTruthy(); + }); + + it('should update countdown every second', () => { + jest.useFakeTimers(); + + // Initial render returns 00:59:59 + mockCalculateFundingCountdown.mockReturnValue('00:59:59'); + + const nextFundingTime = Date.now() + 3600000; + + const { getByText } = render( + , + ); + + // Verify initial render + expect(getByText('(00:59:59)')).toBeTruthy(); + + // Update mock for next call + mockCalculateFundingCountdown.mockReturnValue('00:59:58'); + + // Fast-forward 1 second to trigger the interval + jest.advanceTimersByTime(1000); + + // The component should have called calculateFundingCountdown multiple times (initial + interval) + expect(mockCalculateFundingCountdown).toHaveBeenCalled(); + + jest.useRealTimers(); + }); + + it('should pass testID when provided', () => { + mockCalculateFundingCountdown.mockReturnValue('00:59:59'); + + const { getByTestId, getByText } = render( + , + ); + + expect(getByTestId('funding-countdown-test')).toBeTruthy(); + expect(getByText('(00:59:59)')).toBeTruthy(); + }); + + it('should accept and apply style prop', () => { + mockCalculateFundingCountdown.mockReturnValue('00:59:59'); + + const customStyle = { marginLeft: 10, fontSize: 20 }; + const { getByTestId } = render( + , + ); + + const element = getByTestId('styled-countdown'); + expect(element.props.style).toEqual(expect.objectContaining(customStyle)); + }); +}); diff --git a/app/components/UI/Perps/components/FundingCountdown/FundingCountdown.tsx b/app/components/UI/Perps/components/FundingCountdown/FundingCountdown.tsx new file mode 100644 index 000000000000..625658e91cb5 --- /dev/null +++ b/app/components/UI/Perps/components/FundingCountdown/FundingCountdown.tsx @@ -0,0 +1,65 @@ +import React, { useState, useEffect, memo } from 'react'; +import type { TextStyle } from 'react-native'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import { calculateFundingCountdown } from '../../utils/marketUtils'; + +interface FundingCountdownProps { + variant?: TextVariant; + color?: TextColor; + style?: TextStyle; + testID?: string; + /** + * Next funding time in milliseconds since epoch (optional, market-specific) + */ + nextFundingTime?: number; + /** + * Funding interval in hours (optional, market-specific) + */ + fundingIntervalHours?: number; +} + +/** + * Isolated countdown component that updates every second + * without causing parent re-renders. + * Supports market-specific funding times when provided. + */ +const FundingCountdown: React.FC = ({ + variant = TextVariant.BodySM, + color = TextColor.Default, + style, + testID, + nextFundingTime, + fundingIntervalHours, +}) => { + const [countdown, setCountdown] = useState(() => + calculateFundingCountdown({ nextFundingTime, fundingIntervalHours }), + ); + + useEffect(() => { + const updateCountdown = () => { + setCountdown( + calculateFundingCountdown({ nextFundingTime, fundingIntervalHours }), + ); + }; + + // Update immediately + updateCountdown(); + + // Then update every second + const interval = setInterval(updateCountdown, 1000); + + return () => clearInterval(interval); + }, [nextFundingTime, fundingIntervalHours]); + + return ( + + ({countdown}) + + ); +}; + +// Memoize to prevent unnecessary re-renders when parent re-renders +export default memo(FundingCountdown); diff --git a/app/components/UI/Perps/components/FundingCountdown/index.ts b/app/components/UI/Perps/components/FundingCountdown/index.ts new file mode 100644 index 000000000000..aefefadb5809 --- /dev/null +++ b/app/components/UI/Perps/components/FundingCountdown/index.ts @@ -0,0 +1 @@ +export { default } from './FundingCountdown'; diff --git a/app/components/UI/Perps/components/LivePriceDisplay/LivePriceDisplay.test.tsx b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceDisplay.test.tsx new file mode 100644 index 000000000000..5a3882192442 --- /dev/null +++ b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceDisplay.test.tsx @@ -0,0 +1,198 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import LivePriceDisplay from './LivePriceDisplay'; +import { usePerpsLivePrices } from '../../hooks/stream'; +import { formatPrice, formatPercentage } from '../../utils/formatUtils'; +import { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; + +// Mock dependencies +jest.mock('../../hooks/stream'); +jest.mock('../../utils/formatUtils'); + +describe('LivePriceDisplay', () => { + const mockUsePerpsLivePrices = usePerpsLivePrices as jest.MockedFunction< + typeof usePerpsLivePrices + >; + const mockFormatPrice = formatPrice as jest.MockedFunction< + typeof formatPrice + >; + const mockFormatPercentage = formatPercentage as jest.MockedFunction< + typeof formatPercentage + >; + + beforeEach(() => { + jest.clearAllMocks(); + mockFormatPrice.mockImplementation((price) => `$${price}`); + mockFormatPercentage.mockImplementation((pct) => `${pct}%`); + }); + + it('should render with live price data', () => { + mockUsePerpsLivePrices.mockReturnValue({ + BTC: { + coin: 'BTC', + price: '50000', + percentChange24h: '5.5', + timestamp: Date.now(), + }, + }); + + const { getByText } = render(); + + expect(getByText('$50000')).toBeTruthy(); + expect(mockUsePerpsLivePrices).toHaveBeenCalledWith({ + symbols: ['BTC'], + throttleMs: 1000, + }); + }); + + it('should render placeholder when no price data available', () => { + mockUsePerpsLivePrices.mockReturnValue({}); + + const { getByText } = render(); + + expect(getByText('--')).toBeTruthy(); + }); + + it('should render price with change when showChange is true', () => { + mockUsePerpsLivePrices.mockReturnValue({ + ETH: { + coin: 'ETH', + price: '3000', + percentChange24h: '-2.5', + timestamp: Date.now(), + }, + }); + + const { getByText } = render(); + + expect(getByText('$3000')).toBeTruthy(); + expect(getByText('-2.5%')).toBeTruthy(); + }); + + it('should render price without change when showChange is false', () => { + mockUsePerpsLivePrices.mockReturnValue({ + SOL: { + coin: 'SOL', + price: '100', + percentChange24h: '10', + timestamp: Date.now(), + }, + }); + + const { getByText, queryByText } = render( + , + ); + + expect(getByText('$100')).toBeTruthy(); + expect(queryByText('10%')).toBeNull(); + }); + + it('should use custom throttle value', () => { + mockUsePerpsLivePrices.mockReturnValue({ + DOGE: { + coin: 'DOGE', + price: '0.1', + percentChange24h: '0', + timestamp: Date.now(), + }, + }); + + render(); + + expect(mockUsePerpsLivePrices).toHaveBeenCalledWith({ + symbols: ['DOGE'], + throttleMs: 500, + }); + }); + + it('should apply custom text styles', () => { + mockUsePerpsLivePrices.mockReturnValue({ + AVAX: { + coin: 'AVAX', + price: '25', + percentChange24h: '0', + timestamp: Date.now(), + }, + }); + + const { getByTestId } = render( + , + ); + + const priceElement = getByTestId('custom-price'); + expect(priceElement).toBeTruthy(); + // Just verify the element exists with the custom testID + // Props are implementation details that shouldn't be tested + }); + + it('should handle positive price change color', () => { + mockUsePerpsLivePrices.mockReturnValue({ + UNI: { + coin: 'UNI', + price: '10', + percentChange24h: '15', + timestamp: Date.now(), + }, + }); + + const { getByText } = render(); + + expect(getByText('15%')).toBeTruthy(); + }); + + it('should handle negative price change color', () => { + mockUsePerpsLivePrices.mockReturnValue({ + LINK: { + coin: 'LINK', + price: '15', + percentChange24h: '-8', + timestamp: Date.now(), + }, + }); + + const { getByText } = render(); + + expect(getByText('-8%')).toBeTruthy(); + }); + + it('should handle zero price change', () => { + mockUsePerpsLivePrices.mockReturnValue({ + MATIC: { + coin: 'MATIC', + price: '1', + percentChange24h: '0', + timestamp: Date.now(), + }, + }); + + const { getByText } = render( + , + ); + + expect(getByText('0%')).toBeTruthy(); + }); + + it('should handle missing percentChange24h', () => { + mockUsePerpsLivePrices.mockReturnValue({ + DOT: { + coin: 'DOT', + price: '5', + timestamp: Date.now(), + // percentChange24h is missing + }, + }); + + const { getByText } = render(); + + expect(getByText('$5')).toBeTruthy(); + expect(getByText('0%')).toBeTruthy(); // Defaults to 0 + }); +}); diff --git a/app/components/UI/Perps/components/LivePriceDisplay/LivePriceDisplay.tsx b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceDisplay.tsx new file mode 100644 index 000000000000..a15f2b788641 --- /dev/null +++ b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceDisplay.tsx @@ -0,0 +1,71 @@ +import React, { memo } from 'react'; +import { View } from 'react-native'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import { usePerpsLivePrices } from '../../hooks/stream'; +import { formatPrice, formatPercentage } from '../../utils/formatUtils'; + +interface LivePriceDisplayProps { + symbol: string; + variant?: TextVariant; + color?: TextColor; + showChange?: boolean; + testID?: string; + throttleMs?: number; +} + +/** + * Component that displays live price updates + * Subscribes to price stream independently to avoid parent re-renders + */ +const LivePriceDisplay: React.FC = ({ + symbol, + variant = TextVariant.BodyMD, + color = TextColor.Default, + showChange = false, + testID, + throttleMs = 1000, // Default to 1 second updates for price displays +}) => { + const prices = usePerpsLivePrices({ + symbols: [symbol], + throttleMs, + }); + + const priceData = prices[symbol]; + + if (!priceData) { + return ( + + -- + + ); + } + + const price = parseFloat(priceData.price); + const change = parseFloat(priceData.percentChange24h || '0'); + + if (showChange) { + const changeColor = change >= 0 ? TextColor.Success : TextColor.Error; + return ( + + + {formatPrice(price)} + + + {formatPercentage(change)} + + + ); + } + + return ( + + {formatPrice(price)} + + ); +}; + +// Memoize to prevent unnecessary re-renders when parent re-renders +export default memo(LivePriceDisplay); diff --git a/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.test.tsx b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.test.tsx new file mode 100644 index 000000000000..1bb3de0e06de --- /dev/null +++ b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.test.tsx @@ -0,0 +1,257 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import LivePriceHeader from './LivePriceHeader'; +import { usePerpsLivePrices } from '../../hooks/stream'; +import { + formatPrice, + formatPercentage, + formatPnl, +} from '../../utils/formatUtils'; +import { useStyles } from '../../../../../component-library/hooks'; + +// Mock dependencies +jest.mock('../../hooks/stream'); +jest.mock('../../utils/formatUtils'); +jest.mock('../../../../../component-library/hooks'); + +describe('LivePriceHeader', () => { + const mockUsePerpsLivePrices = usePerpsLivePrices as jest.MockedFunction< + typeof usePerpsLivePrices + >; + const mockFormatPrice = formatPrice as jest.MockedFunction< + typeof formatPrice + >; + const mockFormatPercentage = formatPercentage as jest.MockedFunction< + typeof formatPercentage + >; + const mockFormatPnl = formatPnl as jest.MockedFunction; + const mockUseStyles = useStyles as jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + mockFormatPrice.mockImplementation((price) => { + const num = typeof price === 'string' ? parseFloat(price) : price; + return `$${num.toFixed(2)}`; + }); + mockFormatPercentage.mockImplementation((pct) => `${pct}%`); + mockFormatPnl.mockImplementation((amount) => { + const num = typeof amount === 'string' ? parseFloat(amount) : amount; + return num >= 0 + ? `+$${Math.abs(num).toFixed(2)}` + : `-$${Math.abs(num).toFixed(2)}`; + }); + mockUseStyles.mockReturnValue({ + styles: { + container: { flexDirection: 'row', alignItems: 'baseline', gap: 6 }, + positionValue: { fontWeight: '700' }, + priceChange24h: { fontSize: 12 }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + theme: {} as any, + }); + }); + + it('should render with live price data', () => { + mockUsePerpsLivePrices.mockReturnValue({ + BTC: { + coin: 'BTC', + price: '50000', + percentChange24h: '5.5', + timestamp: Date.now(), + }, + }); + + const { getByText } = render(); + + expect(getByText('$50000.00')).toBeTruthy(); + // 5.5% of 50000 = 2750 + expect(getByText('+$2750.00 (5.5%)')).toBeTruthy(); + }); + + it('should use fallback values when no live data', () => { + mockUsePerpsLivePrices.mockReturnValue({}); + + const { getByText } = render( + , + ); + + expect(getByText('$3000.00')).toBeTruthy(); + // 2.5% of 3000 = 75 + expect(getByText('+$75.00 (2.5%)')).toBeTruthy(); + }); + + it('should handle negative price change', () => { + mockUsePerpsLivePrices.mockReturnValue({ + SOL: { + coin: 'SOL', + price: '100', + percentChange24h: '-10', + timestamp: Date.now(), + }, + }); + + const { getByText } = render(); + + expect(getByText('$100.00')).toBeTruthy(); + // -10% of 100 = -10 + expect(getByText('-$10.00 (-10%)')).toBeTruthy(); + }); + + it('should handle positive price change color', () => { + mockUsePerpsLivePrices.mockReturnValue({ + AVAX: { + coin: 'AVAX', + price: '25', + percentChange24h: '8', + timestamp: Date.now(), + }, + }); + + const { getByText } = render(); + + expect(getByText('+$2.00 (8%)')).toBeTruthy(); + }); + + it('should handle zero price change', () => { + mockUsePerpsLivePrices.mockReturnValue({ + MATIC: { + coin: 'MATIC', + price: '1', + percentChange24h: '0', + timestamp: Date.now(), + }, + }); + + const { getByText } = render(); + + expect(getByText('$1.00')).toBeTruthy(); + expect(getByText('+$0.00 (0%)')).toBeTruthy(); + }); + + it('should use custom throttle value', () => { + mockUsePerpsLivePrices.mockReturnValue({ + DOGE: { + coin: 'DOGE', + price: '0.1', + percentChange24h: '0', + timestamp: Date.now(), + }, + }); + + render(); + + expect(mockUsePerpsLivePrices).toHaveBeenCalledWith({ + symbols: ['DOGE'], + throttleMs: 2000, + }); + }); + + it('should use default throttle value of 1000ms', () => { + mockUsePerpsLivePrices.mockReturnValue({ + UNI: { + coin: 'UNI', + price: '10', + percentChange24h: '0', + timestamp: Date.now(), + }, + }); + + render(); + + expect(mockUsePerpsLivePrices).toHaveBeenCalledWith({ + symbols: ['UNI'], + throttleMs: 1000, + }); + }); + + it('should apply test IDs correctly', () => { + mockUsePerpsLivePrices.mockReturnValue({ + LINK: { + coin: 'LINK', + price: '15', + percentChange24h: '3', + timestamp: Date.now(), + }, + }); + + const { getByTestId } = render( + , + ); + + expect(getByTestId('price-test')).toBeTruthy(); + expect(getByTestId('change-test')).toBeTruthy(); + }); + + it('should handle missing percentChange24h', () => { + mockUsePerpsLivePrices.mockReturnValue({ + DOT: { + coin: 'DOT', + price: '5', + timestamp: Date.now(), + // percentChange24h is missing + }, + }); + + const { getByText } = render(); + + expect(getByText('$5.00')).toBeTruthy(); + expect(getByText('+$0.00 (0%)')).toBeTruthy(); // Defaults to 0 + }); + + it('should calculate change amount correctly', () => { + mockUsePerpsLivePrices.mockReturnValue({ + ADA: { + coin: 'ADA', + price: '0.5', + percentChange24h: '20', + timestamp: Date.now(), + }, + }); + + const { getByText } = render(); + + expect(getByText('$0.50')).toBeTruthy(); + // 20% of 0.5 = 0.1 + expect(getByText('+$0.10 (20%)')).toBeTruthy(); + }); + + it('should use fallback values as defaults', () => { + mockUsePerpsLivePrices.mockReturnValue({}); + + const { getByText } = render( + , + ); + + expect(getByText('$0.60')).toBeTruthy(); + // -5% of 0.6 = -0.03 + expect(getByText('-$0.03 (-5%)')).toBeTruthy(); + }); + + it('should prefer live data over fallback', () => { + mockUsePerpsLivePrices.mockReturnValue({ + ALGO: { + coin: 'ALGO', + price: '0.2', + percentChange24h: '15', + timestamp: Date.now(), + }, + }); + + const { getByText } = render( + , + ); + + // Should use live data, not fallback + expect(getByText('$0.20')).toBeTruthy(); + // 15% of 0.2 = 0.03 + expect(getByText('+$0.03 (15%)')).toBeTruthy(); + }); +}); diff --git a/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx new file mode 100644 index 000000000000..630f99fc17e2 --- /dev/null +++ b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx @@ -0,0 +1,96 @@ +import React, { memo } from 'react'; +import { View, StyleSheet } from 'react-native'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import { usePerpsLivePrices } from '../../hooks/stream'; +import { + formatPrice, + formatPercentage, + formatPnl, +} from '../../utils/formatUtils'; +import { useStyles } from '../../../../../component-library/hooks'; + +interface LivePriceHeaderProps { + symbol: string; + fallbackPrice?: string; + fallbackChange?: string; + testIDPrice?: string; + testIDChange?: string; + throttleMs?: number; +} + +const styleSheet = () => + StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'baseline', + gap: 6, + }, + positionValue: { + fontWeight: '700', + }, + priceChange24h: { + fontSize: 12, + }, + }); + +/** + * Component that displays live price and change for header + * Subscribes to price stream independently to avoid parent re-renders + */ +const LivePriceHeader: React.FC = ({ + symbol, + fallbackPrice = '0', + fallbackChange = '0', + testIDPrice, + testIDChange, + throttleMs = 1000, // Balanced updates for header (1 update per second) +}) => { + const { styles } = useStyles(styleSheet, {}); + const prices = usePerpsLivePrices({ + symbols: [symbol], + throttleMs, + }); + + const priceData = prices[symbol]; + + // Use fallback data if no live data yet + const displayPrice = priceData + ? parseFloat(priceData.price) + : parseFloat(fallbackPrice); + const displayChange = priceData + ? parseFloat(priceData.percentChange24h || '0') + : parseFloat(fallbackChange); + + const isPositiveChange = displayChange >= 0; + const changeColor = isPositiveChange ? TextColor.Success : TextColor.Error; + + // Calculate fiat change amount (exactly as original) + const changeAmount = (displayChange / 100) * displayPrice; + + return ( + + + {formatPrice(displayPrice)} + + + {formatPnl(changeAmount)} ({formatPercentage(displayChange.toString())}) + + + ); +}; + +// Memoize to prevent unnecessary re-renders when parent re-renders +export default memo(LivePriceHeader); diff --git a/app/components/UI/Perps/components/LivePriceDisplay/index.ts b/app/components/UI/Perps/components/LivePriceDisplay/index.ts new file mode 100644 index 000000000000..87012a2c5cd5 --- /dev/null +++ b/app/components/UI/Perps/components/LivePriceDisplay/index.ts @@ -0,0 +1 @@ +export { default } from './LivePriceDisplay'; diff --git a/app/components/UI/Perps/components/PerpsClosePositionBottomSheet/PerpsClosePositionBottomSheet.test.tsx b/app/components/UI/Perps/components/PerpsClosePositionBottomSheet/PerpsClosePositionBottomSheet.test.tsx index 43b56eba93e6..c1192f399d3c 100644 --- a/app/components/UI/Perps/components/PerpsClosePositionBottomSheet/PerpsClosePositionBottomSheet.test.tsx +++ b/app/components/UI/Perps/components/PerpsClosePositionBottomSheet/PerpsClosePositionBottomSheet.test.tsx @@ -66,7 +66,7 @@ jest.mock('../PerpsSlider/PerpsSlider', () => { // Mock stream hooks jest.mock('../../hooks/stream', () => ({ - useLivePrices: jest.fn(() => ({ + usePerpsLivePrices: jest.fn(() => ({ BTC: { price: '45000' }, ETH: { price: '2500' }, })), diff --git a/app/components/UI/Perps/components/PerpsClosePositionBottomSheet/PerpsClosePositionBottomSheet.tsx b/app/components/UI/Perps/components/PerpsClosePositionBottomSheet/PerpsClosePositionBottomSheet.tsx index 63e04003ae2d..3af0d073ec5a 100644 --- a/app/components/UI/Perps/components/PerpsClosePositionBottomSheet/PerpsClosePositionBottomSheet.tsx +++ b/app/components/UI/Perps/components/PerpsClosePositionBottomSheet/PerpsClosePositionBottomSheet.tsx @@ -26,7 +26,7 @@ import { usePerpsOrderFees, usePerpsClosePositionValidation, } from '../../hooks'; -import { useLivePrices } from '../../hooks/stream'; +import { usePerpsLivePrices } from '../../hooks/stream'; import { formatPositionSize, formatPrice } from '../../utils/formatUtils'; import PerpsSlider from '../PerpsSlider/PerpsSlider'; import { createStyles } from './PerpsClosePositionBottomSheet.styles'; @@ -80,9 +80,9 @@ const PerpsClosePositionBottomSheet: React.FC< const [limitPriceInputFocused, setLimitPriceInputFocused] = useState(false); // Subscribe to real-time price with 1s debounce for position closing - const priceData = useLivePrices({ + const priceData = usePerpsLivePrices({ symbols: isVisible ? [position.coin] : [], - debounceMs: 1000, + throttleMs: 1000, }); const currentPrice = priceData[position.coin]?.price ? parseFloat(priceData[position.coin].price) diff --git a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx index adbacfa0be98..ac4ca5110e42 100644 --- a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx +++ b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx @@ -59,7 +59,7 @@ jest.mock('../../../../../../locales/i18n', () => ({ // Mock stream hooks jest.mock('../../hooks/stream', () => ({ - useLivePrices: jest.fn(() => ({})), + usePerpsLivePrices: jest.fn(() => ({})), })); // Mock usePerpsConnection hook @@ -266,9 +266,9 @@ describe('PerpsLimitPriceBottomSheet', () => { jest.clearAllMocks(); mockUseTheme.mockReturnValue(mockTheme); - // Mock useLivePrices hook to return empty by default - const { useLivePrices } = jest.requireMock('../../hooks/stream'); - useLivePrices.mockReturnValue({}); + // Mock usePerpsLivePrices hook to return empty by default + const { usePerpsLivePrices } = jest.requireMock('../../hooks/stream'); + usePerpsLivePrices.mockReturnValue({}); // Mock usePerpsConnection hook const { usePerpsConnection } = jest.requireMock('../../hooks/index'); @@ -361,8 +361,8 @@ describe('PerpsLimitPriceBottomSheet', () => { describe('Price Data Integration', () => { it('uses real-time price data when available', () => { // Arrange - Mock returns real-time data - const { useLivePrices } = jest.requireMock('../../hooks/stream'); - useLivePrices.mockReturnValue({ + const { usePerpsLivePrices } = jest.requireMock('../../hooks/stream'); + usePerpsLivePrices.mockReturnValue({ ETH: { price: '3200.00', markPrice: '3201.00', @@ -382,8 +382,8 @@ describe('PerpsLimitPriceBottomSheet', () => { it('falls back to passed current price when real-time data unavailable', () => { // Arrange - Mock returns no real-time data - const { useLivePrices } = jest.requireMock('../../hooks/stream'); - useLivePrices.mockReturnValue({}); + const { usePerpsLivePrices } = jest.requireMock('../../hooks/stream'); + usePerpsLivePrices.mockReturnValue({}); // Act render(); @@ -395,8 +395,8 @@ describe('PerpsLimitPriceBottomSheet', () => { it('displays unavailable prices when no data', () => { // Arrange const propsWithoutPrice = { ...defaultProps, currentPrice: 0 }; - const { useLivePrices } = jest.requireMock('../../hooks/stream'); - useLivePrices.mockReturnValue({}); + const { usePerpsLivePrices } = jest.requireMock('../../hooks/stream'); + usePerpsLivePrices.mockReturnValue({}); // Act render(); @@ -407,8 +407,8 @@ describe('PerpsLimitPriceBottomSheet', () => { it('calculates default bid/ask spreads when order book data unavailable', () => { // Arrange - Mock returns only basic price data - const { useLivePrices } = jest.requireMock('../../hooks/stream'); - useLivePrices.mockReturnValue({ + const { usePerpsLivePrices } = jest.requireMock('../../hooks/stream'); + usePerpsLivePrices.mockReturnValue({ ETH: { price: '3000.00', markPrice: '3001.00', diff --git a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx index 64df6e013aee..5d701f6f0c39 100644 --- a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx +++ b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx @@ -17,7 +17,7 @@ import { useTheme } from '../../../../../util/theme'; import Keypad from '../../../../Base/Keypad'; import { formatPrice } from '../../utils/formatUtils'; import { createStyles } from './PerpsLimitPriceBottomSheet.styles'; -import { useLivePrices } from '../../hooks/stream'; +import { usePerpsLivePrices } from '../../hooks/stream'; import { ORDER_BOOK_SPREAD } from '../../constants/hyperLiquidConfig'; interface PerpsLimitPriceBottomSheetProps { @@ -44,11 +44,11 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ // Initialize with initial limit price or empty to show placeholder const [limitPrice, setLimitPrice] = useState(initialLimitPrice || ''); - // Get real-time price data with 500ms debounce for limit price bottom sheet + // Get real-time price data with 1000ms throttle for limit price bottom sheet // Only subscribe when visible - const priceData = useLivePrices({ + const priceData = usePerpsLivePrices({ symbols: isVisible ? [asset] : [], - debounceMs: 500, + throttleMs: 1000, }); const currentPriceData = priceData[asset]; diff --git a/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.test.tsx b/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.test.tsx index 9a6f7d2d6b59..1be725d252f4 100644 --- a/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.test.tsx @@ -8,6 +8,9 @@ import { PerpsMarketHeaderSelectorsIDs } from '../../../../../../e2e/selectors/P import { PerpsMarketData } from '../../controllers/types'; import ButtonIcon from '../../../../../component-library/components/Buttons/ButtonIcon'; +// Mock PerpsStreamManager +jest.mock('../../providers/PerpsStreamManager'); + const mockMarket: PerpsMarketData = { symbol: 'BTC', name: 'Bitcoin', @@ -29,8 +32,6 @@ describe('PerpsMarketHeader', () => { const { getByTestId } = renderWithProvider( , { state: initialState }, @@ -44,8 +45,6 @@ describe('PerpsMarketHeader', () => { const { UNSAFE_getByType } = renderWithProvider( , @@ -63,8 +62,6 @@ describe('PerpsMarketHeader', () => { const { UNSAFE_getByType } = renderWithProvider( , diff --git a/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.tsx b/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.tsx index c458525614ed..9488e9253749 100644 --- a/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.tsx +++ b/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.tsx @@ -16,20 +16,12 @@ import { useStyles } from '../../../../../component-library/hooks'; import RemoteImage from '../../../../Base/RemoteImage'; import type { PerpsMarketData } from '../../controllers/types'; import { usePerpsAssetMetadata } from '../../hooks/usePerpsAssetsMetadata'; -import { - formatPercentage, - formatPnl, - formatPrice, - parseCurrencyString, - parsePercentageString, -} from '../../utils/formatUtils'; import { styleSheet } from './PerpsMarketHeader.styles'; import { PerpsMarketHeaderSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; +import LivePriceHeader from '../LivePriceDisplay/LivePriceHeader'; interface PerpsMarketHeaderProps { market: PerpsMarketData; - currentPrice?: number; - priceChange24h?: number; onBackPress?: () => void; onMorePress?: () => void; testID?: string; @@ -37,8 +29,6 @@ interface PerpsMarketHeaderProps { const PerpsMarketHeader: React.FC = ({ market, - currentPrice, - priceChange24h, onBackPress, onMorePress, testID, @@ -46,14 +36,6 @@ const PerpsMarketHeader: React.FC = ({ const { styles } = useStyles(styleSheet, {}); const { assetUrl } = usePerpsAssetMetadata(market.symbol); - const displayPrice = currentPrice || parseCurrencyString(market.price || '0'); - const displayChange = - priceChange24h ?? parsePercentageString(market.change24hPercent); - const isPositiveChange = displayChange >= 0; - - // Calculate fiat change amount - const changeAmount = (displayChange / 100) * displayPrice; - return ( {/* Back Button */} @@ -93,21 +75,14 @@ const PerpsMarketHeader: React.FC = ({ - - {formatPrice(displayPrice)} - - - {formatPnl(changeAmount)} ( - {formatPercentage(displayChange.toString())}) - + diff --git a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.test.tsx b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.test.tsx index b915eb988651..7a4bfd95e111 100644 --- a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.test.tsx @@ -48,7 +48,6 @@ describe('PerpsMarketStatisticsCard', () => { volume24h: '$1,234,567.89', openInterest: '$987,654.32', fundingRate: '0.0125%', - fundingCountdown: '02:15:30', currentPrice: 47500, priceChange24h: 0.05, isLoading: false, @@ -86,7 +85,6 @@ describe('PerpsMarketStatisticsCard', () => { // Check funding rate row expect(getByText('perps.market.funding_rate')).toBeOnTheScreen(); expect(getByText('0.0125%')).toBeOnTheScreen(); - expect(getByText('(02:15:30)')).toBeOnTheScreen(); }); it('displays positive funding rate in success color', () => { @@ -177,7 +175,6 @@ describe('PerpsMarketStatisticsCard', () => { expect(getByTestId('perps-statistics-volume-24h')).toBeOnTheScreen(); expect(getByTestId('perps-statistics-open-interest')).toBeOnTheScreen(); expect(getByTestId('perps-statistics-funding-rate')).toBeOnTheScreen(); - expect(getByTestId('perps-statistics-funding-countdown')).toBeOnTheScreen(); }); it('handles edge case with very small funding rate values', () => { @@ -212,15 +209,6 @@ describe('PerpsMarketStatisticsCard', () => { expect(getByText('15.7500%')).toBeOnTheScreen(); }); - it('renders funding countdown in parentheses', () => { - const { getByText } = render( - , - ); - - const countdownText = getByText('(02:15:30)'); - expect(countdownText).toBeOnTheScreen(); - }); - it('displays all market statistics with proper formatting', () => { const { getByText } = render( , @@ -232,7 +220,6 @@ describe('PerpsMarketStatisticsCard', () => { expect(getByText('$1,234,567.89')).toBeOnTheScreen(); // volume24h expect(getByText('$987,654.32')).toBeOnTheScreen(); // openInterest expect(getByText('0.0125%')).toBeOnTheScreen(); // fundingRate - expect(getByText('(02:15:30)')).toBeOnTheScreen(); // fundingCountdown }); it('calls onTooltipPress only when info icons are pressed', () => { diff --git a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.tsx b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.tsx index 89adcdc5aa4c..4f182cf766dc 100644 --- a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.tsx +++ b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.tsx @@ -13,12 +13,14 @@ import Text, { import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from './PerpsMarketStatisticsCard.styles'; import type { PerpsMarketStatisticsCardProps } from './PerpsMarketStatisticsCard.types'; -// TODO: Consider renaming to PerpsMarketStatisticsCard since it isn't tied to a specific view anymore import { PerpsMarketDetailsViewSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; +import FundingCountdown from '../FundingCountdown'; const PerpsMarketStatisticsCard: React.FC = ({ marketStats, onTooltipPress, + nextFundingTime, + fundingIntervalHours, }) => { const { styles } = useStyles(styleSheet, {}); @@ -120,16 +122,16 @@ const PerpsMarketStatisticsCard: React.FC = ({ > {marketStats.fundingRate} - - ({marketStats.fundingCountdown}) - + /> diff --git a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.types.ts b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.types.ts index 3a9facbfe743..ab2c38acd1ea 100644 --- a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.types.ts +++ b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.types.ts @@ -4,4 +4,12 @@ import type { PerpsTooltipContentKey } from '../PerpsBottomSheetTooltip'; export interface PerpsMarketStatisticsCardProps { marketStats: ReturnType; onTooltipPress: (contentKey: PerpsTooltipContentKey) => void; + /** + * Next funding time in milliseconds since epoch (optional, market-specific) + */ + nextFundingTime?: number; + /** + * Funding interval in hours (optional, market-specific) + */ + fundingIntervalHours?: number; } diff --git a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx index 7cb0352568c5..22d237f11fb0 100644 --- a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx @@ -107,13 +107,11 @@ jest.mock('../../../../../core/Engine', () => ({ const mockMarketStats: PerpsMarketTabsProps['marketStats'] = { currentPrice: 45000, - priceChange24h: 1125, high24h: '$46,000.00', low24h: '$44,000.00', volume24h: '$1.23B', openInterest: '$500M', fundingRate: '+0.01%', - fundingCountdown: '5h 30m', isLoading: false, refresh: jest.fn(), }; diff --git a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx index e6210247e55a..00e7d166c8ee 100644 --- a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx @@ -33,7 +33,8 @@ const PerpsMarketTabs: React.FC = ({ unfilledOrders = [], onPositionUpdate, onActiveTabChange, - priceData, + nextFundingTime, + fundingIntervalHours, }) => { const { styles } = useStyles(styleSheet, {}); const fadeAnim = useRef(new Animated.Value(0)).current; @@ -167,12 +168,20 @@ const PerpsMarketTabs: React.FC = ({ {renderTooltipModal()} ); } + const getTabTestId = (tabId: string) => { + if (tabId === 'position') return PerpsMarketTabsSelectorsIDs.POSITION_TAB; + if (tabId === 'orders') return PerpsMarketTabsSelectorsIDs.ORDERS_TAB; + return PerpsMarketTabsSelectorsIDs.STATISTICS_TAB; + }; + const renderTabBar = () => ( {tabs.map((tab) => { @@ -183,13 +192,7 @@ const PerpsMarketTabs: React.FC = ({ style={[styles.tab]} onPress={() => handleTabChange(tab.id)} activeOpacity={0.7} - testID={ - tab.id === 'position' - ? PerpsMarketTabsSelectorsIDs.POSITION_TAB - : tab.id === 'orders' - ? PerpsMarketTabsSelectorsIDs.ORDERS_TAB - : PerpsMarketTabsSelectorsIDs.STATISTICS_TAB - } + testID={getTabTestId(tab.id)} > = ({ expanded showIcon onPositionUpdate={onPositionUpdate} - priceData={priceData} /> ); @@ -233,6 +235,8 @@ const PerpsMarketTabs: React.FC = ({ ); diff --git a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.types.ts b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.types.ts index fbf18134ffaa..9165823b318b 100644 --- a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.types.ts +++ b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.types.ts @@ -1,4 +1,4 @@ -import type { Position, Order, PriceUpdate } from '../../controllers/types'; +import type { Position, Order } from '../../controllers/types'; import { usePerpsMarketStats } from '../../hooks'; export interface TabViewProps { @@ -12,5 +12,12 @@ export interface PerpsMarketTabsProps { unfilledOrders: Order[]; onPositionUpdate?: () => Promise; onActiveTabChange?: (tabId: string) => void; - priceData?: PriceUpdate | null; + /** + * Next funding time in milliseconds since epoch (optional, market-specific) + */ + nextFundingTime?: number; + /** + * Funding interval in hours (optional, market-specific) + */ + fundingIntervalHours?: number; } diff --git a/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.tsx b/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.tsx index e3541a1776a6..9d7144a2cd5d 100644 --- a/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.tsx +++ b/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.tsx @@ -70,7 +70,17 @@ const PerpsOpenOrderCard: React.FC = ({ // Derive order data for display const derivedData = useMemo(() => { - const direction = order.side === 'buy' ? 'long' : 'short'; + // For reduce-only orders (TP/SL), show them as closing positions + let direction: OpenOrderCardDerivedData['direction']; + if (order.reduceOnly || order.isTrigger) { + // This is a TP/SL order closing a position + // If side is 'sell', it's closing a long position + // If side is 'buy', it's closing a short position + direction = order.side === 'sell' ? 'Close Long' : 'Close Short'; + } else { + // Regular order + direction = order.side === 'buy' ? 'long' : 'short'; + } // Calculate size in USD const sizeInUSD = BigNumber(order.originalSize) diff --git a/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.types.ts b/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.types.ts index 16e36310e87e..317518716ddb 100644 --- a/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.types.ts +++ b/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.types.ts @@ -10,7 +10,7 @@ export interface PerpsOpenOrderCardProps { } export interface OpenOrderCardDerivedData { - direction: 'long' | 'short'; + direction: 'long' | 'short' | 'Close Long' | 'Close Short'; sizeInUSD: string; fillPercentage: number; } diff --git a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx index eb7211091ad8..4cf969939d17 100644 --- a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx +++ b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx @@ -428,91 +428,8 @@ describe('PerpsPositionCard', () => { }); describe('Hook Integration', () => { - it('calls loadPositions when usePerpsTPSLUpdate onSuccess is triggered', async () => { - // Arrange - const mockLoadPositions = jest.fn().mockResolvedValue(undefined); - const mockOnPositionUpdate = jest.fn().mockResolvedValue(undefined); - const mockHandleUpdateTPSL = jest.fn().mockResolvedValue(undefined); - - const { usePerpsPositions, usePerpsTPSLUpdate } = - jest.requireMock('../../hooks'); - - usePerpsPositions.mockReturnValue({ - loadPositions: mockLoadPositions, - }); - - // Mock implementation to capture and immediately call the onSuccess callback - usePerpsTPSLUpdate.mockImplementation( - ({ onSuccess }: { onSuccess: () => void }) => { - // Simulate the onSuccess callback being called - setTimeout(() => { - onSuccess(); - }, 0); - return { - handleUpdateTPSL: mockHandleUpdateTPSL, - isUpdating: false, - }; - }, - ); - - // Act - render( - , - ); - - // Wait for async operations to complete - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Assert - expect(mockLoadPositions).toHaveBeenCalledWith({ isRefresh: true }); - expect(mockOnPositionUpdate).toHaveBeenCalled(); - }); - - it('calls loadPositions when usePerpsClosePosition onSuccess is triggered', async () => { - // Arrange - const mockLoadPositions = jest.fn().mockResolvedValue(undefined); - const mockOnPositionUpdate = jest.fn().mockResolvedValue(undefined); - const mockHandleClosePosition = jest.fn().mockResolvedValue(undefined); - - const { usePerpsPositions, usePerpsClosePosition } = - jest.requireMock('../../hooks'); - - usePerpsPositions.mockReturnValue({ - loadPositions: mockLoadPositions, - }); - - // Mock implementation to capture and immediately call the onSuccess callback - usePerpsClosePosition.mockImplementation( - ({ onSuccess }: { onSuccess: () => void }) => { - // Simulate the onSuccess callback being called - setTimeout(() => { - onSuccess(); - }, 0); - return { - handleClosePosition: mockHandleClosePosition, - isClosing: false, - }; - }, - ); - - // Act - render( - , - ); - - // Wait for async operations to complete - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Assert - expect(mockLoadPositions).toHaveBeenCalledWith({ isRefresh: true }); - expect(mockOnPositionUpdate).toHaveBeenCalled(); - }); + // Tests removed - loadPositions no longer exists with WebSocket streaming + // Positions update automatically via WebSocket subscriptions it('returns early from handleCardPress when isLoading is true', () => { // Arrange diff --git a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx index a4daaa336441..2b9966c913cc 100644 --- a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx +++ b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx @@ -36,7 +36,6 @@ import { usePerpsAssetMetadata } from '../../hooks/usePerpsAssetsMetadata'; import RemoteImage from '../../../../Base/RemoteImage'; import { usePerpsMarkets, - usePerpsPositions, usePerpsTPSLUpdate, usePerpsClosePosition, } from '../../hooks'; @@ -70,32 +69,23 @@ const PerpsPositionCard: React.FC = ({ null, ); - const { loadPositions } = usePerpsPositions({ - loadOnMount: true, - refreshOnFocus: true, - }); - const { handleUpdateTPSL, isUpdating } = usePerpsTPSLUpdate({ onSuccess: () => { - // Refresh positions to show updated data - loadPositions({ isRefresh: true }).then(() => { - // Also call parent's position update callback if provided - if (onPositionUpdate) { - onPositionUpdate(); - } - }); + // Positions update automatically via WebSocket + // Call parent's position update callback if provided + if (onPositionUpdate) { + onPositionUpdate(); + } }, }); const { handleClosePosition, isClosing } = usePerpsClosePosition({ onSuccess: () => { - // Refresh positions after successful close - loadPositions({ isRefresh: true }).then(() => { - // Also call parent's position update callback if provided - if (onPositionUpdate) { - onPositionUpdate(); - } - }); + // Positions update automatically via WebSocket + // Call parent's position update callback if provided + if (onPositionUpdate) { + onPositionUpdate(); + } setIsClosePositionVisible(false); setSelectedPosition(null); }, diff --git a/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.test.tsx b/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.test.tsx index d05ef4508f58..653ca3ec379d 100644 --- a/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.test.tsx +++ b/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.test.tsx @@ -57,7 +57,7 @@ jest.mock('../../hooks', () => ({ // Mock stream hooks jest.mock('../../hooks/stream', () => ({ - useLivePrices: jest.fn(() => ({})), // Return empty object for prices + usePerpsLivePrices: jest.fn(() => ({})), // Return empty object for prices })); // Mock format utilities diff --git a/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.tsx b/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.tsx index 5dced08d4d5a..cdbcc1ad70b7 100644 --- a/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.tsx +++ b/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.tsx @@ -25,7 +25,7 @@ import { strings } from '../../../../../../locales/i18n'; import type { Position } from '../../controllers/types'; import { createStyles } from './PerpsTPSLBottomSheet.styles'; import { usePerpsPerformance } from '../../hooks'; -import { useLivePrices } from '../../hooks/stream'; +import { usePerpsLivePrices } from '../../hooks/stream'; import { PerpsMeasurementName } from '../../constants/performanceMetrics'; import { PerpsEventProperties, @@ -106,9 +106,9 @@ const PerpsTPSLBottomSheet: React.FC = ({ // Subscribe to real-time price only when visible and we have an asset // Use 1s debounce for TP/SL bottom sheet - const priceData = useLivePrices({ + const priceData = usePerpsLivePrices({ symbols: isVisible && asset ? [asset] : [], - debounceMs: 1000, + throttleMs: 1000, }); const livePrice = priceData[asset]?.price ? parseFloat(priceData[asset].price) diff --git a/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.test.tsx b/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.test.tsx index 056ee2f186ae..cfc1af2b8b83 100644 --- a/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.test.tsx +++ b/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.test.tsx @@ -1,20 +1,19 @@ /* eslint-disable import/no-namespace */ -import React from 'react'; import { + fireEvent, render, screen, - fireEvent, waitFor, - act, } from '@testing-library/react-native'; +import React from 'react'; import { Animated } from 'react-native'; -import PerpsTabControlBar from './PerpsTabControlBar'; -import * as PerpsHooks from '../../hooks'; import * as ComponentLibraryHooks from '../../../../../component-library/hooks'; import DevLogger from '../../../../../core/SDKConnect/utils/DevLogger'; -import { Position } from '../../controllers'; +import * as PerpsHooks from '../../hooks'; +import PerpsTabControlBar from './PerpsTabControlBar'; // Mock dependencies +jest.mock('../../providers/PerpsStreamManager'); jest.mock('../../../../../component-library/hooks', () => ({ useStyles: jest.fn(() => ({ styles: { @@ -33,6 +32,13 @@ jest.mock('../../hooks', () => ({ useBalanceComparison: jest.fn(), })); +jest.mock('../../hooks/stream', () => ({ + usePerpsLivePositions: jest.fn(() => ({ + positions: [], + isInitialLoading: false, + })), +})); + jest.mock('../../utils/formatUtils', () => ({ formatPerpsFiat: jest.fn( (balance: string) => `$${parseFloat(balance || '0').toFixed(2)}`, @@ -87,7 +93,6 @@ describe('PerpsTabControlBar', () => { // Mock implementations const mockGetAccountState = jest.fn(); - const mockSubscribeToPositions = jest.fn(); const mockStartPulseAnimation = jest.fn(); const mockStopAnimation = jest.fn(); const mockCompareAndUpdateBalance = jest.fn(); @@ -125,7 +130,6 @@ describe('PerpsTabControlBar', () => { getPositions: jest.fn(), getAccountState: mockGetAccountState, subscribeToPrices: jest.fn(), - subscribeToPositions: mockSubscribeToPositions, subscribeToOrderFills: jest.fn(), deposit: jest.fn(), getDepositRoutes: jest.fn(), @@ -150,9 +154,6 @@ describe('PerpsTabControlBar', () => { // Default successful responses mockGetAccountState.mockResolvedValue(defaultAccountState); - mockSubscribeToPositions.mockReturnValue(() => { - /* empty unsubscribe function */ - }); mockCompareAndUpdateBalance.mockReturnValue('increase'); }); @@ -242,55 +243,7 @@ describe('PerpsTabControlBar', () => { }); }); - describe('WebSocket Subscription and Polling', () => { - it('subscribes to position updates on mount', async () => { - render(); - - expect(mockSubscribeToPositions).toHaveBeenCalledWith({ - callback: expect.any(Function), - }); - }); - - it('refreshes balance when position updates are received', async () => { - let positionCallback: ((positions: Position[]) => void) | null = null; - mockSubscribeToPositions.mockImplementation(({ callback }) => { - positionCallback = callback; - return () => { - /* empty unsubscribe function */ - }; - }); - - render(); - - await waitFor(() => { - expect(mockSubscribeToPositions).toHaveBeenCalled(); - }); - - // Simulate position update - await act(async () => { - positionCallback?.([ - { id: '1', symbol: 'BTC' }, - ] as unknown as Position[]); - }); - - await waitFor(() => { - expect(mockGetAccountState).toHaveBeenCalledTimes(2); // Initial + position update - }); - }); - - it('only calls getAccountState once without position updates', async () => { - render(); - - // Fast forward time to ensure no polling occurs - act(() => { - jest.advanceTimersByTime(60000); - }); - - await waitFor(() => { - expect(mockGetAccountState).toHaveBeenCalledTimes(1); // Initial load only - }); - }); - }); + // WebSocket subscription tests removed - usePerpsLivePositions handles subscriptions internally describe('Press Handler', () => { it('calls onManageBalancePress when pressed', async () => { @@ -371,16 +324,7 @@ describe('PerpsTabControlBar', () => { }); describe('Cleanup and Memory Management', () => { - it('cleans up subscription on unmount', async () => { - const mockUnsubscribe = jest.fn(); - mockSubscribeToPositions.mockReturnValue(mockUnsubscribe); - - const { unmount } = render(); - - unmount(); - - expect(mockUnsubscribe).toHaveBeenCalled(); - }); + // Subscription cleanup test removed - handled by usePerpsLivePositions internally it('stops animation on unmount', async () => { const { unmount } = render(); @@ -390,13 +334,7 @@ describe('PerpsTabControlBar', () => { expect(mockStopAnimation).toHaveBeenCalled(); }); - it('handles cleanup when subscription returns null', async () => { - mockSubscribeToPositions.mockReturnValue(null); - - const { unmount } = render(); - - expect(() => unmount()).not.toThrow(); - }); + // Null subscription test removed - no longer applicable }); describe('Edge Cases', () => { @@ -426,42 +364,7 @@ describe('PerpsTabControlBar', () => { }); }); - it('handles multiple rapid balance updates via position changes', async () => { - let positionCallback: ((positions: Position[]) => void) | undefined; - mockSubscribeToPositions.mockImplementation(({ callback }) => { - positionCallback = callback; - return jest.fn(); - }); - - render(); - - // Simulate multiple rapid position updates with different values - await act(async () => { - positionCallback?.([ - { - coin: 'BTC', - size: '1.0', - entryPrice: '50000', - unrealizedPnl: '100', - }, - ] as unknown as Position[]); - positionCallback?.([ - { - coin: 'ETH', - size: '2.0', - entryPrice: '3000', - unrealizedPnl: '200', - }, - ] as unknown as Position[]); - positionCallback?.([ - { coin: 'SOL', size: '3.0', entryPrice: '100', unrealizedPnl: '300' }, - ] as unknown as Position[]); - }); - - await waitFor(() => { - expect(mockGetAccountState).toHaveBeenCalledTimes(4); // Initial + 3 position updates - }); - }); + // Test removed - position updates handled by usePerpsLivePositions internally }); describe('Integration', () => { diff --git a/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.tsx b/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.tsx index 02438ffff717..21f8a7debf7b 100644 --- a/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.tsx +++ b/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.tsx @@ -18,6 +18,7 @@ import { useColorPulseAnimation, useBalanceComparison, } from '../../hooks'; +import { usePerpsLivePositions } from '../../hooks/stream'; import { AccountState } from '../../controllers'; import DevLogger from '../../../../../core/SDKConnect/utils/DevLogger'; import { formatPerpsFiat } from '../../utils/formatUtils'; @@ -37,7 +38,7 @@ export const PerpsTabControlBar: React.FC = ({ unrealizedPnl: '', }); - const { getAccountState, subscribeToPositions } = usePerpsTrading(); + const { getAccountState } = usePerpsTrading(); // Use the reusable hooks const { startPulseAnimation, getAnimatedStyle, stopAnimation } = @@ -78,54 +79,47 @@ export const PerpsTabControlBar: React.FC = ({ // Track last positions hash to detect actual changes const lastPositionsHashRef = useRef(''); - // Auto-refresh setup with WebSocket subscription + polling fallback + // Use StreamManager for real-time position updates + const { positions } = usePerpsLivePositions({ + throttleMs: 2000, // Check every 2 seconds for balance updates + }); + + // Auto-refresh balance when positions change useEffect(() => { // Initial load getAccountBalance(); + }, [getAccountBalance]); - // Set up WebSocket subscription for real-time position updates - let unsubscribePositions: (() => void) | null = null; - - try { - unsubscribePositions = subscribeToPositions({ - callback: (positions) => { - // Create a simple hash of positions to detect actual changes - const positionsHash = JSON.stringify( - positions.map((p) => ({ - coin: p.coin, - size: p.size, - entryPrice: p.entryPrice, - unrealizedPnl: p.unrealizedPnl, - })), - ); - - // Only refresh if positions actually changed - if (positionsHash !== lastPositionsHashRef.current) { - DevLogger.log( - 'PerpsTabControlBar: Position change detected, refreshing balance', - ); - lastPositionsHashRef.current = positionsHash; - getAccountBalance(); - } - }, - }); - } catch (error) { + // Monitor position changes and refresh balance + useEffect(() => { + // Create a simple hash of positions to detect actual changes + const positionsHash = JSON.stringify( + positions.map((p) => ({ + coin: p.coin, + size: p.size, + entryPrice: p.entryPrice, + unrealizedPnl: p.unrealizedPnl, + })), + ); + + // Only refresh if positions actually changed + if ( + positionsHash !== lastPositionsHashRef.current && + lastPositionsHashRef.current !== '' + ) { DevLogger.log( - 'PerpsTabControlBar: Failed to subscribe to positions, using polling only', - error, + 'PerpsTabControlBar: Position change detected, refreshing balance', ); + lastPositionsHashRef.current = positionsHash; + getAccountBalance(); + } else if (lastPositionsHashRef.current === '') { + // First time, just store the hash + lastPositionsHashRef.current = positionsHash; } + }, [positions, getAccountBalance]); - return () => { - // Cleanup WebSocket subscription - if (unsubscribePositions) { - unsubscribePositions(); - } - - // Cleanup animations - stopAnimation(); - }; - }, [getAccountBalance, subscribeToPositions, stopAnimation]); + // Cleanup animations on unmount + useEffect(() => () => stopAnimation(), [stopAnimation]); const handlePress = () => { onManageBalancePress?.(); diff --git a/app/components/UI/Perps/controllers/PerpsController.ts b/app/components/UI/Perps/controllers/PerpsController.ts index f426b9aab0ba..762f0be52532 100644 --- a/app/components/UI/Perps/controllers/PerpsController.ts +++ b/app/components/UI/Perps/controllers/PerpsController.ts @@ -53,6 +53,7 @@ import type { OrderResult, Position, SubscribeOrderFillsParams, + SubscribeOrdersParams, SubscribePositionsParams, SubscribePricesParams, SwitchProviderResult, @@ -1711,6 +1712,14 @@ export class PerpsController extends BaseController< return provider.subscribeToOrderFills(params); } + /** + * Subscribe to live order updates + */ + subscribeToOrders(params: SubscribeOrdersParams): () => void { + const provider = this.getActiveProvider(); + return provider.subscribeToOrders(params); + } + /** * Configure live data throttling */ diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts index 3020ca792fb4..f383b30b3ed5 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts @@ -2475,12 +2475,13 @@ describe('HyperLiquidProvider', () => { expect(result).toEqual([]); }); - it('should handle metaAndAssetCtxs call successfully', async () => { + it('should handle meta and predictedFundings calls successfully', async () => { mockClientService.getInfoClient = jest.fn().mockReturnValue({ meta: jest.fn().mockResolvedValue({ universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], }), allMids: jest.fn().mockResolvedValue({ BTC: '50000' }), + predictedFundings: jest.fn().mockResolvedValue([]), metaAndAssetCtxs: jest.fn().mockResolvedValue([ { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, [ @@ -2496,8 +2497,9 @@ describe('HyperLiquidProvider', () => { const result = await provider.getMarketDataWithPrices(); expect(Array.isArray(result)).toBe(true); + expect(mockClientService.getInfoClient().meta).toHaveBeenCalled(); expect( - mockClientService.getInfoClient().metaAndAssetCtxs, + mockClientService.getInfoClient().predictedFundings, ).toHaveBeenCalled(); }); }); diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts index 8a1af42a754a..d0165e15aa79 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts @@ -22,6 +22,7 @@ import { HyperLiquidWalletService } from '../../services/HyperLiquidWalletServic import { adaptAccountStateFromSDK, adaptMarketFromSDK, + adaptOrderFromSDK, adaptPositionFromSDK, buildAssetMapping, formatHyperLiquidPrice, @@ -70,6 +71,7 @@ import type { Position, ReadyToTradeResult, SubscribeOrderFillsParams, + SubscribeOrdersParams, SubscribePositionsParams, SubscribePricesParams, ToggleTestnetResult, @@ -1177,62 +1179,8 @@ export class HyperLiquidProvider implements IPerpsProvider { DevLogger.log('Currently open orders received:', rawOrders); - // Transform HyperLiquid open orders to abstract Order type - const orders: Order[] = (rawOrders || []).map((rawOrder) => { - const orderId = rawOrder.oid?.toString() || ''; - const symbol = rawOrder.coin; - const side = rawOrder.side === 'B' ? 'buy' : 'sell'; - const detailedOrderType = rawOrder.orderType || ''; - const orderType = detailedOrderType.toLowerCase().includes('limit') - ? 'limit' - : 'market'; - const size = rawOrder.sz; - const originalSize = rawOrder.origSz || size; - const price = rawOrder.limitPx || rawOrder.triggerPx || '0'; - const isTrigger = rawOrder.isTrigger || false; - const reduceOnly = rawOrder.reduceOnly || false; - - // Calculate filled and remaining size - const currentSize = parseFloat(size); - const origSize = parseFloat(originalSize); - const filledSize = origSize - currentSize; - - // Check for TP/SL in child orders - let takeProfitPrice: string | undefined; - let stopLossPrice: string | undefined; - - if (rawOrder.children && rawOrder.children.length > 0) { - rawOrder.children.forEach((child: typeof rawOrder) => { - if (child.isTrigger && child.orderType) { - if (child.orderType.includes('Take Profit')) { - takeProfitPrice = child.triggerPx || child.limitPx; - } else if (child.orderType.includes('Stop')) { - stopLossPrice = child.triggerPx || child.limitPx; - } - } - }); - } - - return { - orderId, - symbol, - side, - orderType, - size, - originalSize, - price, - filledSize: filledSize.toString(), - remainingSize: size, - status: 'open' as const, - timestamp: rawOrder.timestamp, - lastUpdated: rawOrder.timestamp, - takeProfitPrice, - stopLossPrice, - detailedOrderType, - isTrigger, - reduceOnly, - }; - }); + // Transform HyperLiquid open orders to abstract Order type using adapter + const orders: Order[] = (rawOrders || []).map(adaptOrderFromSDK); return orders; } catch (error) { @@ -1355,9 +1303,10 @@ export class HyperLiquidProvider implements IPerpsProvider { const infoClient = this.clientService.getInfoClient(); // Fetch all required data in parallel for better performance - const [perpsMeta, allMids] = await Promise.all([ + const [perpsMeta, allMids, predictedFundings] = await Promise.all([ infoClient.meta(), infoClient.allMids(), + infoClient.predictedFundings(), ]); if (!perpsMeta?.universe || !allMids) { @@ -1373,6 +1322,7 @@ export class HyperLiquidProvider implements IPerpsProvider { universe: perpsMeta.universe, assetCtxs, allMids, + predictedFundings, }); } catch (error) { DevLogger.log('Error getting market data with prices:', error); @@ -1453,6 +1403,8 @@ export class HyperLiquidProvider implements IPerpsProvider { }; } } catch (error) { + // Log the error before falling back + DevLogger.log('Failed to get max leverage for symbol', error); // If we can't get max leverage, use the default as fallback const defaultMaxLeverage = PERPS_CONSTANTS.DEFAULT_MAX_LEVERAGE; if (params.leverage < 1 || params.leverage > defaultMaxLeverage) { @@ -1810,6 +1762,13 @@ export class HyperLiquidProvider implements IPerpsProvider { return this.subscriptionService.subscribeToOrderFills(params); } + /** + * Subscribe to live order updates + */ + subscribeToOrders(params: SubscribeOrdersParams): () => void { + return this.subscriptionService.subscribeToOrders(params); + } + /** * Configure live data settings */ diff --git a/app/components/UI/Perps/controllers/types/index.ts b/app/components/UI/Perps/controllers/types/index.ts index eb1e62108e5b..3fd910028340 100644 --- a/app/components/UI/Perps/controllers/types/index.ts +++ b/app/components/UI/Perps/controllers/types/index.ts @@ -143,6 +143,14 @@ export interface PerpsMarketData { * Trading volume as formatted string (e.g., '$1.2B', '$850M') */ volume: string; + /** + * Next funding time in milliseconds since epoch (optional, market-specific) + */ + nextFundingTime?: number; + /** + * Funding interval in hours (optional, market-specific) + */ + fundingIntervalHours?: number; } export interface ToggleTestnetResult { @@ -340,6 +348,12 @@ export interface SubscribeOrderFillsParams { since?: number; // Future: only fills after timestamp } +export interface SubscribeOrdersParams { + callback: (orders: Order[]) => void; + accountId?: CaipAccountId; // Optional: defaults to selected account + includeHistory?: boolean; // Optional: include filled/canceled orders +} + export interface LiquidationPriceParams { entryPrice: number; leverage: number; @@ -390,7 +404,7 @@ export interface Order { remainingSize: string; // Amount remaining status: 'open' | 'filled' | 'canceled' | 'rejected' | 'triggered' | 'queued'; // Normalized status timestamp: number; // Order timestamp - lastUpdated: number; // Last status update timestamp + lastUpdated?: number; // Last status update timestamp (optional - not provided by all APIs) // TODO: Consider creating separate type for OpenOrders (UI Orders) potentially if optional properties muddy up the original Order type takeProfitPrice?: string; // Take profit price (if set) stopLossPrice?: string; // Stop loss price (if set) @@ -479,6 +493,7 @@ export interface IPerpsProvider { subscribeToPrices(params: SubscribePricesParams): () => void; subscribeToPositions(params: SubscribePositionsParams): () => void; subscribeToOrderFills(params: SubscribeOrderFillsParams): () => void; + subscribeToOrders(params: SubscribeOrdersParams): () => void; // Live data configuration setLiveDataConfig(config: Partial): void; diff --git a/app/components/UI/Perps/hooks/index.ts b/app/components/UI/Perps/hooks/index.ts index 8b3113ce86f9..5ef361a145d6 100644 --- a/app/components/UI/Perps/hooks/index.ts +++ b/app/components/UI/Perps/hooks/index.ts @@ -34,7 +34,6 @@ export { usePerpsPaymentTokens } from './usePerpsPaymentTokens'; // UI utility hooks export { useBalanceComparison } from './useBalanceComparison'; export { useColorPulseAnimation } from './useColorPulseAnimation'; -export { usePerpsPositions } from './usePerpsPositions'; export { usePerpsTPSLUpdate } from './usePerpsTPSLUpdate'; export { usePerpsClosePosition } from './usePerpsClosePosition'; export { usePerpsOrderFees, formatFeeRate } from './usePerpsOrderFees'; @@ -49,7 +48,6 @@ export { usePerpsFirstTimeUser } from './usePerpsFirstTimeUser'; // Transaction data hooks export { usePerpsOrderFills } from './usePerpsOrderFills'; export { usePerpsOrders } from './usePerpsOrders'; -export { usePerpsOpenOrders } from './usePerpsOpenOrders'; export { usePerpsFunding } from './usePerpsFunding'; // Event tracking hook diff --git a/app/components/UI/Perps/hooks/stream/index.ts b/app/components/UI/Perps/hooks/stream/index.ts index b7ca3f7b3a34..5241e8f85307 100644 --- a/app/components/UI/Perps/hooks/stream/index.ts +++ b/app/components/UI/Perps/hooks/stream/index.ts @@ -1,177 +1,19 @@ -import { useEffect, useState } from 'react'; -import { usePerpsStream } from '../../providers/PerpsStreamManager'; -import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; -import type { +// Export individual hooks with proper naming convention +export { usePerpsLivePrices } from './usePerpsLivePrices'; +export { usePerpsLiveOrders } from './usePerpsLiveOrders'; +export { usePerpsLivePositions } from './usePerpsLivePositions'; +export { usePerpsLiveFills } from './usePerpsLiveFills'; + +// Export types for convenience +export type { UsePerpsLivePricesOptions } from './usePerpsLivePrices'; +export type { UsePerpsLiveOrdersOptions } from './usePerpsLiveOrders'; +export type { UsePerpsLivePositionsOptions } from './usePerpsLivePositions'; +export type { UsePerpsLiveFillsOptions } from './usePerpsLiveFills'; + +// Re-export types from controllers +export type { + PriceUpdate, Order, Position, OrderFill, - PriceUpdate, } from '../../controllers/types'; - -export interface UseLivePricesOptions { - /** Array of symbols to subscribe to */ - symbols: string[]; - /** Debounce delay in milliseconds (default: 100ms) */ - debounceMs?: number; -} - -/** - * Hook for live price updates with component-specific debouncing - * @param options - Configuration options for the hook - * @returns Record of symbol to price data - */ -export function useLivePrices( - options: UseLivePricesOptions, -): Record { - const { symbols, debounceMs = 100 } = options; - const stream = usePerpsStream(); - const [prices, setPrices] = useState>({}); - - useEffect(() => { - if (symbols.length === 0) return; - - DevLogger.log( - `useLivePrices: Subscribing to symbols with ${debounceMs}ms debounce`, - { symbols }, - ); - - const unsubscribe = stream.prices.subscribeToSymbols({ - symbols, - callback: (newPrices) => { - if (!newPrices) { - return; - } - DevLogger.log( - `useLivePrices: Received price update (${debounceMs}ms debounce)`, - { - symbols: Object.keys(newPrices), - prices: Object.entries(newPrices).map(([coin, data]) => ({ - coin, - price: data.price, - })), - }, - ); - setPrices(newPrices); - }, - debounceMs, - }); - - return () => { - DevLogger.log( - `useLivePrices: Unsubscribing from symbols (${debounceMs}ms debounce)`, - { symbols }, - ); - unsubscribe(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [stream, symbols.join(','), debounceMs]); - - return prices; -} - -export interface UseLiveOrdersOptions { - /** Debounce delay in milliseconds (default: 500ms) */ - debounceMs?: number; -} - -/** - * Hook for live order updates with component-specific debouncing - * @param options - Configuration options for the hook - * @returns Array of orders - */ -export function useLiveOrders(options: UseLiveOrdersOptions = {}): Order[] { - const { debounceMs = 500 } = options; - const stream = usePerpsStream(); - const [orders, setOrders] = useState([]); - - useEffect(() => { - const unsubscribe = stream.orders.subscribe({ - callback: (newOrders) => { - if (!newOrders) { - return; - } - setOrders(newOrders); - }, - debounceMs, - }); - - return unsubscribe; - }, [stream, debounceMs]); - - return orders; -} - -export interface UseLivePositionsOptions { - /** Debounce delay in milliseconds (default: 1000ms) */ - debounceMs?: number; -} - -/** - * Hook for live position updates with component-specific debouncing - * @param options - Configuration options for the hook - * @returns Array of positions - */ -export function useLivePositions( - options: UseLivePositionsOptions = {}, -): Position[] { - const { debounceMs = 1000 } = options; - const stream = usePerpsStream(); - const [positions, setPositions] = useState([]); - - useEffect(() => { - const unsubscribe = stream.positions.subscribe({ - callback: (newPositions) => { - if (!newPositions) { - return; - } - setPositions(newPositions); - }, - debounceMs, - }); - - return unsubscribe; - }, [stream, debounceMs]); - - return positions; -} - -export interface UseLiveFillsOptions { - /** Debounce delay in milliseconds (default: 0ms for immediate updates) */ - debounceMs?: number; -} - -/** - * Hook for live order fill updates with component-specific debouncing - * @param options - Configuration options for the hook - * @returns Array of order fills - */ -export function useLiveFills(options: UseLiveFillsOptions = {}): OrderFill[] { - const { debounceMs = 0 } = options; - const stream = usePerpsStream(); - const [fills, setFills] = useState([]); - - useEffect(() => { - const unsubscribe = stream.fills.subscribe({ - callback: (newFills) => { - if (!newFills) { - return; - } - setFills(newFills); - }, - debounceMs, - }); - - return unsubscribe; - }, [stream, debounceMs]); - - return fills; -} - -// Export types for components to use -export type { PriceUpdate } from '../../controllers/types'; - -// Future hooks to be added: -// export function useLiveFunding(debounceMs: number = 5000): Funding[] { ... } -// export function useLiveAccountState(debounceMs: number = 2000): AccountState { ... } -// export function useLiveOrderBook(symbol: string, debounceMs: number = 100): OrderBook { ... } -// export function useLiveTrades(symbol: string, debounceMs: number = 0): Trade[] { ... } diff --git a/app/components/UI/Perps/hooks/stream/useLiveFills.test.ts b/app/components/UI/Perps/hooks/stream/useLiveFills.test.ts index e24223a0cd07..cc4f147302a2 100644 --- a/app/components/UI/Perps/hooks/stream/useLiveFills.test.ts +++ b/app/components/UI/Perps/hooks/stream/useLiveFills.test.ts @@ -1,6 +1,6 @@ import { renderHook, act, waitFor } from '@testing-library/react-native'; import React from 'react'; -import { useLiveFills } from './index'; +import { usePerpsLiveFills } from './index'; import type { OrderFill } from '../../controllers/types'; // Mock the stream provider @@ -16,7 +16,7 @@ jest.mock('../../providers/PerpsStreamManager', () => ({ children, })); -describe('useLiveFills', () => { +describe('usePerpsLiveFills', () => { const mockFill: OrderFill = { orderId: 'order-1', symbol: 'BTC-PERP', @@ -40,14 +40,14 @@ describe('useLiveFills', () => { }); it('should subscribe to fills on mount', () => { - const debounceMs = 2000; + const throttleMs = 2000; mockSubscribe.mockReturnValue(jest.fn()); - renderHook(() => useLiveFills({ debounceMs })); + renderHook(() => usePerpsLiveFills({ throttleMs })); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs, + throttleMs, }); }); @@ -55,7 +55,7 @@ describe('useLiveFills', () => { const mockUnsubscribe = jest.fn(); mockSubscribe.mockReturnValue(mockUnsubscribe); - const { unmount } = renderHook(() => useLiveFills()); + const { unmount } = renderHook(() => usePerpsLiveFills()); unmount(); @@ -69,7 +69,7 @@ describe('useLiveFills', () => { return jest.fn(); }); - const { result } = renderHook(() => useLiveFills()); + const { result } = renderHook(() => usePerpsLiveFills()); // Initially empty expect(result.current).toEqual([]); @@ -89,18 +89,18 @@ describe('useLiveFills', () => { }); }); - it('should use default debounce value when not provided', () => { + it('should use default throttle value when not provided', () => { mockSubscribe.mockReturnValue(jest.fn()); - renderHook(() => useLiveFills()); + renderHook(() => usePerpsLiveFills()); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs: 0, // Default value for fills (immediate) + throttleMs: 0, // Default value for fills (immediate) }); }); - it('should handle debounce changes', () => { + it('should handle throttle changes', () => { const mockUnsubscribe1 = jest.fn(); const mockUnsubscribe2 = jest.fn(); @@ -109,25 +109,25 @@ describe('useLiveFills', () => { .mockReturnValueOnce(mockUnsubscribe2); const { rerender } = renderHook( - ({ debounceMs }) => useLiveFills({ debounceMs }), + ({ throttleMs }) => usePerpsLiveFills({ throttleMs }), { - initialProps: { debounceMs: 2000 }, + initialProps: { throttleMs: 2000 }, }, ); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs: 2000, + throttleMs: 2000, }); - // Change debounce - rerender({ debounceMs: 3000 }); + // Change throttle + rerender({ throttleMs: 3000 }); - // Should resubscribe with new debounce + // Should resubscribe with new throttle expect(mockUnsubscribe1).toHaveBeenCalled(); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs: 3000, + throttleMs: 3000, }); }); @@ -138,7 +138,7 @@ describe('useLiveFills', () => { return jest.fn(); }); - const { result } = renderHook(() => useLiveFills()); + const { result } = renderHook(() => usePerpsLiveFills()); act(() => { capturedCallback([]); @@ -156,7 +156,7 @@ describe('useLiveFills', () => { return jest.fn(); }); - const { result } = renderHook(() => useLiveFills()); + const { result } = renderHook(() => usePerpsLiveFills()); // Send null update (should be handled gracefully) act(() => { @@ -193,7 +193,7 @@ describe('useLiveFills', () => { return jest.fn(); }); - const { result } = renderHook(() => useLiveFills()); + const { result } = renderHook(() => usePerpsLiveFills()); // First update const firstFills: OrderFill[] = [mockFill]; @@ -228,7 +228,7 @@ describe('useLiveFills', () => { return jest.fn(); }); - const { result } = renderHook(() => useLiveFills()); + const { result } = renderHook(() => usePerpsLiveFills()); const now = Date.now(); const fills: OrderFill[] = [ @@ -254,7 +254,7 @@ describe('useLiveFills', () => { return jest.fn(); }); - const { result } = renderHook(() => useLiveFills()); + const { result } = renderHook(() => usePerpsLiveFills()); const fills: OrderFill[] = [ { ...mockFill, orderId: 'order-fill-1', symbol: 'BTC-PERP' }, diff --git a/app/components/UI/Perps/hooks/stream/useLiveOrders.test.ts b/app/components/UI/Perps/hooks/stream/useLiveOrders.test.ts index 467fb8631f90..d1e38d905f94 100644 --- a/app/components/UI/Perps/hooks/stream/useLiveOrders.test.ts +++ b/app/components/UI/Perps/hooks/stream/useLiveOrders.test.ts @@ -1,6 +1,6 @@ import { renderHook, act, waitFor } from '@testing-library/react-native'; import React from 'react'; -import { useLiveOrders } from './index'; +import { usePerpsLiveOrders } from './index'; import type { Order } from '../../controllers/types'; // Mock the stream provider @@ -16,7 +16,7 @@ jest.mock('../../providers/PerpsStreamManager', () => ({ children, })); -describe('useLiveOrders', () => { +describe('usePerpsLiveOrders', () => { const mockOrder: Order = { orderId: 'order-1', symbol: 'BTC-PERP', @@ -40,14 +40,14 @@ describe('useLiveOrders', () => { }); it('should subscribe to orders on mount', () => { - const debounceMs = 2000; + const throttleMs = 2000; mockSubscribe.mockReturnValue(jest.fn()); - renderHook(() => useLiveOrders({ debounceMs })); + renderHook(() => usePerpsLiveOrders({ throttleMs })); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs, + throttleMs, }); }); @@ -55,7 +55,7 @@ describe('useLiveOrders', () => { const mockUnsubscribe = jest.fn(); mockSubscribe.mockReturnValue(mockUnsubscribe); - const { unmount } = renderHook(() => useLiveOrders()); + const { unmount } = renderHook(() => usePerpsLiveOrders()); unmount(); @@ -69,7 +69,7 @@ describe('useLiveOrders', () => { return jest.fn(); }); - const { result } = renderHook(() => useLiveOrders()); + const { result } = renderHook(() => usePerpsLiveOrders()); // Initially empty expect(result.current).toEqual([]); @@ -89,18 +89,18 @@ describe('useLiveOrders', () => { }); }); - it('should use default debounce value when not provided', () => { + it('should use default throttle value when not provided', () => { mockSubscribe.mockReturnValue(jest.fn()); - renderHook(() => useLiveOrders()); + renderHook(() => usePerpsLiveOrders()); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs: 500, // Default value for orders + throttleMs: 0, // Default value for orders (no throttling for instant updates) }); }); - it('should handle debounce changes', () => { + it('should handle throttle changes', () => { const mockUnsubscribe1 = jest.fn(); const mockUnsubscribe2 = jest.fn(); @@ -109,25 +109,25 @@ describe('useLiveOrders', () => { .mockReturnValueOnce(mockUnsubscribe2); const { rerender } = renderHook( - ({ debounceMs }) => useLiveOrders({ debounceMs }), + ({ throttleMs }) => usePerpsLiveOrders({ throttleMs }), { - initialProps: { debounceMs: 500 }, + initialProps: { throttleMs: 500 }, }, ); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs: 500, + throttleMs: 500, }); - // Change debounce - rerender({ debounceMs: 1000 }); + // Change throttle + rerender({ throttleMs: 1000 }); - // Should resubscribe with new debounce + // Should resubscribe with new throttle expect(mockUnsubscribe1).toHaveBeenCalled(); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs: 1000, + throttleMs: 1000, }); }); @@ -138,7 +138,7 @@ describe('useLiveOrders', () => { return jest.fn(); }); - const { result } = renderHook(() => useLiveOrders()); + const { result } = renderHook(() => usePerpsLiveOrders()); act(() => { capturedCallback([]); @@ -156,7 +156,7 @@ describe('useLiveOrders', () => { return jest.fn(); }); - const { result } = renderHook(() => useLiveOrders()); + const { result } = renderHook(() => usePerpsLiveOrders()); // Send null update (should be handled gracefully) act(() => { @@ -193,7 +193,7 @@ describe('useLiveOrders', () => { return jest.fn(); }); - const { result } = renderHook(() => useLiveOrders()); + const { result } = renderHook(() => usePerpsLiveOrders()); // First update const firstOrders: Order[] = [mockOrder]; diff --git a/app/components/UI/Perps/hooks/stream/useLivePositions.test.ts b/app/components/UI/Perps/hooks/stream/useLivePositions.test.ts index fde1017b0045..28835539ce9c 100644 --- a/app/components/UI/Perps/hooks/stream/useLivePositions.test.ts +++ b/app/components/UI/Perps/hooks/stream/useLivePositions.test.ts @@ -1,6 +1,6 @@ import { renderHook, act, waitFor } from '@testing-library/react-native'; import React from 'react'; -import { useLivePositions } from './index'; +import { usePerpsLivePositions } from './index'; import type { Position } from '../../controllers/types'; // Mock the stream provider @@ -16,7 +16,7 @@ jest.mock('../../providers/PerpsStreamManager', () => ({ children, })); -describe('useLivePositions', () => { +describe('usePerpsLivePositions', () => { const mockPosition: Position = { coin: 'BTC-PERP', size: '1.0', @@ -48,14 +48,14 @@ describe('useLivePositions', () => { }); it('should subscribe to positions on mount', () => { - const debounceMs = 3000; + const throttleMs = 3000; mockSubscribe.mockReturnValue(jest.fn()); - renderHook(() => useLivePositions({ debounceMs })); + renderHook(() => usePerpsLivePositions({ throttleMs })); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs, + throttleMs, }); }); @@ -63,7 +63,7 @@ describe('useLivePositions', () => { const mockUnsubscribe = jest.fn(); mockSubscribe.mockReturnValue(mockUnsubscribe); - const { unmount } = renderHook(() => useLivePositions()); + const { unmount } = renderHook(() => usePerpsLivePositions()); unmount(); @@ -77,10 +77,13 @@ describe('useLivePositions', () => { return jest.fn(); }); - const { result } = renderHook(() => useLivePositions()); + const { result } = renderHook(() => usePerpsLivePositions()); - // Initially empty - expect(result.current).toEqual([]); + // Initially empty with loading state + expect(result.current).toEqual({ + positions: [], + isInitialLoading: true, + }); // Simulate positions update const positions: Position[] = [ @@ -93,22 +96,25 @@ describe('useLivePositions', () => { }); await waitFor(() => { - expect(result.current).toEqual(positions); + expect(result.current).toEqual({ + positions, + isInitialLoading: false, + }); }); }); - it('should use default debounce value when not provided', () => { + it('should use default throttle value when not provided', () => { mockSubscribe.mockReturnValue(jest.fn()); - renderHook(() => useLivePositions()); + renderHook(() => usePerpsLivePositions()); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs: 1000, // Default value for positions + throttleMs: 0, // Default value for positions (no throttling for instant updates) }); }); - it('should handle debounce changes', () => { + it('should handle throttle changes', () => { const mockUnsubscribe1 = jest.fn(); const mockUnsubscribe2 = jest.fn(); @@ -117,25 +123,25 @@ describe('useLivePositions', () => { .mockReturnValueOnce(mockUnsubscribe2); const { rerender } = renderHook( - ({ debounceMs }) => useLivePositions({ debounceMs }), + ({ throttleMs }) => usePerpsLivePositions({ throttleMs }), { - initialProps: { debounceMs: 1000 }, + initialProps: { throttleMs: 0 }, }, ); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs: 1000, + throttleMs: 0, }); - // Change debounce - rerender({ debounceMs: 2000 }); + // Change throttle + rerender({ throttleMs: 2000 }); - // Should resubscribe with new debounce + // Should resubscribe with new throttle expect(mockUnsubscribe1).toHaveBeenCalled(); expect(mockSubscribe).toHaveBeenCalledWith({ callback: expect.any(Function), - debounceMs: 2000, + throttleMs: 2000, }); }); @@ -146,14 +152,17 @@ describe('useLivePositions', () => { return jest.fn(); }); - const { result } = renderHook(() => useLivePositions()); + const { result } = renderHook(() => usePerpsLivePositions()); act(() => { capturedCallback([]); }); await waitFor(() => { - expect(result.current).toEqual([]); + expect(result.current).toEqual({ + positions: [], + isInitialLoading: false, + }); }); }); @@ -164,7 +173,7 @@ describe('useLivePositions', () => { return jest.fn(); }); - const { result } = renderHook(() => useLivePositions()); + const { result } = renderHook(() => usePerpsLivePositions()); // Send null update (should be handled gracefully) act(() => { @@ -172,7 +181,10 @@ describe('useLivePositions', () => { }); // Should not crash and positions should remain empty - expect(result.current).toEqual([]); + expect(result.current).toEqual({ + positions: [], + isInitialLoading: true, + }); // Send undefined update act(() => { @@ -180,7 +192,10 @@ describe('useLivePositions', () => { }); // Should still not crash - expect(result.current).toEqual([]); + expect(result.current).toEqual({ + positions: [], + isInitialLoading: true, + }); // Send valid update to ensure it still works const validPositions: Position[] = [mockPosition]; @@ -190,7 +205,10 @@ describe('useLivePositions', () => { }); await waitFor(() => { - expect(result.current).toEqual(validPositions); + expect(result.current).toEqual({ + positions: validPositions, + isInitialLoading: false, + }); }); }); @@ -201,7 +219,7 @@ describe('useLivePositions', () => { return jest.fn(); }); - const { result } = renderHook(() => useLivePositions()); + const { result } = renderHook(() => usePerpsLivePositions()); // First update const firstPositions: Position[] = [mockPosition]; @@ -210,7 +228,10 @@ describe('useLivePositions', () => { }); await waitFor(() => { - expect(result.current).toEqual(firstPositions); + expect(result.current).toEqual({ + positions: firstPositions, + isInitialLoading: false, + }); }); // Second update with different positions @@ -224,8 +245,11 @@ describe('useLivePositions', () => { }); await waitFor(() => { - expect(result.current).toEqual(secondPositions); - expect(result.current).not.toContain(mockPosition); + expect(result.current).toEqual({ + positions: secondPositions, + isInitialLoading: false, + }); + expect(result.current.positions).not.toContain(mockPosition); }); }); @@ -236,7 +260,7 @@ describe('useLivePositions', () => { return jest.fn(); }); - const { result } = renderHook(() => useLivePositions()); + const { result } = renderHook(() => usePerpsLivePositions()); // Initial position const initialPosition: Position = { ...mockPosition }; @@ -245,7 +269,7 @@ describe('useLivePositions', () => { }); await waitFor(() => { - expect(result.current[0].unrealizedPnl).toBe('1000'); + expect(result.current.positions[0].unrealizedPnl).toBe('1000'); }); // Update with changed PnL @@ -260,8 +284,8 @@ describe('useLivePositions', () => { }); await waitFor(() => { - expect(result.current[0].unrealizedPnl).toBe('2000'); - expect(result.current[0].positionValue).toBe('52000'); + expect(result.current.positions[0].unrealizedPnl).toBe('2000'); + expect(result.current.positions[0].positionValue).toBe('52000'); }); }); }); diff --git a/app/components/UI/Perps/hooks/stream/useLivePrices.test.ts b/app/components/UI/Perps/hooks/stream/useLivePrices.test.ts index 5c2a8bca7438..fed03edeee03 100644 --- a/app/components/UI/Perps/hooks/stream/useLivePrices.test.ts +++ b/app/components/UI/Perps/hooks/stream/useLivePrices.test.ts @@ -1,6 +1,6 @@ import { renderHook, act, waitFor } from '@testing-library/react-native'; import React from 'react'; -import { useLivePrices } from './index'; +import { usePerpsLivePrices } from './index'; import type { PriceUpdate } from '../../controllers/types'; // Mock the stream provider @@ -16,7 +16,7 @@ jest.mock('../../providers/PerpsStreamManager', () => ({ children, })); -describe('useLivePrices', () => { +describe('usePerpsLivePrices', () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); @@ -28,16 +28,16 @@ describe('useLivePrices', () => { it('should subscribe to prices on mount', () => { const symbols = ['BTC-PERP', 'ETH-PERP']; - const debounceMs = 1000; + const throttleMs = 1000; mockSubscribeToSymbols.mockReturnValue(jest.fn()); - renderHook(() => useLivePrices({ symbols, debounceMs })); + renderHook(() => usePerpsLivePrices({ symbols, throttleMs })); expect(mockSubscribeToSymbols).toHaveBeenCalledWith({ symbols, callback: expect.any(Function), - debounceMs, + throttleMs, }); }); @@ -46,7 +46,7 @@ describe('useLivePrices', () => { mockSubscribeToSymbols.mockReturnValue(mockUnsubscribe); const { unmount } = renderHook(() => - useLivePrices({ symbols: ['BTC-PERP'] }), + usePerpsLivePrices({ symbols: ['BTC-PERP'] }), ); unmount(); @@ -63,7 +63,7 @@ describe('useLivePrices', () => { }); const { result } = renderHook(() => - useLivePrices({ symbols: ['BTC-PERP', 'ETH-PERP'] }), + usePerpsLivePrices({ symbols: ['BTC-PERP', 'ETH-PERP'] }), ); // Initially empty @@ -104,15 +104,15 @@ describe('useLivePrices', () => { }); }); - it('should use default debounce value when not provided', () => { + it('should use default throttle value when not provided', () => { mockSubscribeToSymbols.mockReturnValue(jest.fn()); - renderHook(() => useLivePrices({ symbols: ['BTC-PERP'] })); + renderHook(() => usePerpsLivePrices({ symbols: ['BTC-PERP'] })); expect(mockSubscribeToSymbols).toHaveBeenCalledWith({ symbols: ['BTC-PERP'], callback: expect.any(Function), - debounceMs: 100, // Default value + throttleMs: 1000, // Default value (1 second for balanced performance) }); }); @@ -125,7 +125,7 @@ describe('useLivePrices', () => { .mockReturnValueOnce(mockUnsubscribe2); const { rerender } = renderHook( - ({ symbols }) => useLivePrices({ symbols }), + ({ symbols }) => usePerpsLivePrices({ symbols }), { initialProps: { symbols: ['BTC-PERP'] }, }, @@ -134,7 +134,7 @@ describe('useLivePrices', () => { expect(mockSubscribeToSymbols).toHaveBeenCalledWith({ symbols: ['BTC-PERP'], callback: expect.any(Function), - debounceMs: 100, + throttleMs: 1000, }); // Change symbols @@ -145,11 +145,11 @@ describe('useLivePrices', () => { expect(mockSubscribeToSymbols).toHaveBeenCalledWith({ symbols: ['ETH-PERP', 'SOL-PERP'], callback: expect.any(Function), - debounceMs: 100, + throttleMs: 1000, }); }); - it('should handle debounce changes', () => { + it('should handle throttle changes', () => { const mockUnsubscribe1 = jest.fn(); const mockUnsubscribe2 = jest.fn(); @@ -158,34 +158,35 @@ describe('useLivePrices', () => { .mockReturnValueOnce(mockUnsubscribe2); const { rerender } = renderHook( - ({ debounceMs }) => useLivePrices({ symbols: ['BTC-PERP'], debounceMs }), + ({ throttleMs }) => + usePerpsLivePrices({ symbols: ['BTC-PERP'], throttleMs }), { - initialProps: { debounceMs: 100 }, + initialProps: { throttleMs: 1000 }, }, ); expect(mockSubscribeToSymbols).toHaveBeenCalledWith({ symbols: ['BTC-PERP'], callback: expect.any(Function), - debounceMs: 100, + throttleMs: 1000, }); - // Change debounce - rerender({ debounceMs: 500 }); + // Change throttle + rerender({ throttleMs: 500 }); - // Should resubscribe with new debounce + // Should resubscribe with new throttle expect(mockUnsubscribe1).toHaveBeenCalled(); expect(mockSubscribeToSymbols).toHaveBeenCalledWith({ symbols: ['BTC-PERP'], callback: expect.any(Function), - debounceMs: 500, + throttleMs: 500, }); }); it('should handle empty symbols array', () => { mockSubscribeToSymbols.mockReturnValue(jest.fn()); - const { result } = renderHook(() => useLivePrices({ symbols: [] })); + const { result } = renderHook(() => usePerpsLivePrices({ symbols: [] })); expect(result.current).toEqual({}); // Should not subscribe with empty array @@ -201,7 +202,7 @@ describe('useLivePrices', () => { }); const { result } = renderHook(() => - useLivePrices({ symbols: ['BTC-PERP', 'ETH-PERP', 'SOL-PERP'] }), + usePerpsLivePrices({ symbols: ['BTC-PERP', 'ETH-PERP', 'SOL-PERP'] }), ); // Add prices one by one @@ -266,7 +267,7 @@ describe('useLivePrices', () => { mockSubscribeToSymbols.mockImplementation(() => unsubscribes[index++]); const { rerender } = renderHook( - ({ symbols }) => useLivePrices({ symbols }), + ({ symbols }) => usePerpsLivePrices({ symbols }), { initialProps: { symbols: ['BTC-PERP'] }, }, @@ -295,7 +296,7 @@ describe('useLivePrices', () => { }); const { result } = renderHook(() => - useLivePrices({ symbols: ['BTC-PERP'] }), + usePerpsLivePrices({ symbols: ['BTC-PERP'] }), ); // Send null update (should be handled gracefully) @@ -339,7 +340,7 @@ describe('useLivePrices', () => { }); const { result, rerender } = renderHook( - ({ symbols }) => useLivePrices({ symbols }), + ({ symbols }) => usePerpsLivePrices({ symbols }), { initialProps: { symbols: ['BTC-PERP', 'ETH-PERP'] }, }, diff --git a/app/components/UI/Perps/hooks/stream/usePerpsLiveFills.ts b/app/components/UI/Perps/hooks/stream/usePerpsLiveFills.ts new file mode 100644 index 000000000000..6c248255541d --- /dev/null +++ b/app/components/UI/Perps/hooks/stream/usePerpsLiveFills.ts @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react'; +import { usePerpsStream } from '../../providers/PerpsStreamManager'; +import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; +import type { OrderFill } from '../../controllers/types'; + +export interface UsePerpsLiveFillsOptions { + /** Throttle delay in milliseconds (default: 0ms for immediate updates) */ + throttleMs?: number; +} + +/** + * Hook for real-time order fill updates via WebSocket subscription + * Provides immediate notification of trade executions + * + * @param options - Configuration options for the hook + * @returns Array of order fills with real-time updates + */ +export function usePerpsLiveFills( + options: UsePerpsLiveFillsOptions = {}, +): OrderFill[] { + const { throttleMs = 0 } = options; + const stream = usePerpsStream(); + const [fills, setFills] = useState([]); + + useEffect(() => { + const logMessage = throttleMs + ? `usePerpsLiveFills: Subscribing with ${throttleMs}ms throttle` + : `usePerpsLiveFills: Subscribing with no throttle (instant updates)`; + DevLogger.log(logMessage); + + const unsubscribe = stream.fills.subscribe({ + callback: (newFills) => { + if (!newFills) { + return; + } + DevLogger.log('usePerpsLiveFills: Received fill update', { + count: newFills.length, + }); + setFills(newFills); + }, + throttleMs, + }); + + return () => { + DevLogger.log('usePerpsLiveFills: Unsubscribing'); + unsubscribe(); + }; + }, [stream, throttleMs]); + + return fills; +} diff --git a/app/components/UI/Perps/hooks/stream/usePerpsLiveOrders.ts b/app/components/UI/Perps/hooks/stream/usePerpsLiveOrders.ts new file mode 100644 index 000000000000..d4b42a0d367e --- /dev/null +++ b/app/components/UI/Perps/hooks/stream/usePerpsLiveOrders.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; +import { usePerpsStream } from '../../providers/PerpsStreamManager'; +import type { Order } from '../../controllers/types'; + +export interface UsePerpsLiveOrdersOptions { + /** Throttle delay in milliseconds (default: 0 - no throttling for instant updates) */ + throttleMs?: number; +} + +/** + * Hook for real-time order updates via WebSocket subscription + * Replaces the old polling-based usePerpsOpenOrders hook + * + * Orders update instantly by default since they don't change frequently + * and users expect immediate feedback when placing/cancelling orders. + * + * @param options - Configuration options for the hook + * @returns Array of current orders with real-time updates + */ +export function usePerpsLiveOrders( + options: UsePerpsLiveOrdersOptions = {}, +): Order[] { + const { throttleMs = 0 } = options; // No throttling by default for instant updates + const stream = usePerpsStream(); + const [orders, setOrders] = useState([]); + + useEffect(() => { + const unsubscribe = stream.orders.subscribe({ + callback: (newOrders) => { + if (!newOrders) { + return; + } + setOrders(newOrders); + }, + throttleMs, + }); + + return () => { + unsubscribe(); + }; + }, [stream, throttleMs]); + + return orders; +} diff --git a/app/components/UI/Perps/hooks/stream/usePerpsLivePositions.ts b/app/components/UI/Perps/hooks/stream/usePerpsLivePositions.ts new file mode 100644 index 000000000000..c850e3fdccc5 --- /dev/null +++ b/app/components/UI/Perps/hooks/stream/usePerpsLivePositions.ts @@ -0,0 +1,82 @@ +import { useEffect, useState, useRef } from 'react'; +import { usePerpsStream } from '../../providers/PerpsStreamManager'; +import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; +import type { Position } from '../../controllers/types'; + +// Stable empty array reference to prevent re-renders +const EMPTY_POSITIONS: Position[] = []; + +export interface UsePerpsLivePositionsOptions { + /** Throttle delay in milliseconds (default: 0 - no throttling for instant updates) */ + throttleMs?: number; +} + +export interface UsePerpsLivePositionsReturn { + /** Array of current positions */ + positions: Position[]; + /** Whether we're waiting for the first real WebSocket data (not cached) */ + isInitialLoading: boolean; +} + +/** + * Hook for real-time position updates via WebSocket subscription + * Replaces the old polling-based usePerpsPositions hook + * + * Positions update instantly by default since changes are important + * (TP/SL modifications, liquidations, etc.) and users need immediate feedback. + * + * @param options - Configuration options for the hook + * @returns Object containing positions array and loading state + */ +export function usePerpsLivePositions( + options: UsePerpsLivePositionsOptions = {}, +): UsePerpsLivePositionsReturn { + const { throttleMs = 0 } = options; // No throttling by default for instant updates + const stream = usePerpsStream(); + const [positions, setPositions] = useState(EMPTY_POSITIONS); + const [isInitialLoading, setIsInitialLoading] = useState(true); + const lastPositionsRef = useRef(EMPTY_POSITIONS); + const hasReceivedFirstUpdate = useRef(false); + + useEffect(() => { + const unsubscribe = stream.positions.subscribe({ + callback: (newPositions) => { + if (!newPositions) { + return; + } + + // Mark that we've received the first real WebSocket update + if (!hasReceivedFirstUpdate.current) { + DevLogger.log( + 'usePerpsLivePositions: Received first WebSocket update', + ); + hasReceivedFirstUpdate.current = true; + setIsInitialLoading(false); + } + // Only update if positions actually changed + // For empty arrays, use stable reference + if (newPositions.length === 0) { + if (lastPositionsRef.current.length === 0) { + // Already empty, don't update + return; + } + lastPositionsRef.current = EMPTY_POSITIONS; + setPositions(EMPTY_POSITIONS); + } else { + lastPositionsRef.current = newPositions; + setPositions(newPositions); + } + }, + throttleMs, + }); + + return () => { + unsubscribe(); + }; + }, [stream, throttleMs]); + + return { + positions, + isInitialLoading, + }; +} diff --git a/app/components/UI/Perps/hooks/stream/usePerpsLivePrices.ts b/app/components/UI/Perps/hooks/stream/usePerpsLivePrices.ts new file mode 100644 index 000000000000..1907f0128e26 --- /dev/null +++ b/app/components/UI/Perps/hooks/stream/usePerpsLivePrices.ts @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react'; +import { usePerpsStream } from '../../providers/PerpsStreamManager'; +import type { PriceUpdate } from '../../controllers/types'; + +export interface UsePerpsLivePricesOptions { + /** Array of symbols to subscribe to */ + symbols: string[]; + /** Throttle delay in milliseconds (default: 1000ms to prevent excessive re-renders) */ + throttleMs?: number; +} + +/** + * Hook for real-time price updates via WebSocket subscription + * Supports component-specific throttling for performance optimization + * + * Prices update every second by default to balance between real-time feel + * and performance. Components can override this based on their needs: + * - Charts might want slower updates (2000ms) + * - Price displays might want faster updates (500ms) + * + * @param options - Configuration options for the hook + * @returns Record of symbol to price data with real-time updates + */ +export function usePerpsLivePrices( + options: UsePerpsLivePricesOptions, +): Record { + const { symbols, throttleMs = 1000 } = options; // 1 second default for balanced performance + const stream = usePerpsStream(); + const [prices, setPrices] = useState>({}); + + useEffect(() => { + if (symbols.length === 0) return; + + const unsubscribe = stream.prices.subscribeToSymbols({ + symbols, + callback: (newPrices) => { + if (!newPrices) { + return; + } + setPrices(newPrices); + }, + throttleMs, + }); + + return () => { + unsubscribe(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stream, symbols.join(','), throttleMs]); + + return prices; +} diff --git a/app/components/UI/Perps/hooks/useHasExistingPosition.test.ts b/app/components/UI/Perps/hooks/useHasExistingPosition.test.ts index 96ce93acd702..dea911eea837 100644 --- a/app/components/UI/Perps/hooks/useHasExistingPosition.test.ts +++ b/app/components/UI/Perps/hooks/useHasExistingPosition.test.ts @@ -1,15 +1,16 @@ import { renderHook } from '@testing-library/react-hooks'; import { useHasExistingPosition } from './useHasExistingPosition'; -import { usePerpsPositions } from './usePerpsPositions'; +import { usePerpsLivePositions } from './stream'; import type { Position } from '../controllers/types'; -// Mock the usePerpsPositions hook -jest.mock('./usePerpsPositions'); +// Mock the usePerpsLivePositions hook +jest.mock('./stream', () => ({ + usePerpsLivePositions: jest.fn(), +})); describe('useHasExistingPosition', () => { - const mockUsePerpsPositions = usePerpsPositions as jest.MockedFunction< - typeof usePerpsPositions - >; + const mockUsePerpsLivePositions = + usePerpsLivePositions as jest.MockedFunction; const mockPositions: Position[] = [ { @@ -59,12 +60,9 @@ describe('useHasExistingPosition', () => { }); it('should return hasPosition as true when position exists for asset', () => { - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: mockPositions, - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); const { result } = renderHook(() => @@ -78,12 +76,9 @@ describe('useHasExistingPosition', () => { }); it('should return hasPosition as false when no position exists for asset', () => { - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: mockPositions, - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); const { result } = renderHook(() => @@ -96,13 +91,10 @@ describe('useHasExistingPosition', () => { expect(result.current.error).toBe(null); }); - it('should return loading state correctly', () => { - mockUsePerpsPositions.mockReturnValue({ + it('should return loading state as false (WebSocket loads from cache)', () => { + mockUsePerpsLivePositions.mockReturnValue({ positions: [], - isLoading: true, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); const { result } = renderHook(() => @@ -111,18 +103,14 @@ describe('useHasExistingPosition', () => { expect(result.current.hasPosition).toBe(false); expect(result.current.existingPosition).toBe(null); - expect(result.current.isLoading).toBe(true); + expect(result.current.isLoading).toBe(false); // Always false with WebSocket expect(result.current.error).toBe(null); }); - it('should return error state correctly', () => { - const errorMessage = 'Failed to load positions'; - mockUsePerpsPositions.mockReturnValue({ + it('should return error as null (WebSocket handles errors internally)', () => { + mockUsePerpsLivePositions.mockReturnValue({ positions: [], - isLoading: false, - isRefreshing: false, - error: errorMessage, - loadPositions: jest.fn(), + isInitialLoading: false, }); const { result } = renderHook(() => @@ -132,35 +120,27 @@ describe('useHasExistingPosition', () => { expect(result.current.hasPosition).toBe(false); expect(result.current.existingPosition).toBe(null); expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(errorMessage); + expect(result.current.error).toBe(null); // Always null with WebSocket }); - it('should pass loadOnMount parameter correctly', () => { - mockUsePerpsPositions.mockReturnValue({ + it('should ignore loadOnMount parameter (WebSocket loads from cache)', () => { + mockUsePerpsLivePositions.mockReturnValue({ positions: [], - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); - renderHook(() => + const { result } = renderHook(() => useHasExistingPosition({ asset: 'BTC', loadOnMount: false }), ); - expect(mockUsePerpsPositions).toHaveBeenCalledWith({ - loadOnMount: false, - refreshOnFocus: true, - }); + // loadOnMount is ignored in WebSocket implementation + expect(result.current.hasPosition).toBe(false); }); it('should handle empty positions array', () => { - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [], - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); const { result } = renderHook(() => @@ -172,33 +152,40 @@ describe('useHasExistingPosition', () => { }); it('should update when positions change', () => { - const { result, rerender } = renderHook(() => - useHasExistingPosition({ asset: 'BTC' }), - ); - // Initially no positions - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: [], - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); - rerender(); + const { result, rerender } = renderHook(() => + useHasExistingPosition({ asset: 'BTC' }), + ); + expect(result.current.hasPosition).toBe(false); // Update with positions - mockUsePerpsPositions.mockReturnValue({ + mockUsePerpsLivePositions.mockReturnValue({ positions: mockPositions, - isLoading: false, - isRefreshing: false, - error: null, - loadPositions: jest.fn(), + isInitialLoading: false, }); rerender(); expect(result.current.hasPosition).toBe(true); expect(result.current.existingPosition).toEqual(mockPositions[0]); }); + + it('should return a no-op refreshPosition function', async () => { + mockUsePerpsLivePositions.mockReturnValue({ + positions: mockPositions, + isInitialLoading: false, + }); + + const { result } = renderHook(() => + useHasExistingPosition({ asset: 'BTC' }), + ); + + // refreshPosition should be a no-op that returns a resolved promise + await expect(result.current.refreshPosition()).resolves.toBeUndefined(); + }); }); diff --git a/app/components/UI/Perps/hooks/useHasExistingPosition.ts b/app/components/UI/Perps/hooks/useHasExistingPosition.ts index e8aaa4c38a4b..057b19208cac 100644 --- a/app/components/UI/Perps/hooks/useHasExistingPosition.ts +++ b/app/components/UI/Perps/hooks/useHasExistingPosition.ts @@ -1,5 +1,5 @@ import { useMemo, useCallback } from 'react'; -import { usePerpsPositions } from './usePerpsPositions'; +import { usePerpsLivePositions } from './stream'; import type { Position } from '../controllers/types'; interface UseHasExistingPositionParams { @@ -12,35 +12,33 @@ interface UseHasExistingPositionParams { interface UseHasExistingPositionReturn { /** Whether user has an existing position for the asset */ hasPosition: boolean; - /** Loading state */ + /** Loading state - always false since WebSocket data loads from cache */ isLoading: boolean; - /** Error state */ + /** Error state - always null for WebSocket subscriptions */ error: string | null; /** The existing position if found */ existingPosition: Position | null; - /** Function to refresh positions data */ + /** Function to refresh positions data - no-op for WebSocket */ refreshPosition: () => Promise; } /** * Hook to check if user has an existing position for a specific asset + * Uses WebSocket subscription for real-time position updates * @param params Parameters for position checking * @returns Object containing position existence info and related states */ export function useHasExistingPosition( params: UseHasExistingPositionParams, ): UseHasExistingPositionReturn { - const { asset, loadOnMount = true } = params; - - // Use the existing positions hook to get all positions - const { positions, isLoading, error, loadPositions } = usePerpsPositions({ - loadOnMount, - refreshOnFocus: true, - }); + const { asset } = params; + // loadOnMount is ignored since WebSocket subscriptions load from cache immediately + // Get real-time positions via WebSocket + const { positions } = usePerpsLivePositions(); // Check if user has an existing position for this asset const existingPosition = useMemo( - () => positions.find((position) => position.coin === asset) || null, + () => (positions || []).find((position) => position.coin === asset) || null, [positions, asset], ); @@ -49,15 +47,19 @@ export function useHasExistingPosition( [existingPosition], ); - // Wrapper function to refresh positions - const refreshPosition = useCallback(async () => { - await loadPositions({ isRefresh: true }); - }, [loadPositions]); + // No-op refresh function for compatibility + // Positions update automatically via WebSocket + const refreshPosition = useCallback( + async () => + // WebSocket positions update automatically, no manual refresh needed + Promise.resolve(), + [], + ); return { hasPosition, - isLoading, - error, + isLoading: false, // WebSocket data loads immediately from cache + error: null, // WebSocket subscriptions handle errors internally existingPosition, refreshPosition, }; diff --git a/app/components/UI/Perps/hooks/usePerpsMarketStats.test.ts b/app/components/UI/Perps/hooks/usePerpsMarketStats.test.ts index 21654e97dc91..3bd01656ddba 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarketStats.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarketStats.test.ts @@ -1,6 +1,6 @@ -import { renderHook, act } from '@testing-library/react-hooks'; -import { usePerpsMarketStats } from './usePerpsMarketStats'; +import { renderHook } from '@testing-library/react-hooks'; import { CandlePeriod } from '../constants/chartConfig'; +import { usePerpsMarketStats } from './usePerpsMarketStats'; // Mock Engine jest.mock('../../../../core/Engine', () => ({ @@ -29,8 +29,8 @@ jest.mock('../utils/formatUtils', () => ({ }, })); -import { usePerpsPositionData } from './usePerpsPositionData'; import Engine from '../../../../core/Engine'; +import { usePerpsPositionData } from './usePerpsPositionData'; const mockedUsePerpsPositionData = jest.mocked(usePerpsPositionData); const mockSubscribeToPrices = Engine.context.PerpsController @@ -91,7 +91,6 @@ describe('usePerpsMarketStats', () => { mockedUsePerpsPositionData.mockReturnValue({ candleData: mockCandleData, - priceData: mockPriceData.BTC, isLoadingHistory: false, refreshCandleData: jest.fn(), }); @@ -99,13 +98,11 @@ describe('usePerpsMarketStats', () => { const { result } = renderHook(() => usePerpsMarketStats('BTC')); expect(result.current.currentPrice).toBe(45000); - expect(result.current.priceChange24h).toBe(2.5); expect(result.current.high24h).toBe('$46,000.00'); expect(result.current.low24h).toBe('$43,500.00'); expect(result.current.volume24h).toBe('$1.23B'); expect(result.current.openInterest).toBe('$987.65M'); expect(result.current.fundingRate).toBe('1.0000%'); - expect(result.current.fundingCountdown).toMatch(/^\d{2}:\d{2}:\d{2}$/); expect(result.current.isLoading).toBe(false); }); @@ -115,7 +112,6 @@ describe('usePerpsMarketStats', () => { mockedUsePerpsPositionData.mockReturnValue({ candleData: null, - priceData: null, isLoadingHistory: true, refreshCandleData: jest.fn(), }); @@ -126,64 +122,13 @@ describe('usePerpsMarketStats', () => { expect(result.current.currentPrice).toBe(0); }); - it('should calculate funding countdown correctly', () => { - // Set current time to 7:30:00 UTC (30 minutes before funding) - const mockDate = new Date('2024-01-01T07:30:00Z'); - jest.setSystemTime(mockDate); - - mockSubscribeToPrices.mockImplementation(({ callback }) => { - callback([mockPriceData.BTC]); - return jest.fn(); - }); - - mockedUsePerpsPositionData.mockReturnValue({ - candleData: mockCandleData, - priceData: mockPriceData.BTC, - isLoadingHistory: false, - refreshCandleData: jest.fn(), - }); - - const { result } = renderHook(() => usePerpsMarketStats('BTC')); - - expect(result.current.fundingCountdown).toBe('00:30:00'); - }); - - it('should update funding countdown every second', () => { - // Set initial time - const mockDate = new Date('2024-01-01T07:30:00Z'); - jest.setSystemTime(mockDate); - - mockSubscribeToPrices.mockImplementation(({ callback }) => { - callback([mockPriceData.BTC]); - return jest.fn(); - }); - - mockedUsePerpsPositionData.mockReturnValue({ - candleData: mockCandleData, - priceData: mockPriceData.BTC, - isLoadingHistory: false, - refreshCandleData: jest.fn(), - }); - - const { result } = renderHook(() => usePerpsMarketStats('BTC')); - - expect(result.current.fundingCountdown).toBe('00:30:00'); - - // Advance time by 1 second - act(() => { - jest.advanceTimersByTime(1000); - jest.setSystemTime(new Date('2024-01-01T07:30:01Z')); - }); - - expect(result.current.fundingCountdown).toBe('00:29:59'); - }); + // Funding countdown tests removed - handled by separate component it('should handle no market data gracefully', () => { mockSubscribeToPrices.mockImplementation(() => jest.fn()); mockedUsePerpsPositionData.mockReturnValue({ candleData: null, - priceData: null, isLoadingHistory: false, refreshCandleData: jest.fn(), }); @@ -191,13 +136,11 @@ describe('usePerpsMarketStats', () => { const { result } = renderHook(() => usePerpsMarketStats('BTC')); expect(result.current.currentPrice).toBe(0); - expect(result.current.priceChange24h).toBe(0); expect(result.current.high24h).toBe('$0.00'); expect(result.current.low24h).toBe('$0.00'); expect(result.current.volume24h).toBe('$0.00'); expect(result.current.openInterest).toBe('$0.00'); expect(result.current.fundingRate).toBe('0.0000%'); - expect(result.current.fundingCountdown).toMatch(/^\d{2}:\d{2}:\d{2}$/); }); it('should format large numbers correctly', () => { @@ -216,7 +159,6 @@ describe('usePerpsMarketStats', () => { mockedUsePerpsPositionData.mockReturnValue({ candleData: mockCandleData, - priceData: largeNumberPriceData.BTC, isLoadingHistory: false, refreshCandleData: jest.fn(), }); @@ -242,7 +184,6 @@ describe('usePerpsMarketStats', () => { mockedUsePerpsPositionData.mockReturnValue({ candleData: mockCandleData, - priceData: negativeFundingData.BTC, isLoadingHistory: false, refreshCandleData: jest.fn(), }); diff --git a/app/components/UI/Perps/hooks/usePerpsMarketStats.ts b/app/components/UI/Perps/hooks/usePerpsMarketStats.ts index 43e2d2bef845..b36348589d48 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarketStats.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarketStats.ts @@ -3,10 +3,7 @@ import Engine from '../../../../core/Engine'; import { usePerpsPositionData } from './usePerpsPositionData'; import type { PriceUpdate } from '../controllers/types'; import { formatPrice, formatLargeNumber } from '../utils/formatUtils'; -import { - calculateFundingCountdown, - calculate24hHighLow, -} from '../utils/marketUtils'; +import { calculate24hHighLow } from '../utils/marketUtils'; import { CandlePeriod, TimeDuration } from '../constants/chartConfig'; interface MarketStats { @@ -15,9 +12,7 @@ interface MarketStats { volume24h: string; openInterest: string; fundingRate: string; - fundingCountdown: string; - currentPrice: number; - priceChange24h: number; + currentPrice?: number; isLoading: boolean; } @@ -38,10 +33,7 @@ export const usePerpsMarketStats = ( symbol: string, ): UsePerpsMarketStatsReturn => { const [marketData, setMarketData] = useState({}); - const [fundingCountdown, setFundingCountdown] = useState('00:00:00'); - const [currentPriceData, setCurrentPriceData] = useState< - PriceUpdate | undefined - >(); + const [initialPrice, setInitialPrice] = useState(); // Get candlestick data for 24h high/low calculation const { candleData, refreshCandleData } = usePerpsPositionData({ @@ -51,6 +43,7 @@ export const usePerpsMarketStats = ( }); // Subscribe to market data updates (funding, open interest, volume) + // Note: We still subscribe to prices but only extract market metadata, not price itself useEffect(() => { if (!symbol) return; @@ -65,13 +58,27 @@ export const usePerpsMarketStats = ( callback: (updates: PriceUpdate[]) => { const update = updates.find((u) => u.coin === symbol); if (update) { - // Set both price data and market data from the same update - setCurrentPriceData(update); - setMarketData({ - funding: update.funding, - openInterest: update.openInterest, - volume24h: update.volume24h, + // Only extract market data, ignore price changes to prevent re-renders + setMarketData((prev) => { + // Check if market data actually changed + if ( + prev.funding === update.funding && + prev.openInterest === update.openInterest && + prev.volume24h === update.volume24h + ) { + return prev; // Return same reference if no change + } + return { + funding: update.funding, + openInterest: update.openInterest, + volume24h: update.volume24h, + }; }); + + // Store initial price only once for high/low calculation fallback + if (!initialPrice && update.price) { + setInitialPrice(parseFloat(update.price)); + } } }, }); @@ -87,32 +94,17 @@ export const usePerpsMarketStats = ( unsubscribe(); } }; - }, [symbol]); - - // Update funding countdown every second - useEffect(() => { - const updateCountdown = () => { - setFundingCountdown(calculateFundingCountdown()); - }; - - updateCountdown(); // Initial update - const interval = setInterval(updateCountdown, 1000); - - return () => clearInterval(interval); - }, []); + }, [symbol, initialPrice]); // Calculate all statistics const stats = useMemo(() => { - const currentPrice = parseFloat(currentPriceData?.price || '0'); - const priceChange24h = parseFloat( - currentPriceData?.percentChange24h || '0', - ); const { high, low } = calculate24hHighLow(candleData); + const fallbackPrice = initialPrice || 0; return { // 24h high/low from candlestick data, with fallback estimates - high24h: high > 0 ? formatPrice(high) : formatPrice(currentPrice), - low24h: low > 0 ? formatPrice(low) : formatPrice(currentPrice), + high24h: high > 0 ? formatPrice(high) : formatPrice(fallbackPrice), + low24h: low > 0 ? formatPrice(low) : formatPrice(fallbackPrice), volume24h: marketData.volume24h ? formatLargeNumber(marketData.volume24h) : '$0.00', @@ -122,12 +114,10 @@ export const usePerpsMarketStats = ( fundingRate: marketData.funding ? `${(marketData.funding * 100).toFixed(4)}%` : '0.0000%', - fundingCountdown, - currentPrice, - priceChange24h, - isLoading: !currentPriceData || !candleData, + currentPrice: fallbackPrice, + isLoading: !candleData, }; - }, [currentPriceData, candleData, marketData, fundingCountdown]); + }, [candleData, marketData, initialPrice]); // Refresh function to reload market data const refresh = useCallback(async () => { diff --git a/app/components/UI/Perps/hooks/usePerpsMarkets.test.ts b/app/components/UI/Perps/hooks/usePerpsMarkets.test.ts index 7ba878bae788..7b3ebfccaaa5 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarkets.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarkets.test.ts @@ -3,7 +3,7 @@ import { waitFor } from '@testing-library/react-native'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import Engine from '../../../../core/Engine'; import { usePerpsMarkets } from './usePerpsMarkets'; -import type { PerpsMarketData } from '../controllers/types'; +import type { PerpsMarketData, IPerpsProvider } from '../controllers/types'; // Mock dependencies jest.mock('../../../../core/SDKConnect/utils/DevLogger'); @@ -30,7 +30,7 @@ jest.mock('../providers/PerpsConnectionProvider', () => ({ // Mock stream hooks jest.mock('./stream', () => ({ - useLivePrices: jest.fn(() => ({})), + usePerpsLivePrices: jest.fn(() => ({})), })); // Mock data @@ -56,7 +56,7 @@ const mockMarketData: PerpsMarketData[] = [ ]; const mockProvider = { - protocolId: 'hyperliquid', + protocolId: 'hyperliquid' as const, getMarketDataWithPrices: jest.fn(), getDepositRoutes: jest.fn(), getWithdrawalRoutes: jest.fn(), @@ -102,7 +102,12 @@ const mockProvider = { getOpenOrders: jest.fn(), getFunding: jest.fn(), getIsFirstTimeUser: jest.fn(), -} as const; + subscribeToOrders: jest.fn(), + unsubscribeFromOrders: jest.fn(), + unsubscribeFromPrices: jest.fn(), + unsubscribeFromPositions: jest.fn(), + unsubscribeFromOrderFills: jest.fn(), +}; const mockPerpsController = Engine.context.PerpsController as jest.Mocked< typeof Engine.context.PerpsController @@ -115,7 +120,9 @@ describe('usePerpsMarkets', () => { jest.useFakeTimers(); // Set up default mocks - mockPerpsController.getActiveProvider.mockReturnValue(mockProvider); + mockPerpsController.getActiveProvider.mockReturnValue( + mockProvider as IPerpsProvider, + ); mockProvider.getMarketDataWithPrices.mockResolvedValue(mockMarketData); }); diff --git a/app/components/UI/Perps/hooks/usePerpsMarkets.ts b/app/components/UI/Perps/hooks/usePerpsMarkets.ts index 78f7eef34948..0851dfc260cc 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarkets.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarkets.ts @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import Engine from '../../../../core/Engine'; import type { PerpsMarketData } from '../controllers/types'; -import { useLivePrices } from './stream'; +import { usePerpsLivePrices } from './stream'; export interface UsePerpsMarketsResult { /** @@ -83,9 +83,9 @@ export const usePerpsMarkets = ( ); // Conditionally subscribe to live prices if enabled - const livePrices = useLivePrices({ + const livePrices = usePerpsLivePrices({ symbols: enableLivePrices ? marketSymbols : [], - debounceMs: livePriceDebounceMs, + throttleMs: livePriceDebounceMs, }); const fetchMarketData = useCallback( diff --git a/app/components/UI/Perps/hooks/usePerpsOpenOrders.test.ts b/app/components/UI/Perps/hooks/usePerpsOpenOrders.test.ts deleted file mode 100644 index 910d7f015dc0..000000000000 --- a/app/components/UI/Perps/hooks/usePerpsOpenOrders.test.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { renderHook, act, waitFor } from '@testing-library/react-native'; -import { usePerpsOpenOrders } from './usePerpsOpenOrders'; -import Engine from '../../../../core/Engine'; -import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; -import type { Order, GetOrdersParams } from '../controllers/types'; - -// Mock Engine and DevLogger -jest.mock('../../../../core/Engine'); -jest.mock('../../../../core/SDKConnect/utils/DevLogger'); -jest.mock('../providers/PerpsConnectionProvider', () => ({ - usePerpsConnection: jest.fn(() => ({ - isInitialized: true, - isConnected: true, - })), -})); - -const mockEngine = Engine as jest.Mocked; -const mockDevLogger = DevLogger as jest.Mocked; - -describe('usePerpsOpenOrders', () => { - const mockOrders: Order[] = [ - { - orderId: 'order-1', - symbol: 'BTC-PERP', - side: 'buy', - originalSize: '1.0', - filledSize: '0.0', - price: '50000', - orderType: 'limit', - status: 'open', - timestamp: Date.now(), - reduceOnly: false, - } as Order, - { - orderId: 'order-2', - symbol: 'ETH-PERP', - side: 'sell', - originalSize: '10.0', - filledSize: '0.0', - price: '3000', - orderType: 'limit', - status: 'open', - timestamp: Date.now(), - reduceOnly: false, - } as Order, - ]; - - const mockParams = { - symbol: 'BTC-PERP', - limit: 100, - }; - - let mockGetOpenOrders: jest.MockedFunction< - (params?: GetOrdersParams) => Promise - >; - - beforeEach(() => { - jest.clearAllMocks(); - jest.useFakeTimers(); - - // Setup default mock implementations - mockGetOpenOrders = jest.fn().mockResolvedValue(mockOrders); - mockEngine.context.PerpsController = { - getOpenOrders: mockGetOpenOrders, - } as unknown as typeof mockEngine.context.PerpsController; - - mockDevLogger.log = jest.fn(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('fetches open orders on mount by default', async () => { - const { result } = renderHook(() => usePerpsOpenOrders()); - - // Initially loading - expect(result.current.isLoading).toBe(true); - expect(result.current.orders).toEqual([]); - expect(result.current.error).toBeNull(); - - await waitFor( - () => { - expect(result.current.isLoading).toBe(false); - }, - { timeout: 3000 }, - ); - - expect(result.current.orders).toEqual(mockOrders); - expect( - mockEngine.context.PerpsController.getOpenOrders, - ).toHaveBeenCalledWith(undefined); - expect(mockDevLogger.log).toHaveBeenCalledWith( - 'Perps: Fetching open orders from controller...', - ); - expect(mockDevLogger.log).toHaveBeenCalledWith( - 'Perps: Successfully fetched open orders', - { - orderCount: 2, - }, - ); - }); - - it('skips initial fetch when skipInitialFetch is true', () => { - const { result } = renderHook(() => - usePerpsOpenOrders({ skipInitialFetch: true }), - ); - - expect(result.current.isLoading).toBe(false); - expect(result.current.orders).toEqual([]); - expect( - mockEngine.context.PerpsController.getOpenOrders, - ).not.toHaveBeenCalled(); - }); - - it('passes params to getOpenOrders when provided', async () => { - const { result } = renderHook(() => - usePerpsOpenOrders({ params: mockParams }), - ); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect( - mockEngine.context.PerpsController.getOpenOrders, - ).toHaveBeenCalledWith(mockParams); - }); - - it('handles successful data refresh', async () => { - const { result } = renderHook(() => usePerpsOpenOrders()); - - // Wait for initial load - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - // Mock new data for refresh - const newOrders = [{ ...mockOrders[0], price: '51000' }]; - mockGetOpenOrders.mockResolvedValueOnce(newOrders); - - // Trigger refresh - await act(async () => { - await result.current.refresh(); - }); - - expect(result.current.isRefreshing).toBe(false); - expect(result.current.orders).toEqual(newOrders); - expect(mockDevLogger.log).toHaveBeenCalledWith( - 'Perps: Fetching open orders from controller...', - ); - }); - - it('handles refresh errors without clearing existing data', async () => { - const { result } = renderHook(() => usePerpsOpenOrders()); - - // Wait for initial load - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - // Mock error on refresh - const errorMessage = 'Network error'; - mockGetOpenOrders.mockRejectedValueOnce(new Error(errorMessage)); - - // Trigger refresh - await act(async () => { - await result.current.refresh(); - }); - - expect(result.current.isRefreshing).toBe(false); - expect(result.current.error).toBe(errorMessage); - // Existing data should be preserved on refresh error - expect(result.current.orders).toEqual(mockOrders); - expect(mockDevLogger.log).toHaveBeenCalledWith( - 'Perps: Failed to fetch open orders', - expect.any(Error), - ); - }); - - it('clears existing data on initial fetch error', async () => { - const errorMessage = 'Initial fetch failed'; - mockGetOpenOrders.mockRejectedValueOnce(new Error(errorMessage)); - - const { result } = renderHook(() => usePerpsOpenOrders()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.error).toBe(errorMessage); - expect(result.current.orders).toEqual([]); - expect(mockDevLogger.log).toHaveBeenCalledWith( - 'Perps: Failed to fetch open orders', - expect.any(Error), - ); - }); - - it('handles non-Error exceptions gracefully', async () => { - mockGetOpenOrders.mockRejectedValueOnce('String error'); - - const { result } = renderHook(() => usePerpsOpenOrders()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.error).toBe('Unknown error occurred'); - expect(result.current.orders).toEqual([]); - }); - - it('enables polling when enablePolling is true', async () => { - const pollingInterval = 1000; - const { result } = renderHook(() => - usePerpsOpenOrders({ enablePolling: true, pollingInterval }), - ); - - // Wait for initial load - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - // Fast-forward time to trigger polling - act(() => { - jest.advanceTimersByTime(pollingInterval); - }); - - await waitFor(() => { - expect( - mockEngine.context.PerpsController.getOpenOrders, - ).toHaveBeenCalledTimes(2); - }); - }); - - it('uses default polling interval when not specified', async () => { - const { result } = renderHook(() => - usePerpsOpenOrders({ enablePolling: true }), - ); - - // Wait for initial load - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - // Fast-forward to default 30 second interval - act(() => { - jest.advanceTimersByTime(30000); - }); - - await waitFor(() => { - expect( - mockEngine.context.PerpsController.getOpenOrders, - ).toHaveBeenCalledTimes(2); - }); - }); - - it('disables polling when enablePolling is false', async () => { - const { result } = renderHook(() => - usePerpsOpenOrders({ enablePolling: false, pollingInterval: 1000 }), - ); - - // Wait for initial load - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - // Fast-forward time - act(() => { - jest.advanceTimersByTime(5000); - }); - - // Should only be called once (initial fetch) - expect( - mockEngine.context.PerpsController.getOpenOrders, - ).toHaveBeenCalledTimes(1); - }); - - it('cleans up polling interval on unmount', () => { - const { unmount } = renderHook(() => - usePerpsOpenOrders({ enablePolling: true, pollingInterval: 1000 }), - ); - - // Unmount the hook - unmount(); - - // Fast-forward time - should not trigger additional calls - act(() => { - jest.advanceTimersByTime(5000); - }); - - // Should only be called once (initial fetch) - expect( - mockEngine.context.PerpsController.getOpenOrders, - ).toHaveBeenCalledTimes(1); - }); - - it('handles empty orders array from controller', async () => { - mockGetOpenOrders.mockResolvedValueOnce([]); - - const { result } = renderHook(() => usePerpsOpenOrders()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.orders).toEqual([]); - expect(mockDevLogger.log).toHaveBeenCalledWith( - 'Perps: Successfully fetched open orders', - { - orderCount: 0, - }, - ); - }); - - it('handles null response from controller', async () => { - mockGetOpenOrders.mockResolvedValueOnce(null as unknown as Order[]); - - const { result } = renderHook(() => usePerpsOpenOrders()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.orders).toEqual([]); - expect(mockDevLogger.log).toHaveBeenCalledWith( - 'Perps: Successfully fetched open orders', - { - orderCount: 0, - }, - ); - }); - - it('maintains separate loading states for initial fetch and refresh', async () => { - const { result } = renderHook(() => usePerpsOpenOrders()); - - // Initial loading state - expect(result.current.isLoading).toBe(true); - expect(result.current.isRefreshing).toBe(false); - - // Wait for initial load to complete - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - // Mock delay for refresh - mockGetOpenOrders.mockImplementationOnce( - () => - new Promise((resolve) => setTimeout(() => resolve(mockOrders), 100)), - ); - - // Trigger refresh - act(() => { - result.current.refresh(); - }); - - // Should show refreshing state - expect(result.current.isRefreshing).toBe(true); - expect(result.current.isLoading).toBe(false); - - // Wait for refresh to complete - await waitFor(() => { - expect(result.current.isRefreshing).toBe(false); - }); - }); - - it('updates params dependency when params change', async () => { - const { result, rerender } = renderHook( - ({ params }) => usePerpsOpenOrders({ params }), - { initialProps: { params: mockParams } }, - ); - - // Wait for initial load - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - // Change params - const newParams = { symbol: 'ETH-PERP', limit: 50 }; - rerender({ params: newParams }); - - // Should refetch with new params - await waitFor(() => { - expect( - mockEngine.context.PerpsController.getOpenOrders, - ).toHaveBeenCalledWith(newParams); - }); - }); -}); diff --git a/app/components/UI/Perps/hooks/usePerpsOrders.test.ts b/app/components/UI/Perps/hooks/usePerpsOrders.test.ts index 435b446835a2..7bd3ba557dc1 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrders.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrders.test.ts @@ -419,120 +419,8 @@ describe('usePerpsOrders', () => { }); }); - describe('Polling functionality', () => { - it('does not poll by default', async () => { - // Arrange - const { result } = renderHook(() => usePerpsOrders()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - // Reset call count - jest.clearAllMocks(); - - // Act - advance time - act(() => { - jest.advanceTimersByTime(60000); // 1 minute - }); - - // Assert - should not have polled - expect(mockPerpsController.getOrders).not.toHaveBeenCalled(); - }); - - it('polls when enablePolling is true', async () => { - // Arrange - const pollingInterval = 30000; // 30 seconds - const { result } = renderHook(() => - usePerpsOrders({ enablePolling: true, pollingInterval }), - ); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - // Reset call count - jest.clearAllMocks(); - - // Act - advance time by polling interval - act(() => { - jest.advanceTimersByTime(pollingInterval); - }); - - // Wait for the polling request to complete - await waitFor(() => { - expect(mockPerpsController.getOrders).toHaveBeenCalledTimes(1); - }); - - // Act - advance time again - act(() => { - jest.advanceTimersByTime(pollingInterval); - }); - - await waitFor(() => { - expect(mockPerpsController.getOrders).toHaveBeenCalledTimes(2); - }); - }); - - it('uses custom polling interval correctly', async () => { - // Arrange - const customInterval = 15000; // 15 seconds - const { result } = renderHook(() => - usePerpsOrders({ - enablePolling: true, - pollingInterval: customInterval, - }), - ); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - jest.clearAllMocks(); - - // Act - advance time less than interval - act(() => { - jest.advanceTimersByTime(customInterval - 1000); - }); - - // Assert - should not poll yet - expect(mockPerpsController.getOrders).not.toHaveBeenCalled(); - - // Act - advance time to complete interval - act(() => { - jest.advanceTimersByTime(1000); - }); - - await waitFor(() => { - expect(mockPerpsController.getOrders).toHaveBeenCalledTimes(1); - }); - }); - - it('stops polling when component unmounts', async () => { - // Arrange - const pollingInterval = 10000; - const { result, unmount } = renderHook(() => - usePerpsOrders({ enablePolling: true, pollingInterval }), - ); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - jest.clearAllMocks(); - - // Act - unmount component - unmount(); - - // Advance time - act(() => { - jest.advanceTimersByTime(pollingInterval * 2); - }); - - // Assert - should not poll after unmount - expect(mockPerpsController.getOrders).not.toHaveBeenCalled(); - }); - }); + // Note: Polling functionality has been removed in favor of WebSocket streaming + // For real-time order updates, use usePerpsLiveOrders from stream hooks describe('Parameter changes', () => { it('refetches data when params change', async () => { diff --git a/app/components/UI/Perps/hooks/usePerpsOrders.ts b/app/components/UI/Perps/hooks/usePerpsOrders.ts index c4717398cc71..bebd0f0bd8d2 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrders.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrders.ts @@ -31,16 +31,6 @@ export interface UsePerpsOrdersOptions { * Parameters to pass to getOrders */ params?: GetOrdersParams; - /** - * Enable automatic polling for live updates - * @default false - */ - enablePolling?: boolean; - /** - * Polling interval in milliseconds - * @default 30000 (30 seconds) - */ - pollingInterval?: number; /** * Skip initial data fetch on mount * @default false @@ -51,16 +41,13 @@ export interface UsePerpsOrdersOptions { /** * Custom hook to fetch and manage Perps orders from the controller * Provides loading states, error handling, and refresh functionality + * Note: This hook fetches historical order data. For real-time open orders, + * use usePerpsLiveOrders from the stream hooks. */ export const usePerpsOrders = ( options: UsePerpsOrdersOptions = {}, ): UsePerpsOrdersResult => { - const { - params, - enablePolling = false, - pollingInterval = 30000, // 30 seconds default - skipInitialFetch = false, - } = options; + const { params, skipInitialFetch = false } = options; const [orders, setOrders] = useState([]); const [isLoading, setIsLoading] = useState(!skipInitialFetch); @@ -117,17 +104,6 @@ export const usePerpsOrders = ( } }, [fetchOrders, skipInitialFetch]); - // Polling effect - useEffect(() => { - if (!enablePolling) return; - - const intervalId = setInterval(() => { - fetchOrders(true); - }, pollingInterval); - - return () => clearInterval(intervalId); - }, [enablePolling, pollingInterval, fetchOrders]); - return { orders, isLoading, diff --git a/app/components/UI/Perps/hooks/usePerpsPositionData.test.ts b/app/components/UI/Perps/hooks/usePerpsPositionData.test.ts index 02bc43d2c5df..a9545f4d7c98 100644 --- a/app/components/UI/Perps/hooks/usePerpsPositionData.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsPositionData.test.ts @@ -8,7 +8,6 @@ jest.mock('../../../../core/Engine', () => ({ context: { PerpsController: { fetchHistoricalCandles: jest.fn(), - subscribeToPrices: jest.fn(), }, }, })); @@ -16,9 +15,6 @@ jest.mock('../../../../core/Engine', () => ({ describe('usePerpsPositionData', () => { const mockFetchHistoricalCandles = Engine.context.PerpsController .fetchHistoricalCandles as jest.Mock; - const mockSubscribeToPrices = Engine.context.PerpsController - .subscribeToPrices as jest.Mock; - const mockUnsubscribe = jest.fn(); const mockCandleData = { candles: [ @@ -26,17 +22,9 @@ describe('usePerpsPositionData', () => { ], }; - const mockPriceUpdate = { - coin: 'ETH', - price: '3000.00', - change24h: 2.5, - markPrice: '3001.00', - }; - beforeEach(() => { jest.clearAllMocks(); mockFetchHistoricalCandles.mockResolvedValue(mockCandleData); - mockSubscribeToPrices.mockReturnValue(mockUnsubscribe); }); it('should fetch historical candles on mount', async () => { @@ -53,40 +41,9 @@ describe('usePerpsPositionData', () => { expect(mockFetchHistoricalCandles).toHaveBeenCalledWith('ETH', '1h', 24); }); - it('should subscribe to price updates on mount', () => { - renderHook(() => - usePerpsPositionData({ - coin: 'ETH', - selectedInterval: CandlePeriod.ONE_HOUR, - selectedDuration: TimeDuration.ONE_DAY, - }), - ); + // Price subscriptions have been removed - use usePerpsLivePrices for real-time prices - expect(mockSubscribeToPrices).toHaveBeenCalledWith({ - symbols: ['ETH'], - callback: expect.any(Function), - }); - }); - - it('should update price data when receiving updates', async () => { - const { result } = renderHook(() => - usePerpsPositionData({ - coin: 'ETH', - selectedInterval: CandlePeriod.ONE_HOUR, - selectedDuration: TimeDuration.ONE_DAY, - }), - ); - - // Get the callback that was passed to subscribeToPrices - const callback = mockSubscribeToPrices.mock.calls[0][0].callback; - - // Trigger price update - act(() => { - callback([mockPriceUpdate]); - }); - - expect(result.current.priceData).toEqual(mockPriceUpdate); - }); + // Price data updates have been moved to usePerpsLivePrices hook it('should handle loading state correctly', async () => { const { result, waitForNextUpdate } = renderHook(() => @@ -107,19 +64,7 @@ describe('usePerpsPositionData', () => { expect(result.current.candleData).toEqual(mockCandleData); }); - it('should unsubscribe on unmount', () => { - const { unmount } = renderHook(() => - usePerpsPositionData({ - coin: 'ETH', - selectedInterval: CandlePeriod.ONE_HOUR, - selectedDuration: TimeDuration.ONE_DAY, - }), - ); - - unmount(); - - expect(mockUnsubscribe).toHaveBeenCalled(); - }); + // Unsubscribe test removed - no subscriptions to clean up anymore it('should handle errors in fetching historical data', async () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); diff --git a/app/components/UI/Perps/hooks/usePerpsPositionData.ts b/app/components/UI/Perps/hooks/usePerpsPositionData.ts index 8a7cba4f71e9..9e1beee800e8 100644 --- a/app/components/UI/Perps/hooks/usePerpsPositionData.ts +++ b/app/components/UI/Perps/hooks/usePerpsPositionData.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; import Engine from '../../../../core/Engine'; -import type { PriceUpdate } from '../controllers/types'; import type { CandleData } from '../types'; import { calculateCandleCount, @@ -21,7 +20,6 @@ export const usePerpsPositionData = ({ selectedInterval, }: UsePerpsPositionDataProps) => { const [candleData, setCandleData] = useState(null); - const [priceData, setPriceData] = useState(null); const [isLoadingHistory, setIsLoadingHistory] = useState(false); const fetchHistoricalCandles = useCallback(async () => { @@ -41,26 +39,6 @@ export const usePerpsPositionData = ({ return historicalData; }, [coin, selectedDuration, selectedInterval]); - const subscribeToPriceUpdates = useCallback(() => { - try { - const unsubscribe = Engine.context.PerpsController.subscribeToPrices({ - symbols: [coin], - callback: (priceUpdates) => { - const update = priceUpdates.find((p) => p.coin === coin); - if (update) { - setPriceData(update); - } - }, - }); - return unsubscribe; - } catch (err) { - console.error('Error subscribing to price updates:', err); - return () => { - // Empty cleanup function on error - }; - } - }, [coin]); - // Load historical candles useEffect(() => { setIsLoadingHistory(true); @@ -78,15 +56,6 @@ export const usePerpsPositionData = ({ loadHistoricalData(); }, [fetchHistoricalCandles]); - // Subscribe to price updates for 24-hour data - useEffect(() => { - const unsubscribe = subscribeToPriceUpdates(); - - return () => { - unsubscribe(); - }; - }, [subscribeToPriceUpdates]); - // Refresh function to reload candle data const refreshCandleData = useCallback(async () => { setIsLoadingHistory(true); @@ -102,7 +71,6 @@ export const usePerpsPositionData = ({ return { candleData, - priceData, isLoadingHistory, refreshCandleData, }; diff --git a/app/components/UI/Perps/hooks/usePerpsPositions.test.ts b/app/components/UI/Perps/hooks/usePerpsPositions.test.ts deleted file mode 100644 index b15829edda8b..000000000000 --- a/app/components/UI/Perps/hooks/usePerpsPositions.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { renderHook, act, waitFor } from '@testing-library/react-native'; -import { usePerpsPositions } from './usePerpsPositions'; -import { usePerpsTrading } from './usePerpsTrading'; -import { useFocusEffect } from '@react-navigation/native'; - -// Mock dependencies -jest.mock('./usePerpsTrading'); -jest.mock('@react-navigation/native', () => ({ - useFocusEffect: jest.fn(), -})); -jest.mock('../../../../core/SDKConnect/utils/DevLogger', () => ({ - DevLogger: { - log: jest.fn(), - }, -})); -jest.mock('../providers/PerpsConnectionProvider', () => ({ - usePerpsConnection: jest.fn(() => ({ - isInitialized: true, - isConnected: true, - })), -})); - -describe('usePerpsPositions', () => { - const mockGetPositions = jest.fn(); - const mockUseFocusEffect = useFocusEffect as jest.Mock; - - beforeEach(() => { - jest.clearAllMocks(); - (usePerpsTrading as jest.Mock).mockReturnValue({ - getPositions: mockGetPositions, - }); - }); - - it('should load positions on mount by default', async () => { - mockGetPositions.mockResolvedValue([ - { coin: 'ETH', size: '1.5', unrealizedPnl: '100' }, - { coin: 'BTC', size: '0.1', unrealizedPnl: '50' }, - ]); - - const { result } = renderHook(() => usePerpsPositions()); - - expect(result.current.isLoading).toBe(true); - expect(result.current.positions).toEqual([]); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(mockGetPositions).toHaveBeenCalled(); - expect(result.current.positions).toHaveLength(2); - expect(result.current.error).toBe(null); - }); - - it('should not load on mount when loadOnMount is false', () => { - mockGetPositions.mockResolvedValue([]); - - renderHook(() => usePerpsPositions({ loadOnMount: false })); - - expect(mockGetPositions).not.toHaveBeenCalled(); - }); - - it('should handle errors correctly', async () => { - const testError = new Error('Failed to fetch positions'); - mockGetPositions.mockRejectedValue(testError); - const onError = jest.fn(); - - const { result } = renderHook(() => usePerpsPositions({ onError })); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.error).toBe('Failed to fetch positions'); - expect(result.current.positions).toEqual([]); - expect(onError).toHaveBeenCalledWith('Failed to fetch positions'); - }); - - it('should refresh positions with isRefresh flag', async () => { - mockGetPositions.mockResolvedValue([]); - - const { result } = renderHook(() => usePerpsPositions()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - // Reset mock to track refresh call - mockGetPositions.mockClear(); - mockGetPositions.mockResolvedValue([ - { coin: 'ETH', size: '2.0', unrealizedPnl: '200' }, - ]); - - await act(async () => { - await result.current.loadPositions({ isRefresh: true }); - }); - - expect(mockGetPositions).toHaveBeenCalled(); - expect(result.current.isRefreshing).toBe(false); - expect(result.current.positions).toHaveLength(1); - }); - - it('should call onSuccess callback when positions load successfully', async () => { - const onSuccess = jest.fn(); - const positions = [{ coin: 'ETH', size: '1.5', unrealizedPnl: '100' }]; - mockGetPositions.mockResolvedValue(positions); - - const { result } = renderHook(() => usePerpsPositions({ onSuccess })); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(onSuccess).toHaveBeenCalledWith(positions); - }); - - it('should setup focus effect when refreshOnFocus is true', () => { - renderHook(() => usePerpsPositions({ refreshOnFocus: true })); - - expect(mockUseFocusEffect).toHaveBeenCalled(); - }); - - it('should not setup focus effect when refreshOnFocus is false', () => { - renderHook(() => usePerpsPositions({ refreshOnFocus: false })); - - // The hook is still called but the callback won't trigger loadPositions - expect(mockUseFocusEffect).toHaveBeenCalled(); - }); -}); diff --git a/app/components/UI/Perps/hooks/usePerpsPrices.ts b/app/components/UI/Perps/hooks/usePerpsPrices.ts index 27d7d39c2cc5..5c4552c4bb0d 100644 --- a/app/components/UI/Perps/hooks/usePerpsPrices.ts +++ b/app/components/UI/Perps/hooks/usePerpsPrices.ts @@ -12,7 +12,7 @@ export interface UsePerpsPricesOptions { /** Whether to include order book data (bid/ask) */ includeOrderBook?: boolean; /** Debounce delay in milliseconds (default: 50ms) */ - debounceMs?: number; + throttleMs?: number; /** Whether to include market data (funding, OI, volume) */ includeMarketData?: boolean; } @@ -29,7 +29,7 @@ export function usePerpsPrices( ): Record { const { includeOrderBook = false, - debounceMs, + throttleMs, includeMarketData = false, } = options; @@ -67,7 +67,7 @@ export function usePerpsPrices( // Use provided debounce or fall back to default const debounceDelay = - debounceMs ?? PERFORMANCE_CONFIG.PRICE_UPDATE_DEBOUNCE_MS; + throttleMs ?? PERFORMANCE_CONFIG.PRICE_UPDATE_DEBOUNCE_MS; // Track if we've received the first update for each symbol // This only resets when symbols change, not debounce settings diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx index 712a4ef74d15..c8cd61b9d919 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx @@ -1,7 +1,11 @@ import React from 'react'; import { render, waitFor, act } from '@testing-library/react-native'; import { Text } from 'react-native'; -import { PerpsStreamProvider, usePerpsStream } from './PerpsStreamManager'; +import { + PerpsStreamProvider, + usePerpsStream, + PerpsStreamManager, +} from './PerpsStreamManager'; import Engine from '../../../../core/Engine'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import type { PriceUpdate } from '../controllers/types'; @@ -27,7 +31,7 @@ const TestPriceComponent = ({ callback: (prices: Record) => { onUpdate?.(prices); }, - debounceMs: 100, + throttleMs: 100, }); return () => { @@ -41,12 +45,16 @@ const TestPriceComponent = ({ describe('PerpsStreamManager', () => { let mockSubscribeToPrices: jest.Mock; let mockUnsubscribeFromPrices: jest.Mock; + let testStreamManager: PerpsStreamManager; beforeEach(() => { jest.clearAllMocks(); jest.clearAllTimers(); jest.useFakeTimers(); + // Create a fresh stream manager for each test + testStreamManager = new PerpsStreamManager(); + // Setup default mocks mockSubscribeToPrices = jest.fn(); mockUnsubscribeFromPrices = jest.fn(); @@ -60,12 +68,14 @@ describe('PerpsStreamManager', () => { }); afterEach(() => { + jest.clearAllTimers(); jest.useRealTimers(); + jest.clearAllMocks(); }); it('should render children correctly', () => { const { getByText } = render( - + Child Component , ); @@ -91,13 +101,395 @@ describe('PerpsStreamManager', () => { console.error = originalError; }); + it('should provide immediate cached data on subscription', async () => { + // Setup mock subscription that will trigger updates + mockSubscribeToPrices.mockImplementation( + (params: { callback: (updates: PriceUpdate[]) => void }) => { + // Simulate immediate cached data with all required fields + const cachedData: PriceUpdate[] = [ + { + coin: 'BTC-PERP', + price: '50000', + percentChange24h: '5', + timestamp: Date.now(), + bestBid: '49900', + bestAsk: '50100', + spread: '200', + markPrice: '50050', + }, + ]; + params.callback(cachedData); + return jest.fn(); + }, + ); + + const onUpdate = jest.fn(); + + render( + + + , + ); + + // Should receive cached data immediately + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledWith({ + 'BTC-PERP': { + coin: 'BTC-PERP', + price: '50000', + timestamp: expect.any(Number), + percentChange24h: '5', + bestBid: '49900', + bestAsk: '50100', + spread: '200', + markPrice: '50050', + funding: undefined, + openInterest: undefined, + volume24h: undefined, + }, + }); + }); + }); + + it('should throttle updates after first immediate update', async () => { + let controllerCallback: ((updates: PriceUpdate[]) => void) | null = null; + mockSubscribeToPrices.mockImplementation( + (params: { callback: (updates: PriceUpdate[]) => void }) => { + controllerCallback = params.callback; + return jest.fn(); + }, + ); + + const onUpdate = jest.fn(); + + render( + + + , + ); + + // Wait for subscription setup + await waitFor(() => { + expect(mockSubscribeToPrices).toHaveBeenCalled(); + }); + + // First update should be immediate + act(() => { + controllerCallback?.([ + { + coin: 'BTC-PERP', + price: '50000', + percentChange24h: '5', + timestamp: Date.now(), + }, + ]); + }); + + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(1); + }); + + // Subsequent updates should be throttled + act(() => { + controllerCallback?.([ + { + coin: 'BTC-PERP', + price: '50100', + percentChange24h: '5.1', + timestamp: Date.now(), + }, + ]); + }); + + // Should not be called immediately + expect(onUpdate).toHaveBeenCalledTimes(1); + + // Fast-forward time to trigger throttled update + act(() => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(2); + expect(onUpdate).toHaveBeenLastCalledWith({ + 'BTC-PERP': { + coin: 'BTC-PERP', + price: '50100', + timestamp: expect.any(Number), + percentChange24h: '5.1', + bestBid: undefined, + bestAsk: undefined, + spread: undefined, + markPrice: undefined, + funding: undefined, + openInterest: undefined, + volume24h: undefined, + }, + }); + }); + }); + + it('should handle multiple rapid updates with throttling', async () => { + let controllerCallback: ((updates: PriceUpdate[]) => void) | null = null; + mockSubscribeToPrices.mockImplementation( + (params: { callback: (updates: PriceUpdate[]) => void }) => { + controllerCallback = params.callback; + return jest.fn(); + }, + ); + + const onUpdate = jest.fn(); + + render( + + + , + ); + + // Wait for subscription setup + await waitFor(() => { + expect(mockSubscribeToPrices).toHaveBeenCalled(); + }); + + // First update (immediate) + act(() => { + controllerCallback?.([ + { + coin: 'BTC-PERP', + price: '50000', + percentChange24h: '5', + timestamp: Date.now(), + }, + ]); + }); + + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(1); + }); + + // Multiple rapid updates during throttle period + act(() => { + controllerCallback?.([ + { + coin: 'BTC-PERP', + price: '50100', + percentChange24h: '5.1', + timestamp: Date.now(), + }, + ]); + controllerCallback?.([ + { + coin: 'BTC-PERP', + price: '50200', + percentChange24h: '5.2', + timestamp: Date.now(), + }, + ]); + controllerCallback?.([ + { + coin: 'BTC-PERP', + price: '50300', + percentChange24h: '5.3', + timestamp: Date.now(), + }, + ]); + }); + + // Still only 1 call (first immediate) + expect(onUpdate).toHaveBeenCalledTimes(1); + + // Advance timer to trigger throttled update + act(() => { + jest.advanceTimersByTime(100); + }); + + // Should receive the latest update + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(2); + expect(onUpdate).toHaveBeenLastCalledWith({ + 'BTC-PERP': { + coin: 'BTC-PERP', + price: '50300', + timestamp: expect.any(Number), + percentChange24h: '5.3', + bestBid: undefined, + bestAsk: undefined, + spread: undefined, + markPrice: undefined, + funding: undefined, + openInterest: undefined, + volume24h: undefined, + }, + }); + }); + }); + + it('should handle subscription without throttling', async () => { + let controllerCallback: ((updates: PriceUpdate[]) => void) | null = null; + mockSubscribeToPrices.mockImplementation( + (params: { callback: (updates: PriceUpdate[]) => void }) => { + controllerCallback = params.callback; + return jest.fn(); + }, + ); + + const TestNoThrottleComponent = ({ + onUpdate, + }: { + onUpdate?: (prices: Record) => void; + }) => { + const stream = usePerpsStream(); + + React.useEffect(() => { + const unsubscribe = stream.prices.subscribeToSymbols({ + symbols: ['ETH-PERP'], + callback: (prices: Record) => { + onUpdate?.(prices); + }, + throttleMs: 0, // No throttling + }); + + return () => { + unsubscribe(); + }; + }, [stream, onUpdate]); + + return No Throttle; + }; + + const onUpdate = jest.fn(); + + render( + + + , + ); + + // Wait for subscription setup + await waitFor(() => { + expect(mockSubscribeToPrices).toHaveBeenCalled(); + }); + + // Reset the mock call count after initial setup + onUpdate.mockClear(); + + // All updates should be immediate when throttleMs is 0 + act(() => { + controllerCallback?.([ + { + coin: 'ETH-PERP', + price: '3000', + timestamp: Date.now(), + percentChange24h: '2', + }, + ]); + }); + + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(1); + }); + + act(() => { + controllerCallback?.([ + { + coin: 'ETH-PERP', + price: '3010', + timestamp: Date.now(), + percentChange24h: '2.1', + }, + ]); + }); + + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(2); + }); + + act(() => { + controllerCallback?.([ + { + coin: 'ETH-PERP', + price: '3020', + timestamp: Date.now(), + percentChange24h: '2.2', + }, + ]); + }); + + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(3); + }); + }); + + it('should clean up timers on unsubscribe', async () => { + let controllerCallback: ((updates: PriceUpdate[]) => void) | null = null; + const unsubscribeMock = jest.fn(); + mockSubscribeToPrices.mockImplementation( + (params: { callback: (updates: PriceUpdate[]) => void }) => { + controllerCallback = params.callback; + return unsubscribeMock; + }, + ); + + const onUpdate = jest.fn(); + + const { unmount } = render( + + + , + ); + + // Wait for subscription setup + await waitFor(() => { + expect(mockSubscribeToPrices).toHaveBeenCalled(); + }); + + // First update (immediate) + act(() => { + controllerCallback?.([ + { + coin: 'BTC-PERP', + price: '50000', + timestamp: Date.now(), + percentChange24h: '5', + }, + ]); + }); + + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledTimes(1); + }); + + // Queue a throttled update + act(() => { + controllerCallback?.([ + { + coin: 'BTC-PERP', + price: '50100', + timestamp: Date.now(), + percentChange24h: '5.1', + }, + ]); + }); + + // Unmount before timer fires + unmount(); + + // Advance timer + act(() => { + jest.advanceTimersByTime(100); + }); + + // Should not receive update after unmount + expect(onUpdate).toHaveBeenCalledTimes(1); + }); + it('should subscribe to prices when component mounts', async () => { mockSubscribeToPrices.mockImplementation(() => jest.fn()); const onUpdate = jest.fn(); render( - + , ); @@ -116,7 +508,7 @@ describe('PerpsStreamManager', () => { mockSubscribeToPrices.mockReturnValue(mockUnsubscribe); const { unmount } = render( - + , ); @@ -143,7 +535,7 @@ describe('PerpsStreamManager', () => { }); render( - + , ); @@ -175,7 +567,7 @@ describe('PerpsStreamManager', () => { }); }); - it('should debounce subsequent updates', async () => { + it('should throttle subsequent updates', async () => { const onUpdate = jest.fn(); let priceCallback: (data: PriceUpdate[]) => void = jest.fn(); @@ -185,7 +577,7 @@ describe('PerpsStreamManager', () => { }); render( - + , ); @@ -233,12 +625,12 @@ describe('PerpsStreamManager', () => { // Should not be called immediately expect(onUpdate).toHaveBeenCalledTimes(1); - // Advance timers to trigger debounce + // Advance timers to trigger throttle act(() => { jest.advanceTimersByTime(100); }); - // Should receive the last update after debounce + // Should receive the last update after throttle await waitFor(() => { expect(onUpdate).toHaveBeenCalledTimes(2); const lastCall = onUpdate.mock.calls[1][0]; @@ -249,7 +641,7 @@ describe('PerpsStreamManager', () => { }); }); - it('should handle multiple subscribers with different debounce times', async () => { + it('should handle multiple subscribers with different throttle times', async () => { const onUpdate1 = jest.fn(); const onUpdate2 = jest.fn(); let priceCallback: (data: PriceUpdate[]) => void = jest.fn(); @@ -268,7 +660,7 @@ describe('PerpsStreamManager', () => { callback: (prices: Record) => { onUpdate1(prices); }, - debounceMs: 100, + throttleMs: 100, }); const sub2 = stream.prices.subscribeToSymbols({ @@ -276,7 +668,7 @@ describe('PerpsStreamManager', () => { callback: (prices: Record) => { onUpdate2(prices); }, - debounceMs: 200, + throttleMs: 200, }); return () => { @@ -289,7 +681,7 @@ describe('PerpsStreamManager', () => { }; render( - + , ); @@ -365,7 +757,7 @@ describe('PerpsStreamManager', () => { callback: (prices: Record) => { onUpdate(prices); }, - debounceMs: 100, + throttleMs: 100, }); return () => { @@ -379,7 +771,7 @@ describe('PerpsStreamManager', () => { const onUpdate = jest.fn(); render( - + , ); @@ -397,7 +789,7 @@ describe('PerpsStreamManager', () => { mockSubscribeToPrices.mockReturnValue(mockUnsubscribe); const { unmount } = render( - + , ); diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.tsx index 90633a45f34e..ba73eca011ea 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.tsx @@ -1,6 +1,5 @@ import React, { createContext, useContext } from 'react'; import Engine from '../../../../core/Engine'; -import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; import type { PriceUpdate, Position, @@ -12,7 +11,7 @@ import type { interface StreamSubscription { id: string; callback: (data: T) => void; - debounceMs: number; + throttleMs?: number; timer?: NodeJS.Timeout; pendingUpdate?: T; hasReceivedFirstUpdate?: boolean; // Track if subscriber has received first update @@ -28,46 +27,40 @@ class StreamChannel { this.subscribers.forEach((subscriber) => { // Check if this is the first update for this subscriber if (!subscriber.hasReceivedFirstUpdate) { - DevLogger.log( - `StreamChannel: First update for subscriber ${subscriber.id}, executing immediately`, - ); subscriber.callback(updates); subscriber.hasReceivedFirstUpdate = true; - return; // Don't set up debounce for the first update + return; // Don't set up throttle for the first update } - // For subsequent updates, use debounce logic + // If no throttling (throttleMs is 0 or undefined), notify immediately + if (!subscriber.throttleMs) { + subscriber.callback(updates); + return; + } + + // For subsequent updates with throttling, use throttle logic // Store pending update subscriber.pendingUpdate = updates; // Only set timer if one isn't already running if (!subscriber.timer) { - DevLogger.log( - `StreamChannel: Setting ${subscriber.debounceMs}ms timer for subscriber ${subscriber.id}`, - ); subscriber.timer = setTimeout(() => { if (subscriber.pendingUpdate) { - DevLogger.log( - `StreamChannel: Executing callback for subscriber ${subscriber.id} after ${subscriber.debounceMs}ms`, - ); subscriber.callback(subscriber.pendingUpdate); subscriber.pendingUpdate = undefined; subscriber.timer = undefined; } - }, subscriber.debounceMs); + }, subscriber.throttleMs); } }); } subscribe(params: { callback: (data: T) => void; - debounceMs: number; + throttleMs?: number; }): () => void { const id = Math.random().toString(36); - DevLogger.log( - `StreamChannel: New subscriber ${id} with ${params.debounceMs}ms debounce`, - ); const subscription: StreamSubscription = { id, ...params, @@ -78,7 +71,6 @@ class StreamChannel { // Give immediate cached data if available const cached = this.getCachedData(); if (cached) { - DevLogger.log(`StreamChannel: Providing cached data to subscriber ${id}`); params.callback(cached); // Mark as having received first update since we provided cached data subscription.hasReceivedFirstUpdate = true; @@ -88,7 +80,6 @@ class StreamChannel { this.connect(); return () => { - DevLogger.log(`StreamChannel: Removing subscriber ${id}`); const sub = this.subscribers.get(id); if (sub?.timer) { clearTimeout(sub.timer); @@ -97,15 +88,13 @@ class StreamChannel { // Disconnect if no subscribers if (this.subscribers.size === 0) { - DevLogger.log('StreamChannel: No subscribers left, disconnecting'); this.disconnect(); } }; } protected connect() { - // Template method for establishing WebSocket connections - // Each stream type overrides this with specific subscription logic + // Override in subclasses } protected disconnect() { @@ -116,8 +105,7 @@ class StreamChannel { } protected getCachedData(): T | null { - // Template method for retrieving cached data for new subscribers - // Each stream type overrides this to return their specific cached format + // Override in subclasses return null; } } @@ -130,7 +118,6 @@ class PriceStreamChannel extends StreamChannel> { protected connect() { if (this.wsSubscription) { - DevLogger.log('PriceStream: Already connected'); return; } @@ -138,14 +125,9 @@ class PriceStreamChannel extends StreamChannel> { const allSymbols = Array.from(this.symbols); if (allSymbols.length === 0) { - DevLogger.log('PriceStream: No symbols to subscribe to yet'); return; } - DevLogger.log('PriceStream: Establishing WebSocket subscription', { - symbols: allSymbols, - }); - this.wsSubscription = Engine.context.PerpsController.subscribeToPrices({ symbols: allSymbols, // Subscribe to specific symbols callback: (updates: PriceUpdate[]) => { @@ -173,7 +155,6 @@ class PriceStreamChannel extends StreamChannel> { this.notifySubscribers(priceMap); }, }); - DevLogger.log('PriceStream: WebSocket subscription established'); } protected getCachedData(): Record | null { @@ -188,7 +169,7 @@ class PriceStreamChannel extends StreamChannel> { subscribeToSymbols(params: { symbols: string[]; callback: (prices: Record) => void; - debounceMs: number; + throttleMs?: number; }): () => void { // Track new symbols const newSymbols: string[] = []; @@ -199,16 +180,8 @@ class PriceStreamChannel extends StreamChannel> { this.symbols.add(s); }); - DevLogger.log( - `PriceStream: Component subscribing to symbols with ${params.debounceMs}ms debounce`, - { symbols: params.symbols, newSymbols }, - ); - // If we have new symbols and WebSocket is already connected, we need to reconnect if (newSymbols.length > 0 && this.wsSubscription) { - DevLogger.log( - 'PriceStream: New symbols detected, reconnecting WebSocket', - ); this.disconnect(); this.connect(); } @@ -222,51 +195,73 @@ class PriceStreamChannel extends StreamChannel> { filtered[symbol] = allPrices[symbol]; } }); - DevLogger.log( - `PriceStream: Sending filtered prices to component (${params.debounceMs}ms debounce)`, - { - symbols: Object.keys(filtered), - count: Object.keys(filtered).length, - }, - ); params.callback(filtered); }, - debounceMs: params.debounceMs, + throttleMs: params.throttleMs, }); } } // Specific channel for orders class OrderStreamChannel extends StreamChannel { + private prewarmUnsubscribe?: () => void; + protected connect() { if (this.wsSubscription) return; - // For now, we'll use polling until WebSocket is available - // This will be replaced with actual WebSocket subscription - const controller = Engine.context.PerpsController; - const pollInterval = setInterval(async () => { - try { - const orders = await controller.getOrders(); + // This calls HyperLiquidSubscriptionService.subscribeToOrders which uses shared webData2 + this.wsSubscription = Engine.context.PerpsController.subscribeToOrders({ + callback: (orders: Order[]) => { this.cache.set('orders', orders); this.notifySubscribers(orders); - } catch (error) { - // Handle error silently - } - }, 5000); - - this.wsSubscription = () => clearInterval(pollInterval); + }, + }); } protected getCachedData() { return this.cache.get('orders') || []; } + + /** + * Pre-warm the channel by creating a persistent subscription + * This keeps the WebSocket connection alive and caches data continuously + * @returns Cleanup function to call when leaving Perps environment + */ + public prewarm(): () => void { + if (this.prewarmUnsubscribe) { + return this.prewarmUnsubscribe; + } + + // Create a real subscription with no-op callback to keep connection alive + this.prewarmUnsubscribe = this.subscribe({ + callback: () => { + // No-op callback - just keeps the connection alive for caching + }, + throttleMs: 0, // No throttle for pre-warm + }); + + return this.prewarmUnsubscribe; + } + + /** + * Cleanup pre-warm subscription + */ + public cleanupPrewarm(): void { + if (this.prewarmUnsubscribe) { + this.prewarmUnsubscribe(); + this.prewarmUnsubscribe = undefined; + } + } } // Specific channel for positions class PositionStreamChannel extends StreamChannel { + private prewarmUnsubscribe?: () => void; + protected connect() { if (this.wsSubscription) return; + // This calls HyperLiquidSubscriptionService.subscribeToPositions which uses shared webData2 this.wsSubscription = Engine.context.PerpsController.subscribeToPositions({ callback: (positions: Position[]) => { this.cache.set('positions', positions); @@ -278,6 +273,37 @@ class PositionStreamChannel extends StreamChannel { protected getCachedData() { return this.cache.get('positions') || []; } + + /** + * Pre-warm the channel by creating a persistent subscription + * This keeps the WebSocket connection alive and caches data continuously + * @returns Cleanup function to call when leaving Perps environment + */ + public prewarm(): () => void { + if (this.prewarmUnsubscribe) { + return this.prewarmUnsubscribe; + } + + // Create a real subscription with no-op callback to keep connection alive + this.prewarmUnsubscribe = this.subscribe({ + callback: () => { + // No-op callback - just keeps the connection alive for caching + }, + throttleMs: 0, // No throttle for pre-warm + }); + + return this.prewarmUnsubscribe; + } + + /** + * Cleanup pre-warm subscription + */ + public cleanupPrewarm(): void { + if (this.prewarmUnsubscribe) { + this.prewarmUnsubscribe(); + this.prewarmUnsubscribe = undefined; + } + } } // Specific channel for fills @@ -302,7 +328,7 @@ class FillStreamChannel extends StreamChannel { } // Main manager class -class PerpsStreamManager { +export class PerpsStreamManager { public readonly prices = new PriceStreamChannel(); public readonly orders = new OrderStreamChannel(); public readonly positions = new PositionStreamChannel(); @@ -318,13 +344,17 @@ class PerpsStreamManager { // Singleton instance const streamManager = new PerpsStreamManager(); +// Export singleton for pre-warming in PerpsConnectionManager +export const getStreamManagerInstance = () => streamManager; + // Context const PerpsStreamContext = createContext(null); export const PerpsStreamProvider: React.FC<{ children: React.ReactNode; -}> = ({ children }) => ( - + testStreamManager?: PerpsStreamManager; // Only for testing +}> = ({ children, testStreamManager }) => ( + {children} ); diff --git a/app/components/UI/Perps/providers/__mocks__/PerpsStreamManager.tsx b/app/components/UI/Perps/providers/__mocks__/PerpsStreamManager.tsx new file mode 100644 index 000000000000..ec41d6e94805 --- /dev/null +++ b/app/components/UI/Perps/providers/__mocks__/PerpsStreamManager.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +// Mock stream manager +const mockStreamManager = { + prices: { + subscribeToSymbols: jest.fn(() => jest.fn()), + subscribe: jest.fn(() => jest.fn()), + }, + orders: { + subscribe: jest.fn(() => jest.fn()), + }, + positions: { + subscribe: jest.fn(() => jest.fn()), + }, + fills: { + subscribe: jest.fn(() => jest.fn()), + }, +}; + +// Mock provider component +export const PerpsStreamProvider = ({ + children, +}: { + children: React.ReactNode; +}) => <>{children}; + +// Mock hook +export const usePerpsStream = jest.fn(() => mockStreamManager); + +// Export the mock stream manager for test access +export const getStreamManagerInstance = () => mockStreamManager; diff --git a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts index 336e486cdb44..768a9b6b80d6 100644 --- a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts +++ b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts @@ -30,6 +30,22 @@ jest.mock('../utils/hyperLiquidAdapter', () => ({ averagePrice: '50000', markPrice: '52000', })), + adaptOrderFromSDK: jest.fn((order: any) => ({ + orderId: order.oid.toString(), + symbol: order.coin, + side: order.side === 'B' ? 'buy' : 'sell', + orderType: 'limit', + size: order.sz, + originalSize: order.sz, + price: order.limitPx || '0', + filledSize: '0', + remainingSize: order.sz, + status: 'open', + timestamp: Date.now(), + detailedOrderType: order.orderType || 'Limit', + isTrigger: false, + reduceOnly: false, + })), })); // Mock DevLogger @@ -103,7 +119,8 @@ describe('HyperLiquidSubscriptionService', () => { return Promise.resolve(mockSubscription); }), webData2: jest.fn((_params: any, callback: any) => { - // Simulate position data + // Simulate position and order data + // First callback immediately setTimeout(() => { callback({ clearinghouseState: { @@ -114,8 +131,51 @@ describe('HyperLiquidSubscriptionService', () => { }, ], }, + openOrders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + }, + ], }); }, 0); + + // Second callback with changed data to ensure updates are triggered + setTimeout(() => { + callback({ + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.2' }, // Changed position size + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 12346, // Changed order ID + coin: 'BTC', + side: 'S', + sz: '0.3', + origSz: '0.5', + limitPx: '51000', + orderType: 'Limit', + timestamp: 1234567890001, + isTrigger: false, + reduceOnly: false, + }, + ], + }); + }, 10); + return Promise.resolve(mockSubscription); }), userFills: jest.fn((_params: any, callback: any) => { @@ -438,6 +498,145 @@ describe('HyperLiquidSubscriptionService', () => { }); }); + describe('Shared WebData2 Subscription', () => { + it('should share webData2 subscription between positions and orders', async () => { + const positionCallback = jest.fn(); + const orderCallback = jest.fn(); + + // Mock getUserAddressWithDefault to return immediately + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0x123' as Hex, + ); + + // Subscribe to positions first + const unsubscribePositions = service.subscribeToPositions({ + callback: positionCallback, + }); + + // Wait for subscription to be established and initial callback + // This will trigger the first webData2 callback which caches both positions and orders + await new Promise((resolve) => setTimeout(resolve, 20)); + + // Verify position callback was called + expect(positionCallback).toHaveBeenCalled(); + + // Subscribe to orders - should reuse same webData2 subscription + // and immediately get cached data + const unsubscribeOrders = service.subscribeToOrders({ + callback: orderCallback, + }); + + // Orders should get cached data immediately (synchronously) + // or after the second webData2 update with changed data + await new Promise((resolve) => setTimeout(resolve, 20)); + + // Should only call webData2 once for shared subscription + expect(mockSubscriptionClient.webData2).toHaveBeenCalledTimes(1); + + // Both callbacks should be called with their respective data + expect(positionCallback).toHaveBeenCalled(); + expect(orderCallback).toHaveBeenCalled(); + + // Cleanup + unsubscribePositions(); + unsubscribeOrders(); + }); + + it('should maintain subscription when one subscriber unsubscribes', async () => { + const positionCallback1 = jest.fn(); + const positionCallback2 = jest.fn(); + + // Subscribe two position callbacks + const unsubscribe1 = service.subscribeToPositions({ + callback: positionCallback1, + }); + + const unsubscribe2 = service.subscribeToPositions({ + callback: positionCallback2, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Unsubscribe first callback + unsubscribe1(); + + // Second callback should still receive updates + mockSubscriptionClient.webData2.mock.calls[0][1]({ + clearinghouseState: { + assetPositions: [ + { + position: { coin: 'BTC', szi: '1.0' }, + }, + ], + }, + openOrders: [], + }); + + expect(positionCallback2).toHaveBeenCalled(); + + unsubscribe2(); + }); + + it('should cache positions and orders data', async () => { + const positionCallback = jest.fn(); + + // Setup webData2 mock to call callback with data + mockSubscriptionClient.webData2.mockImplementation( + (_addr: any, callback: any) => { + setTimeout(() => { + callback({ + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.0' }, + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 123, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '0.5', + limitPx: '50000', + orderType: 'Limit', + timestamp: Date.now(), + isTrigger: false, + reduceOnly: false, + }, + ], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: positionCallback, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Should receive cached data on new subscription + const newCallback = jest.fn(); + const unsubscribe2 = service.subscribeToPositions({ + callback: newCallback, + }); + + // New subscriber should get cached data immediately + expect(newCallback).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ coin: 'BTC' })]), + ); + + unsubscribe(); + unsubscribe2(); + }); + }); + describe('Subscription Lifecycle', () => { it('should unsubscribe from position updates successfully', async () => { const mockCallback = jest.fn(); @@ -741,8 +940,9 @@ describe('HyperLiquidSubscriptionService', () => { setTimeout(() => { callback({ clearinghouseState: { - // No assetPositions + assetPositions: [], // Empty array instead of undefined }, + openOrders: [], // Also need openOrders array }); }, 0); return Promise.resolve({ diff --git a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts index b2393615b4f9..cec3f6785963 100644 --- a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts +++ b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts @@ -20,13 +20,19 @@ import type { PriceUpdate, Position, OrderFill, + Order, SubscribePricesParams, SubscribePositionsParams, SubscribeOrderFillsParams, + SubscribeOrdersParams, } from '../controllers/types'; -import { adaptPositionFromSDK } from '../utils/hyperLiquidAdapter'; +import { + adaptPositionFromSDK, + adaptOrderFromSDK, +} from '../utils/hyperLiquidAdapter'; import type { HyperLiquidClientService } from './HyperLiquidClientService'; import type { HyperLiquidWalletService } from './HyperLiquidWalletService'; +import type { CaipAccountId } from '@metamask/utils'; import { strings } from '../../../../../locales/i18n'; /** @@ -45,6 +51,7 @@ export class HyperLiquidSubscriptionService { >(); private positionSubscribers = new Set<(positions: Position[]) => void>(); private orderFillSubscribers = new Set<(fills: OrderFill[]) => void>(); + private orderSubscribers = new Set<(orders: Order[]) => void>(); // Track which subscribers want market data private marketDataSubscribers = new Map< @@ -58,6 +65,14 @@ export class HyperLiquidSubscriptionService { private globalL2BookSubscriptions = new Map(); private symbolSubscriberCounts = new Map(); + // Shared webData2 subscription for positions and orders + private sharedWebData2Subscription?: Subscription; + private webData2SubscriptionPromise?: Promise; + private positionSubscriberCount = 0; + private orderSubscriberCount = 0; + private cachedPositions: Position[] = []; + private cachedOrders: Order[] = []; + // Global price data cache private cachedPriceData = new Map(); @@ -187,68 +202,214 @@ export class HyperLiquidSubscriptionService { } /** - * Subscribe to live position updates + * Ensure shared webData2 subscription is active (singleton pattern) + * This subscription provides both positions and orders data */ - public subscribeToPositions(params: SubscribePositionsParams): () => void { - const { callback, accountId } = params; - const unsubscribe = this.createSubscription( - this.positionSubscribers, - callback, - ); + private async ensureSharedWebData2Subscription( + accountId?: CaipAccountId, + ): Promise { + // Return existing subscription if active + if (this.sharedWebData2Subscription) { + return; + } - let subscription: Subscription | undefined; + // Return existing promise if subscription is being established + if (this.webData2SubscriptionPromise) { + return this.webData2SubscriptionPromise; + } + // Create new subscription promise to prevent race conditions + this.webData2SubscriptionPromise = + this.createWebData2Subscription(accountId); + + try { + await this.webData2SubscriptionPromise; + } catch (error) { + // Clear promise on error so it can be retried + this.webData2SubscriptionPromise = undefined; + throw error; + } + } + + /** + * Create the actual webData2 subscription + */ + private async createWebData2Subscription( + accountId?: CaipAccountId, + ): Promise { this.clientService.ensureSubscriptionClient( this.walletService.createWalletAdapter(), ); const subscriptionClient = this.clientService.getSubscriptionClient(); - if (subscriptionClient) { - this.walletService - .getUserAddressWithDefault(accountId) - .then((userAddress) => { - if (!subscriptionClient) { - throw new Error( - strings('perps.errors.subscriptionClientNotInitialized'), - ); - } + if (!subscriptionClient) { + throw new Error(strings('perps.errors.subscriptionClientNotInitialized')); + } - return subscriptionClient.webData2( - { user: userAddress }, - (data: WsWebData2) => { - if (data.clearinghouseState.assetPositions) { - const positions: Position[] = - data.clearinghouseState.assetPositions - .filter((assetPos) => assetPos.position.szi !== '0') - .map((assetPos) => adaptPositionFromSDK(assetPos)); - - callback(positions); + const userAddress = await this.walletService.getUserAddressWithDefault( + accountId, + ); + + return new Promise((resolve, reject) => { + subscriptionClient + .webData2({ user: userAddress }, (data: WsWebData2) => { + // Extract and process positions with TP/SL data + const positions = data.clearinghouseState.assetPositions + .filter((assetPos) => assetPos.position.szi !== '0') + .map((assetPos) => adaptPositionFromSDK(assetPos)); + + // Extract TP/SL from openOrders for positions + const tpslMap = new Map< + string, + { takeProfitPrice?: string; stopLossPrice?: string } + >(); + + // Also extract regular orders for order subscribers + const orders: Order[] = []; + + (data.openOrders || []).forEach((order) => { + // Process trigger orders for TP/SL + if (order.triggerPx) { + const coin = order.coin; + const position = positions.find((p) => p.coin === coin); + + if (position) { + const existing = tpslMap.get(coin) || {}; + const isLong = parseFloat(position.size) > 0; + + // Determine if it's TP or SL based on order type + if (order.orderType?.includes('Take Profit')) { + existing.takeProfitPrice = order.triggerPx; + } else if (order.orderType?.includes('Stop')) { + existing.stopLossPrice = order.triggerPx; + } else { + // Fallback: determine based on trigger price vs entry price + const triggerPrice = parseFloat(order.triggerPx); + const entryPrice = parseFloat(position.entryPrice || '0'); + + if (isLong) { + if (triggerPrice > entryPrice) { + existing.takeProfitPrice = order.triggerPx; + } else { + existing.stopLossPrice = order.triggerPx; + } + } else if (triggerPrice < entryPrice) { + existing.takeProfitPrice = order.triggerPx; + } else { + existing.stopLossPrice = order.triggerPx; + } + } + + tpslMap.set(coin, existing); } - }, - ); + } + + // Convert ALL open orders to Order format using adapter + // We NO LONGER skip TP/SL orders - they should appear in the orders list too + // TP/SL orders are both: + // 1. Used to populate position TP/SL fields (done above) + // 2. Shown as separate orders in the orders list (done here) + const convertedOrder = adaptOrderFromSDK(order); + orders.push(convertedOrder); + }); + + // Merge positions with TP/SL data, ensuring fields are always present + const positionsWithTPSL = positions.map((position) => { + const tpsl = tpslMap.get(position.coin) || {}; + return { + ...position, + takeProfitPrice: tpsl.takeProfitPrice || undefined, + stopLossPrice: tpsl.stopLossPrice || undefined, + }; + }); + + // Check if positions actually changed + const positionsChanged = + JSON.stringify(positionsWithTPSL) !== + JSON.stringify(this.cachedPositions); + const ordersChanged = + JSON.stringify(orders) !== JSON.stringify(this.cachedOrders); + + // Only update and notify if data actually changed + if (positionsChanged) { + this.cachedPositions = positionsWithTPSL; + this.positionSubscribers.forEach((callback) => { + callback(positionsWithTPSL); + }); + } + + if (ordersChanged) { + this.cachedOrders = orders; + this.orderSubscribers.forEach((callback) => { + callback(orders); + }); + } }) .then((sub) => { - subscription = sub; + this.sharedWebData2Subscription = sub; + DevLogger.log( + 'Shared webData2 subscription established (single connection for positions + orders)', + ); + resolve(); }) .catch((error) => { DevLogger.log( - strings('perps.errors.failedToSubscribePosition'), + 'Failed to establish shared webData2 subscription', error, ); + reject(error instanceof Error ? error : new Error(String(error))); }); + }); + } + + /** + * Clean up shared webData2 subscription when no longer needed + */ + private cleanupSharedWebData2Subscription(): void { + const totalSubscribers = + this.positionSubscriberCount + this.orderSubscriberCount; + + if (totalSubscribers <= 0 && this.sharedWebData2Subscription) { + this.sharedWebData2Subscription.unsubscribe().catch((error: Error) => { + DevLogger.log('Failed to unsubscribe shared webData2', error); + }); + this.sharedWebData2Subscription = undefined; + this.webData2SubscriptionPromise = undefined; + this.positionSubscriberCount = 0; + this.orderSubscriberCount = 0; + this.cachedPositions = []; + this.cachedOrders = []; + DevLogger.log('Shared webData2 subscription cleaned up'); } + } + + /** + * Subscribe to live position updates with TP/SL data + */ + public subscribeToPositions(params: SubscribePositionsParams): () => void { + const { callback, accountId } = params; + const unsubscribe = this.createSubscription( + this.positionSubscribers, + callback, + ); + + // Increment position subscriber count + this.positionSubscriberCount++; + + // Immediately provide cached data if available + if (this.cachedPositions.length > 0) { + callback(this.cachedPositions); + } + + // Ensure shared subscription is active + this.ensureSharedWebData2Subscription(accountId).catch((error) => { + DevLogger.log(strings('perps.errors.failedToSubscribePosition'), error); + }); return () => { unsubscribe(); - - if (subscription) { - subscription.unsubscribe().catch((error: Error) => { - DevLogger.log( - strings('perps.errors.failedToUnsubscribePosition'), - error, - ); - }); - } + this.positionSubscriberCount--; + this.cleanupSharedWebData2Subscription(); }; } @@ -325,6 +486,37 @@ export class HyperLiquidSubscriptionService { }; } + /** + * Subscribe to live order updates + * Uses the shared webData2 subscription to avoid duplicate connections + */ + public subscribeToOrders(params: SubscribeOrdersParams): () => void { + const { callback, accountId } = params; + const unsubscribe = this.createSubscription( + this.orderSubscribers, + callback, + ); + + // Increment order subscriber count + this.orderSubscriberCount++; + + // Immediately provide cached data if available + if (this.cachedOrders.length > 0) { + callback(this.cachedOrders); + } + + // Ensure shared subscription is active + this.ensureSharedWebData2Subscription(accountId).catch((error) => { + DevLogger.log(strings('perps.errors.failedToSubscribeOrders'), error); + }); + + return () => { + unsubscribe(); + this.orderSubscriberCount--; + this.cleanupSharedWebData2Subscription(); + }; + } + /** * Create subscription with common error handling */ @@ -720,6 +912,8 @@ export class HyperLiquidSubscriptionService { this.globalAllMidsSubscription = undefined; this.globalActiveAssetSubscriptions.clear(); this.globalL2BookSubscriptions.clear(); + this.sharedWebData2Subscription = undefined; + this.webData2SubscriptionPromise = undefined; DevLogger.log('HyperLiquid: Subscription service cleared', { timestamp: new Date().toISOString(), diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.ts b/app/components/UI/Perps/services/PerpsConnectionManager.ts index f8fe9d331f8a..fd864f2c71e4 100644 --- a/app/components/UI/Perps/services/PerpsConnectionManager.ts +++ b/app/components/UI/Perps/services/PerpsConnectionManager.ts @@ -1,5 +1,6 @@ import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; import Engine from '../../../../core/Engine'; +import { getStreamManagerInstance } from '../providers/PerpsStreamManager'; /** * Singleton manager for Perps connection state @@ -13,6 +14,8 @@ class PerpsConnectionManagerClass { private isInitialized = false; private connectionRefCount = 0; private initPromise: Promise | null = null; + private hasPreloaded = false; + private prewarmCleanups: (() => void)[] = []; private constructor() { // Private constructor to enforce singleton pattern @@ -69,6 +72,9 @@ class PerpsConnectionManagerClass { this.isConnected = true; this.isConnecting = false; DevLogger.log('PerpsConnectionManager: Successfully connected'); + + // Pre-load positions and orders subscriptions to populate cache + await this.preloadSubscriptions(); } catch (error) { this.isConnecting = false; this.isConnected = false; @@ -98,10 +104,15 @@ class PerpsConnectionManagerClass { DevLogger.log( 'PerpsConnectionManager: Disconnecting (no more references)', ); + + // Clean up preloaded subscriptions + this.cleanupPreloadedSubscriptions(); + // Reset state before disconnecting to prevent race conditions this.isConnected = false; this.isInitialized = false; this.isConnecting = false; + this.hasPreloaded = false; // Reset pre-load flag on disconnect await Engine.context.PerpsController.disconnect(); } catch (error) { @@ -111,6 +122,83 @@ class PerpsConnectionManagerClass { } } + /** + * Pre-load critical WebSocket subscriptions to populate cache + * This ensures positions and orders are available immediately when components mount + * Uses the StreamManager singleton to ensure single WebSocket connections + */ + private async preloadSubscriptions(): Promise { + // Only pre-load once per session + if (this.hasPreloaded) { + DevLogger.log('PerpsConnectionManager: Already pre-loaded, skipping'); + return; + } + + try { + DevLogger.log( + 'PerpsConnectionManager: Pre-loading WebSocket subscriptions via StreamManager', + ); + this.hasPreloaded = true; + + // Get the singleton StreamManager instance + const streamManager = getStreamManagerInstance(); + + // Pre-warm the positions and orders channels + // This creates persistent subscriptions that keep connections alive + // Store cleanup functions to call when leaving Perps + const positionCleanup = streamManager.positions.prewarm(); + const orderCleanup = streamManager.orders.prewarm(); + + this.prewarmCleanups.push(positionCleanup, orderCleanup); + + // Give subscriptions a moment to receive initial data + await new Promise((resolve) => setTimeout(resolve, 100)); + + DevLogger.log( + 'PerpsConnectionManager: Pre-loading complete with persistent subscriptions', + ); + } catch (error) { + DevLogger.log( + 'PerpsConnectionManager: Failed to pre-load subscriptions', + error, + ); + // Non-critical error - components will still work with on-demand subscriptions + } + } + + /** + * Clean up pre-loaded subscriptions + * Called when leaving the Perps environment + */ + private cleanupPreloadedSubscriptions(): void { + if (this.prewarmCleanups.length === 0) { + DevLogger.log( + 'PerpsConnectionManager: No pre-warm subscriptions to cleanup', + ); + return; + } + + DevLogger.log( + `PerpsConnectionManager: Cleaning up ${this.prewarmCleanups.length} pre-warm subscriptions`, + ); + + // Call all cleanup functions + this.prewarmCleanups.forEach((cleanup) => { + try { + cleanup(); + } catch (error) { + DevLogger.log( + 'PerpsConnectionManager: Error during pre-warm cleanup', + error, + ); + } + }); + + // Clear the array + this.prewarmCleanups = []; + DevLogger.log('PerpsConnectionManager: Pre-warm cleanup complete'); + } + getConnectionState() { return { isConnected: this.isConnected, diff --git a/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts b/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts index eca662336516..77183abc8c68 100644 --- a/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts +++ b/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts @@ -4,6 +4,7 @@ import { adaptOrderToSDK, + adaptOrderFromSDK, adaptPositionFromSDK, adaptMarketFromSDK, adaptAccountStateFromSDK, @@ -19,6 +20,7 @@ import type { SpotClearinghouseState, } from '@deeeed/hyperliquid-node20/esm/src/types/info/accounts'; import type { PerpsUniverse } from '@deeeed/hyperliquid-node20/esm/src/types/info/assets'; +import type { FrontendOrder } from '@deeeed/hyperliquid-node20/esm/src/types/info/orders'; import { SpotBalance } from '@deeeed/hyperliquid-node20'; // Mock the isHexString utility @@ -125,6 +127,398 @@ describe('hyperLiquidAdapter', () => { }); }); + describe('adaptOrderFromSDK', () => { + it('should convert basic buy order from SDK', () => { + const frontendOrder: FrontendOrder = { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + triggerCondition: '', + triggerPx: '', + children: [], + isPositionTpsl: false, + tif: null, + cloid: null, + }; + + const result = adaptOrderFromSDK(frontendOrder); + + expect(result).toEqual({ + orderId: '12345', + symbol: 'BTC', + side: 'buy', + orderType: 'limit', + size: '0.5', + originalSize: '1.0', + price: '50000', + filledSize: '0.5', + remainingSize: '0.5', + status: 'open', + timestamp: 1234567890000, + detailedOrderType: 'Limit', + isTrigger: false, + reduceOnly: false, + }); + }); + + it('should convert sell order from SDK', () => { + const frontendOrder: FrontendOrder = { + oid: 54321, + coin: 'ETH', + side: 'A', + sz: '2.0', + origSz: '2.0', + limitPx: '3000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: true, + triggerCondition: '', + triggerPx: '', + children: [], + isPositionTpsl: false, + tif: null, + cloid: null, + }; + + const result = adaptOrderFromSDK(frontendOrder); + + expect(result).toEqual({ + orderId: '54321', + symbol: 'ETH', + side: 'sell', + orderType: 'limit', + size: '2.0', + originalSize: '2.0', + price: '3000', + filledSize: '0', + remainingSize: '2.0', + status: 'open', + timestamp: 1234567890000, + detailedOrderType: 'Limit', + isTrigger: false, + reduceOnly: true, + }); + }); + + it('should handle market order', () => { + const frontendOrder: FrontendOrder = { + oid: 99999, + coin: 'SOL', + side: 'B', + sz: '10', + origSz: '10', + orderType: 'Market', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + triggerCondition: '', + triggerPx: '', + limitPx: '', + children: [], + isPositionTpsl: false, + tif: null, + cloid: null, + }; + + const result = adaptOrderFromSDK(frontendOrder); + + expect(result).toEqual({ + orderId: '99999', + symbol: 'SOL', + side: 'buy', + orderType: 'market', + size: '10', + originalSize: '10', + price: '0', + filledSize: '0', + remainingSize: '10', + status: 'open', + timestamp: 1234567890000, + detailedOrderType: 'Market', + isTrigger: false, + reduceOnly: false, + }); + }); + + it('should handle trigger order with triggerPx', () => { + const frontendOrder: FrontendOrder = { + oid: 11111, + coin: 'AVAX', + side: 'A', + sz: '5', + origSz: '5', + triggerPx: '25.50', + orderType: 'Stop Market', + timestamp: 1234567890000, + isTrigger: true, + reduceOnly: true, + triggerCondition: '', + limitPx: '', + children: [], + isPositionTpsl: false, + tif: null, + cloid: null, + }; + + const result = adaptOrderFromSDK(frontendOrder); + + expect(result).toEqual({ + orderId: '11111', + symbol: 'AVAX', + side: 'sell', + orderType: 'market', + size: '5', + originalSize: '5', + price: '25.50', + filledSize: '0', + remainingSize: '5', + status: 'open', + timestamp: 1234567890000, + detailedOrderType: 'Stop Market', + isTrigger: true, + reduceOnly: true, + }); + }); + + it('should handle order with child orders (TP/SL)', () => { + const frontendOrder: FrontendOrder = { + oid: 22222, + coin: 'UNI', + side: 'B', + sz: '100', + origSz: '100', + limitPx: '10', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + triggerCondition: '', + triggerPx: '', + isPositionTpsl: false, + tif: null, + cloid: null, + children: [ + { + oid: 22223, + coin: 'UNI', + side: 'A', + sz: '100', + origSz: '100', + triggerPx: '12', + orderType: 'Take Profit Market', + timestamp: 1234567890001, + isTrigger: true, + reduceOnly: true, + triggerCondition: '', + limitPx: '', + children: [], + isPositionTpsl: true, + tif: null, + cloid: null, + }, + { + oid: 22224, + coin: 'UNI', + side: 'A', + sz: '100', + origSz: '100', + triggerPx: '8', + orderType: 'Stop Market', // 'Stop Loss' is not a valid OrderType + timestamp: 1234567890002, + isTrigger: true, + reduceOnly: true, + triggerCondition: '', + limitPx: '', + children: [], + isPositionTpsl: true, + tif: null, + cloid: null, + }, + ], + }; + + const result = adaptOrderFromSDK(frontendOrder); + + expect(result).toEqual({ + orderId: '22222', + symbol: 'UNI', + side: 'buy', + orderType: 'limit', + size: '100', + originalSize: '100', + price: '10', + filledSize: '0', + remainingSize: '100', + status: 'open', + timestamp: 1234567890000, + detailedOrderType: 'Limit', + isTrigger: false, + reduceOnly: false, + takeProfitPrice: '12', + stopLossPrice: '8', + }); + }); + + it('should handle partially filled order', () => { + const frontendOrder: FrontendOrder = { + oid: 33333, + coin: 'LINK', + side: 'B', + sz: '30', + origSz: '100', + limitPx: '15', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + triggerCondition: '', + triggerPx: '', + children: [], + isPositionTpsl: false, + tif: null, + cloid: null, + }; + + const result = adaptOrderFromSDK(frontendOrder); + + expect(result).toEqual({ + orderId: '33333', + symbol: 'LINK', + side: 'buy', + orderType: 'limit', + size: '30', + originalSize: '100', + price: '15', + filledSize: '70', // 100 - 30 + remainingSize: '30', + status: 'open', + timestamp: 1234567890000, + detailedOrderType: 'Limit', + isTrigger: false, + reduceOnly: false, + }); + }); + + it('should handle order without origSz', () => { + const frontendOrder: FrontendOrder = { + oid: 44444, + coin: 'MATIC', + side: 'A', + sz: '500', + limitPx: '1.2', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + origSz: '500', + triggerCondition: '', + triggerPx: '', + children: [], + isPositionTpsl: false, + tif: null, + cloid: null, + }; + + const result = adaptOrderFromSDK(frontendOrder); + + expect(result).toEqual({ + orderId: '44444', + symbol: 'MATIC', + side: 'sell', + orderType: 'limit', + size: '500', + originalSize: '500', // Uses sz as default + price: '1.2', + filledSize: '0', + remainingSize: '500', + status: 'open', + timestamp: 1234567890000, + detailedOrderType: 'Limit', + isTrigger: false, + reduceOnly: false, + }); + }); + + it('should determine order type from orderType string', () => { + const frontendOrder: FrontendOrder = { + oid: 55555, + coin: 'DOT', + side: 'B', + sz: '20', + origSz: '20', + orderType: 'Limit', // 'Limit at 5.5' is not a valid OrderType + limitPx: '5.5', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + triggerCondition: '', + triggerPx: '', + children: [], + isPositionTpsl: false, + tif: null, + cloid: null, + }; + + const result = adaptOrderFromSDK(frontendOrder); + + expect(result.orderType).toBe('limit'); + expect(result.price).toBe('5.5'); + }); + + it('should handle child order with limitPx instead of triggerPx', () => { + const frontendOrder: FrontendOrder = { + oid: 66666, + coin: 'ADA', + side: 'B', + sz: '1000', + origSz: '1000', + limitPx: '0.5', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + triggerCondition: '', + triggerPx: '', + isPositionTpsl: false, + tif: null, + cloid: null, + children: [ + { + oid: 66667, + coin: 'ADA', + side: 'A', + sz: '1000', + origSz: '1000', + limitPx: '0.6', // Using limitPx instead of triggerPx + orderType: 'Take Profit Limit', + timestamp: 1234567890001, + isTrigger: true, + reduceOnly: true, + triggerCondition: '', + triggerPx: '', + children: [], + isPositionTpsl: true, + tif: null, + cloid: null, + }, + ], + }; + + const result = adaptOrderFromSDK(frontendOrder); + + expect(result.takeProfitPrice).toBe('0.6'); + expect(result.stopLossPrice).toBeUndefined(); + }); + }); + describe('adaptPositionFromSDK', () => { it('should convert asset position correctly', () => { const assetPosition: AssetPosition = { diff --git a/app/components/UI/Perps/utils/hyperLiquidAdapter.ts b/app/components/UI/Perps/utils/hyperLiquidAdapter.ts index 7dfca72f8045..01272dc3066d 100644 --- a/app/components/UI/Perps/utils/hyperLiquidAdapter.ts +++ b/app/components/UI/Perps/utils/hyperLiquidAdapter.ts @@ -5,10 +5,12 @@ import type { SpotClearinghouseState, } from '@deeeed/hyperliquid-node20/esm/src/types/info/accounts'; import type { PerpsUniverse } from '@deeeed/hyperliquid-node20/esm/src/types/info/assets'; +import type { FrontendOrder } from '@deeeed/hyperliquid-node20/esm/src/types/info/orders'; import { isHexString } from '@metamask/utils'; import type { AccountState, MarketInfo, + Order, OrderParams as PerpsOrderParams, Position, } from '../controllers/types'; @@ -84,6 +86,90 @@ export function adaptPositionFromSDK(assetPosition: AssetPosition): Position { }; } +/** + * Transform HyperLiquid SDK order to MetaMask Perps API format + * Handles both REST API responses (FrontendOrder) and WebSocket data formats + * @param rawOrder - Raw order data from HyperLiquid SDK (frontendOpenOrders or webData2) + * @returns MetaMask Perps API order object + */ +export function adaptOrderFromSDK(rawOrder: FrontendOrder): Order { + // Extract basic fields with appropriate conversions + const orderId = rawOrder.oid.toString(); + const symbol = rawOrder.coin; + + // Convert side: HyperLiquid uses 'B' for Buy and 'A' for Ask (Sell) + const side: 'buy' | 'sell' = rawOrder.side === 'B' ? 'buy' : 'sell'; + + // Get detailed order type from API + const detailedOrderType = rawOrder.orderType; + + // Determine if this is a trigger order (TP/SL) + const isTrigger = rawOrder.isTrigger; + const reduceOnly = rawOrder.reduceOnly; + + // Determine basic order type + let orderType: 'limit' | 'market' = 'market'; + if (detailedOrderType.toLowerCase().includes('limit') || rawOrder.limitPx) { + orderType = 'limit'; + } + + // For trigger orders (TP/SL), use triggerPx as the price + const price = rawOrder.limitPx || rawOrder.triggerPx || '0'; + + // Sizes + const size = rawOrder.sz; + const originalSize = rawOrder.origSz || size; + + // Calculate filled and remaining size + const currentSize = parseFloat(size); + const origSize = parseFloat(originalSize); + const filledSize = origSize - currentSize; + + // Check for TP/SL in child orders (REST API feature) + let takeProfitPrice: string | undefined; + let stopLossPrice: string | undefined; + + if (rawOrder.children && rawOrder.children.length > 0) { + rawOrder.children.forEach((child) => { + if (child.isTrigger && child.orderType) { + if (child.orderType.includes('Take Profit')) { + takeProfitPrice = child.triggerPx || child.limitPx; + } else if (child.orderType.includes('Stop')) { + stopLossPrice = child.triggerPx || child.limitPx; + } + } + }); + } + + // Build the order object + const order: Order = { + orderId, + symbol, + side, + orderType, + size, + originalSize, + price, + filledSize: filledSize.toString(), + remainingSize: size, + status: 'open' as const, // All orders from frontendOpenOrders/webData2 are open + timestamp: rawOrder.timestamp, + detailedOrderType, + isTrigger, + reduceOnly, + }; + + // Add optional fields if they exist + if (takeProfitPrice) { + order.takeProfitPrice = takeProfitPrice; + } + if (stopLossPrice) { + order.stopLossPrice = stopLossPrice; + } + + return order; +} + /** * Transform SDK market info to MetaMask Perps API format * @param sdkMarket - Market metadata from HyperLiquid SDK diff --git a/app/components/UI/Perps/utils/marketDataTransform.test.ts b/app/components/UI/Perps/utils/marketDataTransform.test.ts index 2c1d04c31a70..c93c82a8f10d 100644 --- a/app/components/UI/Perps/utils/marketDataTransform.test.ts +++ b/app/components/UI/Perps/utils/marketDataTransform.test.ts @@ -10,7 +10,11 @@ import { formatVolume, HyperLiquidMarketData, } from './marketDataTransform'; -import { AllMids, PerpsAssetCtx } from '@deeeed/hyperliquid-node20'; +import { + AllMids, + PerpsAssetCtx, + PredictedFunding, +} from '@deeeed/hyperliquid-node20'; // Helper function to create mock asset context with all required properties const createMockAssetCtx = (overrides: Record = {}) => ({ @@ -200,6 +204,97 @@ describe('marketDataTransform', () => { expect(result[0].volume).toBe('$1B'); // Has context expect(result[1].volume).toBe('$0'); // No context }); + + it('handles predicted funding data correctly', () => { + // Arrange + const hyperLiquidData: HyperLiquidMarketData = { + universe: [mockUniverseAsset], + assetCtxs: [mockAssetCtx], + allMids: mockAllMids, + predictedFundings: [ + [ + 'BTC', + [ + [ + 'HyperLiquid', + { + fundingRate: '0.001', + nextFundingTime: 1234567890000, + fundingIntervalHours: 8, + }, + ], + ], + ], + ], + }; + + // Act + const result = transformMarketData(hyperLiquidData); + + // Assert + expect(result[0].nextFundingTime).toBe(1234567890000); + expect(result[0].fundingIntervalHours).toBe(8); + }); + + it('handles malformed funding data without crashing', () => { + // Arrange - Test various edge cases that could cause destructuring errors + const testCases = [ + // Case 1: fundingData[1][0] is not an array + { + predictedFundings: [['BTC', ['not-an-array']]], + }, + // Case 2: fundingData[1][0] is an array with less than 2 elements + { + predictedFundings: [['BTC', [['HyperLiquid']]]], + }, + // Case 3: fundingData[1][0] is null + { + predictedFundings: [['BTC', [null]]], + }, + // Case 4: fundingData[1] is empty array + { + predictedFundings: [['BTC', []]], + }, + // Case 5: fundingData[1] is undefined + { + predictedFundings: [['BTC', undefined]], + }, + ]; + + testCases.forEach((testCase) => { + const hyperLiquidData: HyperLiquidMarketData = { + universe: [mockUniverseAsset], + assetCtxs: [mockAssetCtx], + allMids: mockAllMids, + predictedFundings: testCase.predictedFundings as PredictedFunding[], + }; + + // Act & Assert - should not throw + expect(() => { + const result = transformMarketData(hyperLiquidData); + // Should return result without funding data + expect(result[0].nextFundingTime).toBeUndefined(); + expect(result[0].fundingIntervalHours).toBeUndefined(); + }).not.toThrow(); + }); + }); + + it('handles missing predicted funding gracefully', () => { + // Arrange + const hyperLiquidData: HyperLiquidMarketData = { + universe: [mockUniverseAsset], + assetCtxs: [mockAssetCtx], + allMids: mockAllMids, + // No predictedFundings field + }; + + // Act + const result = transformMarketData(hyperLiquidData); + + // Assert + expect(result[0].nextFundingTime).toBeUndefined(); + expect(result[0].fundingIntervalHours).toBeUndefined(); + }); }); describe('formatPrice', () => { diff --git a/app/components/UI/Perps/utils/marketDataTransform.ts b/app/components/UI/Perps/utils/marketDataTransform.ts index d8d90d2a77be..9a7585d13f15 100644 --- a/app/components/UI/Perps/utils/marketDataTransform.ts +++ b/app/components/UI/Perps/utils/marketDataTransform.ts @@ -3,6 +3,7 @@ import type { PerpsUniverse, PerpsAssetCtx, AllMids, + PredictedFunding, } from '@deeeed/hyperliquid-node20'; /** @@ -12,6 +13,7 @@ export interface HyperLiquidMarketData { universe: PerpsUniverse[]; assetCtxs: PerpsAssetCtx[]; allMids: AllMids; + predictedFundings?: PredictedFunding[]; } /** @@ -22,7 +24,7 @@ export interface HyperLiquidMarketData { export function transformMarketData( hyperLiquidData: HyperLiquidMarketData, ): PerpsMarketData[] { - const { universe, assetCtxs, allMids } = hyperLiquidData; + const { universe, assetCtxs, allMids, predictedFundings } = hyperLiquidData; return universe.map((asset) => { const symbol = asset.name; @@ -57,6 +59,30 @@ export function transformMarketData( // Format volume (dayNtlVlm is daily notional volume) const volume = assetCtx ? parseFloat(assetCtx.dayNtlVlm) : 0; + // Extract funding time data if available + let nextFundingTime: number | undefined; + let fundingIntervalHours: number | undefined; + + if (predictedFundings) { + // Find the funding data for this specific symbol + const fundingData = predictedFundings.find( + ([assetSymbol]) => assetSymbol === symbol, + ); + // eslint-disable-next-line @typescript-eslint/prefer-optional-chain + if (fundingData && fundingData[1] && fundingData[1].length > 0) { + // Get the first exchange's funding data (usually HyperLiquid itself) + // Safely check if the first element is an array with at least 2 elements + const firstExchange = fundingData[1][0]; + if (Array.isArray(firstExchange) && firstExchange.length >= 2) { + const exchangeData = firstExchange[1]; + if (exchangeData) { + nextFundingTime = exchangeData.nextFundingTime; + fundingIntervalHours = exchangeData.fundingIntervalHours; + } + } + } + } + return { symbol, name: symbol, // HyperLiquid uses symbol as name @@ -67,6 +93,8 @@ export function transformMarketData( ? '0.00%' : formatPercentage(change24hPercent), volume: isNaN(volume) ? '$0' : formatVolume(volume), + nextFundingTime, + fundingIntervalHours, }; }); } diff --git a/app/components/UI/Perps/utils/marketUtils.test.ts b/app/components/UI/Perps/utils/marketUtils.test.ts index 89dd11cf2ea7..b4c39eefd9e2 100644 --- a/app/components/UI/Perps/utils/marketUtils.test.ts +++ b/app/components/UI/Perps/utils/marketUtils.test.ts @@ -65,6 +65,91 @@ describe('marketUtils', () => { const result = calculateFundingCountdown(); expect(result).toBe('00:01:05'); // 1 minute 5 seconds until 8:00 }); + + it('should use specific next funding time when provided', () => { + const mockDate = new Date('2024-01-01T12:00:00.000Z'); + jest.setSystemTime(mockDate); + + // Next funding time is in 2 hours 30 minutes + const nextFundingTime = mockDate.getTime() + (2 * 60 + 30) * 60 * 1000; + + const result = calculateFundingCountdown({ nextFundingTime }); + expect(result).toBe('02:30:00'); + }); + + it('should use specific next funding time with seconds', () => { + const mockDate = new Date('2024-01-01T12:00:00.000Z'); + jest.setSystemTime(mockDate); + + // Next funding time is in 1 hour 15 minutes 45 seconds + const nextFundingTime = + mockDate.getTime() + (1 * 60 * 60 + 15 * 60 + 45) * 1000; + + const result = calculateFundingCountdown({ nextFundingTime }); + expect(result).toBe('01:15:45'); + }); + + it('should handle expired specific funding time', () => { + const mockDate = new Date('2024-01-01T12:00:00.000Z'); + jest.setSystemTime(mockDate); + + // Next funding time is in the past + const nextFundingTime = mockDate.getTime() - 1000; + + const result = calculateFundingCountdown({ nextFundingTime }); + // Falls back to default calculation when specific time is expired + expect(result).toBe('04:00:00'); // 4 hours until 16:00 + }); + + it('should handle edge case at 59 seconds', () => { + // Set time to 07:59:01 UTC (59 seconds before funding) + const mockDate = new Date('2024-01-01T07:59:01.000Z'); + jest.setSystemTime(mockDate); + + const result = calculateFundingCountdown(); + expect(result).toBe('00:00:59'); + }); + + it('should handle edge case with 60 seconds exactly', () => { + // Set time to 07:59:00 UTC (60 seconds before funding) + const mockDate = new Date('2024-01-01T07:59:00.000Z'); + jest.setSystemTime(mockDate); + + const result = calculateFundingCountdown(); + expect(result).toBe('00:01:00'); + }); + + it('should handle different UTC hours correctly', () => { + // Test each hour of the day + const testCases = [ + { hour: 0, expected: '08:00:00' }, // 00:00 -> 08:00 + { hour: 4, expected: '04:00:00' }, // 04:00 -> 08:00 + { hour: 8, expected: '08:00:00' }, // 08:00 -> 16:00 + { hour: 12, expected: '04:00:00' }, // 12:00 -> 16:00 + { hour: 16, expected: '08:00:00' }, // 16:00 -> 00:00 (next day) + { hour: 20, expected: '04:00:00' }, // 20:00 -> 00:00 (next day) + ]; + + testCases.forEach(({ hour, expected }) => { + const mockDate = new Date( + `2024-01-01T${hour.toString().padStart(2, '0')}:00:00.000Z`, + ); + jest.setSystemTime(mockDate); + + const result = calculateFundingCountdown(); + expect(result).toBe(expected); + }); + }); + + it('should handle time with custom funding interval', () => { + const mockDate = new Date('2024-01-01T10:00:00.000Z'); + jest.setSystemTime(mockDate); + + // Custom 4 hour funding interval (not used in default calculation) + const result = calculateFundingCountdown({ fundingIntervalHours: 4 }); + // Still uses default calculation when no nextFundingTime provided + expect(result).toBe('06:00:00'); // 6 hours until 16:00 + }); }); describe('calculate24hHighLow', () => { diff --git a/app/components/UI/Perps/utils/marketUtils.ts b/app/components/UI/Perps/utils/marketUtils.ts index 4dbaa08a49df..f78762016b03 100644 --- a/app/components/UI/Perps/utils/marketUtils.ts +++ b/app/components/UI/Perps/utils/marketUtils.ts @@ -1,11 +1,46 @@ import type { CandleData, CandleStick } from '../types'; +interface FundingCountdownParams { + /** + * Next funding time in milliseconds since epoch (optional, market-specific) + */ + nextFundingTime?: number; + /** + * Funding interval in hours (optional, market-specific) + * Default is 8 hours for HyperLiquid + */ + fundingIntervalHours?: number; +} + /** * Calculate the time until the next funding period - * HyperLiquid has 8-hour funding periods at 00:00, 08:00, and 16:00 UTC + * Supports market-specific funding times when provided + * Falls back to default HyperLiquid 8-hour periods at 00:00, 08:00, and 16:00 UTC */ -export const calculateFundingCountdown = (): string => { +export const calculateFundingCountdown = ( + params?: FundingCountdownParams, +): string => { const now = new Date(); + const nowMs = now.getTime(); + + // If we have a specific next funding time, use it + if (params?.nextFundingTime && params.nextFundingTime > nowMs) { + const msUntilFunding = params.nextFundingTime - nowMs; + const totalSeconds = Math.floor(msUntilFunding / 1000); + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + // Format as HH:MM:SS + const formattedHours = String(hours).padStart(2, '0'); + const formattedMinutes = String(minutes).padStart(2, '0'); + const formattedSeconds = String(seconds).padStart(2, '0'); + + return `${formattedHours}:${formattedMinutes}:${formattedSeconds}`; + } + + // Fall back to default calculation for HyperLiquid (8-hour periods) const utcHour = now.getUTCHours(); const utcMinutes = now.getUTCMinutes(); const utcSeconds = now.getUTCSeconds(); diff --git a/e2e/api-mocking/default-mocks.js b/e2e/api-mocking/default-mocks.js new file mode 100644 index 000000000000..6a58515d0c59 --- /dev/null +++ b/e2e/api-mocking/default-mocks.js @@ -0,0 +1,380 @@ +/** + * Default mock responses for common MetaMask mobile endpoints + * These are used as fallbacks when no specific mock is provided + */ + +import { getAuthMocks } from './mock-responses/auth-mocks'; +import { SWAPS_FEATURE_FLAG_RESPONSE } from './mock-responses/feature-flags-mocks'; +import { + ACCOUNTS_API_ACTIVE_NETWORKS_RESPONSE, + ACCOUNTS_API_TRANSACTIONS_RESPONSE, + ACTIVE_NETWORKS_RESPONSE, +} from './mock-responses/accounts-api-responses'; +import { + POOLED_STAKING_VAULT_RESPONSE, + STAKING_API_LENDING_RESPONSE, +} from './mock-responses/staking-api-responses-mocks'; +import { TOKEN_API_TOKENS_RESPONSE } from './mock-responses/token-api-responses'; + +// Get auth mocks +const authMocks = getAuthMocks(); + +export const DEFAULT_MOCKS = { + GET: [ + // Auth mocks + ...authMocks.GET, + { + urlEndpoint: + 'https://dapp-scanning.api.cx.metamask.io/v2/scan?url=www.google.com', + responseCode: 200, + response: { + domainName: 'google.com', + recommendedAction: 'NONE', + }, + }, + { + urlEndpoint: + 'https://dapp-scanning.api.cx.metamask.io/v2/scan?url=google.com', + responseCode: 200, + response: { + domainName: 'google.com', + recommendedAction: 'NONE', + }, + }, + { + urlEndpoint: + 'https://dapp-scanning.api.cx.metamask.io/v2/scan?url=localhost', + responseCode: 200, + response: { + domainName: 'localhost', + recommendedAction: 'NONE', + }, + }, + { + urlEndpoint: + 'https://dapp-scanning.api.cx.metamask.io/v2/scan?url=verify.walletconnect.com', + responseCode: 200, + response: { + domainName: 'verify.walletconnect.com', + recommendedAction: 'NONE', + }, + }, + { + urlEndpoint: + 'https://dapp-scanning.api.cx.metamask.io/v2/scan?url=portfolio.metamask.io', + responseCode: 200, + response: { + domainName: 'portfolio.metamask.io', + recommendedAction: 'NONE', + }, + }, + { + urlEndpoint: + 'https://min-api.cryptocompare.com/data/pricemulti?fsyms=usd&tsyms=usd', + responseCode: 200, + response: { + USD: { + USD: 1, + }, + }, + }, + { + urlEndpoint: + 'https://min-api.cryptocompare.com/data/pricemulti?fsyms=ETH&tsyms=usd', + responseCode: 200, + response: { + ETH: { + USD: 3807.92, + }, + }, + }, + { + urlEndpoint: 'https://security-alerts.api.cx.metamask.io/validate/0x539', + responseCode: 200, + response: { + block: null, + result_type: 'Benign', + reason: '', + description: '', + features: [], + }, + }, + { + urlEndpoint: + 'https://user-storage.api.cx.metamask.io/api/v1/userstorage/addressBook', + responseCode: 200, + response: [], + }, + { + urlEndpoint: + 'https://authentication.api.cx.metamask.io/api/v2/nonce?identifier=0x030b4cfd21a0a0aca69b038e6d268f8eb83a8ea43610aabcd4ff6a19d13e0d10ba', + responseCode: 200, + response: { + nonce: 'gxTzW7WWhXSLlbCg', + identifier: + '0x030b4cfd21a0a0aca69b038e6d268f8eb83a8ea43610aabcd4ff6a19d13e0d10ba', + expires_in: 300, + }, + }, + { + urlEndpoint: + 'https://authentication.api.cx.metamask.io/api/v2/nonce?identifier=0x0306d1490b98c04a4265247ef4a4337e84be3430221a2156804bab2387bb1169b8', + responseCode: 200, + response: { + nonce: '9kFZyPPFkud2z4Ug', + identifier: + '0x0306d1490b98c04a4265247ef4a4337e84be3430221a2156804bab2387bb1169b8', + expires_in: 300, + }, + }, + { + urlEndpoint: + 'https://api.web3auth.io/fnd-service/node-details?network=sapphire_mainnet&verifier=auth-connection-id&verifierId=user-id&keyType=secp256k1&sigType=ecdsa-secp256k1', + responseCode: 200, + response: { + nodeDetails: { + currentEpoch: '1', + torusNodeEndpoints: [ + 'https://node-1.node.web3auth.io/sss/jrpc', + 'https://node-2.node.web3auth.io/sss/jrpc', + 'https://node-3.node.web3auth.io/sss/jrpc', + 'https://node-4.node.web3auth.io/sss/jrpc', + 'https://node-5.node.web3auth.io/sss/jrpc', + ], + torusNodeSSSEndpoints: [ + 'https://node-1.node.web3auth.io/sss/jrpc', + 'https://node-2.node.web3auth.io/sss/jrpc', + 'https://node-3.node.web3auth.io/sss/jrpc', + 'https://node-4.node.web3auth.io/sss/jrpc', + 'https://node-5.node.web3auth.io/sss/jrpc', + ], + torusNodeRSSEndpoints: [ + 'https://node-1.node.web3auth.io/rss', + 'https://node-2.node.web3auth.io/rss', + 'https://node-3.node.web3auth.io/rss', + 'https://node-4.node.web3auth.io/rss', + 'https://node-5.node.web3auth.io/rss', + ], + torusNodeTSSEndpoints: [ + 'https://node-1.node.web3auth.io/tss', + 'https://node-2.node.web3auth.io/tss', + 'https://node-3.node.web3auth.io/tss', + 'https://node-4.node.web3auth.io/tss', + 'https://node-5.node.web3auth.io/tss', + ], + torusIndexes: [1, 2, 3, 4, 5], + torusNodePub: [ + { + X: 'e0925898fee0e9e941fdca7ee88deec99939ae9407e923535c4d4a3a3ff8b052', + Y: '54b9fea924e3f3e40791f9987f4234ae4222412d65b74068032fa5d8b63375c1', + }, + { + X: '9124cf1e280aab32ba50dffd2de81cecabc13d82d2c1fe9de82f3b3523f9b637', + Y: 'fca939a1ceb42ce745c55b21ef094f543b457630cb63a94ef4f1afeee2b1f107', + }, + { + X: '555f681a63d469cc6c3a58a97e29ebd277425f0e6159708e7c7bf05f18f89476', + Y: '606f2bcc0884fa5b64366fc3e8362e4939841b56acd60d5f4553cf36b891ac4e', + }, + { + X: '2b5f58d8e340f1ab922e89b3a69a68930edfe51364644a456335e179bc130128', + Y: '4b4daa05939426e3cbe7d08f0e773d2bf36f64c00d04620ee6df2a7af4d2247', + }, + { + X: '3ecbb6a68afe72cf34ec6c0a12b5cb78a0d2e83ba402983b6adbc5f36219861a', + Y: 'dc1031c5cc8f0472bd521a62a64ebca9e163902c247bf05937daf4ae835091e4', + }, + ], + }, + success: true, + }, + }, + { + urlEndpoint: 'https://accounts.api.cx.metamask.io/v1/supportedNetworks', + responseCode: 200, + response: { + fullSupport: [1, 137, 56, 59144, 8453, 10, 42161, 534352, 1329], + partialSupport: { balances: [42220, 43114] }, + }, + }, + { + urlEndpoint: 'https://swap.dev-api.cx.metamask.io/featureFlags', + responseCode: 200, + response: SWAPS_FEATURE_FLAG_RESPONSE, + }, + { + urlEndpoint: 'https://swap.api.cx.metamask.io/featureFlags', + responseCode: 200, + response: SWAPS_FEATURE_FLAG_RESPONSE, + }, + { + urlEndpoint: + 'https://accounts.api.cx.metamask.io/v2/activeNetworks?accountIds=eip155%3A0%3A0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + responseCode: 200, + response: ACCOUNTS_API_ACTIVE_NETWORKS_RESPONSE, + }, + { + urlEndpoint: + 'https://accounts.api.cx.metamask.io/v2/accounts/0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3/balances?networks=1', + responseCode: 200, + response: { + count: 1, + balances: [ + { + object: 'token', + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + type: 'native', + timestamp: '2015-07-30T15:26:13.000Z', + decimals: 18, + chainId: 1, + balance: '0.000000000000000000', + }, + ], + unprocessedNetworks: [], + }, + }, + { + urlEndpoint: + 'https://accounts.api.cx.metamask.io/v2/accounts/0xaa4179e7f103701e904d27df223a39aa9c27405a/balances?networks=1%2C59144%2C8453%2C42161%2C56%2C10%2C137', + responseCode: 200, + response: { count: 0, balances: [], unprocessedNetworks: [] }, + }, + { + urlEndpoint: + 'https://accounts.api.cx.metamask.io/v1/accounts/0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3/transactions?networks=0x1,0x89,0x38,0xe708,0x2105,0xa,0xa4b1,0x82750,0x531&sortDirection=DESC', + responseCode: 200, + response: ACCOUNTS_API_TRANSACTIONS_RESPONSE, + }, + { + urlEndpoint: + 'https://staking.api.cx.metamask.io/v1/pooled-staking/vault/1/apys?days=365&order=desc', + responseCode: 200, + response: POOLED_STAKING_VAULT_RESPONSE, + }, + { + urlEndpoint: 'https://staking.api.cx.metamask.io/v1/lending/markets', + responseCode: 200, + response: STAKING_API_LENDING_RESPONSE, + }, + { + urlEndpoint: + 'https://staking.api.cx.metamask.io/v1/pooled-staking/eligibility?addresses=0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + responseCode: 200, + response: { eligible: true }, + }, + { + urlEndpoint: + 'https://staking.api.cx.metamask.io/v1/lending/positions/0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + responseCode: 200, + response: { positions: [] }, + }, + { + urlEndpoint: + 'https://staking.api.cx.metamask.io/v1/pooled-staking/vault/1', + responseCode: 200, + response: { + apy: '2.423922825407589424778761061946903', + capacity: + '115792089237316195423570985008687907853269984665640564039457584007913129639935', + feePercent: 1500, + totalAssets: '34582364608391084442226', + vaultAddress: '0x4fef9d741011476750a243ac70b9789a63dd47df', + }, + }, + { + urlEndpoint: + 'https://staking.api.cx.metamask.io/v1/pooled-staking/vault/1/apys/averages', + responseCode: 200, + response: { + oneDay: '2.160630689308144746', + oneWeek: '2.42203859587349324429', + oneMonth: '2.49056583176788989407', + threeMonths: '2.52669759044094515534', + sixMonths: '2.65550671135719381941', + oneYear: '2.6612714152041561994', + }, + }, + { + urlEndpoint: + 'https://staking.api.cx.metamask.io/v1/pooled-staking/stakes/1?accounts=0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + responseCode: 200, + response: { + accounts: [ + { + account: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + lifetimeRewards: '0', + assets: '0', + exitRequests: [], + }, + ], + exchangeRate: '1.034162108591709262', + }, + }, + { + urlEndpoint: 'https://on-ramp.api.cx.metamask.io/geolocation', + responseCode: 200, + response: 'US', + }, + { + urlEndpoint: + 'https://token.api.cx.metamask.io/tokens/1337?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false', + responseCode: 200, + response: TOKEN_API_TOKENS_RESPONSE, + }, + { + urlEndpoint: + 'https://defiadapters.api.cx.metamask.io/positions/0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + responseCode: 200, + response: { + data: [], + }, + }, + { + urlEndpoint: + 'https://dweb.link/ipfs/Qmaisz6NMhDB51cCvNWa1GMS7LU1pAxdF4Ld6Ft9kZEP2a#x-ipfs-companion-no-redirect', + responseCode: 200, + response: 'Hello from IPFS Gateway Checker', + }, + ], + POST: [ + // Auth mocks + ...authMocks.POST, + { + urlEndpoint: 'https://api.segment.io/v1/track', + responseCode: 200, + response: { + success: true, + }, + }, + { + urlEndpoint: 'https://api.mixpanel.com/track', + responseCode: 200, + response: { + status: 1, + }, + }, + { + urlEndpoint: 'https://token-api.metaswap.codefi.network/tokens', + responseCode: 200, + response: [], + }, + { + urlEndpoint: 'https://security-alerts.api.cx.metamask.io/validate', + responseCode: 200, + response: { + flagAsDangerous: 0, + }, + }, + { + urlEndpoint: + 'https://pulse.walletconnect.org/batch?projectId=017a80231854c3b1c56df7bb46bba859&st=events_sdk&sv=js-2.19.2&sp=desktop', + responseCode: 200, + response: {}, + }, + ], + PUT: [], + DELETE: [], + PATCH: [], +}; diff --git a/e2e/api-mocking/mock-e2e-allowlist.js b/e2e/api-mocking/mock-e2e-allowlist.js index b67cb2a04995..165ecad4d9c6 100644 --- a/e2e/api-mocking/mock-e2e-allowlist.js +++ b/e2e/api-mocking/mock-e2e-allowlist.js @@ -2,27 +2,28 @@ // This list is temporary and the goal is to reduce it to 0, meaning all requests are mocked in our e2e tests. export const ALLOWLISTED_HOSTS = [ - 'localhost', + '0.0.0.0', '127.0.0.1', + 'localhost', '10.0.2.2', // Android emulator host 'api.tenderly.co', 'rpc.tenderly.co', + 'virtual.mainnet.rpc.tenderly.co', ]; export const ALLOWLISTED_URLS = [ // Temporarily allow existing live requests during migration 'https://client-config.api.cx.metamask.io/v1/flags?client=mobile&distribution=main&environment=dev', 'https://staking.api.cx.metamask.io/v1/lending/1/markets', - 'https://staking.api.cx.metamask.io/v1/pooled-staking/stakes/1?accounts=0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', - 'https://staking.api.cx.metamask.io/v1/pooled-staking/eligibility?addresses=0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', - 'https://staking.api.cx.metamask.io/v1/pooled-staking/vault/1', - 'https://staking.api.cx.metamask.io/v1/lending/markets', - 'https://staking.api.cx.metamask.io/v1/pooled-staking/vault/1/apys?days=365&order=desc', - 'https://staking.api.cx.metamask.io/v1/pooled-staking/vault/1/apys/averages', - 'https://staking.api.cx.metamask.io/v1/lending/positions/0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', - 'https://mainnet.infura.io/v3/8f4bc0ed77aa4a2c886a4d929754f414', + 'https://staking.api.cx.metamask.io/v1/lending/positions/CEQ87PmqFPA8cajAXYVrFT2FQobRrAT4Wd53FvfgYrrd', 'https://min-api.cryptocompare.com/data/pricemulti?fsyms=btc%2Csol&tsyms=usd', 'https://pulse.walletconnect.org/batch?projectId=e698cc28a9e75eb175ae3c991ac7eb2a&st=events_sdk&sv=js-2.19.2&sp=desktop', 'https://clients3.google.com/generate_204', + 'https://api.avax.network/ext/bc/C/rpc', 'https://security-alerts.api.cx.metamask.io/validate/0x539', + 'https://token.api.cx.metamask.io/tokens/1?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false', + // this should be fixed in code to remove the double slash before transactions, mock without double slash already in the defaults + 'https://accounts.api.cx.metamask.io/v1/accounts/0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3//transactions?networks=0x1,0x89,0x38,0xe708,0x2105,0xa,0xa4b1,0x82750,0x531&sortDirection=DESC', + // this should be fixed in code to remove the double slash before balances, mock without double slash already in the default mocks + 'https://accounts.api.cx.metamask.io/v2/accounts//balances?networks=1', ]; diff --git a/e2e/api-mocking/mock-responses/accounts-api-responses.ts b/e2e/api-mocking/mock-responses/accounts-api-responses.ts new file mode 100644 index 000000000000..9f32c9962ed0 --- /dev/null +++ b/e2e/api-mocking/mock-responses/accounts-api-responses.ts @@ -0,0 +1,1208 @@ +export const ACCOUNTS_API_TRANSACTIONS_RESPONSE = { + data: [ + { + hash: '0x027c29d4b64ae91d6c2cc6f72a5b1885e5ab17ef1b6602452448043e13f6770d', + timestamp: '2025-07-26T19:31:31.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138979757, + blockHash: + '0x2c8759771b01cb297c89dfa7b3cdd816848a9a88fb3bdbc810c7eaf9b3275609', + gas: 5000000, + gasUsed: 2131592, + gasPrice: '1000933', + effectiveGasPrice: '1000933', + nonce: 3130, + cumulativeGasUsed: 7434815, + methodId: '0x441ff998', + value: '0', + to: '0xfd59cd78fa951c750ef5d129390e16807304264a', + from: '0x085cc749b6f9334fd94abdc473abefdbc790f057', + isError: false, + valueTransfers: [ + { + from: '0x085cc749b6f9334fd94abdc473abefdbc790f057', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0xfd59cd78fa951c750ef5d129390e16807304264a', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x33e808a4107cf537b858c67a4b267ddf4e1e08fc6ca0476ee585a7855587b1f5', + timestamp: '2025-07-26T19:31:30.000Z', + chainId: 137, + accountId: 'eip155:137:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 74444312, + blockHash: + '0x1ce60ca3603baae547a5f47beaf0d67117d462e978e55ef9e28f5aba01927491', + gas: 816820, + gasUsed: 809230, + gasPrice: '28600000085', + effectiveGasPrice: '28600000085', + nonce: 4557, + cumulativeGasUsed: 14287546, + methodId: '0xa06c1a33', + value: '0', + to: '0x5ca2ad27e80a4d1fc814b12afc0478be2b111ae0', + from: '0xf9caf760edc7870f0e6dc1fa87b560e447aabc97', + isError: false, + valueTransfers: [ + { + from: '0xe7804c37c13166ff0b37f5ae0bb07a3aebb6e245', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: '1', + contractAddress: '0x5ca2ad27e80a4d1fc814b12afc0478be2b111ae0', + symbol: 'Swap your Voucher on ethers.bio.link', + name: '✅ ETHERS VOUCHER', + transferType: 'erc20', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x1659dd108b18824828631d4dcb501fdd961d45a0b479b9b4c71b88746c775a3e', + timestamp: '2025-07-26T19:31:27.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138979755, + blockHash: + '0x287f0caa3a603f67b08e2b0fd6cb26b014d209b7fe93a51e9333a43f0efca8d9', + gas: 1019124, + gasUsed: 1009941, + gasPrice: '1211023', + effectiveGasPrice: '1211023', + nonce: 9041, + cumulativeGasUsed: 2275038, + methodId: '0xa06c1a33', + value: '0', + to: '0xd9d2cb4ee2388c1b453f572f9ccbd4419d73c8c8', + from: '0x3e26c186419b52d70f77d8ffb8eb2b1615adcd2d', + isError: false, + valueTransfers: [ + { + from: '0x3e26c186419b52d70f77d8ffb8eb2b1615adcd2d', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0xd9d2cb4ee2388c1b453f572f9ccbd4419d73c8c8', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x5dec791534a92a4a170d193f5d9e77e209c574f36e4de36b47fac8f276a5ac57', + timestamp: '2025-07-26T19:31:25.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138979754, + blockHash: + '0xf2f146ecebe791b7ad372291b0e97df63e10428ff155fd91480f3f593b05622c', + gas: 5000000, + gasUsed: 2131592, + gasPrice: '1000932', + effectiveGasPrice: '1000932', + nonce: 3129, + cumulativeGasUsed: 9392520, + methodId: '0x441ff998', + value: '0', + to: '0xfd59cd78fa951c750ef5d129390e16807304264a', + from: '0x085cc749b6f9334fd94abdc473abefdbc790f057', + isError: false, + valueTransfers: [ + { + from: '0x085cc749b6f9334fd94abdc473abefdbc790f057', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0xfd59cd78fa951c750ef5d129390e16807304264a', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x155b33f5a84c27b1af5d3a712d67f1bec2561d3bd792a1928cc0086e0561cec1', + timestamp: '2025-07-26T19:31:21.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138979752, + blockHash: + '0xb0f384961a8d309c2f376138038dfc759a569d315b5465a7d92fc462aa14fcc0', + gas: 5000000, + gasUsed: 2131592, + gasPrice: '1000929', + effectiveGasPrice: '1000929', + nonce: 3128, + cumulativeGasUsed: 7584613, + methodId: '0x441ff998', + value: '0', + to: '0xfd59cd78fa951c750ef5d129390e16807304264a', + from: '0x085cc749b6f9334fd94abdc473abefdbc790f057', + isError: false, + valueTransfers: [ + { + from: '0x085cc749b6f9334fd94abdc473abefdbc790f057', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0xfd59cd78fa951c750ef5d129390e16807304264a', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x527290a4001ab0dc13338af016c83dbf61c3e200b29d984187aff5149aa1f0fc', + timestamp: '2025-07-26T19:31:17.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138979750, + blockHash: + '0x73d83bb2e7ec4b1da7a6acf13d768aafe75a648fa2149fed204a2e0b87936728', + gas: 4699788, + gasUsed: 4439587, + gasPrice: '1172', + effectiveGasPrice: '1172', + nonce: 46241, + cumulativeGasUsed: 19957046, + methodId: '0x729ad39e', + value: '0', + to: '0xef1cee46176605fe90519981db858d01d1c4745f', + from: '0xc070ccd5c2c4d824f0c1cf50675b8ac4feb4e0e2', + isError: false, + valueTransfers: [ + { + from: '0xef1cee46176605fe90519981db858d01d1c4745f', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0xef1cee46176605fe90519981db858d01d1c4745f', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0xcdd24e1414c8fa3fb6e8148497b96cd476020bd0ee90dfc31aca5126820e8fa5', + timestamp: '2025-07-26T19:30:47.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 23005418, + blockHash: + '0x47fe85c31e198367d060512b5bf85b446f46c58a04fb9b7760f6fb97716dc4ca', + gas: 200000, + gasUsed: 61418, + gasPrice: '248473479', + effectiveGasPrice: '248473479', + nonce: 597, + cumulativeGasUsed: 18106189, + methodId: '0xa9059cbb', + value: '0', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + from: '0x8b4ea4f8a25ed1b7b18044bb1a2bec9425603572', + isError: false, + valueTransfers: [ + { + from: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + to: '0x8b4ea4f8a25ed1b7b18044bb1a2bec9425603572', + amount: '120000', + decimal: 6, + contractAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + name: 'USD Coin', + transferType: 'erc20', + }, + ], + logs: [], + transactionProtocol: 'ERC_20', + transactionCategory: 'TRANSFER', + transactionType: 'ERC_20_TRANSFER', + readable: 'Token: Transfer', + }, + { + hash: '0x942a03ff7599f739e00a19c991130a0b4bebfebcfd24b31b5a007e6047272166', + timestamp: '2025-07-23T23:54:55.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138858059, + blockHash: + '0xcb47e8545ec8432a30ef70b659d6de0ac4d48471356a58b3e35d433a7aa09702', + gas: 5000000, + gasUsed: 2368988, + gasPrice: '1028339', + effectiveGasPrice: '1028339', + nonce: 288, + cumulativeGasUsed: 15167965, + methodId: '0x441ff998', + value: '0', + to: '0x74f59dfa8d9e8ef9dfca86c6a5e7e617c26f52ba', + from: '0xe2def864f6def93144144b8f7a1d29de46ba7e56', + isError: false, + valueTransfers: [ + { + from: '0xe2def864f6def93144144b8f7a1d29de46ba7e56', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0x74f59dfa8d9e8ef9dfca86c6a5e7e617c26f52ba', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x18e6db5a5f2ce5af3f37dbe9d6fb055e03d5e1188f3ad9df8fd934d80e9f7cdd', + timestamp: '2025-07-23T23:54:51.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138858057, + blockHash: + '0xd7959aad3a786095de7ede87a951214281e2f62bc62081114938cba5ce1567d7', + gas: 5000000, + gasUsed: 2368988, + gasPrice: '1028409', + effectiveGasPrice: '1028409', + nonce: 287, + cumulativeGasUsed: 3267144, + methodId: '0x441ff998', + value: '0', + to: '0x74f59dfa8d9e8ef9dfca86c6a5e7e617c26f52ba', + from: '0xe2def864f6def93144144b8f7a1d29de46ba7e56', + isError: false, + valueTransfers: [ + { + from: '0xe2def864f6def93144144b8f7a1d29de46ba7e56', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0x74f59dfa8d9e8ef9dfca86c6a5e7e617c26f52ba', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0xa3d438d71085b354cf2dfd65beef373f616b688f3110d3275201a2e97ceb313d', + timestamp: '2025-07-23T23:54:47.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138858055, + blockHash: + '0x03defe95d5d4b8a57062dd2436e1f23bb2fb9a23fccbf31c0bd23f898d880dc8', + gas: 5000000, + gasUsed: 2368988, + gasPrice: '1028429', + effectiveGasPrice: '1028429', + nonce: 286, + cumulativeGasUsed: 3130984, + methodId: '0x441ff998', + value: '0', + to: '0x74f59dfa8d9e8ef9dfca86c6a5e7e617c26f52ba', + from: '0xe2def864f6def93144144b8f7a1d29de46ba7e56', + isError: false, + valueTransfers: [ + { + from: '0xe2def864f6def93144144b8f7a1d29de46ba7e56', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0x74f59dfa8d9e8ef9dfca86c6a5e7e617c26f52ba', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x7e337b9135cc9408acdd045b743e34f7d4d13ed53d21728b1ceaf2f4de6d83e1', + timestamp: '2025-07-23T23:33:38.000Z', + chainId: 137, + accountId: 'eip155:137:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 74329442, + blockHash: + '0x05d4002c3d302306ba0f3b7d5f414fbf950d9615d867f2a405f83ac92aab4949', + gas: 499111, + gasUsed: 415926, + gasPrice: '30000000000', + effectiveGasPrice: '30000000000', + nonce: 3945, + cumulativeGasUsed: 15454145, + methodId: '0x729ad39e', + value: '0', + to: '0xa264885c44a58f20bd0cc27ccec57660d7b98ca1', + from: '0x081b6355a6fd09367b68c54be1848bd30e3d8dff', + isError: false, + valueTransfers: [ + { + from: '0xe7804c37c13166ff0b37f5ae0bb07a3aebb6e245', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: '1000000000000000000', + decimal: 18, + contractAddress: '0xa264885c44a58f20bd0cc27ccec57660d7b98ca1', + symbol: 'WWW.ARBQUEST.LIVE VISIT TO SWAP', + name: '✅ ARB Voucher', + transferType: 'erc20', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x767f0dc01b2d7458f849bd82c680b345daf428c3d2e895902aa9021a65ff3e32', + timestamp: '2025-07-23T23:30:01.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138857312, + blockHash: + '0x3ee254f749296105bfb74c794a6949d875000dc07cebe2b801d0cd4738546d15', + gas: 8051574, + gasUsed: 6655655, + gasPrice: '57741', + effectiveGasPrice: '57741', + nonce: 52069, + cumulativeGasUsed: 17895936, + methodId: '0x729ad39e', + value: '0', + to: '0xe07f9d9bbb7e84da658cfa42cbd71f087c5e87f4', + from: '0x1ce7f95ec52e748e7afa4bca38edc6fef413ab2f', + isError: false, + valueTransfers: [ + { + from: '0xe07f9d9bbb7e84da658cfa42cbd71f087c5e87f4', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0xe07f9d9bbb7e84da658cfa42cbd71f087c5e87f4', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x61b94f12e36e27c0da740073413c855c08e1d64f92e1fe9f111a373bda0e3187', + timestamp: '2025-07-23T23:30:01.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138857312, + blockHash: + '0x3ee254f749296105bfb74c794a6949d875000dc07cebe2b801d0cd4738546d15', + gas: 7953562, + gasUsed: 6574622, + gasPrice: '57553', + effectiveGasPrice: '57553', + nonce: 42649, + cumulativeGasUsed: 24470558, + methodId: '0x729ad39e', + value: '0', + to: '0x2b54f5293b1960de7faabfef6bcf2499a4903861', + from: '0x63b1413fe071cc71bdcdfe0cdaa81c40401088f3', + isError: false, + valueTransfers: [ + { + from: '0x2b54f5293b1960de7faabfef6bcf2499a4903861', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0x2b54f5293b1960de7faabfef6bcf2499a4903861', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x5b6ddb41ede3697c2cfc3b14239ffc443346ec312199e01572acf15e7df00cca', + timestamp: '2025-07-23T23:29:53.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138857308, + blockHash: + '0x8770643b7c0d5067e9e9c5578076fed80f312bf901b4c95016774ef34bfeb48a', + gas: 8246529, + gasUsed: 7790839, + gasPrice: '38535', + effectiveGasPrice: '38535', + nonce: 50879, + cumulativeGasUsed: 13002280, + methodId: '0x729ad39e', + value: '0', + to: '0xe1bf0fda900ed301f370e50a6edcb43580bff2ad', + from: '0x059594bb49c2ed5d99f91ada331803982889fdb7', + isError: false, + valueTransfers: [ + { + from: '0xe1bf0fda900ed301f370e50a6edcb43580bff2ad', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0xe1bf0fda900ed301f370e50a6edcb43580bff2ad', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x2cdfb79de64a220a717c4e3b4f8f6842afa59d7dafeb62ae050e9be0395fe970', + timestamp: '2025-07-23T23:29:47.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138857305, + blockHash: + '0x8bfad84ae6851d3b96cc9870e76f5df3fdb68870baa234a9998a488d8b3b7e2e', + gas: 5329084, + gasUsed: 5034198, + gasPrice: '38650', + effectiveGasPrice: '38650', + nonce: 36595, + cumulativeGasUsed: 14626722, + methodId: '0x729ad39e', + value: '0', + to: '0x5da41596b45526c15ca2b68375437da5ab2c0243', + from: '0xc070ccd5c2c4d824f0c1cf50675b8ac4feb4e0e2', + isError: false, + valueTransfers: [ + { + from: '0x5da41596b45526c15ca2b68375437da5ab2c0243', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0x5da41596b45526c15ca2b68375437da5ab2c0243', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0xdb2c3455378d278bc7ff19ecc00ddbed95940718e4ab1417720444148d7c9796', + timestamp: '2025-07-23T23:29:43.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138857303, + blockHash: + '0x271a1d6ac1425c4bc4e71d8f6e50741070cb8b1e5934267a3fd1c0dcb07f4c25', + gas: 7397881, + gasUsed: 6115200, + gasPrice: '54928', + effectiveGasPrice: '54928', + nonce: 45772, + cumulativeGasUsed: 16006228, + methodId: '0x729ad39e', + value: '0', + to: '0x799f442248fa09b838ea84b5044751ee1d2a4ee5', + from: '0xc53b40ef36693b0d6fe4a17a4b9ce8c40402a8fd', + isError: false, + valueTransfers: [ + { + from: '0x799f442248fa09b838ea84b5044751ee1d2a4ee5', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0x799f442248fa09b838ea84b5044751ee1d2a4ee5', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x5ea58c6b6c7fb83dd6bd1bbb56c063dc98e5dc16e32b4005fdf7ec4e8eb52984', + timestamp: '2025-07-23T23:29:41.000Z', + chainId: 10, + accountId: 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 138857302, + blockHash: + '0x91794267a2efd11c974c7abafe9729133c4872a2d3de18e07fad2754bad81b30', + gas: 9189574, + gasUsed: 7596522, + gasPrice: '52521', + effectiveGasPrice: '52521', + nonce: 43312, + cumulativeGasUsed: 35426429, + methodId: '0x729ad39e', + value: '0', + to: '0x0c20980c94ad027e2340d65edb22445b40288fea', + from: '0x7648d50120a436045dcac8d54c2e7acd848e48bb', + isError: false, + valueTransfers: [ + { + from: '0x0c20980c94ad027e2340d65edb22445b40288fea', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '0', + contractAddress: '0x0c20980c94ad027e2340d65edb22445b40288fea', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x3ebd870467f68fd21877893eb67e3c6ccb25464d01b938b441732ff415547f5c', + timestamp: '2025-04-19T01:41:11.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 22299831, + blockHash: + '0xb8d2f604ca30aeb55c6a765518d3221b42b14ecd9c73936aba872b5880d8cf96', + gas: 21000, + gasUsed: 21000, + gasPrice: '279367455', + effectiveGasPrice: '279367455', + nonce: 2, + cumulativeGasUsed: 24447918, + methodId: null, + value: '53829416400', + to: '0x7052f331db3d924686f08cf47d36d912160d127a', + from: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + isError: false, + valueTransfers: [ + { + from: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + to: '0x7052f331db3d924686f08cf47d36d912160d127a', + amount: '53829416400', + decimal: 18, + transferType: 'normal', + }, + ], + logs: [], + transactionType: 'STANDARD', + transactionCategory: 'STANDARD', + readable: 'Native Transfer', + }, + { + hash: '0xfc887fc95845e3aa8ae52fc03217dc57c962047846474dd605e006bdd1aa4ff9', + timestamp: '2025-04-11T07:54:11.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 22244333, + blockHash: + '0xfe3c1a87497e1019ca26eaced5fa62f91f0ec4edd3c67092c6e1e2d04fb431a9', + gas: 70018, + gasUsed: 64868, + gasPrice: '661810236', + effectiveGasPrice: '661810236', + nonce: 1, + cumulativeGasUsed: 4683910, + methodId: '0xb88d4fde', + value: '0', + to: '0x6cb26df0c825fece867a84658f87b0ecbcea72f6', + from: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + isError: false, + valueTransfers: [ + { + from: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + to: '0x5f4a2f10181852b5e46244168f4a8aed00fc8850', + tokenId: '2875', + contractAddress: '0x6cb26df0c825fece867a84658f87b0ecbcea72f6', + transferType: 'erc721', + }, + ], + logs: [], + transactionProtocol: 'ERC_721', + transactionCategory: 'TRANSFER', + transactionType: 'ERC_721_TRANSFER', + readable: 'Nft: Transfer', + }, + { + hash: '0xf72c330a2fdd3354b6b11f9230fd4b245d755b1a96f28ddd8fb46e1b47e91012', + timestamp: '2025-04-11T07:54:11.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 22244333, + blockHash: + '0xfe3c1a87497e1019ca26eaced5fa62f91f0ec4edd3c67092c6e1e2d04fb431a9', + gas: 21000, + gasUsed: 21000, + gasPrice: '3409951183', + effectiveGasPrice: '3409951183', + nonce: 7106, + cumulativeGasUsed: 4619042, + methodId: null, + value: '46338629104248', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + from: '0x9146bc6960a81ce3e60d3a0b51d8a503747c56ef', + isError: false, + valueTransfers: [ + { + from: '0x9146bc6960a81ce3e60d3a0b51d8a503747c56ef', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: '46338629104248', + decimal: 18, + transferType: 'normal', + }, + ], + logs: [], + transactionType: 'STANDARD', + transactionCategory: 'STANDARD', + readable: 'Native Transfer', + }, + { + hash: '0xb86195d482105457978fc9a02de391383f7724d9d219e70e641ccd2c0e6b9864', + timestamp: '2025-03-23T07:59:25.000Z', + chainId: 8453, + accountId: 'eip155:8453:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 27963709, + blockHash: + '0xabb3d7bc9620ea7a73a9e946f2e9e7cec702b40d7d88b14325a2c6e3d471f663', + gas: 173477, + gasUsed: 170953, + gasPrice: '3033731', + effectiveGasPrice: '3033731', + nonce: 38994, + cumulativeGasUsed: 48495543, + methodId: '0x729ad39e', + value: '0', + to: '0x67c9f4737f36a7dec28b4d3f4220c31f3b9aeae6', + from: '0x18a0d23d9bccfb8ef993a1bf226f44e7713eddc8', + isError: false, + valueTransfers: [ + { + from: '0x698dc45e4f10966f6d1d98e3bfd7071d8144c233', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + tokenId: '0', + contractAddress: '0x67c9f4737f36a7dec28b4d3f4220c31f3b9aeae6', + transferType: 'erc721', + }, + ], + logs: [], + transactionProtocol: 'ERC_721', + transactionCategory: 'TRANSFER', + transactionType: 'ERC_721_TRANSFER', + readable: 'Nft: Transfer', + }, + { + hash: '0xf3cc5507af7c8adfbe0483af86f1bffd0c21b44b0d9dea708289c76572bea1af', + timestamp: '2025-03-23T07:57:59.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 22108244, + blockHash: + '0x1168f453d13486ea7a91f42fa2cb077b8f455b5a7f5d4e75a486085ada0c2c02', + gas: 34706, + gasUsed: 29906, + gasPrice: '523379845', + effectiveGasPrice: '523379845', + nonce: 0, + cumulativeGasUsed: 4535331, + methodId: '0xa9059cbb', + value: '0', + to: '0x6b175474e89094c44da98b954eedeac495271d0f', + from: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + isError: false, + valueTransfers: [ + { + from: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + to: '0x792a83e74d76fcdab681249c82bc8ec6e1aa1111', + amount: '100000000000000000', + decimal: 18, + contractAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + symbol: 'DAI', + name: 'Dai Stablecoin', + transferType: 'erc20', + }, + ], + logs: [], + transactionProtocol: 'ERC_20', + transactionCategory: 'TRANSFER', + transactionType: 'ERC_20_TRANSFER', + readable: 'Token: Transfer', + }, + { + hash: '0x70b9b7fe330d882cdb49a84a159a167d68a6c4e0c6a38f321c38dfb3c4c792ab', + timestamp: '2025-03-23T07:57:59.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 22108244, + blockHash: + '0x1168f453d13486ea7a91f42fa2cb077b8f455b5a7f5d4e75a486085ada0c2c02', + gas: 21000, + gasUsed: 21000, + gasPrice: '523379845', + effectiveGasPrice: '523379845', + nonce: 2554, + cumulativeGasUsed: 4505425, + methodId: null, + value: '18164420900570', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + from: '0x0004405239ff2d9ce60acc56e0b302a3299c4840', + isError: false, + valueTransfers: [ + { + from: '0x0004405239ff2d9ce60acc56e0b302a3299c4840', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: '18164420900570', + decimal: 18, + transferType: 'normal', + }, + ], + logs: [], + transactionType: 'STANDARD', + transactionCategory: 'STANDARD', + readable: 'Native Transfer', + }, + { + hash: '0x449f6329b17246be05a611ede3761fad67c1f9637357f137b5416bec52389dd7', + timestamp: '2024-06-29T22:34:08.000Z', + chainId: 137, + accountId: 'eip155:137:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 58765539, + blockHash: + '0x4a862c753dd066318a69c6b92b0955cee0b30733a95d82a237d9e2293b915a91', + gas: 11000000, + gasUsed: 8919284, + gasPrice: '31500000028', + effectiveGasPrice: '31500000028', + nonce: 9529, + cumulativeGasUsed: 11952907, + methodId: '0xfaf67b43', + value: '0', + to: '0x76f8e90320b64590c1a11f5c63ffbbdc83371279', + from: '0xc078e264196e2233039aaa422d4861090dbfb86a', + isError: false, + valueTransfers: [ + { + from: '0x0000000000000000000000000000000000000000', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '1', + contractAddress: '0x76f8e90320b64590c1a11f5c63ffbbdc83371279', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x6dd52a01691b54d25b6098df1f80e1b181449ec237560054ad1351e176c21406', + timestamp: '2024-06-29T18:27:46.000Z', + chainId: 137, + accountId: 'eip155:137:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 58758592, + blockHash: + '0x71b52fd770b65d6ad71a16e729634f1dcac141413e20dd45ebe92ecc313330fd', + gas: 11000000, + gasUsed: 8919488, + gasPrice: '31500000089', + effectiveGasPrice: '31500000089', + nonce: 3456, + cumulativeGasUsed: 14031888, + methodId: '0xfaf67b43', + value: '0', + to: '0xc60ba1c956331d76c8e781a7147519ed0427661a', + from: '0xda3e3f3a946e759d84408462c538e74d4f44ddd0', + isError: false, + valueTransfers: [ + { + from: '0x0000000000000000000000000000000000000000', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '1', + contractAddress: '0xc60ba1c956331d76c8e781a7147519ed0427661a', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0x01d737867887caafe0502a329736b543e6828cc30a13853bb73c63cc72bfc6c4', + timestamp: '2024-06-20T18:15:41.000Z', + chainId: 137, + accountId: 'eip155:137:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 58399154, + blockHash: + '0x7e2a1cda6cfa5491861f419f067b962b2074c541f51d3caf78ade8d390a4eabc', + gas: 11000000, + gasUsed: 6745986, + gasPrice: '31500000033', + effectiveGasPrice: '31500000033', + nonce: 438, + cumulativeGasUsed: 11201316, + methodId: '0xfaf67b43', + value: '0', + to: '0x33d67d15214129e47e357510a2d0f1a25d66ae9b', + from: '0x02de77e62c6b11c64a76fb7341cd678787f8addd', + isError: false, + valueTransfers: [ + { + from: '0x0000000000000000000000000000000000000000', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '1', + contractAddress: '0x33d67d15214129e47e357510a2d0f1a25d66ae9b', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0xc143c1535a4036180db08cc15d526a5861a4b190ff172796edb8bf762f2ab72d', + timestamp: '2024-06-20T18:14:59.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 20134660, + blockHash: + '0x0432270a0889ab380c04738cd00fdc125c6b8b602fe959c029f0224ce0cdb910', + gas: 94617, + gasUsed: 62248, + gasPrice: '8572767507', + effectiveGasPrice: '8572767507', + nonce: 1688, + cumulativeGasUsed: 6762162, + methodId: '0xa9059cbb', + value: '0', + to: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + from: '0x2990079bcdee240329a520d2444386fc119da21a', + isError: false, + valueTransfers: [ + { + from: '0x2990079bcdee240329a520d2444386fc119da21a', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: '120000', + decimal: 6, + contractAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + name: 'USD Coin', + transferType: 'erc20', + }, + ], + logs: [], + transactionProtocol: 'ERC_20', + transactionCategory: 'TRANSFER', + transactionType: 'ERC_20_TRANSFER', + readable: 'Token: Transfer', + }, + { + hash: '0xbc67f71241b92b5c902d4fd0257e8d7137ebec0f608829bba964ce3dfdcf51b7', + timestamp: '2024-06-20T18:14:57.000Z', + chainId: 137, + accountId: 'eip155:137:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 58399134, + blockHash: + '0x83eaf35919c5c8ba46779533b38589f0e33739320c52e6a588b615d87df50cbc', + gas: 11000000, + gasUsed: 5183636, + gasPrice: '31500000029', + effectiveGasPrice: '31500000029', + nonce: 6037, + cumulativeGasUsed: 7954869, + methodId: '0xfaf67b43', + value: '0', + to: '0xfe5d94a2b1b3066c814ee5fd024f8c3f99ef51f4', + from: '0x4c4404d760b18f824735f4ad746f7d809baf3432', + isError: false, + valueTransfers: [ + { + from: '0x0000000000000000000000000000000000000000', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: '1', + contractAddress: '0xfe5d94a2b1b3066c814ee5fd024f8c3f99ef51f4', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'SPAM_TOKEN_TRANSFER', + transactionCategory: 'TRANSFER', + readable: 'Spam Token: Transfer', + transactionProtocol: 'SPAM_TOKEN', + }, + { + hash: '0xa73254e3cc0c03c2af521b09ed848ec1f37c6e6f0b9ca92781c39f52fa3796cc', + timestamp: '2024-06-20T18:14:45.000Z', + chainId: 137, + accountId: 'eip155:137:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 58399128, + blockHash: + '0x823df9d476c3e9963b6ae37be3392ba88d5c95edaaeeed07136c290b0fb2056e', + gas: 53812, + gasUsed: 48920, + gasPrice: '30000000028', + effectiveGasPrice: '30000000028', + nonce: 1531, + cumulativeGasUsed: 10254933, + methodId: '0x9c96eec5', + value: '0', + to: '0x870b5c4e16adf0ac629cd6053438a71908fc6132', + from: '0x3d6f605526f69d0376c6f4aceee5f2a47e255f89', + isError: false, + valueTransfers: [ + { + from: '0x9d1b1669c73b033dfe47ae5a0164ab96df25b944', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: '999098777100000000000', + decimal: 18, + contractAddress: '0x870b5c4e16adf0ac629cd6053438a71908fc6132', + symbol: 'Invitation Link : https://zero-bridge.xyz/', + name: 'LayerZero : Bridge Rewards', + transferType: 'erc20', + }, + ], + logs: [], + transactionType: 'GENERIC_CONTRACT_CALL', + transactionCategory: 'CONTRACT_CALL', + readable: 'Unidentified Transaction', + }, + { + hash: '0xa87e7bc4ce49ff43541ea5d6e2af5a0281caacdd356418be1c1d85113acf7e74', + timestamp: '2024-06-20T18:14:35.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 20134658, + blockHash: + '0xc5aeba1b9a31ddb7f39d071a8d2b1d68eedbd3ab403e2cf8a351d24a20fbada8', + gas: 78298, + gasUsed: 51806, + gasPrice: '7453274534', + effectiveGasPrice: '7453274534', + nonce: 1687, + cumulativeGasUsed: 20241794, + methodId: '0xa9059cbb', + value: '0', + to: '0x6b175474e89094c44da98b954eedeac495271d0f', + from: '0x2990079bcdee240329a520d2444386fc119da21a', + isError: false, + valueTransfers: [ + { + from: '0x2990079bcdee240329a520d2444386fc119da21a', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: '100000000000000000', + decimal: 18, + contractAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + symbol: 'DAI', + name: 'Dai Stablecoin', + transferType: 'erc20', + }, + ], + logs: [], + transactionProtocol: 'ERC_20', + transactionCategory: 'TRANSFER', + transactionType: 'ERC_20_TRANSFER', + readable: 'Token: Transfer', + }, + { + hash: '0x4d6d83631f1742144e95397b5910bf4807192be38ad3a79d390cf4744c0c6550', + timestamp: '2024-06-19T16:28:23.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 20126979, + blockHash: + '0x4a8789c29ef4bc8ced1ff59735181d76b9548d8996e50b9175f5f84a6fc169c4', + gas: 71969, + gasUsed: 59816, + gasPrice: '13332526504', + effectiveGasPrice: '13332526504', + nonce: 1672, + cumulativeGasUsed: 13365022, + methodId: '0xf242432a', + value: '0', + to: '0xb66a603f4cfe17e3d27b87a8bfcad319856518b8', + from: '0x2990079bcdee240329a520d2444386fc119da21a', + isError: false, + valueTransfers: [ + { + from: '0x2990079bcdee240329a520d2444386fc119da21a', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: 1, + tokenId: + '100059584189867566852894599154934721552567499366058460268305346163854916190212', + contractAddress: '0xb66a603f4cfe17e3d27b87a8bfcad319856518b8', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'GENERIC_CONTRACT_CALL', + transactionCategory: 'CONTRACT_CALL', + readable: 'Unidentified Transaction', + }, + { + hash: '0x064a773c959cfae4c5911fc730cb7920351e55f81e7aa661246b90c382f0054e', + timestamp: '2024-06-19T14:59:47.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 20126537, + blockHash: + '0x99b36ebd93ea840414cc09f6c441afc3cddada3ece4b7f95e28e66e2545d7d08', + gas: 98368, + gasUsed: 42056, + gasPrice: '7691903129', + effectiveGasPrice: '7691903129', + nonce: 1669, + cumulativeGasUsed: 24774395, + methodId: '0xf242432a', + value: '0', + to: '0xb66a603f4cfe17e3d27b87a8bfcad319856518b8', + from: '0x2990079bcdee240329a520d2444386fc119da21a', + isError: false, + valueTransfers: [ + { + from: '0x2990079bcdee240329a520d2444386fc119da21a', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: null, + tokenId: + '100059584189867566852894599154934721552567499366058460268305346163854916190212', + contractAddress: '0xb66a603f4cfe17e3d27b87a8bfcad319856518b8', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'GENERIC_CONTRACT_CALL', + transactionCategory: 'CONTRACT_CALL', + readable: 'Unidentified Transaction', + }, + { + hash: '0x833d5e703c269f691ae609805be789aac2a605662bb15099ce5361228ebed9e2', + timestamp: '2024-06-17T20:46:23.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 20113979, + blockHash: + '0x3ec9c170e8f059142f106daa5c54ebc2a0bb7c552d5e43ad8696d97bfee2de31', + gas: 98368, + gasUsed: 42056, + gasPrice: '7822363494', + effectiveGasPrice: '7822363494', + nonce: 1636, + cumulativeGasUsed: 10428073, + methodId: '0xf242432a', + value: '0', + to: '0xb66a603f4cfe17e3d27b87a8bfcad319856518b8', + from: '0x2990079bcdee240329a520d2444386fc119da21a', + isError: false, + valueTransfers: [ + { + from: '0x2990079bcdee240329a520d2444386fc119da21a', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + amount: null, + tokenId: + '100059584189867566852894599154934721552567499366058460268305346163854916190212', + contractAddress: '0xb66a603f4cfe17e3d27b87a8bfcad319856518b8', + transferType: 'erc1155', + }, + ], + logs: [], + transactionType: 'GENERIC_CONTRACT_CALL', + transactionCategory: 'CONTRACT_CALL', + readable: 'Unidentified Transaction', + }, + { + hash: '0x1686ff4f3ca5aec7cdc13dbd786ef90741b09fe1150024a920fa72b8939238ae', + timestamp: '2024-06-17T20:35:47.000Z', + chainId: 1, + accountId: 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + blockNumber: 20113926, + blockHash: + '0x380b18f09a627e24741e84cd47f00f57eea1ba67931e7ce010f7e85894af6d0b', + gas: 99802, + gasUsed: 61419, + gasPrice: '8368575474', + effectiveGasPrice: '8368575474', + nonce: 1632, + cumulativeGasUsed: 13180858, + methodId: '0x23b872dd', + value: '0', + to: '0x6cb26df0c825fece867a84658f87b0ecbcea72f6', + from: '0x2990079bcdee240329a520d2444386fc119da21a', + isError: false, + valueTransfers: [ + { + from: '0x2990079bcdee240329a520d2444386fc119da21a', + to: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + tokenId: '2875', + contractAddress: '0x6cb26df0c825fece867a84658f87b0ecbcea72f6', + transferType: 'erc721', + }, + ], + logs: [], + transactionCategory: 'TRANSFER', + transactionProtocol: 'ERC_721', + transactionType: 'ERC_721_TRANSFER', + readable: 'Nft: Transfer', + }, + ], + unprocessedNetworks: [], + pageInfo: { hasNextPage: false, cursor: null, count: 34 }, +}; + +export const ACCOUNTS_API_ACTIVE_NETWORKS_RESPONSE = { + activeNetworks: [ + 'eip155:1:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + 'eip155:10:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + 'eip155:137:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + 'eip155:8453:0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + ], +}; diff --git a/e2e/api-mocking/mock-responses/feature-flags-mocks.ts b/e2e/api-mocking/mock-responses/feature-flags-mocks.ts new file mode 100644 index 000000000000..9cd9d334615e --- /dev/null +++ b/e2e/api-mocking/mock-responses/feature-flags-mocks.ts @@ -0,0 +1,164 @@ +export const SWAPS_FEATURE_FLAG_RESPONSE = { + ethereum: { + mobile_active: true, + extension_active: true, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + v2: { swapAndSend: { enabled: false } }, + smartTransactions: { + expectedDeadline: 45, + maxDeadline: 160, + returnTxHashAsap: false, + mobileActive: true, + extensionActive: true, + mobileActiveIos: true, + mobileActiveAndroid: true, + }, + }, + bsc: { + mobile_active: true, + extension_active: true, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + v2: { swapAndSend: { enabled: false } }, + smartTransactions: { + mobileActive: true, + extensionActive: true, + mobileActiveIos: true, + mobileActiveAndroid: true, + }, + }, + polygon: { + mobile_active: true, + extension_active: true, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + v2: { swapAndSend: { enabled: false } }, + smartTransactions: {}, + }, + avalanche: { + mobile_active: true, + extension_active: true, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + v2: { swapAndSend: { enabled: false } }, + smartTransactions: {}, + }, + arbitrum: { + mobile_active: true, + extension_active: true, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + v2: { swapAndSend: { enabled: false } }, + smartTransactions: { + mobileActive: true, + extensionActive: true, + mobileActiveIos: true, + mobileActiveAndroid: true, + }, + }, + optimism: { + mobile_active: true, + extension_active: true, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + v2: { swapAndSend: { enabled: false } }, + smartTransactions: {}, + }, + zksync: { + mobile_active: true, + extension_active: true, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + v2: { swapAndSend: { enabled: false } }, + smartTransactions: {}, + }, + linea: { + mobile_active: true, + extension_active: true, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + v2: { swapAndSend: { enabled: false } }, + smartTransactions: {}, + }, + base: { + mobile_active: true, + extension_active: true, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + v2: { swapAndSend: { enabled: false } }, + smartTransactions: { + mobileActive: true, + extensionActive: true, + mobileActiveIos: true, + mobileActiveAndroid: true, + }, + }, + sei: { + mobile_active: true, + extension_active: false, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: true, + extensionActive: false, + mobileActiveIOS: true, + mobileActiveAndroid: true, + v2: { swapAndSend: { enabled: true } }, + smartTransactions: {}, + }, + smart_transactions: { mobile_active: true, extension_active: true }, + smartTransactions: { + mobileActive: true, + extensionActive: true, + mobileActiveIOS: false, + mobileActiveAndroid: false, + mobileReturnTxHashAsap: true, + extensionReturnTxHashAsap: true, + batchStatusPollingInterval: 5000, + }, + transactions: { + acceleratedPollingEnabled: false, + acceleratedPollingInterval: 5, + maxAcceleratedPolls: 0, + }, + swapRedesign: { mobileActive: false, extensionActive: true }, + migrateToV2: { extensionActive: false, mobileActive: false }, + compliance: { merkleScienceMinThreshold: 25000 }, + multiChainAssets: { pollingSeconds: 0 }, +}; diff --git a/e2e/api-mocking/mock-responses/staking-api-responses-mocks.ts b/e2e/api-mocking/mock-responses/staking-api-responses-mocks.ts new file mode 100644 index 000000000000..a73cf5914020 --- /dev/null +++ b/e2e/api-mocking/mock-responses/staking-api-responses-mocks.ts @@ -0,0 +1,3463 @@ +export const POOLED_STAKING_VAULT_RESPONSE = [ + { + id: 2148691, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-08-05T00:00:00.000Z', + daily_apy: '2.160630689308144746', + created_at: '2025-08-06T01:00:01.209Z', + updated_at: '2025-08-06T01:00:01.209Z', + }, + { + id: 2137242, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-08-04T00:00:00.000Z', + daily_apy: '2.594766097760912223', + created_at: '2025-08-05T01:00:00.675Z', + updated_at: '2025-08-05T01:00:00.675Z', + }, + { + id: 2125229, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-08-03T00:00:00.000Z', + daily_apy: '2.230985359762322456', + created_at: '2025-08-04T01:00:03.147Z', + updated_at: '2025-08-04T01:00:03.147Z', + }, + { + id: 2113939, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-08-02T00:00:00.000Z', + daily_apy: '2.822078388979096626', + created_at: '2025-08-03T01:00:02.344Z', + updated_at: '2025-08-03T01:00:02.344Z', + }, + { + id: 2102453, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-08-01T00:00:00.000Z', + daily_apy: '2.354552962265530254', + created_at: '2025-08-02T01:00:00.479Z', + updated_at: '2025-08-02T01:00:00.479Z', + }, + { + id: 2090629, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-31T00:00:00.000Z', + daily_apy: '2.430527730769124502', + created_at: '2025-08-01T01:00:00.630Z', + updated_at: '2025-08-01T01:00:00.630Z', + }, + { + id: 2078463, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-30T00:00:00.000Z', + daily_apy: '2.360728942269321903', + created_at: '2025-07-31T01:00:00.432Z', + updated_at: '2025-07-31T01:00:00.432Z', + }, + { + id: 2067053, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-29T00:00:00.000Z', + daily_apy: '2.550855288009415321', + created_at: '2025-07-30T01:00:00.453Z', + updated_at: '2025-07-30T01:00:00.453Z', + }, + { + id: 2055302, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-28T00:00:00.000Z', + daily_apy: '2.699143982584143850', + created_at: '2025-07-29T01:00:00.728Z', + updated_at: '2025-07-29T01:00:00.728Z', + }, + { + id: 2044149, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-27T00:00:00.000Z', + daily_apy: '2.260023781340267047', + created_at: '2025-07-28T01:00:00.468Z', + updated_at: '2025-07-28T01:00:00.468Z', + }, + { + id: 2032916, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-26T00:00:00.000Z', + daily_apy: '2.367243569636514657', + created_at: '2025-07-27T01:00:00.570Z', + updated_at: '2025-07-27T01:00:00.570Z', + }, + { + id: 2019474, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-25T00:00:00.000Z', + daily_apy: '2.425507062525974170', + created_at: '2025-07-26T01:00:00.376Z', + updated_at: '2025-07-26T01:00:00.376Z', + }, + { + id: 2007822, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-24T00:00:00.000Z', + daily_apy: '2.416965602179156029', + created_at: '2025-07-25T01:00:00.220Z', + updated_at: '2025-07-25T01:00:00.220Z', + }, + { + id: 1996570, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-23T00:00:00.000Z', + daily_apy: '2.758675024861472788', + created_at: '2025-07-24T01:00:00.525Z', + updated_at: '2025-07-24T01:00:00.525Z', + }, + { + id: 1985426, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-22T00:00:00.000Z', + daily_apy: '2.666457904467836947', + created_at: '2025-07-23T01:00:00.300Z', + updated_at: '2025-07-23T01:00:00.300Z', + }, + { + id: 1971170, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-21T00:00:00.000Z', + daily_apy: '2.231467458835929591', + created_at: '2025-07-22T01:00:00.584Z', + updated_at: '2025-07-22T01:00:00.584Z', + }, + { + id: 1959984, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-20T00:00:00.000Z', + daily_apy: '2.301110730349541980', + created_at: '2025-07-21T01:00:00.883Z', + updated_at: '2025-07-21T01:00:00.883Z', + }, + { + id: 1948555, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-19T00:00:00.000Z', + daily_apy: '2.352087941423962002', + created_at: '2025-07-20T00:01:31.192Z', + updated_at: '2025-07-20T00:01:31.192Z', + }, + { + id: 1937514, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-18T00:00:00.000Z', + daily_apy: '2.709889646777819303', + created_at: '2025-07-19T01:00:00.307Z', + updated_at: '2025-07-19T01:00:00.307Z', + }, + { + id: 1926404, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-17T00:00:00.000Z', + daily_apy: '2.578081601755443584', + created_at: '2025-07-18T01:00:00.230Z', + updated_at: '2025-07-18T01:00:00.230Z', + }, + { + id: 1915046, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-16T00:00:00.000Z', + daily_apy: '2.511785450400075201', + created_at: '2025-07-17T00:01:31.166Z', + updated_at: '2025-07-17T00:01:31.166Z', + }, + { + id: 1904077, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-15T00:00:00.000Z', + daily_apy: '2.242968214074173364', + created_at: '2025-07-16T01:00:00.280Z', + updated_at: '2025-07-16T01:00:00.280Z', + }, + { + id: 1893132, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-14T00:00:00.000Z', + daily_apy: '2.572947490988122566', + created_at: '2025-07-15T01:00:00.622Z', + updated_at: '2025-07-15T01:00:00.622Z', + }, + { + id: 1881756, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-13T00:00:00.000Z', + daily_apy: '2.689218566706491482', + created_at: '2025-07-14T00:01:31.157Z', + updated_at: '2025-07-14T00:01:31.157Z', + }, + { + id: 1870771, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-12T00:00:00.000Z', + daily_apy: '2.365846320573506748', + created_at: '2025-07-13T01:00:00.303Z', + updated_at: '2025-07-13T01:00:00.303Z', + }, + { + id: 1859898, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-11T00:00:00.000Z', + daily_apy: '2.611392743830641121', + created_at: '2025-07-12T01:00:00.552Z', + updated_at: '2025-07-12T01:00:00.552Z', + }, + { + id: 1849051, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-10T00:00:00.000Z', + daily_apy: '2.474061921678370022', + created_at: '2025-07-11T01:00:00.318Z', + updated_at: '2025-07-11T01:00:00.318Z', + }, + { + id: 1837776, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-09T00:00:00.000Z', + daily_apy: '2.414119937632374226', + created_at: '2025-07-10T01:00:00.390Z', + updated_at: '2025-07-10T01:00:00.390Z', + }, + { + id: 1829760, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-08T00:00:00.000Z', + daily_apy: '3.010997754405619248', + created_at: '2025-07-09T07:00:00.507Z', + updated_at: '2025-07-09T07:00:00.507Z', + }, + { + id: 1819432, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-07T00:00:00.000Z', + daily_apy: '2.551856786885392865', + created_at: '2025-07-08T01:00:00.298Z', + updated_at: '2025-07-08T01:00:00.298Z', + }, + { + id: 1808679, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-06T00:00:00.000Z', + daily_apy: '2.322264721475653208', + created_at: '2025-07-07T01:00:00.482Z', + updated_at: '2025-07-07T01:00:00.482Z', + }, + { + id: 1797950, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-05T00:00:00.000Z', + daily_apy: '2.545299229175557965', + created_at: '2025-07-06T01:00:00.323Z', + updated_at: '2025-07-06T01:00:00.323Z', + }, + { + id: 1786799, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-04T00:00:00.000Z', + daily_apy: '2.238388442775579425', + created_at: '2025-07-05T01:00:00.445Z', + updated_at: '2025-07-05T01:00:00.445Z', + }, + { + id: 1776118, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-03T00:00:00.000Z', + daily_apy: '2.483210588259805088', + created_at: '2025-07-04T01:00:00.649Z', + updated_at: '2025-07-04T01:00:00.649Z', + }, + { + id: 1764050, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-02T00:00:00.000Z', + daily_apy: '2.361765502010076272', + created_at: '2025-07-03T00:44:17.079Z', + updated_at: '2025-07-03T00:44:17.079Z', + }, + { + id: 1752167, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-07-01T00:00:00.000Z', + daily_apy: '2.560299460784560951', + created_at: '2025-07-02T01:00:00.254Z', + updated_at: '2025-07-02T01:00:00.254Z', + }, + { + id: 1743842, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-30T00:00:00.000Z', + daily_apy: '3.092940485915711615', + created_at: '2025-07-01T07:51:52.275Z', + updated_at: '2025-07-01T07:51:52.275Z', + }, + { + id: 1729618, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-29T00:00:00.000Z', + daily_apy: '2.473858935145985675', + created_at: '2025-06-30T01:00:00.398Z', + updated_at: '2025-06-30T01:00:00.398Z', + }, + { + id: 1718983, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-28T00:00:00.000Z', + daily_apy: '2.636138892886318473', + created_at: '2025-06-29T01:00:00.277Z', + updated_at: '2025-06-29T01:00:00.277Z', + }, + { + id: 1708081, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-27T00:00:00.000Z', + daily_apy: '2.364594758530096958', + created_at: '2025-06-28T00:01:30.698Z', + updated_at: '2025-06-28T00:01:30.698Z', + }, + { + id: 1697568, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-26T00:00:00.000Z', + daily_apy: '2.599179838082240376', + created_at: '2025-06-27T01:00:00.401Z', + updated_at: '2025-06-27T01:00:00.401Z', + }, + { + id: 1687007, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-25T00:00:00.000Z', + daily_apy: '2.781241096874648894', + created_at: '2025-06-26T01:00:00.495Z', + updated_at: '2025-06-26T01:00:00.495Z', + }, + { + id: 1676614, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-24T00:00:00.000Z', + daily_apy: '3.313982129947740597', + created_at: '2025-06-25T01:00:00.300Z', + updated_at: '2025-06-25T01:00:00.300Z', + }, + { + id: 1665738, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-23T00:00:00.000Z', + daily_apy: '2.920984915416314934', + created_at: '2025-06-24T01:00:00.264Z', + updated_at: '2025-06-24T01:00:00.264Z', + }, + { + id: 1655321, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-22T00:00:00.000Z', + daily_apy: '2.508811228066068916', + created_at: '2025-06-23T01:00:00.510Z', + updated_at: '2025-06-23T01:00:00.510Z', + }, + { + id: 1644494, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-21T00:00:00.000Z', + daily_apy: '2.417145276593338606', + created_at: '2025-06-22T01:00:00.462Z', + updated_at: '2025-06-22T01:00:00.462Z', + }, + { + id: 1634125, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-20T00:00:00.000Z', + daily_apy: '3.132806762001707522', + created_at: '2025-06-21T01:00:00.369Z', + updated_at: '2025-06-21T01:00:00.369Z', + }, + { + id: 1623780, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-19T00:00:00.000Z', + daily_apy: '2.326934563020984126', + created_at: '2025-06-20T01:00:00.390Z', + updated_at: '2025-06-20T01:00:00.390Z', + }, + { + id: 1613458, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-18T00:00:00.000Z', + daily_apy: '2.528577333658723341', + created_at: '2025-06-19T01:00:01.798Z', + updated_at: '2025-06-19T01:00:01.798Z', + }, + { + id: 1603161, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-17T00:00:00.000Z', + daily_apy: '2.420846308287214689', + created_at: '2025-06-18T01:00:00.215Z', + updated_at: '2025-06-18T01:00:00.215Z', + }, + { + id: 1592902, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-16T00:00:00.000Z', + daily_apy: '2.317700676927453592', + created_at: '2025-06-17T01:00:01.107Z', + updated_at: '2025-06-17T01:00:01.107Z', + }, + { + id: 1582653, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-15T00:00:00.000Z', + daily_apy: '2.847038769765440542', + created_at: '2025-06-16T01:00:00.234Z', + updated_at: '2025-06-16T01:00:00.234Z', + }, + { + id: 1572428, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-14T00:00:00.000Z', + daily_apy: '2.661913891382594137', + created_at: '2025-06-15T01:00:00.311Z', + updated_at: '2025-06-15T01:00:00.311Z', + }, + { + id: 1562227, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-13T00:00:00.000Z', + daily_apy: '2.395936713018122124', + created_at: '2025-06-14T01:00:00.477Z', + updated_at: '2025-06-14T01:00:00.477Z', + }, + { + id: 1552056, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-12T00:00:00.000Z', + daily_apy: '2.308595718513410841', + created_at: '2025-06-13T01:00:00.357Z', + updated_at: '2025-06-13T01:00:00.357Z', + }, + { + id: 1541904, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-11T00:00:00.000Z', + daily_apy: '2.714989932921688108', + created_at: '2025-06-12T01:00:00.377Z', + updated_at: '2025-06-12T01:00:00.377Z', + }, + { + id: 1531775, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-10T00:00:00.000Z', + daily_apy: '2.730241025946509735', + created_at: '2025-06-11T01:00:00.451Z', + updated_at: '2025-06-11T01:00:00.451Z', + }, + { + id: 1504410, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-09T00:00:00.000Z', + daily_apy: '2.438940357185122676', + created_at: '2025-06-10T01:00:01.847Z', + updated_at: '2025-06-10T01:00:01.847Z', + }, + { + id: 1494384, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-08T00:00:00.000Z', + daily_apy: '2.394719753798906958', + created_at: '2025-06-09T01:00:01.247Z', + updated_at: '2025-06-09T01:00:01.247Z', + }, + { + id: 1484327, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-07T00:00:00.000Z', + daily_apy: '2.646813409672606029', + created_at: '2025-06-08T01:00:00.257Z', + updated_at: '2025-06-08T01:00:00.257Z', + }, + { + id: 1474294, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-06T00:00:00.000Z', + daily_apy: '3.610860546185630642', + created_at: '2025-06-07T01:00:00.227Z', + updated_at: '2025-06-07T01:00:00.227Z', + }, + { + id: 1464233, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-05T00:00:00.000Z', + daily_apy: '2.866066000911694912', + created_at: '2025-06-06T01:00:00.204Z', + updated_at: '2025-06-06T01:00:00.204Z', + }, + { + id: 1454249, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-04T00:00:00.000Z', + daily_apy: '2.548511908606253429', + created_at: '2025-06-05T01:00:00.236Z', + updated_at: '2025-06-05T01:00:00.236Z', + }, + { + id: 1444704, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-03T00:00:00.000Z', + daily_apy: '2.765209089148432467', + created_at: '2025-06-04T01:00:00.229Z', + updated_at: '2025-06-04T01:00:00.229Z', + }, + { + id: 1434767, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-02T00:00:00.000Z', + daily_apy: '2.718609123877091482', + created_at: '2025-06-03T01:00:00.287Z', + updated_at: '2025-06-03T01:00:00.287Z', + }, + { + id: 1424854, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-06-01T00:00:00.000Z', + daily_apy: '2.674633072827114657', + created_at: '2025-06-02T01:00:00.242Z', + updated_at: '2025-06-02T01:00:00.242Z', + }, + { + id: 1414965, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-31T00:00:00.000Z', + daily_apy: '2.503740742292486449', + created_at: '2025-06-01T01:00:00.252Z', + updated_at: '2025-06-01T01:00:00.252Z', + }, + { + id: 1405100, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-30T00:00:00.000Z', + daily_apy: '2.377321111112683739', + created_at: '2025-05-31T01:00:00.247Z', + updated_at: '2025-05-31T01:00:00.247Z', + }, + { + id: 1394849, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-29T00:00:00.000Z', + daily_apy: '2.266832495716882467', + created_at: '2025-05-30T00:01:30.569Z', + updated_at: '2025-05-30T00:01:30.569Z', + }, + { + id: 1385032, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-28T00:00:00.000Z', + daily_apy: '2.761684766061966040', + created_at: '2025-05-29T01:00:01.647Z', + updated_at: '2025-05-29T01:00:01.647Z', + }, + { + id: 1375197, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-27T00:00:00.000Z', + daily_apy: '2.254981325304014602', + created_at: '2025-05-28T01:00:00.227Z', + updated_at: '2025-05-28T01:00:00.227Z', + }, + { + id: 1365429, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-26T00:00:00.000Z', + daily_apy: '2.455358672817514712', + created_at: '2025-05-27T01:00:00.247Z', + updated_at: '2025-05-27T01:00:00.247Z', + }, + { + id: 1355725, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-25T00:00:00.000Z', + daily_apy: '2.171054355556651327', + created_at: '2025-05-26T01:00:00.204Z', + updated_at: '2025-05-26T01:00:00.204Z', + }, + { + id: 1346004, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-24T00:00:00.000Z', + daily_apy: '1.923545158765472788', + created_at: '2025-05-25T01:00:00.223Z', + updated_at: '2025-05-25T01:00:00.223Z', + }, + { + id: 1336268, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-23T00:00:00.000Z', + daily_apy: '2.616579553526408131', + created_at: '2025-05-24T01:00:00.242Z', + updated_at: '2025-05-24T01:00:00.242Z', + }, + { + id: 1326634, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-22T00:00:00.000Z', + daily_apy: '2.208794804288317035', + created_at: '2025-05-23T01:00:00.363Z', + updated_at: '2025-05-23T01:00:00.363Z', + }, + { + id: 1316985, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-21T00:00:00.000Z', + daily_apy: '1.975900247716129148', + created_at: '2025-05-22T01:00:00.244Z', + updated_at: '2025-05-22T01:00:00.244Z', + }, + { + id: 1307360, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-20T00:00:00.000Z', + daily_apy: '1.823334231538022954', + created_at: '2025-05-21T01:00:00.224Z', + updated_at: '2025-05-21T01:00:00.224Z', + }, + { + id: 1297969, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-19T00:00:00.000Z', + daily_apy: '1.931945364844438938', + created_at: '2025-05-20T01:00:00.198Z', + updated_at: '2025-05-20T01:00:00.198Z', + }, + { + id: 1288028, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-18T00:00:00.000Z', + daily_apy: '1.946455943490720299', + created_at: '2025-05-19T01:00:00.281Z', + updated_at: '2025-05-19T01:00:00.281Z', + }, + { + id: 1278475, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-17T00:00:00.000Z', + daily_apy: '1.857306102146380254', + created_at: '2025-05-18T01:00:00.228Z', + updated_at: '2025-05-18T01:00:00.228Z', + }, + { + id: 1268946, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-16T00:00:00.000Z', + daily_apy: '2.636579843247607965', + created_at: '2025-05-17T01:00:00.196Z', + updated_at: '2025-05-17T01:00:00.196Z', + }, + { + id: 1259479, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-15T00:00:00.000Z', + daily_apy: '2.730951603838317644', + created_at: '2025-05-16T01:00:00.427Z', + updated_at: '2025-05-16T01:00:00.427Z', + }, + { + id: 1250719, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-14T00:00:00.000Z', + daily_apy: '2.892905192811717752', + created_at: '2025-05-15T01:00:00.216Z', + updated_at: '2025-05-15T01:00:00.216Z', + }, + { + id: 1241959, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-13T00:00:00.000Z', + daily_apy: '2.963932043397683281', + created_at: '2025-05-14T01:00:00.239Z', + updated_at: '2025-05-14T01:00:00.239Z', + }, + { + id: 1233199, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-12T00:00:00.000Z', + daily_apy: '2.888689132021701945', + created_at: '2025-05-13T01:00:00.409Z', + updated_at: '2025-05-13T01:00:00.409Z', + }, + { + id: 1224439, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-11T00:00:00.000Z', + daily_apy: '2.734279654635252893', + created_at: '2025-05-12T01:00:00.255Z', + updated_at: '2025-05-12T01:00:00.255Z', + }, + { + id: 1215679, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-10T00:00:00.000Z', + daily_apy: '2.319366804802240503', + created_at: '2025-05-11T01:00:00.371Z', + updated_at: '2025-05-11T01:00:00.371Z', + }, + { + id: 1206919, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-09T00:00:00.000Z', + daily_apy: '2.919990925078954060', + created_at: '2025-05-10T01:00:00.494Z', + updated_at: '2025-05-10T01:00:00.494Z', + }, + { + id: 1198159, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-08T00:00:00.000Z', + daily_apy: '2.854227652040402271', + created_at: '2025-05-09T01:00:00.241Z', + updated_at: '2025-05-09T01:00:00.241Z', + }, + { + id: 1188669, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-07T00:00:00.000Z', + daily_apy: '2.655421738777143586', + created_at: '2025-05-08T00:35:03.897Z', + updated_at: '2025-05-08T00:35:03.897Z', + }, + { + id: 1179544, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-06T00:00:00.000Z', + daily_apy: '2.295015278636215653', + created_at: '2025-05-07T01:00:00.282Z', + updated_at: '2025-05-07T01:00:00.282Z', + }, + { + id: 1171149, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-05T00:00:00.000Z', + daily_apy: '2.463374048331001162', + created_at: '2025-05-06T01:00:00.221Z', + updated_at: '2025-05-06T01:00:00.221Z', + }, + { + id: 1162389, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-04T00:00:00.000Z', + daily_apy: '2.521618298931638330', + created_at: '2025-05-05T01:00:00.287Z', + updated_at: '2025-05-05T01:00:00.287Z', + }, + { + id: 1153629, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-03T00:00:00.000Z', + daily_apy: '2.595101808726805420', + created_at: '2025-05-04T01:00:00.371Z', + updated_at: '2025-05-04T01:00:00.371Z', + }, + { + id: 1144869, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-02T00:00:00.000Z', + daily_apy: '2.787938980201913496', + created_at: '2025-05-03T01:09:28.224Z', + updated_at: '2025-05-03T01:09:28.224Z', + }, + { + id: 1136474, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-05-01T00:00:00.000Z', + daily_apy: '2.431794215797376217', + created_at: '2025-05-02T01:00:00.264Z', + updated_at: '2025-05-02T01:00:00.264Z', + }, + { + id: 1127714, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-30T00:00:00.000Z', + daily_apy: '2.552364609574939420', + created_at: '2025-05-01T01:00:00.212Z', + updated_at: '2025-05-01T01:00:00.212Z', + }, + { + id: 1118954, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-29T00:00:00.000Z', + daily_apy: '2.597304459121422799', + created_at: '2025-04-30T01:00:00.402Z', + updated_at: '2025-04-30T01:00:00.402Z', + }, + { + id: 1109829, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-28T00:00:00.000Z', + daily_apy: '2.639328108417040417', + created_at: '2025-04-29T01:00:00.276Z', + updated_at: '2025-04-29T01:00:00.276Z', + }, + { + id: 1100704, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-27T00:00:00.000Z', + daily_apy: '2.731128574803426936', + created_at: '2025-04-28T00:54:02.772Z', + updated_at: '2025-04-28T00:54:02.772Z', + }, + { + id: 1091944, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-26T00:00:00.000Z', + daily_apy: '2.513730750183385288', + created_at: '2025-04-27T01:00:00.388Z', + updated_at: '2025-04-27T01:00:00.388Z', + }, + { + id: 1083184, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-25T00:00:00.000Z', + daily_apy: '3.657783187026349447', + created_at: '2025-04-26T01:00:00.476Z', + updated_at: '2025-04-26T01:00:00.476Z', + }, + { + id: 1074424, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-24T00:00:00.000Z', + daily_apy: '2.497561674999690265', + created_at: '2025-04-25T01:00:00.425Z', + updated_at: '2025-04-25T01:00:00.425Z', + }, + { + id: 1065664, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-23T00:00:00.000Z', + daily_apy: '2.434718736272556139', + created_at: '2025-04-24T00:41:56.479Z', + updated_at: '2025-04-24T00:41:56.479Z', + }, + { + id: 1056904, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-22T00:00:00.000Z', + daily_apy: '3.089233192365398230', + created_at: '2025-04-23T01:00:00.592Z', + updated_at: '2025-04-23T01:00:00.592Z', + }, + { + id: 1048168, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-21T00:00:00.000Z', + daily_apy: '3.073296504073085343', + created_at: '2025-04-22T01:00:00.247Z', + updated_at: '2025-04-22T01:00:00.247Z', + }, + { + id: 1039093, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-20T00:00:00.000Z', + daily_apy: '2.351103366533415929', + created_at: '2025-04-21T00:37:31.958Z', + updated_at: '2025-04-21T00:37:31.958Z', + }, + { + id: 1030405, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-19T00:00:00.000Z', + daily_apy: '2.225352639174754480', + created_at: '2025-04-20T01:00:00.251Z', + updated_at: '2025-04-20T01:00:00.251Z', + }, + { + id: 1021741, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-18T00:00:00.000Z', + daily_apy: '2.644886616949982688', + created_at: '2025-04-19T01:00:00.357Z', + updated_at: '2025-04-19T01:00:00.357Z', + }, + { + id: 1012741, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-17T00:00:00.000Z', + daily_apy: '3.042329145189525000', + created_at: '2025-04-18T00:57:21.407Z', + updated_at: '2025-04-18T00:57:21.407Z', + }, + { + id: 1004125, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-16T00:00:00.000Z', + daily_apy: '3.643247075892654388', + created_at: '2025-04-17T01:00:00.386Z', + updated_at: '2025-04-17T01:00:00.386Z', + }, + { + id: 995533, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-15T00:00:00.000Z', + daily_apy: '2.520282215494650327', + created_at: '2025-04-16T01:00:00.359Z', + updated_at: '2025-04-16T01:00:00.359Z', + }, + { + id: 986965, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-14T00:00:00.000Z', + daily_apy: '3.208098989378294303', + created_at: '2025-04-15T01:00:00.244Z', + updated_at: '2025-04-15T01:00:00.244Z', + }, + { + id: 978421, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-13T00:00:00.000Z', + daily_apy: '2.721165617974596073', + created_at: '2025-04-14T01:24:47.357Z', + updated_at: '2025-04-14T01:24:47.357Z', + }, + { + id: 969901, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-12T00:00:00.000Z', + daily_apy: '2.250107844448287445', + created_at: '2025-04-13T01:00:00.586Z', + updated_at: '2025-04-13T01:00:00.586Z', + }, + { + id: 961405, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-11T00:00:00.000Z', + daily_apy: '2.344017241063298728', + created_at: '2025-04-12T01:00:00.643Z', + updated_at: '2025-04-12T01:00:00.643Z', + }, + { + id: 952933, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-10T00:00:00.000Z', + daily_apy: '2.569225132702528208', + created_at: '2025-04-11T01:00:00.279Z', + updated_at: '2025-04-11T01:00:00.279Z', + }, + { + id: 944485, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-09T00:00:00.000Z', + daily_apy: '3.234066352579156858', + created_at: '2025-04-10T01:00:00.450Z', + updated_at: '2025-04-10T01:00:00.450Z', + }, + { + id: 936412, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-08T00:00:00.000Z', + daily_apy: '2.404572998161749392', + created_at: '2025-04-09T01:00:00.472Z', + updated_at: '2025-04-09T01:00:00.472Z', + }, + { + id: 930462, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-07T00:00:00.000Z', + daily_apy: '16.274294029981817920', + created_at: '2025-04-08T08:00:00.412Z', + updated_at: '2025-04-08T08:00:00.412Z', + }, + { + id: 922784, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-06T00:00:00.000Z', + daily_apy: '2.327823284439173894', + created_at: '2025-04-07T01:00:00.387Z', + updated_at: '2025-04-07T01:00:00.387Z', + }, + { + id: 914432, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-05T00:00:00.000Z', + daily_apy: '2.713790128923583407', + created_at: '2025-04-06T01:00:00.337Z', + updated_at: '2025-04-06T01:00:00.337Z', + }, + { + id: 906104, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-04T00:00:00.000Z', + daily_apy: '3.137879600264120575', + created_at: '2025-04-05T01:40:12.734Z', + updated_at: '2025-04-05T01:40:12.734Z', + }, + { + id: 897800, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-03T00:00:00.000Z', + daily_apy: '2.749937989528119524', + created_at: '2025-04-04T01:00:00.477Z', + updated_at: '2025-04-04T01:00:00.477Z', + }, + { + id: 889520, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-02T00:00:00.000Z', + daily_apy: '2.808586782003876191', + created_at: '2025-04-03T01:00:00.288Z', + updated_at: '2025-04-03T01:00:00.288Z', + }, + { + id: 881264, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-04-01T00:00:00.000Z', + daily_apy: '2.641420673236496893', + created_at: '2025-04-02T01:00:00.522Z', + updated_at: '2025-04-02T01:00:00.522Z', + }, + { + id: 873032, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-31T00:00:00.000Z', + daily_apy: '2.782427831689054822', + created_at: '2025-04-01T01:00:00.645Z', + updated_at: '2025-04-01T01:00:00.645Z', + }, + { + id: 864140, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-30T00:00:00.000Z', + daily_apy: '2.435077125451363938', + created_at: '2025-03-31T01:00:00.403Z', + updated_at: '2025-03-31T01:00:00.403Z', + }, + { + id: 855956, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-29T00:00:00.000Z', + daily_apy: '2.862289970264342588', + created_at: '2025-03-30T01:00:00.743Z', + updated_at: '2025-03-30T01:00:00.743Z', + }, + { + id: 847796, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-28T00:00:00.000Z', + daily_apy: '2.777681416203091593', + created_at: '2025-03-29T01:00:00.472Z', + updated_at: '2025-03-29T01:00:00.472Z', + }, + { + id: 835532, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-27T00:00:00.000Z', + daily_apy: '2.407050370521265321', + created_at: '2025-03-28T01:00:00.320Z', + updated_at: '2025-03-28T01:00:00.320Z', + }, + { + id: 820184, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-26T00:00:00.000Z', + daily_apy: '2.334231120347892478', + created_at: '2025-03-27T01:00:00.594Z', + updated_at: '2025-03-27T01:00:00.594Z', + }, + { + id: 803520, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-25T00:00:00.000Z', + daily_apy: '2.288783268450069690', + created_at: '2025-03-26T01:00:00.327Z', + updated_at: '2025-03-26T01:00:00.327Z', + }, + { + id: 787248, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-24T00:00:00.000Z', + daily_apy: '3.207491419307942588', + created_at: '2025-03-25T01:00:00.352Z', + updated_at: '2025-03-25T01:00:00.352Z', + }, + { + id: 771365, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-23T00:00:00.000Z', + daily_apy: '2.427588569162684071', + created_at: '2025-03-24T01:00:00.532Z', + updated_at: '2025-03-24T01:00:00.532Z', + }, + { + id: 754825, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-22T00:00:00.000Z', + daily_apy: '2.427961598326381858', + created_at: '2025-03-23T01:00:00.458Z', + updated_at: '2025-03-23T01:00:00.458Z', + }, + { + id: 738673, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-21T00:00:00.000Z', + daily_apy: '2.221222106732271571', + created_at: '2025-03-22T01:00:00.581Z', + updated_at: '2025-03-22T01:00:00.581Z', + }, + { + id: 722908, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-20T00:00:00.000Z', + daily_apy: '3.109400223632458407', + created_at: '2025-03-21T01:00:00.403Z', + updated_at: '2025-03-21T01:00:00.403Z', + }, + { + id: 706182, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-19T00:00:00.000Z', + daily_apy: '2.114127349815966869', + created_at: '2025-03-20T01:00:00.412Z', + updated_at: '2025-03-20T01:00:00.412Z', + }, + { + id: 690174, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-18T00:00:00.000Z', + daily_apy: '1.995508052653456692', + created_at: '2025-03-19T01:00:00.254Z', + updated_at: '2025-03-19T01:00:00.254Z', + }, + { + id: 674213, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-17T00:00:00.000Z', + daily_apy: '2.808134980278030199', + created_at: '2025-03-18T01:00:00.236Z', + updated_at: '2025-03-18T01:00:00.236Z', + }, + { + id: 658682, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-16T00:00:00.000Z', + daily_apy: '2.046101340514063938', + created_at: '2025-03-17T01:00:00.504Z', + updated_at: '2025-03-17T01:00:00.504Z', + }, + { + id: 642842, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-15T00:00:00.000Z', + daily_apy: '2.412183366907788883', + created_at: '2025-03-16T01:00:00.448Z', + updated_at: '2025-03-16T01:00:00.448Z', + }, + { + id: 627026, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-14T00:00:00.000Z', + daily_apy: '2.416168547971019726', + created_at: '2025-03-15T01:00:01.011Z', + updated_at: '2025-03-15T01:00:01.011Z', + }, + { + id: 610915, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-13T00:00:00.000Z', + daily_apy: '3.320387982775072235', + created_at: '2025-03-14T01:00:00.516Z', + updated_at: '2025-03-14T01:00:00.516Z', + }, + { + id: 595171, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-12T00:00:00.000Z', + daily_apy: '2.398300210461381471', + created_at: '2025-03-13T01:00:01.132Z', + updated_at: '2025-03-13T01:00:01.132Z', + }, + { + id: 579789, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-11T00:00:00.000Z', + daily_apy: '2.509179581886257688', + created_at: '2025-03-12T01:00:00.790Z', + updated_at: '2025-03-12T01:00:00.790Z', + }, + { + id: 563878, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-10T00:00:00.000Z', + daily_apy: '3.040062426245623838', + created_at: '2025-03-11T01:00:00.498Z', + updated_at: '2025-03-11T01:00:00.498Z', + }, + { + id: 548653, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-09T00:00:00.000Z', + daily_apy: '3.129336844068671681', + created_at: '2025-03-10T01:00:00.818Z', + updated_at: '2025-03-10T01:00:00.818Z', + }, + { + id: 532798, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-08T00:00:00.000Z', + daily_apy: '2.863111335359804701', + created_at: '2025-03-09T01:00:00.390Z', + updated_at: '2025-03-09T01:00:00.390Z', + }, + { + id: 517621, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-07T00:00:00.000Z', + daily_apy: '2.264822137341302157', + created_at: '2025-03-08T01:00:00.482Z', + updated_at: '2025-03-08T01:00:00.482Z', + }, + { + id: 501814, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-06T00:00:00.000Z', + daily_apy: '2.532227088223784071', + created_at: '2025-03-07T01:00:00.255Z', + updated_at: '2025-03-07T01:00:00.255Z', + }, + { + id: 486358, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-05T00:00:00.000Z', + daily_apy: '2.732211047232251383', + created_at: '2025-03-06T01:00:00.523Z', + updated_at: '2025-03-06T01:00:00.523Z', + }, + { + id: 470926, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-04T00:00:00.000Z', + daily_apy: '2.512702783538479314', + created_at: '2025-03-05T01:00:00.556Z', + updated_at: '2025-03-05T01:00:00.556Z', + }, + { + id: 455873, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-03T00:00:00.000Z', + daily_apy: '2.763614942213400498', + created_at: '2025-03-04T01:00:00.316Z', + updated_at: '2025-03-04T01:00:00.316Z', + }, + { + id: 440322, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-02T00:00:00.000Z', + daily_apy: '3.428494642246296405', + created_at: '2025-03-03T01:00:00.700Z', + updated_at: '2025-03-03T01:00:00.700Z', + }, + { + id: 425246, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-03-01T00:00:00.000Z', + daily_apy: '2.154023267416044690', + created_at: '2025-03-02T01:00:00.870Z', + updated_at: '2025-03-02T01:00:00.870Z', + }, + { + id: 409659, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-28T00:00:00.000Z', + daily_apy: '2.598153256102263053', + created_at: '2025-03-01T01:00:00.566Z', + updated_at: '2025-03-01T01:00:00.566Z', + }, + { + id: 394443, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-27T00:00:00.000Z', + daily_apy: '3.762723089359417810', + created_at: '2025-02-28T01:00:00.524Z', + updated_at: '2025-02-28T01:00:00.524Z', + }, + { + id: 379274, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-26T00:00:00.000Z', + daily_apy: '2.229242686432259735', + created_at: '2025-02-27T01:00:00.610Z', + updated_at: '2025-02-27T01:00:00.610Z', + }, + { + id: 364311, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-25T00:00:00.000Z', + daily_apy: '3.084851610358903374', + created_at: '2025-02-26T01:00:00.611Z', + updated_at: '2025-02-26T01:00:00.611Z', + }, + { + id: 348804, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-24T00:00:00.000Z', + daily_apy: '2.178221453819123396', + created_at: '2025-02-25T01:00:00.695Z', + updated_at: '2025-02-25T01:00:00.695Z', + }, + { + id: 333461, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-23T00:00:00.000Z', + daily_apy: '2.279118367895585177', + created_at: '2025-02-24T01:00:00.520Z', + updated_at: '2025-02-24T01:00:00.520Z', + }, + { + id: 318485, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-22T00:00:00.000Z', + daily_apy: '2.162399050384939712', + created_at: '2025-02-23T01:00:00.438Z', + updated_at: '2025-02-23T01:00:00.438Z', + }, + { + id: 303874, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-21T00:00:00.000Z', + daily_apy: '2.456315229864533296', + created_at: '2025-02-22T01:00:01.098Z', + updated_at: '2025-02-22T01:00:01.098Z', + }, + { + id: 288678, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-20T00:00:00.000Z', + daily_apy: '3.114867534342414989', + created_at: '2025-02-21T01:00:00.390Z', + updated_at: '2025-02-21T01:00:00.390Z', + }, + { + id: 274160, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-19T00:00:00.000Z', + daily_apy: '2.273150114369428540', + created_at: '2025-02-20T01:00:00.686Z', + updated_at: '2025-02-20T01:00:00.686Z', + }, + { + id: 259375, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-18T00:00:00.000Z', + daily_apy: '2.601753752988867146', + created_at: '2025-02-19T01:00:00.460Z', + updated_at: '2025-02-19T01:00:00.460Z', + }, + { + id: 244325, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-17T00:00:00.000Z', + daily_apy: '2.371788704658418308', + created_at: '2025-02-18T01:00:00.579Z', + updated_at: '2025-02-18T01:00:00.579Z', + }, + { + id: 229949, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-16T00:00:00.000Z', + daily_apy: '2.037130166329167644', + created_at: '2025-02-17T01:00:00.368Z', + updated_at: '2025-02-17T01:00:00.368Z', + }, + { + id: 214997, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-15T00:00:00.000Z', + daily_apy: '2.495509141072538330', + created_at: '2025-02-16T01:00:00.737Z', + updated_at: '2025-02-16T01:00:00.737Z', + }, + { + id: 200715, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-14T00:00:00.000Z', + daily_apy: '2.760147959320520741', + created_at: '2025-02-15T01:00:00.521Z', + updated_at: '2025-02-15T01:00:00.521Z', + }, + { + id: 185862, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-13T00:00:00.000Z', + daily_apy: '2.620957696005122124', + created_at: '2025-02-14T01:00:00.438Z', + updated_at: '2025-02-14T01:00:00.438Z', + }, + { + id: 171673, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-12T00:00:00.000Z', + daily_apy: '3.520427702298995520', + created_at: '2025-02-13T01:00:00.384Z', + updated_at: '2025-02-13T01:00:00.384Z', + }, + { + id: 156917, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-11T00:00:00.000Z', + daily_apy: '2.314556111097796460', + created_at: '2025-02-12T01:00:00.293Z', + updated_at: '2025-02-12T01:00:00.293Z', + }, + { + id: 142517, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-10T00:00:00.000Z', + daily_apy: '2.171292704662784237', + created_at: '2025-02-11T01:00:00.521Z', + updated_at: '2025-02-11T01:00:00.521Z', + }, + { + id: 128165, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-09T00:00:00.000Z', + daily_apy: '2.676286927153115044', + created_at: '2025-02-10T01:00:00.790Z', + updated_at: '2025-02-10T01:00:00.790Z', + }, + { + id: 113861, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-08T00:00:00.000Z', + daily_apy: '2.386368022241520299', + created_at: '2025-02-09T01:00:00.401Z', + updated_at: '2025-02-09T01:00:00.401Z', + }, + { + id: 99624, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-07T00:00:00.000Z', + daily_apy: '2.422358786455122843', + created_at: '2025-02-08T01:00:00.409Z', + updated_at: '2025-02-08T01:00:00.409Z', + }, + { + id: 85398, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-06T00:00:00.000Z', + daily_apy: '2.265223347079687334', + created_at: '2025-02-07T01:00:00.464Z', + updated_at: '2025-02-07T01:00:00.464Z', + }, + { + id: 71538, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-05T00:00:00.000Z', + daily_apy: '2.718738720546652931', + created_at: '2025-02-06T01:00:00.630Z', + updated_at: '2025-02-06T01:00:00.630Z', + }, + { + id: 57125, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-04T00:00:00.000Z', + daily_apy: '2.305794112369113883', + created_at: '2025-02-05T01:00:00.369Z', + updated_at: '2025-02-05T01:00:00.369Z', + }, + { + id: 43061, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-03T00:00:00.000Z', + daily_apy: '2.861341474969266577', + created_at: '2025-02-04T01:00:00.488Z', + updated_at: '2025-02-04T01:00:00.488Z', + }, + { + id: 29343, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-02T00:00:00.000Z', + daily_apy: '2.214925435010173341', + created_at: '2025-02-03T01:00:00.681Z', + updated_at: '2025-02-03T01:00:00.681Z', + }, + { + id: 15374, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-02-01T00:00:00.000Z', + daily_apy: '2.490612483336790321', + created_at: '2025-02-02T01:00:00.369Z', + updated_at: '2025-02-02T01:00:00.369Z', + }, + { + id: 1453, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-31T00:00:00.000Z', + daily_apy: '2.384122251339697898', + created_at: '2025-02-01T01:00:00.892Z', + updated_at: '2025-02-01T01:00:00.892Z', + }, + { + id: 1, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-30T00:00:00.000Z', + daily_apy: '2.588418773657355365', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 2, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-29T00:00:00.000Z', + daily_apy: '2.369512244109374004', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 3, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-28T00:00:00.000Z', + daily_apy: '2.562467575560847069', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 4, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-27T00:00:00.000Z', + daily_apy: '2.911412020118756416', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 5, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-26T00:00:00.000Z', + daily_apy: '2.438629958970523673', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 6, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-25T00:00:00.000Z', + daily_apy: '2.153496352059892035', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 7, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-24T00:00:00.000Z', + daily_apy: '2.114212655218078374', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 8, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-23T00:00:00.000Z', + daily_apy: '2.003296807378464491', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 9, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-22T00:00:00.000Z', + daily_apy: '2.687482649277195133', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 10, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-21T00:00:00.000Z', + daily_apy: '2.861342120295913440', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 11, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-20T00:00:00.000Z', + daily_apy: '3.774434617124937777', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 12, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-19T00:00:00.000Z', + daily_apy: '2.663583581696357190', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 13, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-18T00:00:00.000Z', + daily_apy: '2.502571414414859403', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 14, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-17T00:00:00.000Z', + daily_apy: '2.515897726411376051', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 15, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-16T00:00:00.000Z', + daily_apy: '3.200407596144865155', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 16, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-15T00:00:00.000Z', + daily_apy: '3.120664973229844248', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 17, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-14T00:00:00.000Z', + daily_apy: '3.077150313762752157', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 18, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-13T00:00:00.000Z', + daily_apy: '2.707778270294490265', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 19, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-12T00:00:00.000Z', + daily_apy: '2.433065699826669027', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 20, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-11T00:00:00.000Z', + daily_apy: '2.527220371813221903', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 21, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-10T00:00:00.000Z', + daily_apy: '2.519988313771537168', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 22, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-09T00:00:00.000Z', + daily_apy: '2.398646948217626051', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 23, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-08T00:00:00.000Z', + daily_apy: '2.733806455540747566', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 24, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-07T00:00:00.000Z', + daily_apy: '2.328383448288102046', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 25, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-06T00:00:00.000Z', + daily_apy: '2.308889054919307301', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 26, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-05T00:00:00.000Z', + daily_apy: '2.667287457568427655', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 27, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-04T00:00:00.000Z', + daily_apy: '2.769141076722838274', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 28, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-03T00:00:00.000Z', + daily_apy: '2.172711392137953816', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 29, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-02T00:00:00.000Z', + daily_apy: '2.380809034996447898', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 30, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2025-01-01T00:00:00.000Z', + daily_apy: '2.316257371201709071', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 31, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-31T00:00:00.000Z', + daily_apy: '2.381666501520736228', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 32, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-30T00:00:00.000Z', + daily_apy: '3.290020080906444690', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 33, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-29T00:00:00.000Z', + daily_apy: '2.615126145462823341', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 34, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-28T00:00:00.000Z', + daily_apy: '2.557189631387469027', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 35, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-27T00:00:00.000Z', + daily_apy: '2.158458164262885066', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 36, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-26T00:00:00.000Z', + daily_apy: '2.206510584899516980', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 37, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-25T00:00:00.000Z', + daily_apy: '2.158614798202507662', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 38, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-24T00:00:00.000Z', + daily_apy: '2.295841281173372788', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 39, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-23T00:00:00.000Z', + daily_apy: '2.537213663964201538', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 40, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-22T00:00:00.000Z', + daily_apy: '2.526363960719146691', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 41, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-21T00:00:00.000Z', + daily_apy: '2.827957235815345022', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 42, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-20T00:00:00.000Z', + daily_apy: '1.923858579064909181', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 43, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-19T00:00:00.000Z', + daily_apy: '2.312101511338184403', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 44, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-18T00:00:00.000Z', + daily_apy: '2.379774509103764028', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 45, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-17T00:00:00.000Z', + daily_apy: '2.139060810693860797', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 46, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-16T00:00:00.000Z', + daily_apy: '2.961584998909087010', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 47, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-15T00:00:00.000Z', + daily_apy: '2.379826551660605144', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 48, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-14T00:00:00.000Z', + daily_apy: '2.510051909115494469', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 49, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-13T00:00:00.000Z', + daily_apy: '2.306349129890238053', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 50, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-12T00:00:00.000Z', + daily_apy: '2.769878005945986504', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 51, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-11T00:00:00.000Z', + daily_apy: '2.114872475542218916', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 52, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-10T00:00:00.000Z', + daily_apy: '2.155594623401686062', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 53, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-09T00:00:00.000Z', + daily_apy: '2.710501371113315376', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 54, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-08T00:00:00.000Z', + daily_apy: '2.399345980776163772', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 55, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-07T00:00:00.000Z', + daily_apy: '2.908041198804088108', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 56, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-06T00:00:00.000Z', + daily_apy: '2.145060119823427799', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 57, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-05T00:00:00.000Z', + daily_apy: '2.841421083128894904', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 58, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-04T00:00:00.000Z', + daily_apy: '3.308055911325480863', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 59, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-03T00:00:00.000Z', + daily_apy: '2.686695914242465032', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 60, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-02T00:00:00.000Z', + daily_apy: '2.536785079364388570', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 61, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-12-01T00:00:00.000Z', + daily_apy: '2.648720732497768971', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 62, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-30T00:00:00.000Z', + daily_apy: '2.082197028966412998', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 63, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-29T00:00:00.000Z', + daily_apy: '2.340216070995490985', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 64, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-28T00:00:00.000Z', + daily_apy: '3.319977937666746903', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 65, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-27T00:00:00.000Z', + daily_apy: '3.703776281413848230', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 66, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-26T00:00:00.000Z', + daily_apy: '3.013172805913127821', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 67, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-25T00:00:00.000Z', + daily_apy: '2.365903942695459458', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 68, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-24T00:00:00.000Z', + daily_apy: '3.071031581384699336', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 69, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-23T00:00:00.000Z', + daily_apy: '3.129977735007652931', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 70, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-22T00:00:00.000Z', + daily_apy: '2.244804237084751327', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 71, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-21T00:00:00.000Z', + daily_apy: '1.950710572454624004', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 72, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-20T00:00:00.000Z', + daily_apy: '3.239912566365526493', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 73, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-19T00:00:00.000Z', + daily_apy: '2.186565614119543639', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 74, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-18T00:00:00.000Z', + daily_apy: '2.500262242085932965', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 75, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-17T00:00:00.000Z', + daily_apy: '2.750629904747951715', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 76, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-16T00:00:00.000Z', + daily_apy: '2.276368493096229591', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 77, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-15T00:00:00.000Z', + daily_apy: '4.185305614351850885', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 78, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-14T00:00:00.000Z', + daily_apy: '4.054927851639739823', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 79, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-13T00:00:00.000Z', + daily_apy: '2.909077278596095022', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 80, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-12T00:00:00.000Z', + daily_apy: '2.307813822034531527', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 81, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-11T00:00:00.000Z', + daily_apy: '1.998366010898116261', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 82, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-10T00:00:00.000Z', + daily_apy: '2.785830933199919137', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 83, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-09T00:00:00.000Z', + daily_apy: '2.285879836319064159', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 84, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-08T00:00:00.000Z', + daily_apy: '2.473256195837844616', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 85, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-07T00:00:00.000Z', + daily_apy: '3.098427253806472069', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 86, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-06T00:00:00.000Z', + daily_apy: '2.698985513984858462', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 87, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-05T00:00:00.000Z', + daily_apy: '2.600165108949929204', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 88, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-04T00:00:00.000Z', + daily_apy: '3.047092823478128359', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 89, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-03T00:00:00.000Z', + daily_apy: '2.262961094949021405', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 90, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-02T00:00:00.000Z', + daily_apy: '2.472755536754316040', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 91, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-11-01T00:00:00.000Z', + daily_apy: '2.213646791454453485', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 92, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-31T00:00:00.000Z', + daily_apy: '1.995347106718121350', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 93, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-30T00:00:00.000Z', + daily_apy: '3.662338703533022788', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 94, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-29T00:00:00.000Z', + daily_apy: '3.998958366621137168', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 95, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-28T00:00:00.000Z', + daily_apy: '1.986877342949697677', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 96, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-27T00:00:00.000Z', + daily_apy: '2.759116101988913662', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 97, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-26T00:00:00.000Z', + daily_apy: '2.190927465124786394', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 98, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-25T00:00:00.000Z', + daily_apy: '2.536275873721945520', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 99, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-24T00:00:00.000Z', + daily_apy: '3.024505178229807577', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 100, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-23T00:00:00.000Z', + daily_apy: '4.579748020726853208', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 101, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-22T00:00:00.000Z', + daily_apy: '2.081328937349204369', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 102, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-21T00:00:00.000Z', + daily_apy: '2.184895085506485232', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 103, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-20T00:00:00.000Z', + daily_apy: '2.398880991852390265', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 104, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-19T00:00:00.000Z', + daily_apy: '2.858904340345552600', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 105, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-18T00:00:00.000Z', + daily_apy: '2.657885568442611670', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 106, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-17T00:00:00.000Z', + daily_apy: '3.193858105866704867', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 107, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-16T00:00:00.000Z', + daily_apy: '2.620667694611860785', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 108, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-15T00:00:00.000Z', + daily_apy: '2.257808905335986283', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 109, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-14T00:00:00.000Z', + daily_apy: '3.048205889855458503', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 110, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-13T00:00:00.000Z', + daily_apy: '3.491271068634706969', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 111, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-12T00:00:00.000Z', + daily_apy: '2.271615154691328816', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 112, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-11T00:00:00.000Z', + daily_apy: '4.405412493954814878', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 113, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-10T00:00:00.000Z', + daily_apy: '2.503050109321094303', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 114, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-09T00:00:00.000Z', + daily_apy: '2.548443687968629314', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 115, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-08T00:00:00.000Z', + daily_apy: '4.136252852174733518', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 116, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-07T00:00:00.000Z', + daily_apy: '2.485272772198996128', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 117, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-06T00:00:00.000Z', + daily_apy: '2.479277102403876272', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 118, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-05T00:00:00.000Z', + daily_apy: '2.837898081632334126', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 119, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-04T00:00:00.000Z', + daily_apy: '2.934606383096206361', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 120, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-03T00:00:00.000Z', + daily_apy: '2.321912203129618805', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 121, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-02T00:00:00.000Z', + daily_apy: '4.075864607847539878', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 122, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-10-01T00:00:00.000Z', + daily_apy: '3.492224428286716040', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 123, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-30T00:00:00.000Z', + daily_apy: '3.043821641472397732', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 124, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-29T00:00:00.000Z', + daily_apy: '2.613509495777192257', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 125, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-28T00:00:00.000Z', + daily_apy: '3.063007925443190708', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 126, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-27T00:00:00.000Z', + daily_apy: '2.378093521658078927', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 127, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-26T00:00:00.000Z', + daily_apy: '2.994244265878227378', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 128, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-25T00:00:00.000Z', + daily_apy: '2.745946218296348711', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 129, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-24T00:00:00.000Z', + daily_apy: '2.405584015735161504', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 130, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-23T00:00:00.000Z', + daily_apy: '2.989605996702352434', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 131, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-22T00:00:00.000Z', + daily_apy: '3.133819559469009126', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 132, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-21T00:00:00.000Z', + daily_apy: '3.281163916757090874', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 133, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-20T00:00:00.000Z', + daily_apy: '2.421092014783149226', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 134, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-19T00:00:00.000Z', + daily_apy: '2.022003308779013827', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 135, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-18T00:00:00.000Z', + daily_apy: '4.529200757864845022', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 136, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-17T00:00:00.000Z', + daily_apy: '3.814667283365271847', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 137, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-16T00:00:00.000Z', + daily_apy: '2.476474847261134347', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 138, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-15T00:00:00.000Z', + daily_apy: '2.220164627763467091', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 139, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-14T00:00:00.000Z', + daily_apy: '2.169901379130526991', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 140, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-13T00:00:00.000Z', + daily_apy: '2.541326517193025111', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 141, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-12T00:00:00.000Z', + daily_apy: '1.954328493653199558', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 142, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-11T00:00:00.000Z', + daily_apy: '4.072906334326804757', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 143, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-10T00:00:00.000Z', + daily_apy: '2.739440400339635841', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 144, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-09T00:00:00.000Z', + daily_apy: '2.273344513516595409', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 145, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-08T00:00:00.000Z', + daily_apy: '3.217442261940307633', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 146, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-07T00:00:00.000Z', + daily_apy: '4.105387731991787334', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 147, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-06T00:00:00.000Z', + daily_apy: '2.563906839247964270', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 148, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-05T00:00:00.000Z', + daily_apy: '2.380780361973878761', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 149, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-04T00:00:00.000Z', + daily_apy: '2.010176278212447677', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 150, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-03T00:00:00.000Z', + daily_apy: '2.014879575261188330', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 151, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-02T00:00:00.000Z', + daily_apy: '2.391696310813180586', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 152, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-09-01T00:00:00.000Z', + daily_apy: '2.691837002881187721', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 153, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-31T00:00:00.000Z', + daily_apy: '2.848137731334544082', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 154, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-30T00:00:00.000Z', + daily_apy: '2.625672748211783960', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 155, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-29T00:00:00.000Z', + daily_apy: '3.172130768572289602', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 156, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-28T00:00:00.000Z', + daily_apy: '2.224165802625017824', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 157, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-27T00:00:00.000Z', + daily_apy: '2.562155658467889340', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 158, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-26T00:00:00.000Z', + daily_apy: '2.242707816052420686', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 159, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-25T00:00:00.000Z', + daily_apy: '2.890637338773570631', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 160, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-24T00:00:00.000Z', + daily_apy: '1.953735894063520686', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 161, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-23T00:00:00.000Z', + daily_apy: '2.341905215163377655', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 162, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-22T00:00:00.000Z', + daily_apy: '2.536577572533897118', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 163, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-21T00:00:00.000Z', + daily_apy: '2.936813438123844461', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 164, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-20T00:00:00.000Z', + daily_apy: '2.331715949254856433', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 165, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-19T00:00:00.000Z', + daily_apy: '2.567146482818828263', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 166, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-18T00:00:00.000Z', + daily_apy: '4.105202614977249668', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 167, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-17T00:00:00.000Z', + daily_apy: '2.997171370184038606', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 168, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-16T00:00:00.000Z', + daily_apy: '2.407456734779822954', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 169, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-15T00:00:00.000Z', + daily_apy: '2.454080118573422788', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 170, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-14T00:00:00.000Z', + daily_apy: '2.002963922567441482', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 171, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-13T00:00:00.000Z', + daily_apy: '2.012008626631090653', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 172, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-12T00:00:00.000Z', + daily_apy: '2.028749136065413606', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 173, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-11T00:00:00.000Z', + daily_apy: '1.976160574262442644', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 174, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-10T00:00:00.000Z', + daily_apy: '1.948307879992547788', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 175, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-09T00:00:00.000Z', + daily_apy: '2.586055369837104757', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 176, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-08T00:00:00.000Z', + daily_apy: '3.923370517729219746', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 177, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-07T00:00:00.000Z', + daily_apy: '1.862965024176516372', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, + { + id: 178, + chain_id: 1, + vault_address: '0x4fef9d741011476750a243ac70b9789a63dd47df', + timestamp: '2024-08-06T00:00:00.000Z', + daily_apy: '2.243214854150509015', + created_at: '2025-01-31T23:05:14.390Z', + updated_at: '2025-01-31T23:05:14.390Z', + }, +]; + +export const STAKING_API_LENDING_RESPONSE = { + markets: [ + { + id: '0x6ab707aca953edaefbc4fd23ba73294241490620', + chainId: 42161, + protocol: 'aave', + name: '0x6ab707aca953edaefbc4fd23ba73294241490620', + address: '0x6ab707aca953edaefbc4fd23ba73294241490620', + netSupplyRate: 3.8161574310387487, + totalSupplyRate: 3.8161574310387487, + rewards: [], + tvlUnderlying: '94482616843290', + underlying: { + address: '0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9', + chainId: 42161, + }, + outputToken: { + address: '0x6ab707aca953edaefbc4fd23ba73294241490620', + chainId: 42161, + }, + }, + { + id: '0x724dc807b04555b71ed48a6896b6f41593b8c637', + chainId: 42161, + protocol: 'aave', + name: '0x724dc807b04555b71ed48a6896b6f41593b8c637', + address: '0x724dc807b04555b71ed48a6896b6f41593b8c637', + netSupplyRate: 3.5114225639239254, + totalSupplyRate: 3.5114225639239254, + rewards: [], + tvlUnderlying: '280326808784312', + underlying: { + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + chainId: 42161, + }, + outputToken: { + address: '0x724dc807b04555b71ed48a6896b6f41593b8c637', + chainId: 42161, + }, + }, + { + id: '0x82e64f49ed5ec1bc6e43dad4fc8af9bb3a2312ee', + chainId: 42161, + protocol: 'aave', + name: '0x82e64f49ed5ec1bc6e43dad4fc8af9bb3a2312ee', + address: '0x82e64f49ed5ec1bc6e43dad4fc8af9bb3a2312ee', + netSupplyRate: 3.3825609249561017, + totalSupplyRate: 3.3825609249561017, + rewards: [], + tvlUnderlying: '7029707533530581450561709', + underlying: { + address: '0xda10009cbd5d07dd0cecc66161fc93d7c9000da1', + chainId: 42161, + }, + outputToken: { + address: '0x82e64f49ed5ec1bc6e43dad4fc8af9bb3a2312ee', + chainId: 42161, + }, + }, + { + id: '0x018008bfb33d285247a21d44e50697654f754e63', + chainId: 1, + protocol: 'aave', + name: '0x018008bfb33d285247a21d44e50697654f754e63', + address: '0x018008bfb33d285247a21d44e50697654f754e63', + netSupplyRate: 3.246198558489036, + totalSupplyRate: 3.246198558489036, + rewards: [], + tvlUnderlying: '176868291129310390562110292', + underlying: { + address: '0x6b175474e89094c44da98b954eedeac495271d0f', + chainId: 1, + }, + outputToken: { + address: '0x018008bfb33d285247a21d44e50697654f754e63', + chainId: 1, + }, + }, + { + id: '0x23878914efe38d27c4d67ab83ed1b93a74d4086a', + chainId: 1, + protocol: 'aave', + name: '0x23878914efe38d27c4d67ab83ed1b93a74d4086a', + address: '0x23878914efe38d27c4d67ab83ed1b93a74d4086a', + netSupplyRate: 3.5842156590894767, + totalSupplyRate: 3.5842156590894767, + rewards: [], + tvlUnderlying: '8075909063710562', + underlying: { + address: '0xdac17f958d2ee523a2206206994597c13d831ec7', + chainId: 1, + }, + outputToken: { + address: '0x23878914efe38d27c4d67ab83ed1b93a74d4086a', + chainId: 1, + }, + }, + { + id: '0x98c23e9d8f34fefb1b7bd6a91b7ff122f4e16f5c', + chainId: 1, + protocol: 'aave', + name: '0x98c23e9d8f34fefb1b7bd6a91b7ff122f4e16f5c', + address: '0x98c23e9d8f34fefb1b7bd6a91b7ff122f4e16f5c', + netSupplyRate: 4.127418405053469, + totalSupplyRate: 4.127418405053469, + rewards: [], + tvlUnderlying: '3918003902274145', + underlying: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: 1, + }, + outputToken: { + address: '0x98c23e9d8f34fefb1b7bd6a91b7ff122f4e16f5c', + chainId: 1, + }, + }, + { + id: '0x4e65fe4dba92790696d040ac24aa414708f5c0ab', + chainId: 8453, + protocol: 'aave', + name: '0x4e65fe4dba92790696d040ac24aa414708f5c0ab', + address: '0x4e65fe4dba92790696d040ac24aa414708f5c0ab', + netSupplyRate: 5.1724226654358745, + totalSupplyRate: 5.1724226654358745, + rewards: [], + tvlUnderlying: '200378429920868', + underlying: { + address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + chainId: 8453, + }, + outputToken: { + address: '0x4e65fe4dba92790696d040ac24aa414708f5c0ab', + chainId: 8453, + }, + }, + { + id: '0x374d7860c4f2f604de0191298dd393703cce84f3', + chainId: 59144, + protocol: 'aave', + name: '0x374d7860c4f2f604de0191298dd393703cce84f3', + address: '0x374d7860c4f2f604de0191298dd393703cce84f3', + netSupplyRate: 3.6878492921828356, + totalSupplyRate: 3.6878492921828356, + rewards: [], + tvlUnderlying: '1875214972417', + underlying: { + address: '0x176211869ca2b568f2a7d4ee941e073a821ee1ff', + chainId: 59144, + }, + outputToken: { + address: '0x374d7860c4f2f604de0191298dd393703cce84f3', + chainId: 59144, + }, + }, + { + id: '0x88231dfec71d4ff5c1e466d08c321944a7adc673', + chainId: 59144, + protocol: 'aave', + name: '0x88231dfec71d4ff5c1e466d08c321944a7adc673', + address: '0x88231dfec71d4ff5c1e466d08c321944a7adc673', + netSupplyRate: 3.890851460575282, + totalSupplyRate: 3.890851460575282, + rewards: [], + tvlUnderlying: '550394667084', + underlying: { + address: '0xa219439258ca9da29e9cc4ce5596924745e12b93', + chainId: 59144, + }, + outputToken: { + address: '0x88231dfec71d4ff5c1e466d08c321944a7adc673', + chainId: 59144, + }, + }, + ], +}; diff --git a/e2e/api-mocking/mock-responses/token-api-responses.ts b/e2e/api-mocking/mock-responses/token-api-responses.ts new file mode 100644 index 000000000000..7c31a81fc523 --- /dev/null +++ b/e2e/api-mocking/mock-responses/token-api-responses.ts @@ -0,0 +1,293 @@ +export const TOKEN_API_TOKENS_RESPONSE = [ + { + address: '0xdac17f958d2ee523a2206206994597c13d831ec7', + symbol: 'USDT', + decimals: 6, + name: 'Tether USD', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1337/0xdac17f958d2ee523a2206206994597c13d831ec7.png', + aggregators: [ + 'uniswapLabs', + 'metamask', + 'aave', + 'coinGecko', + 'openSwap', + 'zerion', + 'oneInch', + 'liFi', + 'xSwap', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'pmm', + 'bancor', + ], + occurrences: 17, + }, + { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + decimals: 6, + name: 'USDCoin', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1337/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.png', + aggregators: [ + 'uniswapLabs', + 'metamask', + 'aave', + 'coinGecko', + 'openSwap', + 'zerion', + 'oneInch', + 'liFi', + 'xSwap', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'pmm', + 'bancor', + ], + occurrences: 17, + }, + { + address: '0x6b175474e89094c44da98b954eedeac495271d0f', + symbol: 'DAI', + decimals: 18, + name: 'Dai Stablecoin', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1337/0x6b175474e89094c44da98b954eedeac495271d0f.png', + aggregators: [ + 'uniswapLabs', + 'metamask', + 'aave', + 'cmc', + 'coinGecko', + 'coinMarketCap', + 'openSwap', + 'zerion', + 'oneInch', + 'liFi', + 'xSwap', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'pmm', + 'bancor', + ], + occurrences: 19, + }, + { + address: '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2', + symbol: 'SUSHI', + decimals: 18, + name: 'Sushi', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1337/0x6b3595068778dd592e39a122f4f5a5cf09c90fe2.png', + aggregators: [ + 'uniswapLabs', + 'metamask', + 'cmc', + 'coinGecko', + 'coinMarketCap', + 'openSwap', + 'zerion', + 'oneInch', + 'liFi', + 'xSwap', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'pmm', + 'bancor', + ], + occurrences: 18, + }, + { + address: '0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9', + symbol: 'AAVE', + decimals: 18, + name: 'Aave', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1337/0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9.png', + aggregators: [ + 'uniswapLabs', + 'metamask', + 'aave', + 'cmc', + 'coinGecko', + 'coinMarketCap', + 'openSwap', + 'zerion', + 'oneInch', + 'liFi', + 'xSwap', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'pmm', + 'bancor', + ], + occurrences: 19, + }, + { + address: '0x111111111117dc0aa78b770fa6a738034120c302', + symbol: '1INCH', + decimals: 18, + name: '1inch', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1337/0x111111111117dc0aa78b770fa6a738034120c302.png', + aggregators: [ + 'uniswapLabs', + 'metamask', + 'coinGecko', + 'coinMarketCap', + 'openSwap', + 'zerion', + 'oneInch', + 'liFi', + 'xSwap', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'pmm', + 'bancor', + ], + occurrences: 17, + }, + { + address: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + symbol: 'WETH', + decimals: 18, + name: 'Wrapped Ether', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1337/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2.png', + aggregators: [ + 'uniswapLabs', + 'metamask', + 'aave', + 'coinGecko', + 'coinMarketCap', + 'openSwap', + 'zerion', + 'oneInch', + 'liFi', + 'xSwap', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'pmm', + ], + occurrences: 17, + }, + { + address: '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599', + symbol: 'WBTC', + decimals: 8, + name: 'Wrapped BTC', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1337/0x2260fac5e5542a773aa44fbcfedf7c193bc2c599.png', + aggregators: [ + 'uniswapLabs', + 'metamask', + 'aave', + 'cmc', + 'coinGecko', + 'coinMarketCap', + 'openSwap', + 'zerion', + 'oneInch', + 'liFi', + 'xSwap', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'pmm', + 'bancor', + ], + occurrences: 19, + }, + { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + name: 'ChainLink Token', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1337/0x514910771af9ca656af840dff83e8264ecf986ca.png', + aggregators: [ + 'uniswapLabs', + 'metamask', + 'aave', + 'cmc', + 'coinGecko', + 'coinMarketCap', + 'openSwap', + 'zerion', + 'oneInch', + 'liFi', + 'xSwap', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'pmm', + 'bancor', + ], + occurrences: 19, + }, + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + symbol: 'UNI', + decimals: 18, + name: 'Uniswap', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1337/0x1f9840a85d5af5bf1d1762f925bdaddc4201f984.png', + aggregators: [ + 'uniswapLabs', + 'metamask', + 'aave', + 'cmc', + 'coinGecko', + 'coinMarketCap', + 'openSwap', + 'zerion', + 'oneInch', + 'liFi', + 'xSwap', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'pmm', + 'bancor', + ], + occurrences: 19, + }, +]; diff --git a/e2e/framework/fixtures/FixtureHelper.ts b/e2e/framework/fixtures/FixtureHelper.ts index 129da74987ac..c5bb7db58c79 100644 --- a/e2e/framework/fixtures/FixtureHelper.ts +++ b/e2e/framework/fixtures/FixtureHelper.ts @@ -41,6 +41,7 @@ import { mockNotificationServices } from '../../specs/notifications/utils/mocks' import { type Mockttp } from 'mockttp'; import { Buffer } from 'buffer'; import crypto from 'crypto'; +import { DEFAULT_MOCKS } from '../../api-mocking/default-mocks'; const logger = createLogger({ name: 'FixtureHelper', @@ -320,6 +321,48 @@ export const stopFixtureServer = async (fixtureServer: FixtureServer) => { logger.debug('The fixture server is stopped'); }; +/** + * Merges test-specific mocks with default mocks, prioritizing test-specific mocks + * @param testSpecificMocks - Test-specific mock events organized by method + * @returns Merged mock events with test-specific mocks taking priority + */ +const mergeWithDefaultMocks = ( + testSpecificMocks: TestSpecificMock | undefined, +) => { + if (!testSpecificMocks) { + return DEFAULT_MOCKS; + } + + const mergedMocks: TestSpecificMock = {}; + + // Get all HTTP methods from both test-specific and default mocks + const allMethods = new Set([ + ...Object.keys(testSpecificMocks), + ...Object.keys(DEFAULT_MOCKS), + ]); + + allMethods.forEach((method) => { + const testMocks = testSpecificMocks[method as keyof TestSpecificMock] || []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const defaultMocks = (DEFAULT_MOCKS as any)[method] || []; + + // Create a set of URLs that already exist in test-specific mocks + const testMockUrls = new Set(testMocks.map((mock) => mock.urlEndpoint)); + + // Filter out default mocks that have the same URL as test-specific mocks + const filteredDefaultMocks = defaultMocks.filter( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (defaultMock: any) => !testMockUrls.has(defaultMock.urlEndpoint), + ); + + // Merge test-specific mocks first, then append non-duplicate default mocks + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mergedMocks as any)[method] = [...testMocks, ...filteredDefaultMocks]; + }); + + return mergedMocks; +}; + export const createMockAPIServer = async ( mockServerInstance?: Mockttp, testSpecificMock?: TestSpecificMock, @@ -351,7 +394,8 @@ export const createMockAPIServer = async ( // testSpecificMock only if (!mockServerInstance && testSpecificMock) { mockServerPort = getMockServerPort(); - mockServer = await startMockServer(testSpecificMock, mockServerPort); + const mergedMocks = mergeWithDefaultMocks(testSpecificMock); + mockServer = await startMockServer(mergedMocks, mockServerPort); logger.debug( `Mock server started from testSpecificMock on port ${mockServerPort}`, @@ -361,7 +405,8 @@ export const createMockAPIServer = async ( // neither if (!mockServerInstance && !testSpecificMock) { mockServerPort = getMockServerPort(); - mockServer = await startMockServer({}, mockServerPort); + const mergedMocks = mergeWithDefaultMocks(testSpecificMock); + mockServer = await startMockServer(mergedMocks, mockServerPort); logger.debug( `Mock server started from testSpecificMock on port ${mockServerPort}`, diff --git a/e2e/specs/assets/multichain/asset-list.spec.ts b/e2e/specs/assets/multichain/asset-list.spec.ts index c80a15b082c4..863719bc57df 100644 --- a/e2e/specs/assets/multichain/asset-list.spec.ts +++ b/e2e/specs/assets/multichain/asset-list.spec.ts @@ -68,6 +68,7 @@ describe(SmokeNetworkAbstractions('Import Tokens'), () => { await WalletView.tapTokenNetworkFilter(); await WalletView.tapTokenNetworkFilterAll(); const avax = WalletView.tokenInWallet('AVAX'); + await WalletView.scrollToToken('AVAX'); await Assertions.expectElementToBeVisible(avax); await WalletView.tapOnToken('AVAX'); await Assertions.expectElementToBeVisible(TokenOverview.sendButton); @@ -120,9 +121,7 @@ describe(SmokeNetworkAbstractions('Import Tokens'), () => { await WalletView.tapTokenNetworkFilter(); await WalletView.tapTokenNetworkFilterAll(); - if (device.getPlatform() === 'ios') { - await WalletView.scrollToToken('AVAX', 'up'); - } + await WalletView.scrollToToken('AVAX'); await WalletView.tapOnToken('AVAX'); await Assertions.expectElementToBeVisible(TokenOverview.container); diff --git a/e2e/specs/browser/browser-tests.spec.ts b/e2e/specs/quarantine/browser/browser-tests.failing.ts similarity index 85% rename from e2e/specs/browser/browser-tests.spec.ts rename to e2e/specs/quarantine/browser/browser-tests.failing.ts index bf821cf9e446..680ce661e37d 100644 --- a/e2e/specs/browser/browser-tests.spec.ts +++ b/e2e/specs/quarantine/browser/browser-tests.failing.ts @@ -1,22 +1,22 @@ -import { SmokeWalletPlatform } from '../../tags'; -import { loginToApp } from '../../viewHelper'; -import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../framework/fixtures/FixtureHelper'; -import ExternalSites from '../../resources/externalsites.json'; -import Browser from '../../pages/Browser/BrowserView'; -import EnsWebsite from '../../pages/Browser/ExternalWebsites/EnsWebsite.ts'; -import TabBarComponent from '../../pages/wallet/TabBarComponent'; -import Assertions from '../../framework/Assertions'; -import ConnectBottomSheet from '../../pages/Browser/ConnectBottomSheet.ts'; -import RedirectWebsite from '../../pages/Browser/ExternalWebsites/RedirectWebsite.ts'; -import UniswapWebsite from '../../pages/Browser/ExternalWebsites/UniswapWebsite.ts'; -import OpenseaWebsite from '../../pages/Browser/ExternalWebsites/OpenseaWebsite.ts'; -import PancakeSwapWebsite from '../../pages/Browser/ExternalWebsites/PancakeSwapWebsite.ts'; -import DownloadFile from '../../pages/Browser/DownloadFile.ts'; -import DownloadFileWebsite from '../../pages/Browser/ExternalWebsites/DownloadFileWebsite.ts'; -import TestHelpers from '../../helpers'; -import CameraWebsite from '../../pages/Browser/ExternalWebsites/Security/CameraWebsite.ts'; -import HistoryDisclosureWebsite from '../../pages/Browser/ExternalWebsites/Security/HistoryDisclosureWebsite.ts'; +import { SmokeWalletPlatform } from '../../../tags.js'; +import { loginToApp } from '../../../viewHelper.ts'; +import FixtureBuilder from '../../../framework/fixtures/FixtureBuilder.ts'; +import { withFixtures } from '../../../framework/fixtures/FixtureHelper.ts'; +import ExternalSites from '../../../resources/externalsites.json'; +import Browser from '../../../pages/Browser/BrowserView.ts'; +import EnsWebsite from '../../../pages/Browser/ExternalWebsites/EnsWebsite.ts'; +import TabBarComponent from '../../../pages/wallet/TabBarComponent.ts'; +import Assertions from '../../../framework/Assertions.ts'; +import ConnectBottomSheet from '../../../pages/Browser/ConnectBottomSheet.ts'; +import RedirectWebsite from '../../../pages/Browser/ExternalWebsites/RedirectWebsite.ts'; +import UniswapWebsite from '../../../pages/Browser/ExternalWebsites/UniswapWebsite.ts'; +import OpenseaWebsite from '../../../pages/Browser/ExternalWebsites/OpenseaWebsite.ts'; +import PancakeSwapWebsite from '../../../pages/Browser/ExternalWebsites/PancakeSwapWebsite.ts'; +import DownloadFile from '../../../pages/Browser/DownloadFile.ts'; +import DownloadFileWebsite from '../../../pages/Browser/ExternalWebsites/DownloadFileWebsite.ts'; +import TestHelpers from '../../../helpers.js'; +import CameraWebsite from '../../../pages/Browser/ExternalWebsites/Security/CameraWebsite.ts'; +import HistoryDisclosureWebsite from '../../../pages/Browser/ExternalWebsites/Security/HistoryDisclosureWebsite.ts'; const getHostFromURL = (url: string): string => { try { diff --git a/locales/languages/en.json b/locales/languages/en.json index bd02864c8967..46de305af263 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1150,6 +1150,9 @@ "available_balance": "Available Balance", "margin_used": "Margin Used", "total_unrealized_pnl": "Total Unrealized P&L" + }, + "tpsl": { + "update_success": "TP/SL updated successfully" } }, "markets": {