diff --git a/app/components/UI/AssetOverview/TokenOverview.testIds.ts b/app/components/UI/AssetOverview/TokenOverview.testIds.ts index f810a19d05b..8feaf17005d 100644 --- a/app/components/UI/AssetOverview/TokenOverview.testIds.ts +++ b/app/components/UI/AssetOverview/TokenOverview.testIds.ts @@ -11,6 +11,8 @@ export const TokenOverviewSelectorsIDs = { ADD_BUTTON: 'token-add-button', CLAIM_BUTTON: 'claim-banner-claim-eth-button', UNSTAKING_BANNER: 'unstaking-banner', + PERPS_POSITION_CARD: 'perps-position-card-touchable', + PERPS_DISCOVERY_BANNER: 'perps-discovery-banner', LONG_BUTTON: 'token-long-button', SHORT_BUTTON: 'token-short-button', MORE_BUTTON: 'token-more-button', diff --git a/app/components/UI/Bridge/constants/default-swap-dest-tokens.ts b/app/components/UI/Bridge/constants/default-swap-dest-tokens.ts index 67f6f318ba8..8526fcafeda 100644 --- a/app/components/UI/Bridge/constants/default-swap-dest-tokens.ts +++ b/app/components/UI/Bridge/constants/default-swap-dest-tokens.ts @@ -6,107 +6,116 @@ import { } from '@metamask/keyring-api'; import { BridgeToken } from '../types'; import { CaipAssetType, Hex } from '@metamask/utils'; -import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { NETWORK_CHAIN_ID } from '../../../../util/networks/customNetworks'; export const DefaultSwapDestTokens: Record = { - [CHAIN_IDS.MAINNET]: { + [NETWORK_CHAIN_ID.MAINNET]: { symbol: 'mUSD', name: 'MetaMask USD', address: '0xaca92e438df0b2401ff60da7e4337b687a2435da', decimals: 6, image: 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xaca92e438df0b2401ff60da7e4337b687a2435da.png', - chainId: CHAIN_IDS.MAINNET, + chainId: NETWORK_CHAIN_ID.MAINNET, }, - [CHAIN_IDS.OPTIMISM]: { + [NETWORK_CHAIN_ID.OPTIMISM]: { symbol: 'USDC', name: 'USD Coin', address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', decimals: 6, image: 'https://static.cx.metamask.io/api/v1/tokenIcons/10/0x0b2c639c533813f4aa9d7837caf62653d097ff85.png', - chainId: CHAIN_IDS.OPTIMISM, + chainId: NETWORK_CHAIN_ID.OPTIMISM, }, - [CHAIN_IDS.BSC]: { + [NETWORK_CHAIN_ID.BSC]: { symbol: 'USDT', name: 'Tether USD', address: '0x55d398326f99059ff775485246999027b3197955', decimals: 18, image: 'https://static.cx.metamask.io/api/v1/tokenIcons/56/0x55d398326f99059ff775485246999027b3197955.png', - chainId: CHAIN_IDS.BSC, + chainId: NETWORK_CHAIN_ID.BSC, }, - [CHAIN_IDS.POLYGON]: { + [NETWORK_CHAIN_ID.POLYGON]: { symbol: 'USDT', name: 'Tether USD', address: '0xc2132d05d31c914a87c6611c10748aeb04b58e8f', decimals: 6, image: 'https://static.cx.metamask.io/api/v1/tokenIcons/137/0xc2132d05d31c914a87c6611c10748aeb04b58e8f.png', - chainId: CHAIN_IDS.POLYGON, + chainId: NETWORK_CHAIN_ID.POLYGON, }, - [CHAIN_IDS.ARBITRUM]: { + [NETWORK_CHAIN_ID.ARBITRUM]: { symbol: 'USDC', name: 'USD Coin', address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', decimals: 6, image: 'https://static.cx.metamask.io/api/v1/tokenIcons/42161/0xaf88d065e77c8cc2239327c5edb3a432268e5831.png', - chainId: CHAIN_IDS.ARBITRUM, + chainId: NETWORK_CHAIN_ID.ARBITRUM, }, - [CHAIN_IDS.AVALANCHE]: { + [NETWORK_CHAIN_ID.AVALANCHE]: { symbol: 'USDC', name: 'USD Coin', address: '0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e', decimals: 6, image: 'https://static.cx.metamask.io/api/v1/tokenIcons/43114/0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e.png', - chainId: CHAIN_IDS.AVALANCHE, + chainId: NETWORK_CHAIN_ID.AVALANCHE, }, - [CHAIN_IDS.BASE]: { + [NETWORK_CHAIN_ID.BASE]: { symbol: 'USDC', name: 'USD Coin', address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', decimals: 6, image: 'https://static.cx.metamask.io/api/v1/tokenIcons/8453/0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.png', - chainId: CHAIN_IDS.BASE, + chainId: NETWORK_CHAIN_ID.BASE, }, - [CHAIN_IDS.LINEA_MAINNET]: { + [NETWORK_CHAIN_ID.LINEA_MAINNET]: { symbol: 'mUSD', name: 'MetaMask USD', address: '0xaca92e438df0b2401ff60da7e4337b687a2435da', decimals: 6, image: 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/59144/erc20/0xaca92e438df0b2401ff60da7e4337b687a2435da.png', - chainId: CHAIN_IDS.LINEA_MAINNET, + chainId: NETWORK_CHAIN_ID.LINEA_MAINNET, }, - [CHAIN_IDS.ZKSYNC_ERA]: { + [NETWORK_CHAIN_ID.ZKSYNC_ERA]: { symbol: 'USDT', name: 'Tether USD', address: '0x493257fd37edb34451f62edf8d2a0c418852ba4c', decimals: 6, image: 'https://static.cx.metamask.io/api/v1/tokenIcons/324/0x493257fd37edb34451f62edf8d2a0c418852ba4c.png', - chainId: CHAIN_IDS.ZKSYNC_ERA, + chainId: NETWORK_CHAIN_ID.ZKSYNC_ERA, }, - [CHAIN_IDS.SEI]: { + [NETWORK_CHAIN_ID.SEI]: { symbol: 'USDC', name: 'USD Coin', address: '0xe15fc38f6d8c56af07bbcbe3baf5708a2bf42392', decimals: 6, image: 'https://static.cx.metamask.io/api/v1/tokenIcons/1329/0xe15fc38f6d8c56af07bbcbe3baf5708a2bf42392.png', - chainId: CHAIN_IDS.SEI, + chainId: NETWORK_CHAIN_ID.SEI, }, - [CHAIN_IDS.MONAD]: { + [NETWORK_CHAIN_ID.MONAD]: { symbol: 'USDC', name: 'USD Coin', address: '0x754704Bc059F8C67012fEd69BC8A327a5aafb603', decimals: 6, image: 'https://static.cx.metamask.io/api/v1/tokenIcons/143/0x754704Bc059F8C67012fEd69BC8A327a5aafb603.png', - chainId: CHAIN_IDS.MONAD, + chainId: NETWORK_CHAIN_ID.MONAD, + }, + [NETWORK_CHAIN_ID.HYPE]: { + symbol: 'USDC', + name: 'USD Coin', + address: '0xb88339CB7199b77E23DB6E890353E22632Ba630f', + decimals: 6, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/999/0xb88339cb7199b77e23db6e890353e22632ba630f.png', + chainId: NETWORK_CHAIN_ID.HYPE, }, [SolScope.Mainnet]: { address: @@ -146,7 +155,7 @@ export const Bip44TokensForDefaultPairs: Record = { decimals: 18, image: 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png', - chainId: CHAIN_IDS.MAINNET, + chainId: NETWORK_CHAIN_ID.MAINNET, }, 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { symbol: 'USDC', @@ -155,7 +164,7 @@ export const Bip44TokensForDefaultPairs: Record = { decimals: 6, image: 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.png', - chainId: CHAIN_IDS.MAINNET, + chainId: NETWORK_CHAIN_ID.MAINNET, }, 'eip155:1/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da': { symbol: 'mUSD', @@ -164,7 +173,7 @@ export const Bip44TokensForDefaultPairs: Record = { decimals: 6, image: 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xaca92e438df0b2401ff60da7e4337b687a2435da.png', - chainId: CHAIN_IDS.MAINNET, + chainId: NETWORK_CHAIN_ID.MAINNET, }, 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { address: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', diff --git a/app/components/UI/Bridge/hooks/useStablecoinsDefaultSlippage/index.ts b/app/components/UI/Bridge/hooks/useStablecoinsDefaultSlippage/index.ts index b9412f4d9f9..2e84cc27b6e 100644 --- a/app/components/UI/Bridge/hooks/useStablecoinsDefaultSlippage/index.ts +++ b/app/components/UI/Bridge/hooks/useStablecoinsDefaultSlippage/index.ts @@ -55,6 +55,9 @@ const StablecoinsByChainId: Partial>> = { [NETWORKS_CHAIN_ID.MONAD]: new Set([ '0x754704Bc059F8C67012fEd69BC8A327a5aafb603', // USDC ]), + [NETWORKS_CHAIN_ID.HYPER_EVM]: new Set([ + '0xb88339CB7199b77E23DB6E890353E22632Ba630f', // USDC + ]), }; /** diff --git a/app/components/UI/Bridge/utils/transaction-history.test.ts b/app/components/UI/Bridge/utils/transaction-history.test.ts index ad3ec7b132f..27f477c4a06 100644 --- a/app/components/UI/Bridge/utils/transaction-history.test.ts +++ b/app/components/UI/Bridge/utils/transaction-history.test.ts @@ -42,15 +42,15 @@ describe('getBridgeTxActivityTitle', () => { assetId: 'eip155:1/erc20:0x123', }, // eslint-disable-next-line @typescript-eslint/no-explicit-any - destChainId: 999 as any, // Non-existent chain ID + destChainId: 123456789 as any, // Non-existent chain ID destAsset: { // eslint-disable-next-line @typescript-eslint/no-explicit-any - chainId: 999 as any, + chainId: 123456789 as any, address: '0x456', decimals: 18, symbol: 'TOKEN', name: 'Test Token', - assetId: 'eip155:999/erc20:0x456', + assetId: 'eip155:123456789/erc20:0x456', }, srcTokenAmount: '1000000000000000000', destTokenAmount: '2000000000000000000', @@ -79,7 +79,7 @@ describe('getBridgeTxActivityTitle', () => { }, destChain: { // eslint-disable-next-line @typescript-eslint/no-explicit-any - chainId: 999 as any, + chainId: 123456789 as any, txHash: '0x456', }, status: StatusTypes.COMPLETE, diff --git a/app/components/UI/Perps/Views/PerpsOrderRedirect.test.tsx b/app/components/UI/Perps/Views/PerpsOrderRedirect.test.tsx new file mode 100644 index 00000000000..e325b61c7f7 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsOrderRedirect.test.tsx @@ -0,0 +1,237 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react-native'; +import { + useNavigation, + useRoute, + StackActions, +} from '@react-navigation/native'; +import PerpsOrderRedirect from './PerpsOrderRedirect'; +import { usePerpsConnection } from '../hooks/usePerpsConnection'; +import { usePerpsTrading } from '../hooks/usePerpsTrading'; +import usePerpsToasts from '../hooks/usePerpsToasts'; +import Routes from '../../../../constants/navigation/Routes'; +import { CONFIRMATION_HEADER_CONFIG } from '../constants/perpsConfig'; + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: jest.fn(), + useRoute: jest.fn(), + StackActions: { + replace: jest.fn(), + }, +})); + +jest.mock('../hooks/usePerpsConnection', () => ({ + usePerpsConnection: jest.fn(), +})); + +jest.mock('../hooks/usePerpsTrading', () => ({ + usePerpsTrading: jest.fn(), +})); + +jest.mock('../hooks/usePerpsToasts', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const MockPerpsLoader = jest.fn((_props: Record) => null); +jest.mock('../components/PerpsLoader', () => ({ + __esModule: true, + default: (props: Record) => MockPerpsLoader(props), +})); + +jest.mock('../../../../util/Logger', () => ({ + log: jest.fn(), + error: jest.fn(), +})); + +jest.mock('../../../../util/errorUtils', () => ({ + ensureError: jest.fn((e) => (e instanceof Error ? e : new Error(String(e)))), +})); + +const mockNavigate = jest.fn(); +const mockGoBack = jest.fn(); +const mockDispatch = jest.fn(); +const mockDepositWithOrder = jest.fn(); +const mockShowToast = jest.fn(); +const mockToastOptions = { + accountManagement: { + oneClickTrade: { + txCreationFailed: { variant: 'error', label: 'Failed' }, + }, + }, +}; + +const mockUseNavigation = jest.mocked(useNavigation); +const mockUseRoute = jest.mocked(useRoute); +const mockUsePerpsConnection = jest.mocked(usePerpsConnection); +const mockUsePerpsTrading = jest.mocked(usePerpsTrading); +const mockUsePerpsToasts = jest.mocked(usePerpsToasts); + +describe('PerpsOrderRedirect', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseNavigation.mockReturnValue({ + navigate: mockNavigate, + goBack: mockGoBack, + dispatch: mockDispatch, + } as never); + + mockUseRoute.mockReturnValue({ + key: 'test', + name: 'PerpsOrderRedirect', + params: { direction: 'long', asset: 'ETH' }, + } as never); + + mockUsePerpsTrading.mockReturnValue({ + depositWithOrder: mockDepositWithOrder, + } as never); + + mockUsePerpsToasts.mockReturnValue({ + showToast: mockShowToast, + PerpsToastOptions: mockToastOptions, + } as never); + }); + + it('renders loader with preparing message', () => { + // Arrange + mockUsePerpsConnection.mockReturnValue({ + isConnected: false, + isInitialized: false, + } as never); + + // Act + render(); + + // Assert + expect(MockPerpsLoader).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Preparing order...', + fullScreen: false, + }), + ); + }); + + it('does not call depositWithOrder when WebSocket is not ready', () => { + // Arrange + mockUsePerpsConnection.mockReturnValue({ + isConnected: false, + isInitialized: false, + } as never); + + // Act + render(); + + // Assert + expect(mockDepositWithOrder).not.toHaveBeenCalled(); + }); + + it('calls depositWithOrder and navigates to confirmation on success', async () => { + // Arrange + mockUsePerpsConnection.mockReturnValue({ + isConnected: true, + isInitialized: true, + } as never); + + mockDepositWithOrder.mockResolvedValue(undefined); + + const mockReplaceAction = { type: 'REPLACE' }; + (StackActions.replace as jest.Mock).mockReturnValue(mockReplaceAction); + + // Act + render(); + + // Assert + await waitFor(() => { + expect(mockDepositWithOrder).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(StackActions.replace).toHaveBeenCalledWith( + Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + { + direction: 'long', + asset: 'ETH', + showPerpsHeader: + CONFIRMATION_HEADER_CONFIG.ShowPerpsHeaderForDepositAndTrade, + }, + ); + expect(mockDispatch).toHaveBeenCalledWith(mockReplaceAction); + }); + }); + + it('shows toast and goes back on depositWithOrder failure', async () => { + // Arrange + mockUsePerpsConnection.mockReturnValue({ + isConnected: true, + isInitialized: true, + } as never); + + mockDepositWithOrder.mockRejectedValue(new Error('Order failed')); + + // Act + render(); + + // Assert + await waitFor(() => { + expect(mockShowToast).toHaveBeenCalledWith( + mockToastOptions.accountManagement.oneClickTrade.txCreationFailed, + ); + expect(mockGoBack).toHaveBeenCalled(); + }); + }); + + it('uses short direction from route params', async () => { + // Arrange + mockUseRoute.mockReturnValue({ + key: 'test', + name: 'PerpsOrderRedirect', + params: { direction: 'short', asset: 'BTC' }, + } as never); + + mockUsePerpsConnection.mockReturnValue({ + isConnected: true, + isInitialized: true, + } as never); + + mockDepositWithOrder.mockResolvedValue(undefined); + + const mockReplaceAction = { type: 'REPLACE' }; + (StackActions.replace as jest.Mock).mockReturnValue(mockReplaceAction); + + // Act + render(); + + // Assert + await waitFor(() => { + expect(StackActions.replace).toHaveBeenCalledWith( + Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + expect.objectContaining({ + direction: 'short', + asset: 'BTC', + }), + ); + }); + }); + + it('does not call depositWithOrder twice on re-render', async () => { + // Arrange + mockUsePerpsConnection.mockReturnValue({ + isConnected: true, + isInitialized: true, + } as never); + + mockDepositWithOrder.mockResolvedValue(undefined); + (StackActions.replace as jest.Mock).mockReturnValue({ type: 'REPLACE' }); + + // Act + const { rerender } = render(); + rerender(); + + // Assert + await waitFor(() => { + expect(mockDepositWithOrder).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/app/components/UI/Perps/Views/PerpsOrderRedirect.tsx b/app/components/UI/Perps/Views/PerpsOrderRedirect.tsx new file mode 100644 index 00000000000..03f473cbd98 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsOrderRedirect.tsx @@ -0,0 +1,116 @@ +import React, { useEffect, useRef } from 'react'; +import { + useNavigation, + useRoute, + RouteProp, + StackActions, +} from '@react-navigation/native'; +import { + Box, + BoxAlignItems, + BoxJustifyContent, +} from '@metamask/design-system-react-native'; +import Routes from '../../../../constants/navigation/Routes'; +import { usePerpsConnection } from '../hooks/usePerpsConnection'; +import { usePerpsTrading } from '../hooks/usePerpsTrading'; +import usePerpsToasts from '../hooks/usePerpsToasts'; +import PerpsLoader from '../components/PerpsLoader'; +import Logger from '../../../../util/Logger'; +import { ensureError } from '../../../../util/errorUtils'; +import { + PERPS_CONSTANTS, + CONFIRMATION_HEADER_CONFIG, +} from '../constants/perpsConfig'; +import type { PerpsNavigationParamList } from '../types/navigation'; + +type RouteParams = RouteProp; + +/** + * PerpsOrderRedirect + * + * A redirect screen that handles navigation from Token Details to the Perps order confirmation. + * This screen: + * 1. Waits for the WebSocket connection to be established (via PerpsConnectionProvider) + * 2. Calls depositWithOrder() to create the pending transaction + * 3. Navigates to the confirmation screen with the transaction ready + * + * This is necessary because Token Details is outside the Perps stack, so the WebSocket + * is not initialized there. By navigating to this screen first, we ensure the WebSocket + * is ready before calling depositWithOrder(). + */ +const PerpsOrderRedirect: React.FC = () => { + const navigation = useNavigation(); + const route = useRoute(); + const { direction, asset } = route.params; + + const { isConnected, isInitialized } = usePerpsConnection(); + const { depositWithOrder } = usePerpsTrading(); + const { showToast, PerpsToastOptions } = usePerpsToasts(); + + const hasStartedRef = useRef(false); + useEffect(() => { + // Wait for WebSocket to be ready + if (!isConnected || !isInitialized) return; + // Prevent double execution + if (hasStartedRef.current) return; + hasStartedRef.current = true; + + Logger.log('[PerpsOrderRedirect] Starting depositWithOrder', { + direction, + asset, + }); + + depositWithOrder() + .then(() => { + Logger.log( + '[PerpsOrderRedirect] depositWithOrder resolved, navigating to confirmation', + ); + // Replace current screen with confirmation (no back to loader) + navigation.dispatch( + StackActions.replace( + Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + { + direction, + asset, + showPerpsHeader: + CONFIRMATION_HEADER_CONFIG.ShowPerpsHeaderForDepositAndTrade, + }, + ), + ); + }) + .catch((error: unknown) => { + const err = ensureError(error); + Logger.error(err, { + feature: PERPS_CONSTANTS.FeatureName, + message: 'Failed to start one-click trade from asset details', + }); + showToast( + PerpsToastOptions.accountManagement.oneClickTrade.txCreationFailed, + ); + // Go back to token details on failure + navigation.goBack(); + }); + }, [ + isConnected, + isInitialized, + direction, + asset, + depositWithOrder, + navigation, + showToast, + PerpsToastOptions, + ]); + + // Match PerpsLoadingSkeleton layout ("Connecting to Perps") so both loaders look the same: top-aligned, centered, pt-20 + return ( + + + + ); +}; + +export default PerpsOrderRedirect; diff --git a/app/components/UI/Perps/__mocks__/serviceMocks.ts b/app/components/UI/Perps/__mocks__/serviceMocks.ts index 526a6123718..0be3e38e173 100644 --- a/app/components/UI/Perps/__mocks__/serviceMocks.ts +++ b/app/components/UI/Perps/__mocks__/serviceMocks.ts @@ -57,6 +57,12 @@ export const createMockInfrastructure = rewards: { getFeeDiscount: jest.fn().mockResolvedValue(0), }, + + // === Cache Invalidation === + cacheInvalidator: { + invalidate: jest.fn(), + invalidateAll: jest.fn(), + }, }) as unknown as jest.Mocked; /** diff --git a/app/components/UI/Perps/adapters/mobileInfrastructure.ts b/app/components/UI/Perps/adapters/mobileInfrastructure.ts index 862cd46dd6a..77e7fede2c7 100644 --- a/app/components/UI/Perps/adapters/mobileInfrastructure.ts +++ b/app/components/UI/Perps/adapters/mobileInfrastructure.ts @@ -15,6 +15,7 @@ import { setMeasurement } from '@sentry/react-native'; import performance from 'react-native-performance'; import { getStreamManagerInstance } from '../providers/PerpsStreamManager'; import Engine from '../../../../core/Engine'; +import { PerpsCacheInvalidator } from '../services/PerpsCacheInvalidator'; import type { PerpsPlatformDependencies, PerpsMetrics, @@ -22,6 +23,7 @@ import type { PerpsTraceValue, PerpsAnalyticsEvent, PerpsAnalyticsProperties, + InvalidateCacheParams, } from '../controllers/types'; /** @@ -128,6 +130,22 @@ function createStreamManagerAdapter() { }; } +/** + * Creates a cache invalidator adapter that delegates to the mobile singleton. + * This allows controller services to invalidate caches without direct dependency + * on the mobile-specific PerpsCacheInvalidator singleton. + */ +function createCacheInvalidatorAdapter() { + return { + invalidate({ cacheType }: InvalidateCacheParams): void { + PerpsCacheInvalidator.invalidate(cacheType); + }, + invalidateAll(): void { + PerpsCacheInvalidator.invalidateAll(); + }, + }; +} + /** * Creates mobile-specific platform dependencies for PerpsController. * Controller access uses messenger pattern (messenger.call()). @@ -201,5 +219,8 @@ export function createMobileInfrastructure(): PerpsPlatformDependencies { caipAccountId, ), }, + + // === Cache Invalidation === + cacheInvalidator: createCacheInvalidatorAdapter(), }; } diff --git a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.styles.ts b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.styles.ts index 984da75000f..ec5fad1c41e 100644 --- a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.styles.ts +++ b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.styles.ts @@ -1,8 +1,13 @@ import { StyleSheet } from 'react-native'; import type { Theme } from '../../../../../util/theme/models'; -const styleSheet = (params: { theme: Theme }) => { - const { theme } = params; +interface StyleSheetParams { + theme: Theme; + iconSize?: number; +} + +const styleSheet = (params: StyleSheetParams) => { + const { theme, iconSize = 40 } = params; const { colors } = theme; return StyleSheet.create({ @@ -10,6 +15,32 @@ const styleSheet = (params: { theme: Theme }) => { backgroundColor: colors.background.default, borderRadius: 12, }, + // Compact mode styles + compactCard: { + paddingVertical: 8, + }, + compactContent: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + compactLeft: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + compactIcon: { + width: iconSize, + height: iconSize, + borderRadius: iconSize / 2, + marginRight: 12, + }, + compactInfo: { + flex: 1, + }, + compactRight: { + alignItems: 'flex-end', + }, header: { flexDirection: 'row', alignItems: 'center', diff --git a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx index 535378a2632..61075ef8ff8 100644 --- a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx +++ b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx @@ -25,10 +25,12 @@ import { formatPerpsFiat, formatPnl, formatPositionSize, + formatPercentage, PRICE_RANGES_MINIMAL_VIEW, PRICE_RANGES_UNIVERSAL, } from '../../utils/formatUtils'; import { getPerpsDisplaySymbol } from '../../utils/marketUtils'; +import PerpsTokenLogo from '../PerpsTokenLogo'; import styleSheet from './PerpsPositionCard.styles'; /** @@ -73,6 +75,14 @@ interface PerpsPositionCardProps { onFlipPress?: () => void; onMarginPress?: () => void; onSharePress?: () => void; + /** Render as a compact row (similar to PerpsCard) */ + compact?: boolean; + /** Press handler for compact mode */ + onPress?: () => void; + /** Test ID for the card */ + testID?: string; + /** Icon size for compact mode (default: 40) */ + iconSize?: number; } const PerpsPositionCard: React.FC = ({ @@ -84,8 +94,12 @@ const PerpsPositionCard: React.FC = ({ onFlipPress: _onFlipPress, onMarginPress, onSharePress, + compact = false, + onPress, + testID, + iconSize = 40, }) => { - const { styles } = useStyles(styleSheet, {}); + const { styles } = useStyles(styleSheet, { iconSize }); const [showSizeInUSD, setShowSizeInUSD] = useState(false); // Determine if position is long or short based on size @@ -172,6 +186,60 @@ const PerpsPositionCard: React.FC = ({ } }; + // Compact mode: render a simplified row view similar to PerpsCard + if (compact) { + const displaySymbol = getPerpsDisplaySymbol(position.symbol); + const roeRaw = Number.parseFloat(position.returnOnEquity || ''); + const hasValidRoe = !Number.isNaN(roeRaw) && Number.isFinite(roeRaw); + const roeDisplay = hasValidRoe + ? formatPercentage(roeRaw * 100, 1) + : PERPS_CONSTANTS.FallbackPercentageDisplay; + + return ( + + + + + + + {displaySymbol} {position.leverage.value}x{' '} + {isLong ? 'long' : 'short'} + + + {formatPositionSize(absoluteSize.toString())} {displaySymbol} + + + + + + {formatPerpsFiat(position.positionValue, { + ranges: PRICE_RANGES_MINIMAL_VIEW, + })} + + = 0 ? TextColor.Success : TextColor.Error} + > + {formatPnl(pnlNum)} ({roeDisplay}) + + + + + ); + } + return ( {/* Header Section */} diff --git a/app/components/UI/Perps/controllers/PerpsController.test.ts b/app/components/UI/Perps/controllers/PerpsController.test.ts index 88fff778145..35f9b411751 100644 --- a/app/components/UI/Perps/controllers/PerpsController.test.ts +++ b/app/components/UI/Perps/controllers/PerpsController.test.ts @@ -24,7 +24,10 @@ import type { SubscribeAccountParams, } from './types'; import { HyperLiquidProvider } from './providers/HyperLiquidProvider'; -import { createMockHyperLiquidProvider } from '../__mocks__/providerMocks'; +import { + createMockHyperLiquidProvider, + createMockPosition, +} from '../__mocks__/providerMocks'; import { createMockInfrastructure, createMockMessenger, @@ -3746,6 +3749,200 @@ describe('PerpsController', () => { }); }); + describe('readOnly mode', () => { + const mockUserAddress = '0xabcdef1234567890abcdef1234567890abcdef12'; + const MockedHyperLiquidProvider = HyperLiquidProvider as jest.MockedClass< + typeof HyperLiquidProvider + >; + + beforeEach(() => { + // Reset mocks before each test + MockedHyperLiquidProvider.mockClear(); + }); + + describe('getPositions with readOnly mode', () => { + it('uses existing provider for readOnly queries when available', async () => { + // Arrange - set up mock provider with properly typed positions + const mockPositions = [ + createMockPosition({ symbol: 'BTC', size: '0.5' }), + ]; + const existingMockProvider = createMockHyperLiquidProvider(); + existingMockProvider.getPositions.mockResolvedValue(mockPositions); + controller.testSetProviders( + new Map([['hyperliquid', existingMockProvider]]), + ); + controller.testMarkInitialized(); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + }); + + // Act + const positions = await controller.getPositions({ + readOnly: true, + userAddress: mockUserAddress, + }); + + // Assert - should use existing provider + expect(existingMockProvider.getPositions).toHaveBeenCalledWith({ + readOnly: true, + userAddress: mockUserAddress, + }); + expect(positions).toEqual(mockPositions); + // Should NOT create a new HyperLiquidProvider instance + expect(MockedHyperLiquidProvider).not.toHaveBeenCalled(); + }); + + it('creates temporary provider for readOnly queries when no activeProviderInstance', async () => { + // Arrange - no activeProviderInstance set (pre-initialization) + const mockPositions = [ + createMockPosition({ symbol: 'ETH', size: '2.0' }), + ]; + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getPositions.mockResolvedValue(mockPositions); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + state.isTestnet = false; + }); + + // Act + const positions = await controller.getPositions({ + readOnly: true, + userAddress: mockUserAddress, + }); + + // Assert - should create a temporary provider for pre-init discovery + expect(MockedHyperLiquidProvider).toHaveBeenCalledWith( + expect.objectContaining({ + isTestnet: false, + }), + ); + expect(positions).toEqual(mockPositions); + }); + + it('bypasses getActiveProvider check for readOnly queries', async () => { + // Arrange - controller not initialized (no provider available via normal path) + const mockPositions = [ + createMockPosition({ symbol: 'BTC', size: '1.0' }), + ]; + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getPositions.mockResolvedValue(mockPositions); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + controller.testUpdate((state) => { + state.initializationState = InitializationState.Initializing; + state.activeProvider = 'aggregated'; + }); + + // Act - should NOT throw despite controller not being initialized + const positions = await controller.getPositions({ + readOnly: true, + userAddress: mockUserAddress, + }); + + // Assert + expect(positions).toEqual(mockPositions); + }); + }); + + describe('getAccountState with readOnly mode', () => { + // Complete AccountState mock with all required fields + const createMockAccountState = (overrides = {}) => ({ + totalBalance: '50000', + availableBalance: '45000', + marginUsed: '5000', + unrealizedPnl: '1000', + returnOnEquity: '20', + ...overrides, + }); + + it('uses existing provider for readOnly queries when available', async () => { + // Arrange + const mockAccountState = createMockAccountState(); + const existingMockProvider = createMockHyperLiquidProvider(); + existingMockProvider.getAccountState.mockResolvedValue( + mockAccountState, + ); + controller.testSetProviders( + new Map([['hyperliquid', existingMockProvider]]), + ); + controller.testMarkInitialized(); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + }); + + // Act + const accountState = await controller.getAccountState({ + readOnly: true, + userAddress: mockUserAddress, + }); + + // Assert - should use existing provider + expect(existingMockProvider.getAccountState).toHaveBeenCalledWith({ + readOnly: true, + userAddress: mockUserAddress, + }); + expect(accountState).toEqual(mockAccountState); + expect(MockedHyperLiquidProvider).not.toHaveBeenCalled(); + }); + + it('creates temporary provider for readOnly queries when no activeProviderInstance', async () => { + // Arrange - no activeProviderInstance set (pre-initialization) + const mockAccountState = createMockAccountState({ + totalBalance: '25000', + availableBalance: '20000', + }); + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getAccountState.mockResolvedValue(mockAccountState); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + state.isTestnet = true; + }); + + // Act + const accountState = await controller.getAccountState({ + readOnly: true, + userAddress: mockUserAddress, + }); + + // Assert - should create a temporary provider for pre-init discovery + expect(MockedHyperLiquidProvider).toHaveBeenCalledWith( + expect.objectContaining({ + isTestnet: true, + }), + ); + expect(accountState).toEqual(mockAccountState); + }); + + it('bypasses getActiveProvider check for readOnly queries', async () => { + // Arrange - controller not initialized (no provider available via normal path) + const mockAccountState = createMockAccountState({ + totalBalance: '10000', + }); + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getAccountState.mockResolvedValue(mockAccountState); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + controller.testUpdate((state) => { + state.initializationState = InitializationState.Initializing; + state.activeProvider = 'aggregated'; + }); + + // Act - should NOT throw despite controller not being initialized + const accountState = await controller.getAccountState({ + readOnly: true, + userAddress: mockUserAddress, + }); + + // Assert + expect(accountState).toEqual(mockAccountState); + }); + }); + }); + describe('setSelectedPaymentToken', () => { it('sets selectedPaymentToken to null when passed null', () => { controller.testUpdate((state) => { diff --git a/app/components/UI/Perps/controllers/PerpsController.ts b/app/components/UI/Perps/controllers/PerpsController.ts index 73bbedc3d1a..5a658c93bd6 100644 --- a/app/components/UI/Perps/controllers/PerpsController.ts +++ b/app/components/UI/Perps/controllers/PerpsController.ts @@ -1930,8 +1930,30 @@ export class PerpsController extends BaseController< /** * Get current positions * Thin delegation to MarketDataService + * + * For readOnly mode, bypasses getActiveProvider() to allow position queries + * without full perps initialization (e.g., for showing positions on token details page) */ async getPositions(params?: GetPositionsParams): Promise { + // For readOnly mode, access provider directly without initialization check + // This allows discovery use cases (checking if user has positions) without full perps setup + if (params?.readOnly && params.userAddress) { + // Use activeProviderInstance if available (respects provider abstraction) + // Fallback to creating HyperLiquidProvider for pre-initialization discovery + // TODO: When adding new providers (MYX), consider a provider factory pattern + const provider = + this.activeProviderInstance ?? + new HyperLiquidProvider({ + isTestnet: this.state.isTestnet, + hip3Enabled: this.hip3Enabled, + allowlistMarkets: this.hip3AllowlistMarkets, + blocklistMarkets: this.hip3BlocklistMarkets, + platformDependencies: this.options.infrastructure, + messenger: this.messenger, + }); + return provider.getPositions(params); + } + const provider = this.getActiveProvider(); return this.marketDataService.getPositions({ provider, @@ -1995,8 +2017,29 @@ export class PerpsController extends BaseController< /** * Get account state (balances, etc.) * Thin delegation to MarketDataService + * + * For readOnly mode, bypasses getActiveProvider() to allow account state queries + * without full perps initialization (e.g., for checking if user has perps funds) */ async getAccountState(params?: GetAccountStateParams): Promise { + // For readOnly mode, access provider directly without initialization check + // This allows discovery use cases (checking if user has perps funds) without full perps setup + if (params?.readOnly && params.userAddress) { + // Use activeProviderInstance if available (respects provider abstraction) + // Fallback to creating HyperLiquidProvider for pre-initialization discovery + const provider = + this.activeProviderInstance ?? + new HyperLiquidProvider({ + isTestnet: this.state.isTestnet, + hip3Enabled: this.hip3Enabled, + allowlistMarkets: this.hip3AllowlistMarkets, + blocklistMarkets: this.hip3BlocklistMarkets, + platformDependencies: this.options.infrastructure, + messenger: this.messenger, + }); + return provider.getAccountState(params); + } + const provider = this.getActiveProvider(); return this.marketDataService.getAccountState({ provider, @@ -2031,23 +2074,18 @@ export class PerpsController extends BaseController< // For readOnly mode, access provider directly without initialization check // This allows discovery use cases (checking if market exists) without full perps setup if (params?.readOnly) { - // Try to get existing provider, or create a temporary one for readOnly queries - // Note: 'aggregated' mode uses activeProviderInstance directly, not the providers map - const { activeProvider } = this.state; - let provider = - activeProvider === 'aggregated' - ? undefined - : this.providers.get(activeProvider); - // Create a temporary provider instance for readOnly queries - // The readOnly path in provider creates a standalone InfoClient without full init - provider ??= new HyperLiquidProvider({ - isTestnet: this.state.isTestnet, - hip3Enabled: this.hip3Enabled, - allowlistMarkets: this.hip3AllowlistMarkets, - blocklistMarkets: this.hip3BlocklistMarkets, - platformDependencies: this.options.infrastructure, - messenger: this.messenger, - }); + // Use activeProviderInstance if available (respects provider abstraction) + // Fallback to creating HyperLiquidProvider for pre-initialization discovery + const provider = + this.activeProviderInstance ?? + new HyperLiquidProvider({ + isTestnet: this.state.isTestnet, + hip3Enabled: this.hip3Enabled, + allowlistMarkets: this.hip3AllowlistMarkets, + blocklistMarkets: this.hip3BlocklistMarkets, + platformDependencies: this.options.infrastructure, + messenger: this.messenger, + }); return provider.getMarkets(params); } diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts index 43258e3a944..3381ae6e71a 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts @@ -25,6 +25,7 @@ import type { import { HyperLiquidProvider } from './HyperLiquidProvider'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes'; import { TradingReadinessCache } from '../../services/TradingReadinessCache'; +import { createStandaloneInfoClient } from '../../utils/standaloneInfoClient'; jest.mock('../../services/HyperLiquidClientService'); jest.mock('../../services/HyperLiquidWalletService'); @@ -37,6 +38,13 @@ jest.mock('../../providers/PerpsStreamManager', () => ({ getStreamManagerInstance: mockGetStreamManagerInstance, })); +// Mock standalone info client for readOnly mode tests +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let mockStandaloneInfoClient: any; +jest.mock('../../utils/standaloneInfoClient', () => ({ + createStandaloneInfoClient: jest.fn(() => mockStandaloneInfoClient), +})); + jest.mock('../../utils/hyperLiquidValidation', () => ({ validateOrderParams: jest.fn(), validateWithdrawalParams: jest.fn(), @@ -7658,4 +7666,226 @@ describe('HyperLiquidProvider', () => { expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); }); }); + + describe('readOnly mode', () => { + const mockUserAddress = '0xabcdef1234567890abcdef1234567890abcdef12'; + const mockCreateStandaloneInfoClient = + createStandaloneInfoClient as jest.MockedFunction< + typeof createStandaloneInfoClient + >; + + beforeEach(() => { + // Reset standalone client mock + mockStandaloneInfoClient = { + clearinghouseState: jest.fn(), + }; + }); + + describe('getPositions with readOnly mode', () => { + it('returns positions via standalone client when readOnly mode enabled', async () => { + // Arrange + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.5', + entryPx: '45000', + positionValue: '22500', + unrealizedPnl: '500', + marginUsed: '2250', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '40000', + maxLeverage: 50, + returnOnEquity: '22.22', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + ], + marginSummary: { + totalMarginUsed: '2250', + accountValue: '25000', + }, + }); + + // Act + const positions = await provider.getPositions({ + readOnly: true, + userAddress: mockUserAddress, + }); + + // Assert + expect(mockCreateStandaloneInfoClient).toHaveBeenCalledWith({ + isTestnet: false, + }); + expect( + mockStandaloneInfoClient.clearinghouseState, + ).toHaveBeenCalledWith({ user: mockUserAddress }); + expect(positions).toHaveLength(1); + expect(positions[0].symbol).toBe('BTC'); + expect(positions[0].size).toBe('0.5'); + }); + + it('filters zero-size positions in readOnly mode', async () => { + // Arrange - include positions with zero size + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.5', + entryPx: '45000', + positionValue: '22500', + unrealizedPnl: '500', + marginUsed: '2250', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '40000', + maxLeverage: 50, + returnOnEquity: '22.22', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + { + position: { + coin: 'ETH', + szi: '0', // Zero size - should be filtered out + entryPx: '3000', + positionValue: '0', + unrealizedPnl: '0', + marginUsed: '0', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '0', + maxLeverage: 50, + returnOnEquity: '0', + cumFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + }, + type: 'oneWay', + }, + ], + marginSummary: { + totalMarginUsed: '2250', + accountValue: '25000', + }, + }); + + // Act + const positions = await provider.getPositions({ + readOnly: true, + userAddress: mockUserAddress, + }); + + // Assert - ETH position with zero size should be filtered out + expect(positions).toHaveLength(1); + expect(positions[0].symbol).toBe('BTC'); + }); + + it('uses testnet endpoint when provider is in testnet mode', async () => { + // Arrange - override isTestnetMode to return true for this test + mockClientService.isTestnetMode.mockReturnValue(true); + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + }); + + // Act + await provider.getPositions({ + readOnly: true, + userAddress: mockUserAddress, + }); + + // Assert + expect(mockCreateStandaloneInfoClient).toHaveBeenCalledWith({ + isTestnet: true, + }); + }); + + it('returns empty array when standalone client fails', async () => { + // Arrange - getPositions catches errors and returns empty array + mockStandaloneInfoClient.clearinghouseState.mockRejectedValue( + new Error('Network error'), + ); + + // Act + const positions = await provider.getPositions({ + readOnly: true, + userAddress: mockUserAddress, + }); + + // Assert - returns empty array instead of throwing (matches implementation) + expect(positions).toEqual([]); + }); + }); + + describe('getAccountState with readOnly mode', () => { + it('returns account state via standalone client when readOnly mode enabled', async () => { + // Arrange + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { + totalMarginUsed: '1000', + accountValue: '50000', + }, + withdrawable: '45000', + crossMarginSummary: { + accountValue: '50000', + totalMarginUsed: '1000', + }, + }); + + // Act + const accountState = await provider.getAccountState({ + readOnly: true, + userAddress: mockUserAddress, + }); + + // Assert + expect(mockCreateStandaloneInfoClient).toHaveBeenCalledWith({ + isTestnet: false, + }); + expect( + mockStandaloneInfoClient.clearinghouseState, + ).toHaveBeenCalledWith({ user: mockUserAddress }); + expect(accountState.totalBalance).toBeDefined(); + }); + + it('uses testnet endpoint when provider is in testnet mode', async () => { + // Arrange - override isTestnetMode to return true for this test + mockClientService.isTestnetMode.mockReturnValue(true); + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + withdrawable: '0', + crossMarginSummary: { accountValue: '0', totalMarginUsed: '0' }, + }); + + // Act + await provider.getAccountState({ + readOnly: true, + userAddress: mockUserAddress, + }); + + // Assert + expect(mockCreateStandaloneInfoClient).toHaveBeenCalledWith({ + isTestnet: true, + }); + }); + + it('throws error when standalone client fails', async () => { + // Arrange + mockStandaloneInfoClient.clearinghouseState.mockRejectedValue( + new Error('API unavailable'), + ); + + // Act & Assert + await expect( + provider.getAccountState({ + readOnly: true, + userAddress: mockUserAddress, + }), + ).rejects.toThrow('API unavailable'); + }); + }); + }); }); diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts index caf1807b964..2e6c370be9f 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts @@ -66,6 +66,7 @@ import type { PerpsAssetCtx, FrontendOrder, SpotMetaResponse, + ClearinghouseStateResponse, } from '../../types/hyperliquid-types'; import { createErrorResult, @@ -4330,6 +4331,19 @@ export class HyperLiquidProvider implements PerpsProvider { } } + /** + * Get clearinghouse state for a user in readOnly mode. + * Creates a standalone InfoClient without requiring full initialization. + */ + private async getReadOnlyClearinghouseState( + userAddress: string, + ): Promise { + const standaloneInfoClient = createStandaloneInfoClient({ + isTestnet: this.clientService.isTestnetMode(), + }); + return standaloneInfoClient.clearinghouseState({ user: userAddress }); + } + /** * Get current positions with TP/SL prices * @@ -4343,6 +4357,33 @@ export class HyperLiquidProvider implements PerpsProvider { */ async getPositions(params?: GetPositionsParams): Promise { try { + // Path 0: Read-only mode for lightweight position queries + // Creates a standalone InfoClient without requiring full initialization + // No wallet, WebSocket, or account setup needed - just HTTP API call + // Use for discovery use cases like showing positions on token details page + if (params?.readOnly && params.userAddress) { + this.deps.debugLogger.log( + 'HyperLiquidProvider: Getting positions in readOnly mode (standalone client)', + { userAddress: params.userAddress }, + ); + + const state = await this.getReadOnlyClearinghouseState( + params.userAddress, + ); + + // Transform positions - skip TP/SL lookup (would require additional API call) + const positions = state.assetPositions + .filter((assetPos) => assetPos.position.szi !== '0') + .map((assetPos) => adaptPositionFromSDK(assetPos)); + + this.deps.debugLogger.log( + 'HyperLiquidProvider: readOnly positions fetched', + { count: positions.length }, + ); + + return positions; + } + // Try WebSocket cache first (unless explicitly bypassed) if ( !params?.skipCache && @@ -4959,6 +5000,31 @@ export class HyperLiquidProvider implements PerpsProvider { */ async getAccountState(params?: GetAccountStateParams): Promise { try { + // Path 0: Read-only mode for lightweight account state queries + // Creates a standalone InfoClient without requiring full initialization + // No wallet, WebSocket, or account setup needed - just HTTP API call + // Use for discovery use cases like checking if user has perps funds + if (params?.readOnly && params.userAddress) { + this.deps.debugLogger.log( + 'HyperLiquidProvider: Getting account state in readOnly mode (standalone client)', + { userAddress: params.userAddress }, + ); + + const perpsState = await this.getReadOnlyClearinghouseState( + params.userAddress, + ); + + // Transform to AccountState - simpler version without spot balance aggregation + const accountState = adaptAccountStateFromSDK(perpsState); + + this.deps.debugLogger.log( + 'HyperLiquidProvider: readOnly account state fetched', + { totalBalance: accountState.totalBalance }, + ); + + return accountState; + } + this.deps.debugLogger.log('Getting account state via HyperLiquid SDK'); // Read-only operation: only need client initialization diff --git a/app/components/UI/Perps/controllers/services/AccountService.ts b/app/components/UI/Perps/controllers/services/AccountService.ts index d9696211b61..5de1e162261 100644 --- a/app/components/UI/Perps/controllers/services/AccountService.ts +++ b/app/components/UI/Perps/controllers/services/AccountService.ts @@ -220,6 +220,9 @@ export class AccountService { }); }); + // Invalidate readOnly caches so external hooks (e.g., usePerpsPositionForAsset) refresh + this.deps.cacheInvalidator.invalidate({ cacheType: 'accountState' }); + traceData = { success: true, txHash: result.txHash || '', diff --git a/app/components/UI/Perps/controllers/services/TradingService.ts b/app/components/UI/Perps/controllers/services/TradingService.ts index 8fe3a7cd040..3c94ff2f329 100644 --- a/app/components/UI/Perps/controllers/services/TradingService.ts +++ b/app/components/UI/Perps/controllers/services/TradingService.ts @@ -366,6 +366,10 @@ export class TradingService { reportOrderToDataLake, }); traceData = { success: true, orderId: result.orderId || '' }; + + // Invalidate readOnly caches so external hooks (e.g., usePerpsPositionForAsset) refresh + this.deps.cacheInvalidator.invalidate({ cacheType: 'positions' }); + this.deps.cacheInvalidator.invalidate({ cacheType: 'accountState' }); } else { traceData = { success: false, error: result.error || 'Unknown error' }; } @@ -1234,6 +1238,10 @@ export class TradingService { ); traceData = { success: true, filledSize: result.filledSize || '' }; + + // Invalidate readOnly caches so external hooks (e.g., usePerpsPositionForAsset) refresh + this.deps.cacheInvalidator.invalidate({ cacheType: 'positions' }); + this.deps.cacheInvalidator.invalidate({ cacheType: 'accountState' }); } else { traceData = { success: false, error: result.error || 'Unknown error' }; } @@ -1445,6 +1453,12 @@ export class TradingService { batchCloseProps, ); + // Invalidate readOnly caches on successful batch close + if (operationResult?.success && operationResult.successCount > 0) { + this.deps.cacheInvalidator.invalidate({ cacheType: 'positions' }); + this.deps.cacheInvalidator.invalidate({ cacheType: 'accountState' }); + } + this.deps.tracer.endTrace({ name: PerpsTraceNames.ClosePosition, id: traceId, @@ -1641,6 +1655,10 @@ export class TradingService { [PERPS_EVENT_PROPERTY.MARGIN_USED]: Math.abs(parseFloat(amount)), [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, }); + + // Invalidate readOnly caches so external hooks refresh + this.deps.cacheInvalidator.invalidate({ cacheType: 'positions' }); + this.deps.cacheInvalidator.invalidate({ cacheType: 'accountState' }); } this.deps.tracer.endTrace({ @@ -1771,6 +1789,10 @@ export class TradingService { [PERPS_EVENT_PROPERTY.ACTION]: 'flip_position', }, ); + + // Invalidate readOnly caches so external hooks refresh + this.deps.cacheInvalidator.invalidate({ cacheType: 'positions' }); + this.deps.cacheInvalidator.invalidate({ cacheType: 'accountState' }); } this.deps.tracer.endTrace({ diff --git a/app/components/UI/Perps/controllers/types/index.ts b/app/components/UI/Perps/controllers/types/index.ts index 267b69ca721..10981b6fee5 100644 --- a/app/components/UI/Perps/controllers/types/index.ts +++ b/app/components/UI/Perps/controllers/types/index.ts @@ -625,11 +625,15 @@ export interface GetPositionsParams { accountId?: CaipAccountId; // Optional: defaults to selected account includeHistory?: boolean; // Optional: include historical positions skipCache?: boolean; // Optional: bypass WebSocket cache and force API call (default: false) + readOnly?: boolean; // Optional: lightweight mode - skip full initialization, use standalone HTTP client (no wallet/WebSocket needed) + userAddress?: string; // Optional: required when readOnly is true - user address to query positions for } export interface GetAccountStateParams { accountId?: CaipAccountId; // Optional: defaults to selected account source?: string; // Optional: source of the call for tracing (e.g., 'health_check', 'initial_connection') + readOnly?: boolean; // Optional: lightweight mode - skip full initialization, use standalone HTTP client (no wallet/WebSocket needed) + userAddress?: string; // Optional: required when readOnly is true - user address to query account state for } export interface GetOrderFillsParams { @@ -1368,6 +1372,7 @@ export interface PerpsRewardsOperations { * - Observability: logger, debugLogger, metrics, performance, tracer * - Platform: streamManager (mobile/extension specific) * - Rewards: fee discount operations + * - Cache: cache invalidation for readOnly queries * * Controller access uses messenger pattern (messenger.call()). */ @@ -1384,4 +1389,39 @@ export interface PerpsPlatformDependencies { // === Rewards (no standard messenger action in core) === rewards: PerpsRewardsOperations; + + // === Cache Invalidation (for readOnly query caches) === + cacheInvalidator: PerpsCacheInvalidator; +} + +/** + * Cache types that can be invalidated. + * Used by readOnly query caches (e.g., usePerpsPositionForAsset). + */ +export type PerpsCacheType = 'positions' | 'accountState' | 'markets'; + +/** + * Parameters for invalidating a specific cache type. + */ +export type InvalidateCacheParams = { + /** The type of cache to invalidate */ + cacheType: PerpsCacheType; +}; + +/** + * Cache invalidation interface for readOnly query caches. + * Allows services to signal when data has changed without depending on + * mobile-specific implementations. + */ +export interface PerpsCacheInvalidator { + /** + * Invalidate a specific cache type. + * Notifies all subscribers that cached data is stale. + */ + invalidate(params: InvalidateCacheParams): void; + + /** + * Invalidate all cache types. + */ + invalidateAll(): void; } diff --git a/app/components/UI/Perps/hooks/index.ts b/app/components/UI/Perps/hooks/index.ts index b578c595419..2075eb8c6fe 100644 --- a/app/components/UI/Perps/hooks/index.ts +++ b/app/components/UI/Perps/hooks/index.ts @@ -102,6 +102,10 @@ export { usePerpsBlockExplorerUrl } from './usePerpsBlockExplorerUrl'; // Utility hooks export { useStableArray } from './useStableArray'; +// Discovery hooks (for use outside perps screens) +export { usePerpsMarketForAsset } from './usePerpsMarketForAsset'; +export { usePerpsPositionForAsset } from './usePerpsPositionForAsset'; + // Tab view hooks export { usePerpsTabExploreData } from './usePerpsTabExploreData'; diff --git a/app/components/UI/Perps/hooks/usePerpsPositionForAsset.test.ts b/app/components/UI/Perps/hooks/usePerpsPositionForAsset.test.ts new file mode 100644 index 00000000000..35ab7f16235 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsPositionForAsset.test.ts @@ -0,0 +1,597 @@ +import { act, waitFor } from '@testing-library/react-native'; +import type { RootState } from '../../../../reducers'; +import { + renderHookWithProvider, + type DeepPartial, +} from '../../../../util/test/renderWithProvider'; +import type { AccountState, Position } from '../controllers/types'; +import { + usePerpsPositionForAsset, + _clearPositionCache, +} from './usePerpsPositionForAsset'; +import { usePerpsTrading } from './usePerpsTrading'; +import { PerpsCacheInvalidator } from '../services/PerpsCacheInvalidator'; + +// Mock dependencies +jest.mock('./usePerpsTrading'); +jest.mock('../../../../core/SDKConnect/utils/DevLogger'); + +// Mock i18n +jest.mock('../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +const mockUsePerpsTrading = usePerpsTrading as jest.MockedFunction< + typeof usePerpsTrading +>; + +const mockPosition: Position = { + symbol: 'ETH', + size: '2', + entryPrice: '3000', + positionValue: '6000', + unrealizedPnl: '100', + returnOnEquity: '0.03', + leverage: { + type: 'cross', + value: 3, + rawUsd: '2000', + }, + liquidationPrice: '2500', + marginUsed: '800', + maxLeverage: 50, + cumulativeFunding: { + allTime: '20', + sinceOpen: '5', + sinceChange: '2', + }, + takeProfitCount: 1, + stopLossCount: 1, +}; + +const mockAccountState: AccountState = { + availableBalance: '10000', + marginUsed: '800', + unrealizedPnl: '100', + returnOnEquity: '0.03', + totalBalance: '10500', +}; + +const mockUserAddress = '0x1234567890123456789012345678901234567890'; + +const createMockState = ( + overrides?: Partial<{ isTestnet: boolean }>, +): DeepPartial => ({ + engine: { + backgroundState: { + PerpsController: { + isTestnet: false, + ...overrides, + }, + AccountsController: { + internalAccounts: { + selectedAccount: 'account-1', + accounts: { + 'account-1': { + id: 'account-1', + type: 'eip155:eoa', + address: mockUserAddress, + metadata: { + name: 'Account 1', + keyring: { type: 'HD Key Tree' }, + importTime: 1234567890, + lastSelected: 1234567890, + }, + scopes: ['eip155:1'], + methods: [], + options: {}, + }, + }, + }, + }, + }, + }, +}); + +describe('usePerpsPositionForAsset', () => { + let mockGetPositions: jest.Mock; + let mockGetAccountState: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + _clearPositionCache(); + PerpsCacheInvalidator._clearAllSubscribers(); + + mockGetPositions = jest.fn().mockResolvedValue([mockPosition]); + mockGetAccountState = jest.fn().mockResolvedValue(mockAccountState); + + mockUsePerpsTrading.mockReturnValue({ + getPositions: mockGetPositions, + getAccountState: mockGetAccountState, + placeOrder: jest.fn(), + cancelOrder: jest.fn(), + closePosition: jest.fn(), + getMarkets: jest.fn(), + subscribeToPrices: jest.fn(), + subscribeToPositions: jest.fn(), + subscribeToOrderFills: jest.fn(), + depositWithConfirmation: jest.fn(), + depositWithOrder: jest.fn(), + clearDepositResult: jest.fn(), + withdraw: jest.fn(), + calculateLiquidationPrice: jest.fn(), + calculateMaintenanceMargin: jest.fn(), + getMaxLeverage: jest.fn(), + updatePositionTPSL: jest.fn(), + updateMargin: jest.fn(), + flipPosition: jest.fn(), + calculateFees: jest.fn(), + validateOrder: jest.fn(), + validateClosePosition: jest.fn(), + validateWithdrawal: jest.fn(), + getOrderFills: jest.fn(), + getOrders: jest.fn(), + getFunding: jest.fn(), + }); + }); + + describe('Initial state', () => { + it('returns loading state when symbol is provided', async () => { + const { result } = renderHookWithProvider( + () => usePerpsPositionForAsset('ETH'), + { state: createMockState() }, + ); + + expect(result.current.isLoading).toBe(true); + expect(result.current.position).toBeNull(); + expect(result.current.error).toBeNull(); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + }); + + it('returns empty state when symbol is null', () => { + const { result } = renderHookWithProvider( + () => usePerpsPositionForAsset(null), + { state: createMockState() }, + ); + + expect(result.current.isLoading).toBe(false); + expect(result.current.position).toBeNull(); + expect(result.current.hasFundsInPerps).toBe(false); + expect(result.current.accountState).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it('returns empty state when symbol is undefined', () => { + const { result } = renderHookWithProvider( + () => usePerpsPositionForAsset(undefined), + { state: createMockState() }, + ); + + expect(result.current.isLoading).toBe(false); + expect(result.current.position).toBeNull(); + expect(result.current.hasFundsInPerps).toBe(false); + }); + }); + + describe('Successful position fetching', () => { + it('fetches position for matching symbol', async () => { + const { result } = renderHookWithProvider( + () => usePerpsPositionForAsset('ETH'), + { state: createMockState() }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.position).toEqual(mockPosition); + expect(result.current.hasFundsInPerps).toBe(true); + expect(result.current.accountState).toEqual(mockAccountState); + expect(result.current.error).toBeNull(); + }); + + it('calls getPositions with readOnly mode and user address', async () => { + renderHookWithProvider(() => usePerpsPositionForAsset('ETH'), { + state: createMockState(), + }); + + await waitFor(() => { + expect(mockGetPositions).toHaveBeenCalledWith({ + readOnly: true, + userAddress: mockUserAddress, + }); + }); + + expect(mockGetAccountState).toHaveBeenCalledWith({ + readOnly: true, + userAddress: mockUserAddress, + }); + }); + + it('handles case-insensitive symbol matching', async () => { + const { result } = renderHookWithProvider( + () => usePerpsPositionForAsset('eth'), + { state: createMockState() }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.position).toEqual(mockPosition); + }); + + it('returns null position when no matching symbol found', async () => { + const { result } = renderHookWithProvider( + () => usePerpsPositionForAsset('BTC'), + { state: createMockState() }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.position).toBeNull(); + expect(result.current.hasFundsInPerps).toBe(true); + expect(result.current.accountState).toEqual(mockAccountState); + }); + + it('returns hasFundsInPerps false when balance is zero', async () => { + mockGetAccountState.mockResolvedValue({ + ...mockAccountState, + totalBalance: '0', + }); + + const { result } = renderHookWithProvider( + () => usePerpsPositionForAsset('ETH'), + { state: createMockState() }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.hasFundsInPerps).toBe(false); + }); + }); + + describe('Caching behavior', () => { + it('uses cached data on subsequent calls', async () => { + const { result, rerender } = renderHookWithProvider( + () => usePerpsPositionForAsset('ETH'), + { state: createMockState() }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockGetPositions).toHaveBeenCalledTimes(1); + + rerender({}); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockGetPositions).toHaveBeenCalledTimes(1); + expect(result.current.position).toEqual(mockPosition); + }); + + it('fetches fresh data after cache clear', async () => { + const { result } = renderHookWithProvider( + () => usePerpsPositionForAsset('ETH'), + { state: createMockState() }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockGetPositions).toHaveBeenCalledTimes(1); + + _clearPositionCache(); + + const { result: result2 } = renderHookWithProvider( + () => usePerpsPositionForAsset('ETH'), + { state: createMockState() }, + ); + + await waitFor(() => { + expect(result2.current.isLoading).toBe(false); + }); + + expect(mockGetPositions).toHaveBeenCalledTimes(2); + }); + }); + + describe('Error handling', () => { + it('handles API errors gracefully', async () => { + mockGetPositions.mockRejectedValue(new Error('Network error')); + + const { result } = renderHookWithProvider( + () => usePerpsPositionForAsset('ETH'), + { state: createMockState() }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.position).toBeNull(); + expect(result.current.hasFundsInPerps).toBe(false); + expect(result.current.error).toBe('Network error'); + }); + + it('handles non-Error exceptions', async () => { + mockGetPositions.mockRejectedValue('String error'); + + const { result } = renderHookWithProvider( + () => usePerpsPositionForAsset('ETH'), + { state: createMockState() }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBe('Failed to check perps position'); + }); + }); + + describe('Empty positions array', () => { + it('handles empty positions array', async () => { + mockGetPositions.mockResolvedValue([]); + + const { result } = renderHookWithProvider( + () => usePerpsPositionForAsset('ETH'), + { state: createMockState() }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.position).toBeNull(); + expect(result.current.hasFundsInPerps).toBe(true); + }); + }); + + describe('Multiple positions', () => { + it('finds the correct position from multiple', async () => { + const btcPosition: Position = { + ...mockPosition, + symbol: 'BTC', + size: '0.5', + entryPrice: '45000', + }; + + mockGetPositions.mockResolvedValue([btcPosition, mockPosition]); + + const { result } = renderHookWithProvider( + () => usePerpsPositionForAsset('ETH'), + { state: createMockState() }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.position).toEqual(mockPosition); + expect(result.current.position?.symbol).toBe('ETH'); + }); + }); + + describe('Network awareness', () => { + it('includes network in cache key for testnet', async () => { + const { result } = renderHookWithProvider( + () => usePerpsPositionForAsset('ETH'), + { state: createMockState({ isTestnet: true }) }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.position).toEqual(mockPosition); + }); + }); + + describe('Unmount behavior', () => { + it('does not update state after unmount', async () => { + let resolvePromise: (value: Position[]) => void; + const slowPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + mockGetPositions.mockReturnValue(slowPromise); + + const { result, unmount } = renderHookWithProvider( + () => usePerpsPositionForAsset('ETH'), + { state: createMockState() }, + ); + + expect(result.current.isLoading).toBe(true); + + unmount(); + + await act(async () => { + resolvePromise([mockPosition]); + }); + + // No error should be thrown - state update should be prevented + }); + }); + + describe('Stale request handling', () => { + it('ignores stale error responses when symbol changes rapidly', async () => { + let rejectFirst: (error: Error) => void; + const slowError = new Promise((_, reject) => { + rejectFirst = reject; + }); + mockGetPositions + .mockReturnValueOnce(slowError) + .mockResolvedValue([mockPosition]); + + renderHookWithProvider(() => usePerpsPositionForAsset('BTC'), { + state: createMockState(), + }); + + // Clear cache and immediately switch symbol + _clearPositionCache(); + + // Rerender with different symbol (simulates rapid change) + const { result: result2 } = renderHookWithProvider( + () => usePerpsPositionForAsset('ETH'), + { state: createMockState() }, + ); + + // Let the first (stale) request error out + await act(async () => { + rejectFirst(new Error('Stale error')); + }); + + await waitFor(() => { + expect(result2.current.isLoading).toBe(false); + }); + + // The ETH position should be fetched successfully + expect(result2.current.position?.symbol).toBe('ETH'); + }); + }); + + describe('Cache invalidation subscription', () => { + it('subscribes to positions and accountState invalidation on mount', async () => { + const { result } = renderHookWithProvider( + () => usePerpsPositionForAsset('ETH'), + { state: createMockState() }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Should have subscribers for both cache types + expect(PerpsCacheInvalidator.getSubscriberCount('positions')).toBe(1); + expect(PerpsCacheInvalidator.getSubscriberCount('accountState')).toBe(1); + }); + + it('unsubscribes from invalidation on unmount', async () => { + const { result, unmount } = renderHookWithProvider( + () => usePerpsPositionForAsset('ETH'), + { state: createMockState() }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(PerpsCacheInvalidator.getSubscriberCount('positions')).toBe(1); + + unmount(); + + expect(PerpsCacheInvalidator.getSubscriberCount('positions')).toBe(0); + expect(PerpsCacheInvalidator.getSubscriberCount('accountState')).toBe(0); + }); + + it('re-fetches data when positions cache is invalidated', async () => { + const { result } = renderHookWithProvider( + () => usePerpsPositionForAsset('ETH'), + { state: createMockState() }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // First fetch + expect(mockGetPositions).toHaveBeenCalledTimes(1); + + // Invalidate positions cache + await act(async () => { + PerpsCacheInvalidator.invalidate('positions'); + }); + + // Should have re-fetched + await waitFor(() => { + expect(mockGetPositions).toHaveBeenCalledTimes(2); + }); + }); + + it('re-fetches data when accountState cache is invalidated', async () => { + const { result } = renderHookWithProvider( + () => usePerpsPositionForAsset('ETH'), + { state: createMockState() }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // First fetch + expect(mockGetAccountState).toHaveBeenCalledTimes(1); + + // Invalidate accountState cache + await act(async () => { + PerpsCacheInvalidator.invalidate('accountState'); + }); + + // Should have re-fetched + await waitFor(() => { + expect(mockGetAccountState).toHaveBeenCalledTimes(2); + }); + }); + + it('updates position data when position is closed and cache invalidated', async () => { + const { result } = renderHookWithProvider( + () => usePerpsPositionForAsset('ETH'), + { state: createMockState() }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Initially has position + expect(result.current.position).toEqual(mockPosition); + + // Simulate position being closed - update mock to return empty positions + mockGetPositions.mockResolvedValue([]); + mockGetAccountState.mockResolvedValue({ + ...mockAccountState, + totalBalance: '0', + }); + + // Invalidate cache (simulates what TradingService does after closePosition) + await act(async () => { + PerpsCacheInvalidator.invalidate('positions'); + PerpsCacheInvalidator.invalidate('accountState'); + }); + + await waitFor(() => { + expect(result.current.position).toBeNull(); + }); + + expect(result.current.hasFundsInPerps).toBe(false); + }); + + it('does not re-fetch when symbol is null', async () => { + renderHookWithProvider(() => usePerpsPositionForAsset(null), { + state: createMockState(), + }); + + // Should not have fetched initially + expect(mockGetPositions).not.toHaveBeenCalled(); + + // Invalidate cache + await act(async () => { + PerpsCacheInvalidator.invalidate('positions'); + }); + + // Still should not fetch since symbol is null + expect(mockGetPositions).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/UI/Perps/hooks/usePerpsPositionForAsset.ts b/app/components/UI/Perps/hooks/usePerpsPositionForAsset.ts new file mode 100644 index 00000000000..21dd3cfaafb --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsPositionForAsset.ts @@ -0,0 +1,328 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useSelector } from 'react-redux'; +import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; +import type { AccountState, Position } from '../controllers/types'; +import { usePerpsTrading } from './usePerpsTrading'; +import { usePerpsNetwork } from './usePerpsNetwork'; +import { selectSelectedInternalAccountFormattedAddress } from '../../../../selectors/accountsController'; +import { PerpsCacheInvalidator } from '../services/PerpsCacheInvalidator'; + +/** + * Result interface for usePerpsPositionForAsset hook + */ +export interface UsePerpsPositionForAssetResult { + /** Position data if user has an open position for this asset */ + position: Position | null; + /** Whether the user has any funds in perps (balance > 0) */ + hasFundsInPerps: boolean; + /** Account state for perps (balance, margin, etc.) */ + accountState: AccountState | null; + /** Whether the hook is still loading */ + isLoading: boolean; + /** Error message if position lookup failed */ + error: string | null; +} + +// Module-level cache for position/account checks +// Persists across component mounts/unmounts for efficient re-use +const positionCache = new Map< + string, + { + position: Position | null; + accountState: AccountState | null; + hasFundsInPerps: boolean; + timestamp: number; + } +>(); + +// Cache TTL: 30 seconds (shorter than market cache due to position changes) +const CACHE_TTL_MS = 30 * 1000; + +/** + * Clear expired entries from the cache + */ +const cleanExpiredCache = () => { + const now = Date.now(); + for (const [key, value] of positionCache.entries()) { + if (now - value.timestamp > CACHE_TTL_MS) { + positionCache.delete(key); + } + } +}; + +/** + * Clear all cache entries - exported for testing purposes only + * @internal + */ +export const _clearPositionCache = (): void => { + positionCache.clear(); +}; + +/** + * usePerpsPositionForAsset Hook + * + * Efficiently checks if a user has an open perps position for a specific asset. + * Designed for use outside of perps screens (e.g., spot asset details page). + * + * Key Features: + * - Module-level caching to avoid repeated API calls + * - Uses readOnly mode - works without full perps initialization (no wallet/WebSocket) + * - Queries positions and account state in parallel for efficiency + * - 30s cache TTL (shorter than market cache due to position volatility) + * + * @param symbol - Token symbol (e.g., 'ETH', 'BTC') + * @returns Object with position, hasFundsInPerps, accountState, isLoading, error + * + * @example + * ```tsx + * const { position, hasFundsInPerps, isLoading } = usePerpsPositionForAsset('ETH'); + * + * if (position) { + * return ; + * } else if (hasFundsInPerps) { + * return ; + * } + * ``` + */ +export const usePerpsPositionForAsset = ( + symbol: string | undefined | null, +): UsePerpsPositionForAssetResult => { + const { getPositions, getAccountState } = usePerpsTrading(); + const perpsNetwork = usePerpsNetwork(); + const userAddress = useSelector( + selectSelectedInternalAccountFormattedAddress, + ); + + // Track if component is still mounted + const isMountedRef = useRef(true); + + // Track current request to prevent stale responses from updating state + const requestIdRef = useRef(0); + + // Normalize symbol for lookup + const lookupSymbol = symbol?.toUpperCase() ?? null; + + // Create cache key with user address and network context + const cacheKey = + lookupSymbol && userAddress + ? `${userAddress}_${lookupSymbol}_${perpsNetwork}` + : null; + + const [state, setState] = useState<{ + position: Position | null; + accountState: AccountState | null; + hasFundsInPerps: boolean; + isLoading: boolean; + error: string | null; + }>(() => { + // Initialize from cache if available + if (!cacheKey) { + return { + position: null, + accountState: null, + hasFundsInPerps: false, + isLoading: false, + error: null, + }; + } + + const cached = positionCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + return { + position: cached.position, + accountState: cached.accountState, + hasFundsInPerps: cached.hasFundsInPerps, + isLoading: false, + error: null, + }; + } + + return { + position: null, + accountState: null, + hasFundsInPerps: false, + isLoading: true, + error: null, + }; + }); + + const checkPositionExists = useCallback(async () => { + if (!lookupSymbol || !cacheKey || !userAddress) { + return; + } + + // Capture current request ID to detect stale responses + const currentRequestId = ++requestIdRef.current; + + // Check cache first (includes user address and network context) + const cached = positionCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + if (isMountedRef.current) { + setState({ + position: cached.position, + accountState: cached.accountState, + hasFundsInPerps: cached.hasFundsInPerps, + isLoading: false, + error: null, + }); + } + return; + } + + try { + // Fetch positions and account state in parallel using readOnly mode + // readOnly: true bypasses full initialization (no wallet/WebSocket needed) + const [positions, accountState] = await Promise.all([ + getPositions({ + readOnly: true, + userAddress, + }), + getAccountState({ + readOnly: true, + userAddress, + }), + ]); + + // Verify this response matches current request (prevents stale updates) + if (requestIdRef.current !== currentRequestId || !isMountedRef.current) { + return; + } + + // Find position matching the symbol + const matchedPosition = positions.find( + (pos) => pos.symbol.toUpperCase() === lookupSymbol, + ); + + // Check if user has any funds in perps (total balance > 0) + const totalBalance = parseFloat(accountState?.totalBalance || '0'); + const hasFundsInPerps = totalBalance > 0; + + // Cache the result + positionCache.set(cacheKey, { + position: matchedPosition || null, + accountState, + hasFundsInPerps, + timestamp: Date.now(), + }); + + // Periodic cache cleanup + cleanExpiredCache(); + + setState({ + position: matchedPosition || null, + accountState, + hasFundsInPerps, + isLoading: false, + error: null, + }); + } catch (err) { + // Verify this error is for current request + if (requestIdRef.current !== currentRequestId || !isMountedRef.current) { + return; + } + + DevLogger.log('usePerpsPositionForAsset: Error checking position:', err); + + // On error, don't cache - allow retry + // Silent failure: return empty state (discovery banner will show) + setState({ + position: null, + accountState: null, + hasFundsInPerps: false, + isLoading: false, + error: + err instanceof Error ? err.message : 'Failed to check perps position', + }); + } + }, [lookupSymbol, cacheKey, userAddress, getPositions, getAccountState]); + + // Track mount state - only set once on mount, cleared on unmount + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + // Effect to check position existence + useEffect(() => { + // Early bail for missing data + if (!cacheKey || !userAddress) { + setState({ + position: null, + accountState: null, + hasFundsInPerps: false, + isLoading: false, + error: null, + }); + return; + } + + // Check if already cached (includes user address and network context) + const cached = positionCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + setState({ + position: cached.position, + accountState: cached.accountState, + hasFundsInPerps: cached.hasFundsInPerps, + isLoading: false, + error: null, + }); + return; + } + + // Need to fetch + setState((prev) => ({ ...prev, isLoading: true })); + checkPositionExists(); + }, [cacheKey, userAddress, checkPositionExists]); + + // Subscribe to cache invalidation events + // When positions or account state change in perps, clear cache and re-fetch + useEffect(() => { + // Debounce invalidation to prevent duplicate API calls when both + // positions and accountState are invalidated together (common after trades) + let invalidationTimeout: ReturnType | null = null; + + // Handler that clears cache and triggers a re-fetch (debounced) + const handleInvalidation = () => { + // Clear any pending invalidation + if (invalidationTimeout) { + clearTimeout(invalidationTimeout); + } + // Debounce: wait briefly to batch multiple invalidations + invalidationTimeout = setTimeout(() => { + _clearPositionCache(); + // Only re-fetch if we have the necessary data + if (cacheKey && userAddress && isMountedRef.current) { + checkPositionExists(); + } + }, 10); // 10ms debounce - enough to batch synchronous invalidations + }; + + // Subscribe to both positions and accountState invalidation + const unsubPositions = PerpsCacheInvalidator.subscribe( + 'positions', + handleInvalidation, + ); + const unsubAccountState = PerpsCacheInvalidator.subscribe( + 'accountState', + handleInvalidation, + ); + + return () => { + if (invalidationTimeout) { + clearTimeout(invalidationTimeout); + } + unsubPositions(); + unsubAccountState(); + }; + }, [cacheKey, userAddress, checkPositionExists]); + + return { + position: state.position, + hasFundsInPerps: state.hasFundsInPerps, + accountState: state.accountState, + isLoading: state.isLoading, + error: state.error, + }; +}; diff --git a/app/components/UI/Perps/hooks/usePerpsTrading.ts b/app/components/UI/Perps/hooks/usePerpsTrading.ts index e149e4984d3..7024289885f 100644 --- a/app/components/UI/Perps/hooks/usePerpsTrading.ts +++ b/app/components/UI/Perps/hooks/usePerpsTrading.ts @@ -13,6 +13,7 @@ import type { GetOrderFillsParams, GetOrdersParams, GetFundingParams, + GetPositionsParams, OrderFill, Order, Funding, @@ -69,10 +70,13 @@ export function usePerpsTrading() { [], ); - const getPositions = useCallback(async (): Promise => { - const controller = Engine.context.PerpsController; - return controller.getPositions(); - }, []); + const getPositions = useCallback( + async (params?: GetPositionsParams): Promise => { + const controller = Engine.context.PerpsController; + return controller.getPositions(params); + }, + [], + ); const getAccountState = useCallback( async (params?: GetAccountStateParams): Promise => { diff --git a/app/components/UI/Perps/routes/index.tsx b/app/components/UI/Perps/routes/index.tsx index fb199d53ab6..ff53bf7c94f 100644 --- a/app/components/UI/Perps/routes/index.tsx +++ b/app/components/UI/Perps/routes/index.tsx @@ -15,6 +15,7 @@ import PerpsHomeView from '../Views/PerpsHomeView/PerpsHomeView'; import PerpsMarketDetailsView from '../Views/PerpsMarketDetailsView'; import PerpsMarketListView from '../Views/PerpsMarketListView'; import PerpsRedirect from '../Views/PerpsRedirect'; +import PerpsOrderRedirect from '../Views/PerpsOrderRedirect'; import PerpsPositionsView from '../Views/PerpsPositionsView'; import PerpsWithdrawView from '../Views/PerpsWithdrawView'; import PerpsClosePositionView from '../Views/PerpsClosePositionView'; @@ -396,6 +397,15 @@ const PerpsScreenStack = () => { }} /> + {/* Order redirect screen - handles one-click trade from token details */} + + { + beforeEach(() => { + // Clear all subscribers before each test + PerpsCacheInvalidator._clearAllSubscribers(); + }); + + describe('Singleton Pattern', () => { + it('returns the same instance on multiple calls', () => { + // The exported PerpsCacheInvalidator is already a singleton + // We can verify by checking that subscriber counts persist + const callback = jest.fn(); + PerpsCacheInvalidator.subscribe('positions', callback); + + expect(PerpsCacheInvalidator.getSubscriberCount('positions')).toBe(1); + }); + }); + + describe('subscribe()', () => { + it('adds a subscriber for a cache type', () => { + const callback = jest.fn(); + + PerpsCacheInvalidator.subscribe('positions', callback); + + expect(PerpsCacheInvalidator.getSubscriberCount('positions')).toBe(1); + }); + + it('allows multiple subscribers for the same cache type', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + const callback3 = jest.fn(); + + PerpsCacheInvalidator.subscribe('positions', callback1); + PerpsCacheInvalidator.subscribe('positions', callback2); + PerpsCacheInvalidator.subscribe('positions', callback3); + + expect(PerpsCacheInvalidator.getSubscriberCount('positions')).toBe(3); + }); + + it('allows subscribers for different cache types', () => { + const positionsCallback = jest.fn(); + const accountCallback = jest.fn(); + const marketsCallback = jest.fn(); + + PerpsCacheInvalidator.subscribe('positions', positionsCallback); + PerpsCacheInvalidator.subscribe('accountState', accountCallback); + PerpsCacheInvalidator.subscribe('markets', marketsCallback); + + expect(PerpsCacheInvalidator.getSubscriberCount('positions')).toBe(1); + expect(PerpsCacheInvalidator.getSubscriberCount('accountState')).toBe(1); + expect(PerpsCacheInvalidator.getSubscriberCount('markets')).toBe(1); + }); + + it('returns an unsubscribe function', () => { + const callback = jest.fn(); + + const unsubscribe = PerpsCacheInvalidator.subscribe( + 'positions', + callback, + ); + + expect(typeof unsubscribe).toBe('function'); + }); + + it('unsubscribe function removes the subscriber', () => { + const callback = jest.fn(); + + const unsubscribe = PerpsCacheInvalidator.subscribe( + 'positions', + callback, + ); + expect(PerpsCacheInvalidator.getSubscriberCount('positions')).toBe(1); + + unsubscribe(); + expect(PerpsCacheInvalidator.getSubscriberCount('positions')).toBe(0); + }); + + it('unsubscribe only removes the specific subscriber', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + const unsubscribe1 = PerpsCacheInvalidator.subscribe( + 'positions', + callback1, + ); + PerpsCacheInvalidator.subscribe('positions', callback2); + + expect(PerpsCacheInvalidator.getSubscriberCount('positions')).toBe(2); + + unsubscribe1(); + expect(PerpsCacheInvalidator.getSubscriberCount('positions')).toBe(1); + + // callback2 should still be called on invalidation + PerpsCacheInvalidator.invalidate('positions'); + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).toHaveBeenCalledTimes(1); + }); + + it('handles calling unsubscribe multiple times gracefully', () => { + const callback = jest.fn(); + + const unsubscribe = PerpsCacheInvalidator.subscribe( + 'positions', + callback, + ); + + unsubscribe(); + unsubscribe(); // Should not throw + unsubscribe(); // Should not throw + + expect(PerpsCacheInvalidator.getSubscriberCount('positions')).toBe(0); + }); + }); + + describe('invalidate()', () => { + it('calls all subscribers for the specified cache type', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + PerpsCacheInvalidator.subscribe('positions', callback1); + PerpsCacheInvalidator.subscribe('positions', callback2); + + PerpsCacheInvalidator.invalidate('positions'); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + }); + + it('does not call subscribers for other cache types', () => { + const positionsCallback = jest.fn(); + const accountCallback = jest.fn(); + + PerpsCacheInvalidator.subscribe('positions', positionsCallback); + PerpsCacheInvalidator.subscribe('accountState', accountCallback); + + PerpsCacheInvalidator.invalidate('positions'); + + expect(positionsCallback).toHaveBeenCalledTimes(1); + expect(accountCallback).not.toHaveBeenCalled(); + }); + + it('handles invalidate with no subscribers gracefully', () => { + // Should not throw + expect(() => { + PerpsCacheInvalidator.invalidate('positions'); + }).not.toThrow(); + }); + + it('continues calling other subscribers if one throws an error', () => { + const errorCallback = jest.fn(() => { + throw new Error('Test error'); + }); + const successCallback = jest.fn(); + + PerpsCacheInvalidator.subscribe('positions', errorCallback); + PerpsCacheInvalidator.subscribe('positions', successCallback); + + // Should not throw + expect(() => { + PerpsCacheInvalidator.invalidate('positions'); + }).not.toThrow(); + + expect(errorCallback).toHaveBeenCalledTimes(1); + expect(successCallback).toHaveBeenCalledTimes(1); + }); + + it('can be called multiple times', () => { + const callback = jest.fn(); + + PerpsCacheInvalidator.subscribe('positions', callback); + + PerpsCacheInvalidator.invalidate('positions'); + PerpsCacheInvalidator.invalidate('positions'); + PerpsCacheInvalidator.invalidate('positions'); + + expect(callback).toHaveBeenCalledTimes(3); + }); + }); + + describe('invalidateAll()', () => { + it('calls all subscribers for all cache types', () => { + const positionsCallback = jest.fn(); + const accountCallback = jest.fn(); + const marketsCallback = jest.fn(); + + PerpsCacheInvalidator.subscribe('positions', positionsCallback); + PerpsCacheInvalidator.subscribe('accountState', accountCallback); + PerpsCacheInvalidator.subscribe('markets', marketsCallback); + + PerpsCacheInvalidator.invalidateAll(); + + expect(positionsCallback).toHaveBeenCalledTimes(1); + expect(accountCallback).toHaveBeenCalledTimes(1); + expect(marketsCallback).toHaveBeenCalledTimes(1); + }); + + it('handles invalidateAll with no subscribers gracefully', () => { + // Should not throw + expect(() => { + PerpsCacheInvalidator.invalidateAll(); + }).not.toThrow(); + }); + + it('continues calling subscribers even if some throw errors', () => { + const errorCallback = jest.fn(() => { + throw new Error('Test error'); + }); + const positionsCallback = jest.fn(); + const marketsCallback = jest.fn(); + + PerpsCacheInvalidator.subscribe('positions', errorCallback); + PerpsCacheInvalidator.subscribe('positions', positionsCallback); + PerpsCacheInvalidator.subscribe('markets', marketsCallback); + + expect(() => { + PerpsCacheInvalidator.invalidateAll(); + }).not.toThrow(); + + expect(errorCallback).toHaveBeenCalledTimes(1); + expect(positionsCallback).toHaveBeenCalledTimes(1); + expect(marketsCallback).toHaveBeenCalledTimes(1); + }); + }); + + describe('getSubscriberCount()', () => { + it('returns 0 for cache types with no subscribers', () => { + expect(PerpsCacheInvalidator.getSubscriberCount('positions')).toBe(0); + expect(PerpsCacheInvalidator.getSubscriberCount('accountState')).toBe(0); + expect(PerpsCacheInvalidator.getSubscriberCount('markets')).toBe(0); + }); + + it('returns correct count after subscribing', () => { + PerpsCacheInvalidator.subscribe('positions', jest.fn()); + PerpsCacheInvalidator.subscribe('positions', jest.fn()); + + expect(PerpsCacheInvalidator.getSubscriberCount('positions')).toBe(2); + }); + + it('returns correct count after unsubscribing', () => { + const unsub1 = PerpsCacheInvalidator.subscribe('positions', jest.fn()); + PerpsCacheInvalidator.subscribe('positions', jest.fn()); + + expect(PerpsCacheInvalidator.getSubscriberCount('positions')).toBe(2); + + unsub1(); + expect(PerpsCacheInvalidator.getSubscriberCount('positions')).toBe(1); + }); + }); + + describe('_clearAllSubscribers()', () => { + it('removes all subscribers for all cache types', () => { + PerpsCacheInvalidator.subscribe('positions', jest.fn()); + PerpsCacheInvalidator.subscribe('positions', jest.fn()); + PerpsCacheInvalidator.subscribe('accountState', jest.fn()); + PerpsCacheInvalidator.subscribe('markets', jest.fn()); + + expect(PerpsCacheInvalidator.getSubscriberCount('positions')).toBe(2); + expect(PerpsCacheInvalidator.getSubscriberCount('accountState')).toBe(1); + expect(PerpsCacheInvalidator.getSubscriberCount('markets')).toBe(1); + + PerpsCacheInvalidator._clearAllSubscribers(); + + expect(PerpsCacheInvalidator.getSubscriberCount('positions')).toBe(0); + expect(PerpsCacheInvalidator.getSubscriberCount('accountState')).toBe(0); + expect(PerpsCacheInvalidator.getSubscriberCount('markets')).toBe(0); + }); + }); + + describe('Integration Scenarios', () => { + it('simulates usePerpsPositionForAsset subscribing and receiving invalidation', () => { + const clearCache = jest.fn(); + const refetch = jest.fn(); + + // Simulate hook subscription + const unsubPositions = PerpsCacheInvalidator.subscribe( + 'positions', + () => { + clearCache(); + refetch(); + }, + ); + const unsubAccount = PerpsCacheInvalidator.subscribe( + 'accountState', + () => { + clearCache(); + refetch(); + }, + ); + + // Simulate TradingService closing a position + PerpsCacheInvalidator.invalidate('positions'); + PerpsCacheInvalidator.invalidate('accountState'); + + expect(clearCache).toHaveBeenCalledTimes(2); + expect(refetch).toHaveBeenCalledTimes(2); + + // Cleanup (simulates useEffect cleanup) + unsubPositions(); + unsubAccount(); + + // After cleanup, invalidation should not trigger callbacks + clearCache.mockClear(); + refetch.mockClear(); + + PerpsCacheInvalidator.invalidate('positions'); + expect(clearCache).not.toHaveBeenCalled(); + expect(refetch).not.toHaveBeenCalled(); + }); + + it('handles multiple hooks subscribing to the same cache type', () => { + const hook1Callback = jest.fn(); + const hook2Callback = jest.fn(); + const hook3Callback = jest.fn(); + + // Multiple token detail pages open simultaneously + const unsub1 = PerpsCacheInvalidator.subscribe( + 'positions', + hook1Callback, + ); + const unsub2 = PerpsCacheInvalidator.subscribe( + 'positions', + hook2Callback, + ); + const unsub3 = PerpsCacheInvalidator.subscribe( + 'positions', + hook3Callback, + ); + + // Position closed - all hooks should be notified + PerpsCacheInvalidator.invalidate('positions'); + + expect(hook1Callback).toHaveBeenCalledTimes(1); + expect(hook2Callback).toHaveBeenCalledTimes(1); + expect(hook3Callback).toHaveBeenCalledTimes(1); + + // One hook unmounts + unsub2(); + + PerpsCacheInvalidator.invalidate('positions'); + + expect(hook1Callback).toHaveBeenCalledTimes(2); + expect(hook2Callback).toHaveBeenCalledTimes(1); // Not called again + expect(hook3Callback).toHaveBeenCalledTimes(2); + + // Cleanup remaining + unsub1(); + unsub3(); + }); + + it('supports all defined cache types', () => { + const cacheTypes: CacheType[] = ['positions', 'accountState', 'markets']; + + cacheTypes.forEach((type) => { + const callback = jest.fn(); + const unsub = PerpsCacheInvalidator.subscribe(type, callback); + + PerpsCacheInvalidator.invalidate(type); + expect(callback).toHaveBeenCalledTimes(1); + + unsub(); + }); + }); + }); +}); diff --git a/app/components/UI/Perps/services/PerpsCacheInvalidator.ts b/app/components/UI/Perps/services/PerpsCacheInvalidator.ts new file mode 100644 index 00000000000..1a96e384400 --- /dev/null +++ b/app/components/UI/Perps/services/PerpsCacheInvalidator.ts @@ -0,0 +1,178 @@ +/** + * PerpsCacheInvalidator + * + * Generic cache invalidation service for Perps readOnly queries. + * Provides loosely-coupled invalidation between cache consumers (hooks) + * and cache invalidators (services that modify data). + * + * Architecture: + * - Hooks subscribe to invalidation events for specific cache types + * - Services call invalidate() after successful operations + * - Subscribers clear their caches and re-fetch data + * + * This pattern allows readOnly hooks (usePerpsPositionForAsset, etc.) to + * maintain fast cached data while still being notified when that data + * becomes stale due to user actions in the perps environment. + * + * @example Hook side (consumer): + * ```typescript + * useEffect(() => { + * const unsub = PerpsCacheInvalidator.subscribe('positions', () => { + * clearMyCache(); + * refetch(); + * }); + * return unsub; + * }, []); + * ``` + * + * @example Service side (producer): + * ```typescript + * // After successful position close + * PerpsCacheInvalidator.invalidate('positions'); + * PerpsCacheInvalidator.invalidate('accountState'); + * ``` + */ + +/** + * Types of caches that can be invalidated. + * - 'positions': Position data caches + * - 'accountState': Account balance/state caches + * - 'markets': Market data caches (rarely changes) + */ +export type CacheType = 'positions' | 'accountState' | 'markets'; + +/** + * Callback function invoked when a cache is invalidated. + * Typically clears local cache and triggers a re-fetch. + */ +export type InvalidationCallback = () => void; + +/** + * Internal class for managing cache invalidation subscriptions. + * Singleton pattern ensures all subscribers share the same instance. + */ +class PerpsCacheInvalidatorService { + private static instance: PerpsCacheInvalidatorService; + private subscribers = new Map>(); + + private constructor() { + // Private constructor for singleton pattern + } + + /** + * Get the singleton instance of the cache invalidator. + */ + public static getInstance(): PerpsCacheInvalidatorService { + if (!PerpsCacheInvalidatorService.instance) { + PerpsCacheInvalidatorService.instance = + new PerpsCacheInvalidatorService(); + } + return PerpsCacheInvalidatorService.instance; + } + + /** + * Subscribe to invalidation events for a specific cache type. + * + * @param type - The cache type to subscribe to + * @param callback - Function to call when cache is invalidated + * @returns Unsubscribe function - call this in useEffect cleanup + * + * @example + * ```typescript + * useEffect(() => { + * const unsubscribe = PerpsCacheInvalidator.subscribe('positions', () => { + * _clearPositionCache(); + * checkPositionExists(); + * }); + * return unsubscribe; + * }, [checkPositionExists]); + * ``` + */ + public subscribe( + type: CacheType, + callback: InvalidationCallback, + ): () => void { + if (!this.subscribers.has(type)) { + this.subscribers.set(type, new Set()); + } + const subscribers = this.subscribers.get(type); + subscribers?.add(callback); + + // Return unsubscribe function + return () => { + this.subscribers.get(type)?.delete(callback); + }; + } + + /** + * Invalidate a specific cache type. + * All subscribers for this cache type will be notified. + * + * @param type - The cache type to invalidate + * + * @example + * ```typescript + * // After closing a position + * PerpsCacheInvalidator.invalidate('positions'); + * PerpsCacheInvalidator.invalidate('accountState'); + * ``` + */ + public invalidate(type: CacheType): void { + const callbacks = this.subscribers.get(type); + if (callbacks) { + callbacks.forEach((callback) => { + try { + callback(); + } catch { + // Silently ignore errors in callbacks to prevent one bad subscriber + // from blocking others. Individual hooks should handle their own errors. + } + }); + } + } + + /** + * Invalidate all cache types. + * Notifies all subscribers regardless of cache type. + * + * @example + * ```typescript + * // After a major state change (e.g., account switch) + * PerpsCacheInvalidator.invalidateAll(); + * ``` + */ + public invalidateAll(): void { + this.subscribers.forEach((callbacks) => { + callbacks.forEach((callback) => { + try { + callback(); + } catch { + // Silently ignore errors in callbacks + } + }); + }); + } + + /** + * Get the number of subscribers for a specific cache type. + * Useful for debugging and testing. + * + * @param type - The cache type to check + * @returns Number of subscribers + */ + public getSubscriberCount(type: CacheType): number { + return this.subscribers.get(type)?.size ?? 0; + } + + /** + * Clear all subscribers. + * WARNING: Only use this for testing purposes. + * @internal + */ + public _clearAllSubscribers(): void { + this.subscribers.clear(); + } +} + +// Export singleton instance +export const PerpsCacheInvalidator = PerpsCacheInvalidatorService.getInstance(); diff --git a/app/components/UI/Perps/types/navigation.ts b/app/components/UI/Perps/types/navigation.ts index fa614701509..2a161e97c9f 100644 --- a/app/components/UI/Perps/types/navigation.ts +++ b/app/components/UI/Perps/types/navigation.ts @@ -215,6 +215,12 @@ export interface PerpsNavigationParamList extends ParamListBase { RedesignedConfirmations: { showPerpsHeader?: boolean; }; + + /** Params for PerpsOrderRedirect - handles one-click trade from token details */ + PerpsOrderRedirect: { + direction: 'long' | 'short'; + asset: string; + }; } /** diff --git a/app/components/UI/TokenDetails/components/AssetOverviewContent.test.tsx b/app/components/UI/TokenDetails/components/AssetOverviewContent.test.tsx new file mode 100644 index 00000000000..ff28d6bec05 --- /dev/null +++ b/app/components/UI/TokenDetails/components/AssetOverviewContent.test.tsx @@ -0,0 +1,186 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import AssetOverviewContent, { + type AssetOverviewContentProps, +} from './AssetOverviewContent'; +import { TokenOverviewSelectorsIDs } from '../../AssetOverview/TokenOverview.testIds'; +import { TokenI } from '../../Tokens/types'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import { backgroundState } from '../../../../util/test/initial-root-state'; +import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../../util/test/accountsControllerTestUtils'; +import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events'; +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '../../Perps/constants/eventNames'; + +const mockHandlePerpsAction = jest.fn(); +const mockTrack = jest.fn(); + +jest.mock('../hooks/usePerpsActions', () => ({ + usePerpsActions: () => ({ + hasPerpsMarket: true, + marketData: { symbol: 'ETH', name: 'ETH', maxLeverage: '50x' }, + isLoading: false, + error: null, + handlePerpsAction: mockHandlePerpsAction, + }), +})); + +jest.mock('../../Perps/hooks/usePerpsEventTracking', () => ({ + usePerpsEventTracking: () => ({ track: mockTrack }), +})); + +jest.mock('../../AssetOverview/hooks/useScrollToMerklRewards', () => ({ + useScrollToMerklRewards: jest.fn(), +})); + +jest.mock('../../Perps/components/PerpsBottomSheetTooltip', () => ({ + __esModule: true, + default: () => null, +})); + +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return { + ...actual, + useNavigation: () => ({ + navigate: jest.fn(), + addListener: jest.fn(() => jest.fn()), + }), + useFocusEffect: jest.fn((cb: () => void) => cb()), + }; +}); + +function createState(isEligible: boolean) { + return { + engine: { + backgroundState: { + ...backgroundState, + PerpsController: { + ...(backgroundState as { PerpsController?: object }).PerpsController, + isEligible, + }, + RemoteFeatureFlagController: { + ...(backgroundState as { RemoteFeatureFlagController?: object }) + .RemoteFeatureFlagController, + remoteFeatureFlags: { + tokenDetailsV2Buttons: true, + }, + }, + AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE, + }, + }, + }; +} + +const defaultToken: TokenI = { + address: '0x123', + chainId: '0x1', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + balance: '1', + balanceFiat: '$2000', + logo: '', + image: '', + isETH: false, + hasBalanceError: false, + aggregators: [], +}; + +const defaultProps: AssetOverviewContentProps = { + token: defaultToken, + balance: '1', + mainBalance: '$2000', + secondaryBalance: '1 ETH', + currentPrice: 2000, + priceDiff: 0, + comparePrice: 2000, + prices: [], + isLoading: false, + timePeriod: '1d', + setTimePeriod: jest.fn(), + chartNavigationButtons: ['1d', '1w', '1m'], + isPerpsEnabled: true, + isMerklCampaignClaimingEnabled: false, + displayBuyButton: false, + displaySwapsButton: false, + currentCurrency: 'USD', + onBuy: jest.fn(), + onSend: jest.fn().mockResolvedValue(undefined), + onReceive: jest.fn(), + goToSwaps: jest.fn(), +}; + +describe('AssetOverviewContent', () => { + describe('Long / Short with perps eligibility', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows geo block modal and tracks event when Long is pressed and user is not eligible', () => { + const { getByTestId } = renderWithProvider( + , + { state: createState(false) }, + ); + + fireEvent.press(getByTestId(TokenOverviewSelectorsIDs.LONG_BUTTON)); + + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.PERPS_SCREEN_VIEWED, + { + [PERPS_EVENT_PROPERTY.SCREEN_TYPE]: + PERPS_EVENT_VALUE.SCREEN_TYPE.GEO_BLOCK_NOTIF, + [PERPS_EVENT_PROPERTY.SOURCE]: + PERPS_EVENT_VALUE.SOURCE.ASSET_DETAIL_SCREEN, + }, + ); + expect(mockHandlePerpsAction).not.toHaveBeenCalled(); + }); + + it('shows geo block modal and tracks event when Short is pressed and user is not eligible', () => { + const { getByTestId } = renderWithProvider( + , + { state: createState(false) }, + ); + + fireEvent.press(getByTestId(TokenOverviewSelectorsIDs.SHORT_BUTTON)); + + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.PERPS_SCREEN_VIEWED, + { + [PERPS_EVENT_PROPERTY.SCREEN_TYPE]: + PERPS_EVENT_VALUE.SCREEN_TYPE.GEO_BLOCK_NOTIF, + [PERPS_EVENT_PROPERTY.SOURCE]: + PERPS_EVENT_VALUE.SOURCE.ASSET_DETAIL_SCREEN, + }, + ); + expect(mockHandlePerpsAction).not.toHaveBeenCalled(); + }); + + it('calls handlePerpsAction with long when Long is pressed and user is eligible', () => { + const { getByTestId } = renderWithProvider( + , + { state: createState(true) }, + ); + + fireEvent.press(getByTestId(TokenOverviewSelectorsIDs.LONG_BUTTON)); + + expect(mockHandlePerpsAction).toHaveBeenCalledWith('long'); + expect(mockTrack).not.toHaveBeenCalled(); + }); + + it('calls handlePerpsAction with short when Short is pressed and user is eligible', () => { + const { getByTestId } = renderWithProvider( + , + { state: createState(true) }, + ); + + fireEvent.press(getByTestId(TokenOverviewSelectorsIDs.SHORT_BUTTON)); + + expect(mockHandlePerpsAction).toHaveBeenCalledWith('short'); + expect(mockTrack).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx index 136d326ee77..53aa88520c4 100644 --- a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx +++ b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx @@ -1,7 +1,8 @@ -import React, { useCallback, useRef } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import { TouchableOpacity, View, + Modal, StyleSheet, TextStyle, ViewStyle, @@ -11,11 +12,10 @@ import { useNavigation } from '@react-navigation/native'; import type { Theme } from '@metamask/design-tokens'; import { strings } from '../../../../../locales/i18n'; import { useStyles } from '../../../../component-library/hooks'; -import DSText, { - getFontFamily, +import Text, { + TextColor, TextVariant, } from '../../../../component-library/components/Texts/Text'; -import Text from '../../../Base/Text'; import AppConstants from '../../../../core/AppConstants'; import Routes from '../../../../constants/navigation/Routes'; import { createWebviewNavDetails } from '../../../Views/SimpleWebview'; @@ -26,7 +26,16 @@ import { } from '../../../hooks/useTokenHistoricalPrices'; import { TokenI } from '../../Tokens/types'; import { usePerpsActions } from '../hooks/usePerpsActions'; -import { PERPS_EVENT_VALUE } from '../../Perps/constants/eventNames'; +import { usePerpsPositionForAsset } from '../../Perps/hooks/usePerpsPositionForAsset'; +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '../../Perps/constants/eventNames'; +import { selectPerpsEligibility } from '../../Perps/selectors/perpsController'; +import PerpsBottomSheetTooltip from '../../Perps/components/PerpsBottomSheetTooltip'; +import { usePerpsEventTracking } from '../../Perps/hooks/usePerpsEventTracking'; +import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events'; +import PerpsPositionCard from '../../Perps/components/PerpsPositionCard'; import Price from '../../AssetOverview/Price'; import ChartNavigationButton from '../../AssetOverview/ChartNavigationButton'; import Balance from '../../AssetOverview/Balance'; @@ -46,7 +55,7 @@ import TronEnergyBandwidthDetail from '../../AssetOverview/TronEnergyBandwidthDe const styleSheet = (params: { theme: Theme }) => { const { theme } = params; - const { colors, typography } = theme; + const { colors } = theme; return StyleSheet.create({ wrapper: { paddingTop: 20, @@ -56,19 +65,12 @@ const styleSheet = (params: { theme: Theme }) => { marginBottom: 20, } as ViewStyle, warning: { - ...typography.sBodyMD, - fontFamily: getFontFamily(TextVariant.BodyMD), borderRadius: 8, borderWidth: 1, borderColor: colors.warning.default, backgroundColor: colors.warning.muted, padding: 20, - } as TextStyle, - warningLinks: { - ...typography.sBodyMD, - fontFamily: getFontFamily(TextVariant.BodyMD), - color: colors.primary.default, - } as TextStyle, + } as ViewStyle, chartNavigationWrapper: { display: 'flex', flexDirection: 'row', @@ -81,10 +83,13 @@ const styleSheet = (params: { theme: Theme }) => { marginBottom: 20, paddingHorizontal: 16, } as ViewStyle, - perpsPositionHeader: { + perpsPositionCardContainer: { paddingHorizontal: 16, paddingTop: 24, } as ViewStyle, + perpsPositionTitle: { + marginBottom: 8, + } as TextStyle, }); }; @@ -171,6 +176,7 @@ const AssetOverviewContent: React.FC = ({ const navigation = useNavigation(); const merklRewardsRef = useRef(null); const merklRewardsYInHeaderRef = useRef(null); + const resetNavigationLockRef = useRef<(() => void) | null>(null); useScrollToMerklRewards(merklRewardsYInHeaderRef); @@ -183,10 +189,54 @@ const AssetOverviewContent: React.FC = ({ symbol: isPerpsEnabled ? token.symbol : null, }); + const isEligible = useSelector(selectPerpsEligibility); + const [isEligibilityModalVisible, setIsEligibilityModalVisible] = + useState(false); + const { track } = usePerpsEventTracking(); + + const closeEligibilityModal = useCallback(() => { + setIsEligibilityModalVisible(false); + resetNavigationLockRef.current?.(); + }, []); + + const handleLongPress = useCallback(() => { + if (!isEligible) { + track(MetaMetricsEvents.PERPS_SCREEN_VIEWED, { + [PERPS_EVENT_PROPERTY.SCREEN_TYPE]: + PERPS_EVENT_VALUE.SCREEN_TYPE.GEO_BLOCK_NOTIF, + [PERPS_EVENT_PROPERTY.SOURCE]: + PERPS_EVENT_VALUE.SOURCE.ASSET_DETAIL_SCREEN, + }); + setIsEligibilityModalVisible(true); + return; + } + handlePerpsAction?.('long'); + }, [isEligible, track, handlePerpsAction]); + + const handleShortPress = useCallback(() => { + if (!isEligible) { + track(MetaMetricsEvents.PERPS_SCREEN_VIEWED, { + [PERPS_EVENT_PROPERTY.SCREEN_TYPE]: + PERPS_EVENT_VALUE.SCREEN_TYPE.GEO_BLOCK_NOTIF, + [PERPS_EVENT_PROPERTY.SOURCE]: + PERPS_EVENT_VALUE.SOURCE.ASSET_DETAIL_SCREEN, + }); + setIsEligibilityModalVisible(true); + return; + } + handlePerpsAction?.('short'); + }, [isEligible, track, handlePerpsAction]); + const { isBuyable, isLoading: isBuyableLoading } = useTokenBuyability(token); const isButtonsLoading = isBuyableLoading || isPerpsLoading; + // Check if user has a position for this asset (only if perps is enabled and market exists) + const { position: perpsPosition, isLoading: isPerpsPositionLoading } = + usePerpsPositionForAsset( + isPerpsEnabled && hasPerpsMarket ? token.symbol : null, + ); + const isTokenTrustworthy = isTokenTrustworthyForPerps(token); const isTokenDetailsV2ButtonsEnabled = useSelector( @@ -224,14 +274,16 @@ const AssetOverviewContent: React.FC = ({ goToBrowserUrl(AppConstants.URLS.TOKEN_BALANCE)} > - - {strings('asset_overview.were_unable')} {token.symbol}{' '} - {strings('asset_overview.balance')}{' '} - - {strings('asset_overview.troubleshooting_missing')} - {' '} - {strings('asset_overview.for_help')} - + + + {strings('asset_overview.were_unable')} {token.symbol}{' '} + {strings('asset_overview.balance')}{' '} + + {strings('asset_overview.troubleshooting_missing')} + {' '} + {strings('asset_overview.for_help')} + + ); @@ -280,11 +332,12 @@ const AssetOverviewContent: React.FC = ({ isNativeCurrency={token.isETH || token.isNative || false} token={token} onBuy={onBuy} - onLong={handlePerpsAction} - onShort={handlePerpsAction} + onLong={handlePerpsAction ? handleLongPress : undefined} + onShort={handlePerpsAction ? handleShortPress : undefined} onSend={onSend} onReceive={onReceive} isLoading={isButtonsLoading} + resetNavigationLockRef={resetNavigationLockRef} /> ) : ( = ({ {isPerpsEnabled && hasPerpsMarket && marketData && - isTokenTrustworthy && ( + isTokenTrustworthy && + !isPerpsPositionLoading && ( <> - - - {strings('asset_overview.perps_position')} - - - + {perpsPosition ? ( + + + {strings('asset_overview.perps_position')} + + + + ) : ( + <> + + + {strings('asset_overview.perps_position')} + + + + + )} )} + {isEligibilityModalVisible && ( + + + + + + )} )} diff --git a/app/components/UI/TokenDetails/components/TokenDetailsActions.tsx b/app/components/UI/TokenDetails/components/TokenDetailsActions.tsx index e011093b052..591e848b30f 100644 --- a/app/components/UI/TokenDetails/components/TokenDetailsActions.tsx +++ b/app/components/UI/TokenDetails/components/TokenDetailsActions.tsx @@ -44,6 +44,8 @@ export interface TokenDetailsActionsProps { onSend: () => void; onReceive: () => void; isLoading?: boolean; + /** Optional ref to receive a callback that resets the navigation lock. Used when Long/Short show a modal instead of navigating (e.g. geo block). */ + resetNavigationLockRef?: React.MutableRefObject<(() => void) | null>; } /** @@ -81,6 +83,7 @@ export const TokenDetailsActions: React.FC = ({ onSend, onReceive, isLoading = false, + resetNavigationLockRef, }) => { const { styles } = useStyles(styleSheet, {}); const canSignTransactions = useSelector(selectCanSignTransactions); @@ -90,6 +93,19 @@ export const TokenDetailsActions: React.FC = ({ // Prevent rapid navigation clicks - locks all buttons during navigation const navigationLockRef = useRef(false); + // Expose reset so parent can unlock when a non-navigating action ends (e.g. geo block modal dismissed) + const resetLock = useCallback(() => { + navigationLockRef.current = false; + }, []); + useEffect(() => { + if (resetNavigationLockRef) { + resetNavigationLockRef.current = resetLock; + return () => { + resetNavigationLockRef.current = null; + }; + } + }, [resetNavigationLockRef, resetLock]); + // Reset lock when screen comes into focus (handles return from navigation) useFocusEffect( useCallback(() => { diff --git a/app/components/UI/TokenDetails/hooks/usePerpsActions.test.ts b/app/components/UI/TokenDetails/hooks/usePerpsActions.test.ts new file mode 100644 index 00000000000..21a2b0d7c3f --- /dev/null +++ b/app/components/UI/TokenDetails/hooks/usePerpsActions.test.ts @@ -0,0 +1,185 @@ +import { renderHook } from '@testing-library/react-native'; +import { useNavigation } from '@react-navigation/native'; +import { usePerpsActions } from './usePerpsActions'; +import { usePerpsMarketForAsset } from '../../Perps/hooks/usePerpsMarketForAsset'; +import Routes from '../../../../constants/navigation/Routes'; + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: jest.fn(), +})); + +jest.mock('../../Perps/hooks/usePerpsMarketForAsset', () => ({ + usePerpsMarketForAsset: jest.fn(), +})); + +const mockNavigate = jest.fn(); +const mockUseNavigation = jest.mocked(useNavigation); +const mockUsePerpsMarketForAsset = jest.mocked(usePerpsMarketForAsset); + +describe('usePerpsActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseNavigation.mockReturnValue({ navigate: mockNavigate } as never); + }); + + it('returns undefined handlePerpsAction when no market exists', () => { + // Arrange + mockUsePerpsMarketForAsset.mockReturnValue({ + hasPerpsMarket: false, + marketData: null, + isLoading: false, + error: null, + }); + + // Act + const { result } = renderHook(() => usePerpsActions({ symbol: 'USDC' })); + + // Assert + expect(result.current.handlePerpsAction).toBeUndefined(); + expect(result.current.hasPerpsMarket).toBe(false); + }); + + it('returns handlePerpsAction when market exists', () => { + // Arrange + mockUsePerpsMarketForAsset.mockReturnValue({ + hasPerpsMarket: true, + marketData: { + symbol: 'ETH', + name: 'ETH', + maxLeverage: '50x', + price: '', + change24h: '', + change24hPercent: '', + volume: '', + }, + isLoading: false, + error: null, + }); + + // Act + const { result } = renderHook(() => usePerpsActions({ symbol: 'ETH' })); + + // Assert + expect(result.current.handlePerpsAction).toEqual(expect.any(Function)); + expect(result.current.hasPerpsMarket).toBe(true); + }); + + it('navigates to PerpsOrderRedirect with long direction', () => { + // Arrange + mockUsePerpsMarketForAsset.mockReturnValue({ + hasPerpsMarket: true, + marketData: { + symbol: 'ETH', + name: 'ETH', + maxLeverage: '50x', + price: '', + change24h: '', + change24hPercent: '', + volume: '', + }, + isLoading: false, + error: null, + }); + + const { result } = renderHook(() => usePerpsActions({ symbol: 'ETH' })); + + // Act + result.current.handlePerpsAction?.('long'); + + // Assert + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.ORDER_REDIRECT, + params: { + direction: 'long', + asset: 'ETH', + }, + }); + }); + + it('navigates to PerpsOrderRedirect with short direction', () => { + // Arrange + mockUsePerpsMarketForAsset.mockReturnValue({ + hasPerpsMarket: true, + marketData: { + symbol: 'BTC', + name: 'BTC', + maxLeverage: '40x', + price: '', + change24h: '', + change24hPercent: '', + volume: '', + }, + isLoading: false, + error: null, + }); + + const { result } = renderHook(() => usePerpsActions({ symbol: 'BTC' })); + + // Act + result.current.handlePerpsAction?.('short'); + + // Assert + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.ORDER_REDIRECT, + params: { + direction: 'short', + asset: 'BTC', + }, + }); + }); + + it('passes null symbol through to usePerpsMarketForAsset', () => { + // Arrange + mockUsePerpsMarketForAsset.mockReturnValue({ + hasPerpsMarket: false, + marketData: null, + isLoading: false, + error: null, + }); + + // Act + renderHook(() => usePerpsActions({ symbol: null })); + + // Assert + expect(mockUsePerpsMarketForAsset).toHaveBeenCalledWith(null); + }); + + it('does not navigate when marketData is null even if called', () => { + // Arrange + mockUsePerpsMarketForAsset.mockReturnValue({ + hasPerpsMarket: true, + marketData: null, + isLoading: false, + error: null, + }); + + const { result } = renderHook(() => usePerpsActions({ symbol: 'ETH' })); + + // Act - handlePerpsAction is defined since hasPerpsMarket=true, but marketData is null + // The internal navigateToOrder should bail early + if (result.current.handlePerpsAction) { + result.current.handlePerpsAction('long'); + } + + // Assert + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('forwards isLoading and error from usePerpsMarketForAsset', () => { + // Arrange + mockUsePerpsMarketForAsset.mockReturnValue({ + hasPerpsMarket: false, + marketData: null, + isLoading: true, + error: 'Network error', + }); + + // Act + const { result } = renderHook(() => usePerpsActions({ symbol: 'ETH' })); + + // Assert + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBe('Network error'); + }); +}); diff --git a/app/components/UI/TokenDetails/hooks/usePerpsActions.ts b/app/components/UI/TokenDetails/hooks/usePerpsActions.ts index c036c51232b..5921c70b1ed 100644 --- a/app/components/UI/TokenDetails/hooks/usePerpsActions.ts +++ b/app/components/UI/TokenDetails/hooks/usePerpsActions.ts @@ -5,7 +5,7 @@ import { type UsePerpsMarketForAssetResult, } from '../../Perps/hooks/usePerpsMarketForAsset'; import Routes from '../../../../constants/navigation/Routes'; -import { PERPS_EVENT_VALUE } from '../../Perps/constants/eventNames'; +import type { OrderDirection } from '../../Perps/types/perps-types'; export interface UsePerpsActionsParams { /** Token symbol, or null to skip the perps market check */ @@ -13,8 +13,8 @@ export interface UsePerpsActionsParams { } export interface UsePerpsActionsResult extends UsePerpsMarketForAssetResult { - /** Handler to navigate to perps market details, undefined if no market exists */ - handlePerpsAction: (() => void) | undefined; + /** Handler to navigate to perps order view with direction, undefined if no market exists */ + handlePerpsAction: ((direction: OrderDirection) => void) | undefined; } /** @@ -23,6 +23,17 @@ export interface UsePerpsActionsResult extends UsePerpsMarketForAssetResult { * Provides navigation handlers for opening long/short perps positions * from the token details screen. * + * Navigation flow: + * 1. User clicks Long/Short button in Token Details + * 2. Navigate to PerpsOrderRedirect (inside Perps stack, so WebSocket initializes) + * 3. PerpsOrderRedirect waits for connection, calls depositWithOrder() + * 4. PerpsOrderRedirect navigates to confirmation screen with transaction ready + * + * This pattern is necessary because: + * - Token Details is OUTSIDE the Perps stack + * - depositWithOrder() requires WebSocket to be initialized + * - WebSocket only initializes inside PerpsConnectionProvider (wraps Perps stack) + * * @param params - Token symbol (pass null to disable perps market lookup) * @returns Object with hasPerpsMarket, marketData, isLoading, error, handlePerpsAction */ @@ -34,17 +45,22 @@ export const usePerpsActions = ({ const { hasPerpsMarket, marketData, isLoading, error } = usePerpsMarketForAsset(symbol); - const navigateToMarketDetails = useCallback(() => { - if (!marketData) return; + const navigateToOrder = useCallback( + (direction: OrderDirection) => { + if (!marketData) return; - navigation.navigate(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKET_DETAILS, - params: { - market: marketData, - source: PERPS_EVENT_VALUE.SOURCE.ASSET_DETAIL_SCREEN, - }, - }); - }, [navigation, marketData]); + // Navigate to the Perps stack, targeting PerpsOrderRedirect + // This ensures WebSocket is initialized before calling depositWithOrder() + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.ORDER_REDIRECT, + params: { + direction, + asset: marketData.symbol, + }, + }); + }, + [navigation, marketData], + ); return useMemo( () => ({ @@ -52,8 +68,8 @@ export const usePerpsActions = ({ marketData, isLoading, error, - handlePerpsAction: hasPerpsMarket ? navigateToMarketDetails : undefined, + handlePerpsAction: hasPerpsMarket ? navigateToOrder : undefined, }), - [hasPerpsMarket, marketData, isLoading, error, navigateToMarketDetails], + [hasPerpsMarket, marketData, isLoading, error, navigateToOrder], ); }; diff --git a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx index 8533a750bd0..f2b88644a63 100644 --- a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx +++ b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx @@ -171,11 +171,16 @@ jest.mock('../../../../../util/networks/customNetworks', () => { }, ]; + const { NETWORK_CHAIN_ID } = jest.requireActual( + '../../../../../util/networks/customNetworks', + ); + return { CustomNetworkImgMapping: mockCustomNetworkImgMapping, PopularList: mockPopularList, UnpopularNetworkList: mockUnpopularNetworkList, getNonEvmNetworkImageSourceByChainId: jest.fn(), + NETWORK_CHAIN_ID, }; }); diff --git a/app/constants/bridge.ts b/app/constants/bridge.ts index b7d25549edd..e38918a0e72 100644 --- a/app/constants/bridge.ts +++ b/app/constants/bridge.ts @@ -1,10 +1,10 @@ import { SolScope, BtcScope, TrxScope } from '@metamask/keyring-api'; -import { CHAIN_IDS } from '@metamask/transaction-controller'; import { CaipChainId, Hex } from '@metamask/utils'; import { BRIDGE_DEV_API_BASE_URL, BRIDGE_PROD_API_BASE_URL, } from '@metamask/bridge-controller'; +import { NETWORK_CHAIN_ID } from '../util/networks/customNetworks'; /** * Native token address (zero address) @@ -19,17 +19,18 @@ export const NETWORK_TO_SHORT_NETWORK_NAME_MAP: Record< Hex | CaipChainId, string > = { - [CHAIN_IDS.MAINNET]: 'Ethereum', - [CHAIN_IDS.LINEA_MAINNET]: 'Linea', - [CHAIN_IDS.POLYGON]: 'Polygon', - [CHAIN_IDS.AVALANCHE]: 'Avalanche', - [CHAIN_IDS.BSC]: 'BNB', - [CHAIN_IDS.ARBITRUM]: 'Arbitrum', - [CHAIN_IDS.OPTIMISM]: 'Optimism', - [CHAIN_IDS.ZKSYNC_ERA]: 'zkSync', - [CHAIN_IDS.BASE]: 'Base', - [CHAIN_IDS.SEI]: 'Sei', - [CHAIN_IDS.MONAD]: 'Monad', + [NETWORK_CHAIN_ID.MAINNET]: 'Ethereum', + [NETWORK_CHAIN_ID.LINEA_MAINNET]: 'Linea', + [NETWORK_CHAIN_ID.POLYGON]: 'Polygon', + [NETWORK_CHAIN_ID.AVALANCHE]: 'Avalanche', + [NETWORK_CHAIN_ID.BSC]: 'BNB', + [NETWORK_CHAIN_ID.ARBITRUM]: 'Arbitrum', + [NETWORK_CHAIN_ID.OPTIMISM]: 'Optimism', + [NETWORK_CHAIN_ID.ZKSYNC_ERA]: 'zkSync', + [NETWORK_CHAIN_ID.BASE]: 'Base', + [NETWORK_CHAIN_ID.SEI]: 'Sei', + [NETWORK_CHAIN_ID.MONAD]: 'Monad', + [NETWORK_CHAIN_ID.HYPE]: 'HyperEVM', [SolScope.Mainnet]: 'Solana', [BtcScope.Mainnet]: 'BTC', [TrxScope.Mainnet]: 'Tron', diff --git a/app/constants/first-party-contracts.ts b/app/constants/first-party-contracts.ts index 7100e7567d9..f5321007680 100644 --- a/app/constants/first-party-contracts.ts +++ b/app/constants/first-party-contracts.ts @@ -32,6 +32,7 @@ const FIRST_PARTY_CONTRACT_NAMES: Record> = { [NETWORKS_CHAIN_ID.ARBITRUM]: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', [NETWORKS_CHAIN_ID.SEI]: '0x099625f63395aA1e2e4d31175f330AB07591bD12', [NETWORKS_CHAIN_ID.MONAD]: '0xFB00D4EA6f3f0d0b4A57b32378075Df408F2aaBA', + [NETWORKS_CHAIN_ID.HYPER_EVM]: '0x730a77f27eE2954106E0895815C1867126172b3e', }, Swaps: { [NETWORKS_CHAIN_ID.MAINNET]: '0x881D40237659C251811CEC9c364ef91dC08D300C', @@ -47,6 +48,7 @@ const FIRST_PARTY_CONTRACT_NAMES: Record> = { '0xf504c1fe13d14DF615E66dcd0ABF39e60c697f34', [NETWORKS_CHAIN_ID.SEI]: '0x962287c9d5B8a682389E61edAE90ec882325d08b', [NETWORKS_CHAIN_ID.MONAD]: '0x962287c9d5B8a682389E61edAE90ec882325d08b', + [NETWORKS_CHAIN_ID.HYPER_EVM]: '0xB165C4d4B8044D4A9276c3d75F08cD6a2874A3b2', }, }; diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index effe89bfb4d..bc6965fcfd9 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -263,6 +263,7 @@ const Routes = { PERPS: { ROOT: 'Perps', PERPS_TAB: 'PerpsTradingView', // Redirect to wallet home and select perps tab + ORDER_REDIRECT: 'PerpsOrderRedirect', // Redirect for one-click trade from token details WITHDRAW: 'PerpsWithdraw', POSITIONS: 'PerpsPositions', PERPS_HOME: 'PerpsMarketListView', // Home screen (positions, orders, watchlist, markets) diff --git a/app/core/Engine/constants.ts b/app/core/Engine/constants.ts index ef506dade9a..a6fea992135 100644 --- a/app/core/Engine/constants.ts +++ b/app/core/Engine/constants.ts @@ -1,4 +1,4 @@ -import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { NETWORK_CHAIN_ID } from '../../util/networks/customNetworks'; /** * Messageable modules that are part of the Engine's context, but are not defined with state. @@ -86,17 +86,18 @@ export const BACKGROUND_STATE_CHANGE_EVENT_NAMES = [ ] as const; export const swapsSupportedChainIds = [ - CHAIN_IDS.MAINNET, - CHAIN_IDS.BSC, - CHAIN_IDS.POLYGON, - CHAIN_IDS.AVALANCHE, - CHAIN_IDS.ARBITRUM, - CHAIN_IDS.OPTIMISM, - CHAIN_IDS.ZKSYNC_ERA, - CHAIN_IDS.LINEA_MAINNET, - CHAIN_IDS.BASE, - CHAIN_IDS.SEI, - CHAIN_IDS.MONAD, + NETWORK_CHAIN_ID.MAINNET, + NETWORK_CHAIN_ID.BSC, + NETWORK_CHAIN_ID.POLYGON, + NETWORK_CHAIN_ID.AVALANCHE, + NETWORK_CHAIN_ID.ARBITRUM, + NETWORK_CHAIN_ID.OPTIMISM, + NETWORK_CHAIN_ID.ZKSYNC_ERA, + NETWORK_CHAIN_ID.LINEA_MAINNET, + NETWORK_CHAIN_ID.BASE, + NETWORK_CHAIN_ID.SEI, + NETWORK_CHAIN_ID.MONAD, + NETWORK_CHAIN_ID.HYPE, ]; export const MAINNET_DISPLAY_NAME = 'Ethereum'; @@ -110,18 +111,20 @@ export const ZK_SYNC_ERA_DISPLAY_NAME = 'zkSync Era'; export const BASE_DISPLAY_NAME = 'Base'; export const SEI_DISPLAY_NAME = 'Sei'; export const MONAD_DISPLAY_NAME = 'Monad'; +export const HYPEREVM_DISPLAY_NAME = 'HyperEVM'; export const NETWORK_TO_NAME_MAP = { - [CHAIN_IDS.MAINNET]: MAINNET_DISPLAY_NAME, - [CHAIN_IDS.LINEA_MAINNET]: LINEA_MAINNET_DISPLAY_NAME, - [CHAIN_IDS.POLYGON]: POLYGON_DISPLAY_NAME, - [CHAIN_IDS.AVALANCHE]: AVALANCHE_DISPLAY_NAME, - [CHAIN_IDS.ARBITRUM]: ARBITRUM_DISPLAY_NAME, - [CHAIN_IDS.BSC]: BNB_DISPLAY_NAME, - [CHAIN_IDS.OPTIMISM]: OPTIMISM_DISPLAY_NAME, - [CHAIN_IDS.ZKSYNC_ERA]: ZK_SYNC_ERA_DISPLAY_NAME, - [CHAIN_IDS.BASE]: BASE_DISPLAY_NAME, - [CHAIN_IDS.SEI]: SEI_DISPLAY_NAME, + [NETWORK_CHAIN_ID.MAINNET]: MAINNET_DISPLAY_NAME, + [NETWORK_CHAIN_ID.LINEA_MAINNET]: LINEA_MAINNET_DISPLAY_NAME, + [NETWORK_CHAIN_ID.POLYGON]: POLYGON_DISPLAY_NAME, + [NETWORK_CHAIN_ID.AVALANCHE]: AVALANCHE_DISPLAY_NAME, + [NETWORK_CHAIN_ID.ARBITRUM]: ARBITRUM_DISPLAY_NAME, + [NETWORK_CHAIN_ID.BSC]: BNB_DISPLAY_NAME, + [NETWORK_CHAIN_ID.OPTIMISM]: OPTIMISM_DISPLAY_NAME, + [NETWORK_CHAIN_ID.ZKSYNC_ERA]: ZK_SYNC_ERA_DISPLAY_NAME, + [NETWORK_CHAIN_ID.BASE]: BASE_DISPLAY_NAME, + [NETWORK_CHAIN_ID.SEI]: SEI_DISPLAY_NAME, // TODO: Update to use CHAIN_IDS.MONAD when it is added to the transaction controller - [CHAIN_IDS.MONAD]: MONAD_DISPLAY_NAME, + [NETWORK_CHAIN_ID.MONAD]: MONAD_DISPLAY_NAME, + [NETWORK_CHAIN_ID.HYPE]: HYPEREVM_DISPLAY_NAME, } as const; diff --git a/docs/perps/perps-architecture.md b/docs/perps/perps-architecture.md index 759fa3609b7..bbd897f7717 100644 --- a/docs/perps/perps-architecture.md +++ b/docs/perps/perps-architecture.md @@ -296,6 +296,100 @@ if (validation.isValid) { } ``` +### ReadOnly Mode (Lightweight Queries) + +For discovery use cases that need perps data without full initialization: + +```typescript +// Check if perps market exists for an asset (usePerpsMarketForAsset hook) +const markets = await perpsController.getMarkets({ + symbols: ['ETH'], + readOnly: true, +}); + +// Query positions for any address without WebSocket, wallet setup, etc. +const positions = await perpsController.getPositions({ + readOnly: true, + userAddress: '0x...', +}); + +// Check if user has perps funds (for discovery banners) +const accountState = await perpsController.getAccountState({ + readOnly: true, + userAddress: '0x...', +}); +``` + +**Supported methods:** `getMarkets`, `getPositions`, `getAccountState` + +**When to use:** + +- Spot token detail pages checking for perps market availability (see `usePerpsMarketForAsset`) +- Token detail pages showing perps positions +- Discovery banners checking if user has perps funds +- Portfolio analytics without entering perps context + +**How it works:** + +1. Bypasses `getActiveProvider()` check (works even when controller is not initialized) +2. Creates standalone HTTP client via `createStandaloneInfoClient` (see `utils/standaloneInfoClient.ts`) +3. No WebSocket, wallet, or account setup required +4. Main DEX only (no HIP-3 multi-DEX aggregation in readOnly mode) + +**Limitations:** + +- No TP/SL data on positions (would require additional API calls) +- No spot balance aggregation on account state +- No real-time updates (HTTP only, no WebSocket) + +### Cache Invalidation + +ReadOnly queries use client-side caching for performance (e.g., 30s TTL for positions). +The `PerpsCacheInvalidator` service provides loosely-coupled cache invalidation when +data changes in the perps environment: + +**Hook side (consumers):** + +```typescript +import { PerpsCacheInvalidator } from '../services/PerpsCacheInvalidator'; + +// Subscribe to invalidation events +useEffect(() => { + const unsubPositions = PerpsCacheInvalidator.subscribe('positions', () => { + clearMyCache(); + refetch(); + }); + const unsubAccount = PerpsCacheInvalidator.subscribe('accountState', () => { + clearMyCache(); + refetch(); + }); + return () => { + unsubPositions(); + unsubAccount(); + }; +}, []); +``` + +**Service side (producers):** + +```typescript +// After successful position change (TradingService) +PerpsCacheInvalidator.invalidate('positions'); +PerpsCacheInvalidator.invalidate('accountState'); + +// After successful withdrawal (AccountService) +PerpsCacheInvalidator.invalidate('accountState'); +``` + +**Cache types:** + +- `positions` - Position data caches (invalidated on order placement, position close) +- `accountState` - Account balance/state caches (invalidated on trades, withdrawals) +- `markets` - Market data caches (rarely changes) + +This pattern allows token detail pages to show accurate position status even after +the user closes positions in the perps environment, without polling or WebSocket overhead. + ## Stream Architecture **Single WebSocket connections shared across all components with component-level debouncing.** diff --git a/e2e/pages/wallet/AccountListBottomSheet.ts b/e2e/pages/wallet/AccountListBottomSheet.ts index 1db62f5f9e5..56d6d4858bc 100644 --- a/e2e/pages/wallet/AccountListBottomSheet.ts +++ b/e2e/pages/wallet/AccountListBottomSheet.ts @@ -145,7 +145,7 @@ class AccountListBottomSheet { await Gestures.waitAndTap(button, { elemDescription: 'Add Account button in V2 multichain accounts', - delay: options?.shouldWait ? 1500 : 0, + delay: options?.shouldWait ? 5000 : 0, }); } diff --git a/package.json b/package.json index 281863d6f4f..c31e4ae64fc 100644 --- a/package.json +++ b/package.json @@ -209,7 +209,7 @@ "@metamask/assets-controllers": "^99.0.0", "@metamask/base-controller": "^9.0.0", "@metamask/bitcoin-wallet-snap": "^1.10.0", - "@metamask/bridge-controller": "^64.8.0", + "@metamask/bridge-controller": "^65.2.0", "@metamask/bridge-status-controller": "^64.4.5", "@metamask/chain-agnostic-permission": "^1.3.0", "@metamask/connectivity-controller": "^0.1.0", @@ -297,7 +297,7 @@ "@metamask/storage-service": "^1.0.0", "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/swaps-controller": "^15.0.0", - "@metamask/transaction-controller": "^62.14.0", + "@metamask/transaction-controller": "^62.15.0", "@metamask/transaction-pay-controller": "^12.1.0", "@metamask/tron-wallet-snap": "^1.21.1", "@metamask/utils": "^11.8.1", diff --git a/tests/smoke/identity/account-syncing/multi-srp.spec.ts b/tests/smoke/identity/account-syncing/multi-srp.spec.ts index 07652bf3d75..1f3ee76172e 100644 --- a/tests/smoke/identity/account-syncing/multi-srp.spec.ts +++ b/tests/smoke/identity/account-syncing/multi-srp.spec.ts @@ -74,6 +74,16 @@ describe(SmokeIdentity('Account syncing - Mutiple SRPs'), () => { prepareEventsEmittedCounter, waitUntilSyncedAccountsNumberEquals, } = arrangeTestUtils(userStorageMockttpController); + + // Wait for the initial full sync to complete before adding accounts. + // The AccountTreeController's enqueueSingleGroupSync silently drops + // sync requests when isAccountTreeSyncingInProgress is true or + // hasAccountTreeSyncingSyncedAtLeastOnce is false, so we must ensure + // the first full sync has finished pushing the initial group. + await waitUntilSyncedAccountsNumberEquals(1); + + // Set up event counter AFTER the initial sync completes so it only + // tracks events from subsequent account mutations. const { waitUntilEventsEmittedNumberEquals } = prepareEventsEmittedCounter( UserStorageMockttpControllerEvents.PUT_SINGLE, diff --git a/tests/smoke/identity/utils/helpers.ts b/tests/smoke/identity/utils/helpers.ts index d96addb77b2..29bf9675094 100644 --- a/tests/smoke/identity/utils/helpers.ts +++ b/tests/smoke/identity/utils/helpers.ts @@ -47,7 +47,7 @@ export const getSrpIdentifierFromHeaders = ( export const arrangeTestUtils = ( userStorageMockttpController: UserStorageMockttpController, ) => { - const BASE_TIMEOUT = 12000; + const BASE_TIMEOUT = 30000; const BASE_INTERVAL = 1000; const prepareEventsEmittedCounter = ( @@ -134,9 +134,13 @@ export const arrangeTestUtils = ( ids.timeout = setTimeout(() => { clearInterval(ids.interval); + const actual = + userStorageMockttpController.paths.get( + USER_STORAGE_GROUPS_FEATURE_KEY, + )?.response?.length ?? 0; reject( new Error( - `Timeout waiting for synced accounts number to be ${expectedNumber}`, + `Timeout waiting for synced accounts number to be ${expectedNumber}\n Actual: ${actual}`, ), ); }, BASE_TIMEOUT); @@ -177,9 +181,13 @@ export const arrangeTestUtils = ( ids.timeout = setTimeout(() => { clearInterval(ids.interval); + const actual = + userStorageMockttpController.paths.get( + USER_STORAGE_FEATURE_NAMES.addressBook, + )?.response?.length ?? 0; reject( new Error( - `Timeout waiting for synced contacts number to be ${expectedNumber}`, + `Timeout waiting for synced contacts number to be ${expectedNumber}\n Actual: ${actual}`, ), ); }, BASE_TIMEOUT); @@ -226,9 +234,11 @@ export const arrangeTestUtils = ( ids.timeout = setTimeout(() => { clearInterval(ids.interval); + const actual = + userStorageMockttpController.paths.get(path)?.response?.length ?? 0; reject( new Error( - `Timeout waiting for synced accounts number to be ${expectedNumber}`, + `Timeout waiting for synced elements at "${path}" to be ${expectedNumber}\n Actual: ${actual}`, ), ); }, BASE_TIMEOUT); diff --git a/tests/smoke/snaps/test-snap-background-events.spec.ts b/tests/smoke/snaps/test-snap-background-events.spec.ts index 5b03dfccd96..3888e471d7a 100644 --- a/tests/smoke/snaps/test-snap-background-events.spec.ts +++ b/tests/smoke/snaps/test-snap-background-events.spec.ts @@ -119,9 +119,20 @@ describe(FlaskBuildTests('Background Events Snap Tests'), () => { await TestSnaps.fillMessage('backgroundEventDateInput', pastDate); await TestSnaps.tapButton('scheduleBackgroundEventWithDateButton'); - await Assertions.expectTextDisplayed( - 'Cannot schedule an event in the past.', - ); + // iOS shows the error as a native alert; Android renders it in the + // web-view result span as JSON with escaped quotes. + if (device.getPlatform() === 'ios') { + await Assertions.expectTextDisplayed( + 'Cannot schedule an event in the past.', + { timeout: 30000 }, + ); + } else { + await TestSnaps.checkResultSpanIncludes( + 'scheduleBackgroundEventResultSpan', + 'Cannot schedule an event in the past.', + { timeout: 30000 }, + ); + } }, ); }); diff --git a/tests/smoke/snaps/test-snap-bip-32.spec.ts b/tests/smoke/snaps/test-snap-bip-32.spec.ts index 4e3d7e12117..54c1b3e0b8c 100644 --- a/tests/smoke/snaps/test-snap-bip-32.spec.ts +++ b/tests/smoke/snaps/test-snap-bip-32.spec.ts @@ -148,9 +148,20 @@ describe(FlaskBuildTests('BIP-32 Snap Tests'), () => { await TestSnaps.selectInDropdown('bip32EntropyDropDown', 'Invalid'); await TestSnaps.fillMessage('messageSecp256k1Input', 'bar baz'); await TestSnaps.tapButton('signMessageBip32Secp256k1Button'); - await Assertions.checkIfTextIsDisplayed( - 'Entropy source with ID "invalid" not found.', - ); + // iOS shows the error as a native alert; Android renders it in the + // web-view result span as JSON with escaped quotes. + if (device.getPlatform() === 'ios') { + await Assertions.expectTextDisplayed( + 'Entropy source with ID "invalid" not found.', + { timeout: 30000 }, + ); + } else { + await TestSnaps.checkResultSpanIncludes( + 'bip32MessageResultSecp256k1Span', + 'Entropy source with ID', + { timeout: 30000 }, + ); + } }, ); }); diff --git a/tests/smoke/snaps/test-snap-bip-44.spec.ts b/tests/smoke/snaps/test-snap-bip-44.spec.ts index b2946a150ad..82d78b05aa7 100644 --- a/tests/smoke/snaps/test-snap-bip-44.spec.ts +++ b/tests/smoke/snaps/test-snap-bip-44.spec.ts @@ -2,8 +2,8 @@ import { FlaskBuildTests } from '../../../e2e/tags'; import { loginToApp, navigateToBrowserView } from '../../../e2e/viewHelper'; import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; import { withFixtures } from '../../framework/fixtures/FixtureHelper'; -import Assertions from '../../framework/Assertions'; import TestSnaps from '../../../e2e/pages/Browser/TestSnaps'; +import Assertions from '../../framework/Assertions'; jest.setTimeout(150_000); @@ -107,9 +107,20 @@ describe(FlaskBuildTests('BIP-44 Snap Tests'), () => { await TestSnaps.selectInDropdown('bip44EntropyDropDown', 'Invalid'); await TestSnaps.fillMessage('messageBip44Input', 'foo bar'); await TestSnaps.tapButton('signMessageBip44Button'); - await Assertions.expectTextDisplayed( - 'Entropy source with ID "invalid" not found.', - ); + // iOS shows the error as a native alert; Android renders it in the + // web-view result span as JSON with escaped quotes. + if (device.getPlatform() === 'ios') { + await Assertions.expectTextDisplayed( + 'Entropy source with ID "invalid" not found.', + { timeout: 30000 }, + ); + } else { + await TestSnaps.checkResultSpanIncludes( + 'bip44SignResultSpan', + 'Entropy source with ID', + { timeout: 30000 }, + ); + } }, ); }); diff --git a/tests/smoke/snaps/test-snap-get-entropy.spec.ts b/tests/smoke/snaps/test-snap-get-entropy.spec.ts index 21d4e2189c6..e25cfea4402 100644 --- a/tests/smoke/snaps/test-snap-get-entropy.spec.ts +++ b/tests/smoke/snaps/test-snap-get-entropy.spec.ts @@ -85,9 +85,20 @@ describe(FlaskBuildTests('Get Entropy Snap Tests'), () => { await TestSnaps.fillMessage('entropyMessageInput', 'foo bar'); await TestSnaps.tapButton('signEntropyMessageButton'); await TestSnaps.approveSignRequest(); - await Assertions.checkIfTextIsDisplayed( - 'Entropy source with ID "invalid" not found.', - ); + // iOS shows the error as a native alert; Android renders it in the + // web-view result span as JSON with escaped quotes. + if (device.getPlatform() === 'ios') { + await Assertions.expectTextDisplayed( + 'Entropy source with ID "invalid" not found.', + { timeout: 30000 }, + ); + } else { + await TestSnaps.checkResultSpanIncludes( + 'entropySignResultSpan', + 'Entropy source with ID', + { timeout: 30000 }, + ); + } }, ); }); diff --git a/yarn.lock b/yarn.lock index 74d47625857..33b686def4e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7614,7 +7614,7 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-controller@npm:^64.8.0, @metamask/bridge-controller@npm:^64.8.2": +"@metamask/bridge-controller@npm:^64.8.2": version: 64.8.2 resolution: "@metamask/bridge-controller@npm:64.8.2" dependencies: @@ -7799,7 +7799,23 @@ __metadata: languageName: node linkType: hard -"@metamask/core-backend@npm:^5.0.0, @metamask/core-backend@npm:^5.1.0": +"@metamask/core-backend@npm:5.0.0": + version: 5.0.0 + resolution: "@metamask/core-backend@npm:5.0.0" + dependencies: + "@metamask/controller-utils": "npm:^11.16.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/profile-sync-controller": "npm:^27.0.0" + "@metamask/utils": "npm:^11.8.1" + uuid: "npm:^8.3.2" + peerDependencies: + "@metamask/accounts-controller": ^35.0.0 + "@metamask/keyring-controller": ^25.0.0 + checksum: 10/c3c8d527ccbc9d56f6ddb5579cc8c58af971e9b81ece48ea7107c48e496ec2574283119cd4b258cc6c733f15d1432632a4e975d7616809147e2d4510dba59219 + languageName: node + linkType: hard + +"@metamask/core-backend@npm:^5.0.0": version: 5.1.0 resolution: "@metamask/core-backend@npm:5.1.0" dependencies: @@ -9682,9 +9698,9 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^62.11.0, @metamask/transaction-controller@npm:^62.13.0, @metamask/transaction-controller@npm:^62.14.0": - version: 62.14.0 - resolution: "@metamask/transaction-controller@npm:62.14.0" +"@metamask/transaction-controller@npm:^62.11.0, @metamask/transaction-controller@npm:^62.13.0, @metamask/transaction-controller@npm:^62.14.0, @metamask/transaction-controller@npm:^62.15.0": + version: 62.15.0 + resolution: "@metamask/transaction-controller@npm:62.15.0" dependencies: "@ethereumjs/common": "npm:^4.4.0" "@ethereumjs/tx": "npm:^5.4.0" @@ -9697,7 +9713,7 @@ __metadata: "@metamask/approval-controller": "npm:^8.0.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.18.0" - "@metamask/core-backend": "npm:^5.1.0" + "@metamask/core-backend": "npm:5.0.0" "@metamask/eth-query": "npm:^4.0.0" "@metamask/gas-fee-controller": "npm:^26.0.2" "@metamask/messenger": "npm:^0.3.0" @@ -9717,7 +9733,7 @@ __metadata: peerDependencies: "@babel/runtime": ^7.0.0 "@metamask/eth-block-tracker": ">=9" - checksum: 10/8ddc99d447990b997becc67617c8a5f4345d6412a6da53314f369bf0f12eff8be142a907e712f82aed6fe428ce08fa386797103d3d55a745b64f85ff5b19dbb0 + checksum: 10/baeb49bed797443030e15c55f4dd487c9ed98a52d746827935b534ad24db4aad705a7ea2a4b031f1c4ea85fd3f5bf4820ed212895e220fe6d65acca278778875 languageName: node linkType: hard @@ -34712,7 +34728,7 @@ __metadata: "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/bitcoin-wallet-snap": "npm:^1.10.0" - "@metamask/bridge-controller": "npm:^64.8.0" + "@metamask/bridge-controller": "npm:^65.2.0" "@metamask/bridge-status-controller": "npm:^64.4.5" "@metamask/browser-passworder": "npm:^5.0.0" "@metamask/build-utils": "npm:^3.0.0" @@ -34811,7 +34827,7 @@ __metadata: "@metamask/test-dapp": "npm:9.5.0" "@metamask/test-dapp-multichain": "npm:^0.17.1" "@metamask/test-dapp-solana": "npm:^0.3.0" - "@metamask/transaction-controller": "npm:^62.14.0" + "@metamask/transaction-controller": "npm:^62.15.0" "@metamask/transaction-pay-controller": "npm:^12.1.0" "@metamask/tron-wallet-snap": "npm:^1.21.1" "@metamask/utils": "npm:^11.8.1"