diff --git a/.github/workflows/push-eas-update.yml b/.github/workflows/push-eas-update.yml index cc760f1b3009..db7437242860 100644 --- a/.github/workflows/push-eas-update.yml +++ b/.github/workflows/push-eas-update.yml @@ -1,4 +1,4 @@ -name: Push OTA Update (EXP Build) +name: Push OTA Update on: workflow_dispatch: @@ -15,6 +15,15 @@ on: description: 'EAS update message' required: true type: string + channel: + description: 'OTA channel to push update to (exp, rc, production)' + required: true + type: choice + options: + - exp + - rc + - production + default: exp permissions: contents: read @@ -24,6 +33,7 @@ env: TARGET_PR_NUMBER: ${{ inputs.pr_number }} BASE_BRANCH_REF: ${{ inputs.base_branch }} UPDATE_MESSAGE: ${{ inputs.message }} + TARGET_CHANNEL: ${{ inputs.channel }} jobs: fingerprint-comparison: @@ -65,8 +75,6 @@ jobs: FINGERPRINT=$(yarn fingerprint:generate) echo "fingerprint=$FINGERPRINT" >> "$GITHUB_OUTPUT" echo "Target PR fingerprint: $FINGERPRINT" - echo "Writing detailed fingerprint file to fingerprint-pr.json" - npx @expo/fingerprint ./ > fingerprint-pr.json - name: Install dependencies (base branch) working-directory: main @@ -82,8 +90,6 @@ jobs: FINGERPRINT=$(yarn fingerprint:generate) echo "fingerprint=$FINGERPRINT" >> "$GITHUB_OUTPUT" echo "Base branch fingerprint: $FINGERPRINT" - echo "Writing detailed fingerprint file to ../fingerprint-base.json" - npx @expo/fingerprint ./ > ../fingerprint-base.json - name: Compare fingerprints id: compare @@ -96,23 +102,17 @@ jobs: exit 1 fi - echo "Target PR fingerprint: $BRANCH_FP" - echo "Base branch fingerprint: $MAIN_FP" - if [ "$BRANCH_FP" = "$MAIN_FP" ]; then echo "✅ Fingerprints match. No native changes detected." echo "equal=true" >> "$GITHUB_OUTPUT" else echo "⚠️ Fingerprints differ. Native changes detected." echo "equal=false" >> "$GITHUB_OUTPUT" - if [ -f fingerprint-base.json ] && [ -f fingerprint-pr.json ]; then - echo "Fingerprint differences:" - npx @expo/fingerprint ./ fingerprint-base.json fingerprint-pr.json || true - else - echo "Detailed fingerprint files not found; skipping diff." - fi fi + echo "Target PR fingerprint: $BRANCH_FP" + echo "Base branch fingerprint: $MAIN_FP" + - name: Record fingerprint summary env: BRANCH_FP: ${{ steps.branch_fingerprint.outputs.fingerprint }} @@ -145,7 +145,7 @@ jobs: push-update: name: Push EAS Update runs-on: ubuntu-latest - environment: build-exp + environment: ${{ inputs.channel == 'exp' && 'build-exp' || inputs.channel == 'rc' && 'build-rc' || 'build-production' }} needs: - fingerprint-comparison - approval @@ -163,24 +163,40 @@ jobs: MM_REMOVE_GLOBAL_NETWORK_SELECTOR: 'true' FEATURES_ANNOUNCEMENTS_ACCESS_TOKEN: ${{ secrets.FEATURES_ANNOUNCEMENTS_ACCESS_TOKEN }} FEATURES_ANNOUNCEMENTS_SPACE_ID: ${{ secrets.FEATURES_ANNOUNCEMENTS_SPACE_ID }} - SEGMENT_WRITE_KEY_QA: ${{ secrets.SEGMENT_WRITE_KEY_QA }} - SEGMENT_PROXY_URL_QA: ${{ secrets.SEGMENT_PROXY_URL_QA }} - SEGMENT_DELETE_API_SOURCE_ID_QA: ${{ secrets.SEGMENT_DELETE_API_SOURCE_ID_QA }} - SEGMENT_REGULATIONS_ENDPOINT_QA: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_QA }} - MM_SENTRY_DSN: ${{ secrets.MM_SENTRY_DSN }} #need to add to secrets + SEGMENT_WRITE_KEY: ${{ secrets.SEGMENT_WRITE_KEY }} + SEGMENT_PROXY_URL: ${{ secrets.SEGMENT_PROXY_URL }} + SEGMENT_DELETE_API_SOURCE_ID: ${{ secrets.SEGMENT_DELETE_API_SOURCE_ID }} + SEGMENT_REGULATIONS_ENDPOINT: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT }} + MM_SENTRY_DSN: ${{ secrets.MM_SENTRY_DSN }} MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} - MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} + IOS_GOOGLE_CLIENT_ID: ${{ secrets.IOS_GOOGLE_CLIENT_ID }} + IOS_GOOGLE_REDIRECT_URI: ${{ secrets.IOS_GOOGLE_REDIRECT_URI }} + ANDROID_APPLE_CLIENT_ID: ${{ secrets.ANDROID_APPLE_CLIENT_ID }} + ANDROID_GOOGLE_CLIENT_ID: ${{ secrets.ANDROID_GOOGLE_CLIENT_ID }} + ANDROID_GOOGLE_SERVER_CLIENT_ID: ${{ secrets.ANDROID_GOOGLE_SERVER_CLIENT_ID }} GOOGLE_SERVICES_B64_IOS: ${{ secrets.GOOGLE_SERVICES_B64_IOS }} GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} - MM_BRANCH_KEY_LIVE: ${{ secrets.MM_BRANCH_KEY_LIVE }} #need to add to secrets - MM_CARD_BAANX_API_CLIENT_KEY_UAT: ${{ secrets.MM_CARD_BAANX_API_CLIENT_KEY_UAT }} #need to add to secrets - WALLET_CONNECT_PROJECT_ID: ${{ secrets.WALLET_CONNECT_PROJECT_ID }} #need to add to secrets - MM_FOX_CODE: ${{ secrets.MM_FOX_CODE_TEST }} #need to add to secrets + MM_BRANCH_KEY_LIVE: ${{ secrets.MM_BRANCH_KEY_LIVE }} + MM_BRANCH_KEY_TEST: ${{ secrets.MM_BRANCH_KEY_TEST }} + MM_CARD_BAANX_API_CLIENT_KEY: ${{ secrets.MM_CARD_BAANX_API_CLIENT_KEY }} + WALLET_CONNECT_PROJECT_ID: ${{ secrets.WALLET_CONNECT_PROJECT_ID }} + MM_FOX_CODE: ${{ secrets.MM_FOX_CODE }} + FCM_CONFIG_API_KEY: ${{ secrets.FCM_CONFIG_API_KEY }} + FCM_CONFIG_AUTH_DOMAIN: ${{ secrets.FCM_CONFIG_AUTH_DOMAIN }} + FCM_CONFIG_STORAGE_BUCKET: ${{ secrets.FCM_CONFIG_STORAGE_BUCKET }} + FCM_CONFIG_PROJECT_ID: ${{ secrets.FCM_CONFIG_PROJECT_ID }} + FCM_CONFIG_MESSAGING_SENDER_ID: ${{ secrets.FCM_CONFIG_MESSAGING_SENDER_ID }} + FCM_CONFIG_APP_ID: ${{ secrets.FCM_CONFIG_APP_ID }} + FCM_CONFIG_MEASUREMENT_ID: ${{ secrets.FCM_CONFIG_MEASUREMENT_ID }} + QUICKNODE_MAINNET_URL: ${{ secrets.QUICKNODE_MAINNET_URL }} + QUICKNODE_ARBITRUM_URL: ${{ secrets.QUICKNODE_ARBITRUM_URL }} + QUICKNODE_AVALANCHE_URL: ${{ secrets.QUICKNODE_AVALANCHE_URL }} + QUICKNODE_BASE_URL: ${{ secrets.QUICKNODE_BASE_URL }} + QUICKNODE_LINEA_MAINNET_URL: ${{ secrets.QUICKNODE_LINEA_MAINNET_URL }} + QUICKNODE_MONAD_URL: ${{ secrets.QUICKNODE_MONAD_URL }} + QUICKNODE_OPTIMISM_URL: ${{ secrets.QUICKNODE_OPTIMISM_URL }} + QUICKNODE_POLYGON_URL: ${{ secrets.QUICKNODE_POLYGON_URL }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -230,7 +246,22 @@ jobs: # Disable LavaMoat sandbox to prevent duplicate bundle executions in CI EXPO_NO_LAVAMOAT: '1' run: | - yarn run build:expo-update:main:exp + echo "🚀 Pushing EAS update for channel: ${TARGET_CHANNEL}" + case "${TARGET_CHANNEL}" in + exp) + yarn run build:expo-update:main:exp + ;; + rc) + yarn run build:expo-update:main:rc + ;; + production) + yarn run build:expo-update:main:prod + ;; + *) + echo "❌ Unsupported TARGET_CHANNEL: ${TARGET_CHANNEL}" >&2 + exit 1 + ;; + esac - name: Update summary if: success() diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.test.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.test.tsx index c13054f43011..9fc4485106af 100644 --- a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.test.tsx +++ b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.test.tsx @@ -49,6 +49,25 @@ jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate: mockNavigate }), })); +// Mock whenEngineReady to prevent Engine access after Jest teardown +jest.mock('../../../../core/Analytics/whenEngineReady', () => ({ + whenEngineReady: jest.fn().mockResolvedValue(undefined), +})); + +// Mock analytics module +jest.mock('../../../../util/analytics/analytics', () => ({ + analytics: { + isEnabled: jest.fn(() => false), + trackEvent: jest.fn(), + optIn: jest.fn().mockResolvedValue(undefined), + optOut: jest.fn().mockResolvedValue(undefined), + getAnalyticsId: jest.fn().mockResolvedValue('test-analytics-id'), + identify: jest.fn(), + trackView: jest.fn(), + isOptedIn: jest.fn().mockResolvedValue(false), + }, +})); + describe('MultichainAccountSelectorList', () => { const mockOnSelectAccount = jest.fn(); @@ -67,8 +86,13 @@ describe('MultichainAccountSelectorList', () => { const searchInput = getByTestId( MULTICHAIN_ACCOUNT_SELECTOR_SEARCH_INPUT_TESTID, ); - fireEvent.changeText(searchInput, searchTerm); + await act(async () => { + fireEvent.changeText(searchInput, searchTerm); + }); + + // Wait for debounce to complete and filtering to occur + // Check both visible and hidden items to ensure filtering has completed await waitFor( () => { expectedVisible.forEach((text) => { @@ -353,19 +377,22 @@ describe('MultichainAccountSelectorList', () => { const searchInput = getByTestId( MULTICHAIN_ACCOUNT_SELECTOR_SEARCH_INPUT_TESTID, ); - fireEvent.changeText(searchInput, 'Test'); + + await act(async () => { + fireEvent.changeText(searchInput, 'Test'); + }); // Immediately after typing, both accounts should still be visible (debounced) expect(queryByText('My Account')).toBeTruthy(); expect(queryByText('Test Account')).toBeTruthy(); - // Wait for debounce delay (300ms) and check that filtering has occurred + // Wait for debounce delay (200ms) and check that filtering has occurred await waitFor( () => { expect(queryByText('My Account')).toBeFalsy(); expect(queryByText('Test Account')).toBeTruthy(); }, - { timeout: 500 }, + { timeout: 1000 }, ); }); @@ -543,7 +570,10 @@ describe('MultichainAccountSelectorList', () => { const searchInput = getByTestId( MULTICHAIN_ACCOUNT_SELECTOR_SEARCH_INPUT_TESTID, ); - fireEvent.changeText(searchInput, 'NonExistentAccount'); + + await act(async () => { + fireEvent.changeText(searchInput, 'NonExistentAccount'); + }); // Wait for debounced search to complete and check empty state await waitFor( @@ -589,17 +619,21 @@ describe('MultichainAccountSelectorList', () => { ); // Test uppercase search - fireEvent.changeText(searchInput, 'MY ACCOUNT'); + await act(async () => { + fireEvent.changeText(searchInput, 'MY ACCOUNT'); + }); await waitFor( () => { expect(queryByText('My Account')).toBeTruthy(); expect(queryByText('Test Account')).toBeFalsy(); }, - { timeout: 500 }, + { timeout: 1000 }, // Increased timeout for debounced search ); // Test mixed case search - fireEvent.changeText(searchInput, 'tEsT aCcOuNt'); + await act(async () => { + fireEvent.changeText(searchInput, 'tEsT aCcOuNt'); + }); await waitFor( () => { expect(queryByText('My Account')).toBeFalsy(); @@ -642,23 +676,27 @@ describe('MultichainAccountSelectorList', () => { expect(queryByText('Test Account')).toBeTruthy(); // Search for something - fireEvent.changeText(searchInput, 'Test'); + await act(async () => { + fireEvent.changeText(searchInput, 'Test'); + }); await waitFor( () => { expect(queryByText('My Account')).toBeFalsy(); expect(queryByText('Test Account')).toBeTruthy(); }, - { timeout: 500 }, + { timeout: 1000 }, ); // Clear search - fireEvent.changeText(searchInput, ''); + await act(async () => { + fireEvent.changeText(searchInput, ''); + }); await waitFor( () => { expect(queryByText('My Account')).toBeTruthy(); expect(queryByText('Test Account')).toBeTruthy(); }, - { timeout: 500 }, + { timeout: 1000 }, ); }); @@ -691,7 +729,9 @@ describe('MultichainAccountSelectorList', () => { ); // Search with leading/trailing whitespace - fireEvent.changeText(searchInput, ' My Account '); + await act(async () => { + fireEvent.changeText(searchInput, ' My Account '); + }); await waitFor( () => { expect(queryByText('My Account')).toBeTruthy(); @@ -973,7 +1013,10 @@ describe('MultichainAccountSelectorList', () => { const searchInput = getByTestId( MULTICHAIN_ACCOUNT_SELECTOR_SEARCH_INPUT_TESTID, ); - fireEvent.changeText(searchInput, 'Test'); + + await act(async () => { + fireEvent.changeText(searchInput, 'Test'); + }); // Wait for debounce and re-render await waitFor( diff --git a/app/components/Nav/App/App.test.tsx b/app/components/Nav/App/App.test.tsx index a6b55a4ca848..e3bd7f098d8a 100644 --- a/app/components/Nav/App/App.test.tsx +++ b/app/components/Nav/App/App.test.tsx @@ -158,6 +158,7 @@ jest.mock('../../../core/Analytics/MetaMetrics'); const mockMetrics = { configure: jest.fn(), addTraitsToUser: jest.fn(), + updateDataRecordingFlag: jest.fn(), }; const mockAuthType = AUTHENTICATION_TYPE.BIOMETRIC; diff --git a/app/components/UI/AccountOverview/index.test.tsx b/app/components/UI/AccountOverview/index.test.tsx index 2e73e4e50b44..80353b936e80 100644 --- a/app/components/UI/AccountOverview/index.test.tsx +++ b/app/components/UI/AccountOverview/index.test.tsx @@ -17,7 +17,7 @@ jest.mock('../../../core/Engine', () => { const { KeyringTypes } = jest.requireActual('@metamask/keyring-controller'); return { - init: () => mockedEngine.init({}), + init: () => mockedEngine.init(''), context: { KeyringController: { getQRKeyringState: async () => ({ subscribe: () => ({}) }), diff --git a/app/components/UI/AssetOverview/AssetOverview.test.tsx b/app/components/UI/AssetOverview/AssetOverview.test.tsx index 126c99087062..4258a3eb1d8b 100644 --- a/app/components/UI/AssetOverview/AssetOverview.test.tsx +++ b/app/components/UI/AssetOverview/AssetOverview.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { fireEvent } from '@testing-library/react-native'; import { zeroAddress } from 'ethereumjs-util'; import { NetworkController } from '@metamask/network-controller'; -import AssetOverview from './AssetOverview'; +import AssetOverview, { getSwapTokens } from './AssetOverview'; import renderWithProvider from '../../../util/test/renderWithProvider'; import { backgroundState } from '../../../util/test/initial-root-state'; import { @@ -273,6 +273,19 @@ jest.mock('../../../components/hooks/useAddNetwork', () => ({ })), })); +// Mock useSwapBridgeNavigation to capture hook arguments while still calling navigation +const mockUseSwapBridgeNavigationArgs = jest.fn(); +jest.mock('../Bridge/hooks/useSwapBridgeNavigation', () => { + const actual = jest.requireActual('../Bridge/hooks/useSwapBridgeNavigation'); + return { + ...actual, + useSwapBridgeNavigation: (args: unknown) => { + mockUseSwapBridgeNavigationArgs(args); + return actual.useSwapBridgeNavigation(args); + }, + }; +}); + const mockUseRampsUnifiedV1Enabled = jest.fn(); jest.mock('../Ramp/hooks/useRampsUnifiedV1Enabled', () => ({ __esModule: true, @@ -299,6 +312,11 @@ const assetFromSearch = { isFromSearch: true, }; +const assetFromTrending = { + ...asset, + isFromTrending: true, +}; + describe('AssetOverview', () => { const mockSendNonEvmAsset = jest.fn(); @@ -1062,6 +1080,139 @@ describe('AssetOverview', () => { }); }); + it('navigates to bridge for buy when coming from trending tokens', async () => { + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + const swapButton = getByTestId('token-swap-button'); + fireEvent.press(swapButton); + + await Promise.resolve(); + + // Navigates to Bridge with unified mode + expect(navigate).toHaveBeenCalledWith('Bridge', { + screen: 'BridgeView', + params: expect.objectContaining({ + bridgeViewMode: 'Unified', + sourcePage: 'MainView', + }), + }); + }); + + describe('useSwapBridgeNavigation token configuration', () => { + beforeEach(() => { + mockUseSwapBridgeNavigationArgs.mockClear(); + }); + + it('passes native token as source and asset as destination when asset is from trending', () => { + renderWithProvider( + , + { state: mockInitialState }, + ); + + // Verify hook was called with correct token configuration + expect(mockUseSwapBridgeNavigationArgs).toHaveBeenCalledWith( + expect.objectContaining({ + // sourceToken is native token (ETH) since user wants to BUY the trending token + sourceToken: expect.objectContaining({ + symbol: 'ETH', + chainId: MOCK_CHAIN_ID, + }), + // destToken is the trending token (what user wants to buy) + destToken: expect.objectContaining({ + address: assetFromTrending.address, + chainId: MOCK_CHAIN_ID, + symbol: assetFromTrending.symbol, + }), + }), + ); + }); + + it('passes asset as source and undefined destination when asset is not from trending', () => { + renderWithProvider( + , + { state: mockInitialState }, + ); + + // Verify hook was called with correct token configuration + expect(mockUseSwapBridgeNavigationArgs).toHaveBeenCalledWith( + expect.objectContaining({ + // sourceToken is the asset itself since user wants to SELL + sourceToken: expect.objectContaining({ + address: asset.address, + chainId: MOCK_CHAIN_ID, + symbol: asset.symbol, + }), + // destToken is undefined since no specific destination + destToken: undefined, + }), + ); + }); + + it('passes asset as source when asset is from search but not trending', () => { + renderWithProvider( + , + { state: mockInitialState }, + ); + + // isFromSearch does not trigger buy mode, only isFromTrending does + expect(mockUseSwapBridgeNavigationArgs).toHaveBeenCalledWith( + expect.objectContaining({ + sourceToken: expect.objectContaining({ + address: assetFromSearch.address, + chainId: MOCK_CHAIN_ID, + symbol: assetFromSearch.symbol, + }), + destToken: undefined, + }), + ); + }); + + it('passes native token of different chain as source when trending asset is on different chain', () => { + const trendingAssetOnPolygon = { + ...assetFromTrending, + chainId: '0x89', // Polygon + }; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + expect(mockUseSwapBridgeNavigationArgs).toHaveBeenCalledWith( + expect.objectContaining({ + // sourceToken is native token of Polygon chain + sourceToken: expect.objectContaining({ + chainId: '0x89', + }), + // destToken is the trending token on Polygon + destToken: expect.objectContaining({ + address: trendingAssetOnPolygon.address, + chainId: '0x89', + }), + }), + ); + }); + }); + describe('Portfolio view network switching', () => { beforeEach(() => { jest.useFakeTimers(); @@ -1642,3 +1793,132 @@ describe('AssetOverview', () => { }); }); }); + +describe('getSwapTokens', () => { + it('returns native token as source and asset as dest when asset is from trending', () => { + const trendingAsset = { + ...asset, + isFromTrending: true, + }; + + const result = getSwapTokens(trendingAsset); + + // sourceToken is the native token for the chain + expect(result.sourceToken).toEqual({ + address: '0x0000000000000000000000000000000000000000', + chainId: MOCK_CHAIN_ID, + decimals: 18, + image: '', + name: 'Ether', + symbol: 'ETH', + }); + // destToken is the bridgeToken built from the asset + expect(result.destToken).toEqual({ + ...trendingAsset, + address: trendingAsset.address, + chainId: MOCK_CHAIN_ID, + decimals: trendingAsset.decimals, + symbol: trendingAsset.symbol, + name: trendingAsset.name, + image: trendingAsset.image, + }); + }); + + it('returns asset as source and undefined dest when asset is not from trending', () => { + const regularAsset = { + ...asset, + isFromTrending: false, + }; + + const result = getSwapTokens(regularAsset); + + // sourceToken is the bridgeToken built from the asset + expect(result.sourceToken).toEqual({ + ...regularAsset, + address: regularAsset.address, + chainId: MOCK_CHAIN_ID, + decimals: regularAsset.decimals, + symbol: regularAsset.symbol, + name: regularAsset.name, + image: regularAsset.image, + }); + expect(result.destToken).toBeUndefined(); + }); + + it('returns asset as source when asset has no isFromTrending property', () => { + const result = getSwapTokens(asset); + + // sourceToken is the bridgeToken built from the asset + expect(result.sourceToken).toEqual({ + ...asset, + address: asset.address, + chainId: MOCK_CHAIN_ID, + decimals: asset.decimals, + symbol: asset.symbol, + name: asset.name, + image: asset.image, + }); + expect(result.destToken).toBeUndefined(); + }); + + it('returns native token for the correct chain when asset is from trending on different chain', () => { + const trendingAssetOnPolygon = { + ...asset, + chainId: '0x89', + isFromTrending: true, + }; + + const result = getSwapTokens(trendingAssetOnPolygon); + + // sourceToken is the native token for Polygon + expect(result.sourceToken).toEqual({ + address: '0x0000000000000000000000000000000000000000', + chainId: '0x89', + decimals: 18, + image: '', + name: 'Polygon', + symbol: 'POL', + }); + // destToken is the bridgeToken built from the asset + expect(result.destToken).toEqual({ + ...trendingAssetOnPolygon, + address: trendingAssetOnPolygon.address, + chainId: '0x89', + decimals: trendingAssetOnPolygon.decimals, + symbol: trendingAssetOnPolygon.symbol, + name: trendingAssetOnPolygon.name, + image: trendingAssetOnPolygon.image, + }); + }); + + it('returns default pair token as sourceToken and native gas token as destToken when asset is native gas token', () => { + const nativeGasToken = { + ...asset, + address: '0x0000000000000000000000000000000000000000', + isETH: true, + }; + + const result = getSwapTokens(nativeGasToken); + + // sourceToken is the default pair token for mainnet (mUSD) + expect(result.sourceToken).toEqual({ + 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: MOCK_CHAIN_ID, + }); + // destToken is the native gas token + expect(result.destToken).toEqual({ + ...nativeGasToken, + address: '0x0000000000000000000000000000000000000000', + chainId: MOCK_CHAIN_ID, + decimals: nativeGasToken.decimals, + symbol: nativeGasToken.symbol, + name: nativeGasToken.name, + image: nativeGasToken.image, + }); + }); +}); diff --git a/app/components/UI/AssetOverview/AssetOverview.tsx b/app/components/UI/AssetOverview/AssetOverview.tsx index ce4454dcba05..566efcaf6bc1 100644 --- a/app/components/UI/AssetOverview/AssetOverview.tsx +++ b/app/components/UI/AssetOverview/AssetOverview.tsx @@ -75,8 +75,13 @@ import { formatWithThreshold } from '../../../util/assets'; import { useSwapBridgeNavigation, SwapBridgeNavigationLocation, + isAssetFromTrending, } from '../Bridge/hooks/useSwapBridgeNavigation'; import { NATIVE_SWAPS_TOKEN_ADDRESS } from '../../../constants/bridge'; +import { + getNativeSourceToken, + getDefaultDestToken, +} from '../Bridge/utils/tokenUtils'; import { TraceName, endTrace } from '../../../util/trace'; ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) import { selectMultichainAssetsRates } from '../../../selectors/multichain'; @@ -84,7 +89,10 @@ import { isEvmAccountType, KeyringAccountType } from '@metamask/keyring-api'; import { useSendNonEvmAsset } from '../../hooks/useSendNonEvmAsset'; ///: END:ONLY_INCLUDE_IF import { calculateAssetPrice } from './utils/calculateAssetPrice'; -import { formatChainIdToCaip } from '@metamask/bridge-controller'; +import { + formatChainIdToCaip, + isNativeAddress, +} from '@metamask/bridge-controller'; import { InitSendLocation } from '../../Views/confirmations/constants/send'; import { useSendNavigation } from '../../Views/confirmations/hooks/useSendNavigation'; import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts'; @@ -105,6 +113,63 @@ import { createStakedTrxAsset } from './utils/createStakedTrxAsset'; import { getDetectedGeolocation } from '../../../reducers/fiatOrders'; import { useRampsButtonClickData } from '../Ramp/hooks/useRampsButtonClickData'; import useRampsUnifiedV1Enabled from '../Ramp/hooks/useRampsUnifiedV1Enabled'; +import { BridgeToken } from '../Bridge/types'; + +/** + * Determines the source and destination tokens for swap/bridge navigation. + * + * When the asset is a native gas token (e.g., ETH), we set sourceToken to the default + * pair token for that chain (e.g., mUSD on mainnet) and destToken to the native token, + * allowing the user to swap INTO the native token. + * + * When coming from the trending tokens list, the user likely wants to BUY the token, + * so we configure the swap with the native token as source and the asset as destination. + * + * Otherwise, we assume they want to SELL, so the asset is the source. + * + * @param asset - The token asset being viewed + * @returns Object containing sourceToken and destToken for swap navigation + */ +export const getSwapTokens = ( + asset: TokenI, +): { + sourceToken: BridgeToken | undefined; + destToken: BridgeToken | undefined; +} => { + const wantsToBuyToken = isAssetFromTrending(asset); + + // Build bridge token from asset + const bridgeToken: BridgeToken = { + ...asset, + address: asset.address ?? NATIVE_SWAPS_TOKEN_ADDRESS, + chainId: asset.chainId as Hex | CaipChainId, + decimals: asset.decimals, + symbol: asset.symbol, + name: asset.name, + image: asset.image, + }; + + // If the asset is a native gas token, set source to the default pair token for that chain + // and dest to the native token, allowing the user to swap INTO the native token + if (isNativeAddress(asset.address)) { + return { + sourceToken: getDefaultDestToken(bridgeToken.chainId), + destToken: bridgeToken, + }; + } + + if (wantsToBuyToken) { + return { + sourceToken: getNativeSourceToken(bridgeToken.chainId), + destToken: bridgeToken, + }; + } + + return { + sourceToken: bridgeToken, + destToken: undefined, + }; +}; interface AssetOverviewProps { asset: TokenI; @@ -198,18 +263,13 @@ const AssetOverview: React.FC = ({ vsCurrency: currentCurrency, }); + const { sourceToken, destToken } = getSwapTokens(asset); + const { goToSwaps, networkModal } = useSwapBridgeNavigation({ location: SwapBridgeNavigationLocation.TokenDetails, sourcePage: 'MainView', - sourceToken: { - ...asset, - address: asset.address ?? NATIVE_SWAPS_TOKEN_ADDRESS, - chainId: asset.chainId as Hex, - decimals: asset.decimals, - symbol: asset.symbol, - name: asset.name, - image: asset.image, - }, + sourceToken, + destToken, }); // Hook for handling non-EVM asset sending diff --git a/app/components/UI/AssetSearch/index.test.tsx b/app/components/UI/AssetSearch/index.test.tsx index 997d41eb9e66..32d0f3bec144 100644 --- a/app/components/UI/AssetSearch/index.test.tsx +++ b/app/components/UI/AssetSearch/index.test.tsx @@ -12,7 +12,7 @@ const mockedEngine = Engine; jest.useFakeTimers(); jest.mock('../../../core/Engine', () => ({ - init: () => mockedEngine.init({}), + init: () => mockedEngine.init(''), context: { KeyringController: { getQRKeyringState: async () => ({ subscribe: () => ({}) }), diff --git a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts index 8d85b3da3b5f..8ae3cbe723af 100644 --- a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts +++ b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts @@ -36,6 +36,19 @@ import { import { areAddressesEqual } from '../../../../../util/address'; import TrendingFeedSessionManager from '../../../Trending/services/TrendingFeedSessionManager'; +/** + * When navigating to the Asset view from trending tokens list, we add a property + * to indicate the user's intent is to BUY the token (not sell). + * + * This is used to determine whether the token should be pre-filled as the + * destination token (buy) rather than source token (sell) in the swap flow. + */ +export const isAssetFromTrending = (asset: unknown) => + typeof asset === 'object' && + asset !== null && + 'isFromTrending' in asset && + asset.isFromTrending === true; + export enum SwapBridgeNavigationLocation { TabBar = 'TabBar', TokenDetails = 'TokenDetails', diff --git a/app/components/UI/Earn/LendingLearnMoreModal/LendingLearnMoreModal.test.tsx b/app/components/UI/Earn/LendingLearnMoreModal/LendingLearnMoreModal.test.tsx index df4edd66211e..fc41b60e57ac 100644 --- a/app/components/UI/Earn/LendingLearnMoreModal/LendingLearnMoreModal.test.tsx +++ b/app/components/UI/Earn/LendingLearnMoreModal/LendingLearnMoreModal.test.tsx @@ -20,7 +20,7 @@ jest.mock('react-native', () => { return { ...actual, Linking: { - openUrl: jest.fn(), + openURL: jest.fn(), }, }; }); @@ -86,6 +86,19 @@ jest.mock('../hooks/useEarnToken', () => ({ }), })); +jest.mock('../../../hooks/useMetrics', () => ({ + useMetrics: () => ({ + trackEvent: jest.fn(), + createEventBuilder: jest.fn(() => ({ + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({}), + })), + }), + MetaMetricsEvents: { + EARN_LENDING_FAQ_LINK_OPENED: 'EARN_LENDING_FAQ_LINK_OPENED', + }, +})); + const mockInitialState = { engine: { backgroundState: { diff --git a/app/components/UI/EvmAccountSelectorList/EvmAccountSelectorList.test.tsx b/app/components/UI/EvmAccountSelectorList/EvmAccountSelectorList.test.tsx index 3adb5f845ef9..904336006f8d 100644 --- a/app/components/UI/EvmAccountSelectorList/EvmAccountSelectorList.test.tsx +++ b/app/components/UI/EvmAccountSelectorList/EvmAccountSelectorList.test.tsx @@ -69,6 +69,25 @@ jest.mock('@react-navigation/native', () => ({ }), })); +// Mock whenEngineReady to prevent Engine access after Jest teardown +jest.mock('../../../core/Analytics/whenEngineReady', () => ({ + whenEngineReady: jest.fn().mockResolvedValue(undefined), +})); + +// Mock analytics module +jest.mock('../../../util/analytics/analytics', () => ({ + analytics: { + isEnabled: jest.fn(() => false), + trackEvent: jest.fn(), + optIn: jest.fn().mockResolvedValue(undefined), + optOut: jest.fn().mockResolvedValue(undefined), + getAnalyticsId: jest.fn().mockResolvedValue('test-analytics-id'), + identify: jest.fn(), + trackView: jest.fn(), + isOptedIn: jest.fn().mockResolvedValue(false), + }, +})); + // Mock useAccounts jest.mock('../../hooks/useAccounts', () => { const useAccountsMock = jest.fn(() => ({ diff --git a/app/components/UI/Navbar/index.test.jsx b/app/components/UI/Navbar/index.test.jsx index 9953a8d42b8b..4c24f6f78c64 100644 --- a/app/components/UI/Navbar/index.test.jsx +++ b/app/components/UI/Navbar/index.test.jsx @@ -79,6 +79,7 @@ jest.mock('../../../core/Analytics', () => ({ MetaMetrics: { getInstance: jest.fn(() => ({ trackEvent: jest.fn(), + updateDataRecordingFlag: jest.fn(), })), trackEvent: jest.fn(), }, diff --git a/app/components/UI/OptinMetrics/index.test.tsx b/app/components/UI/OptinMetrics/index.test.tsx index d430b654a032..356a64259447 100644 --- a/app/components/UI/OptinMetrics/index.test.tsx +++ b/app/components/UI/OptinMetrics/index.test.tsx @@ -1,9 +1,7 @@ import OptinMetrics from './index.tsx'; import { renderScreen } from '../../../util/test/renderWithProvider'; -import { MetaMetrics, MetaMetricsEvents } from '../../../core/Analytics'; import { fireEvent, screen, waitFor } from '@testing-library/react-native'; import { strings } from '../../../../locales/i18n'; -import { MetricsEventBuilder } from '../../../core/Analytics/MetricsEventBuilder'; import { MetaMetricsOptInSelectorsIDs } from '../../../../e2e/selectors/Onboarding/MetaMetricsOptIn.selectors'; import { Platform } from 'react-native'; import Device from '../../../util/device'; @@ -14,16 +12,38 @@ InteractionManager.runAfterInteractions = jest.fn(async (callback) => callback(), ); -jest.mock('../../../core/Analytics/MetaMetrics'); +// Mock analytics module +jest.mock('../../../util/analytics/analytics', () => ({ + analytics: { + isEnabled: jest.fn(() => true), + trackEvent: jest.fn(), + optIn: jest.fn().mockResolvedValue(undefined), + optOut: jest.fn().mockResolvedValue(undefined), + getAnalyticsId: jest.fn().mockResolvedValue('test-analytics-id'), + identify: jest.fn(), + trackView: jest.fn(), + isOptedIn: jest.fn().mockResolvedValue(false), + }, +})); + +// Mock MetaMetrics for events and getInstance +jest.mock('../../../core/Analytics/MetaMetrics', () => ({ + MetaMetricsEvents: jest.requireActual('../../../core/Analytics/MetaMetrics') + .MetaMetricsEvents, + getInstance: jest.fn(() => ({ + createDataDeletionTask: jest.fn(), + checkDataDeleteStatus: jest.fn(), + getDeleteRegulationCreationDate: jest.fn(), + getDeleteRegulationId: jest.fn(), + isDataRecorded: jest.fn(), + updateDataRecordingFlag: jest.fn(), + })), +})); -const mockMetrics = { - trackEvent: jest.fn().mockImplementation(() => Promise.resolve()), - enable: jest.fn(() => Promise.resolve()), - addTraitsToUser: jest.fn(() => Promise.resolve()), - isEnabled: jest.fn(() => true), -}; +// Import analytics to access mocks +import { analytics } from '../../../util/analytics/analytics'; -(MetaMetrics.getInstance as jest.Mock).mockReturnValue(mockMetrics); +const mockAnalytics = analytics as jest.Mocked; jest.mock( '../../../util/metrics/UserSettingsAnalyticsMetaData/generateUserProfileAnalyticsMetaData', @@ -122,20 +142,19 @@ describe('OptinMetrics', () => { }), ); await waitFor(() => { - expect(mockMetrics.trackEvent).toHaveBeenNthCalledWith( + expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( 1, - MetricsEventBuilder.createEventBuilder( - MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED, - ) - .addProperties({ + expect.objectContaining({ + name: 'Analytics Preference Selected', + properties: expect.objectContaining({ has_marketing_consent: false, is_metrics_opted_in: true, location: 'onboarding_metametrics', updated_after_onboarding: false, - }) - .build(), + }), + }), ); - expect(mockMetrics.addTraitsToUser).toHaveBeenNthCalledWith(1, { + expect(mockAnalytics.identify).toHaveBeenNthCalledWith(1, { chain_id_list: ['eip155:1'], deviceProp: 'Device value', userProp: 'User value', @@ -154,20 +173,19 @@ describe('OptinMetrics', () => { }), ); await waitFor(() => { - expect(mockMetrics.trackEvent).toHaveBeenNthCalledWith( + expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( 1, - MetricsEventBuilder.createEventBuilder( - MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED, - ) - .addProperties({ + expect.objectContaining({ + name: 'Analytics Preference Selected', + properties: expect.objectContaining({ has_marketing_consent: true, is_metrics_opted_in: true, location: 'onboarding_metametrics', updated_after_onboarding: false, - }) - .build(), + }), + }), ); - expect(mockMetrics.addTraitsToUser).toHaveBeenNthCalledWith(1, { + expect(mockAnalytics.identify).toHaveBeenNthCalledWith(1, { chain_id_list: ['eip155:1'], deviceProp: 'Device value', userProp: 'User value', @@ -196,7 +214,7 @@ describe('OptinMetrics', () => { fireEvent.press(screen.getByText(strings('privacy_policy.continue'))); await waitFor(() => { - expect(mockMetrics.enable).toHaveBeenCalledWith(true); + expect(mockAnalytics.optIn).toHaveBeenCalled(); }); jest.clearAllMocks(); @@ -206,7 +224,7 @@ describe('OptinMetrics', () => { fireEvent.press(screen.getByText(strings('privacy_policy.continue'))); await waitFor(() => { - expect(mockMetrics.enable).toHaveBeenCalledWith(false); + expect(mockAnalytics.optOut).toHaveBeenCalled(); }); }); @@ -219,7 +237,7 @@ describe('OptinMetrics', () => { fireEvent.press(screen.getByText(strings('privacy_policy.continue'))); await waitFor(() => { - expect(mockMetrics.enable).toHaveBeenCalledWith(true); + expect(mockAnalytics.optIn).toHaveBeenCalled(); }); jest.clearAllMocks(); @@ -229,7 +247,7 @@ describe('OptinMetrics', () => { fireEvent.press(screen.getByText(strings('privacy_policy.continue'))); await waitFor(() => { - expect(mockMetrics.enable).toHaveBeenCalledWith(false); + expect(mockAnalytics.optOut).toHaveBeenCalled(); }); }); @@ -239,7 +257,7 @@ describe('OptinMetrics', () => { fireEvent.press(screen.getByText(strings('privacy_policy.continue'))); await waitFor(() => { - expect(mockMetrics.enable).toHaveBeenCalledWith(true); + expect(mockAnalytics.optIn).toHaveBeenCalled(); }); }); @@ -254,7 +272,7 @@ describe('OptinMetrics', () => { fireEvent.press(screen.getByText(strings('privacy_policy.continue'))); await waitFor(() => { - expect(mockMetrics.enable).toHaveBeenCalledWith(false); + expect(mockAnalytics.optOut).toHaveBeenCalled(); }); }); }); @@ -306,7 +324,7 @@ describe('OptinMetrics', () => { ); await waitFor(() => { - expect(mockMetrics.trackEvent).toHaveBeenCalledWith( + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( expect.objectContaining({ properties: expect.objectContaining({ has_marketing_consent: false, @@ -328,7 +346,7 @@ describe('OptinMetrics', () => { ); await waitFor(() => { - expect(mockMetrics.trackEvent).toHaveBeenCalledWith( + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( expect.objectContaining({ properties: expect.objectContaining({ has_marketing_consent: true, @@ -357,7 +375,7 @@ describe('OptinMetrics', () => { fireEvent.press(screen.getByText(strings('privacy_policy.continue'))); await waitFor(() => { - expect(mockMetrics.enable).toHaveBeenCalledWith(false); + expect(mockAnalytics.optOut).toHaveBeenCalled(); }); }); }); @@ -483,7 +501,7 @@ describe('OptinMetrics', () => { fireEvent.press(screen.getByText(strings('privacy_policy.continue'))); await waitFor(() => { - expect(mockMetrics.enable).toHaveBeenCalled(); + expect(mockAnalytics.optIn).toHaveBeenCalled(); }); }); @@ -498,7 +516,7 @@ describe('OptinMetrics', () => { fireEvent.press(screen.getByText(strings('privacy_policy.continue'))); await waitFor(() => { - expect(mockMetrics.trackEvent).toHaveBeenCalledWith( + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( expect.objectContaining({ properties: expect.objectContaining({ has_marketing_consent: false, @@ -515,7 +533,7 @@ describe('OptinMetrics', () => { fireEvent.press(screen.getByText(strings('privacy_policy.continue'))); await waitFor(() => { - expect(mockMetrics.trackEvent).toHaveBeenCalledWith( + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( expect.objectContaining({ properties: expect.objectContaining({ has_marketing_consent: true, @@ -582,7 +600,7 @@ describe('OptinMetrics', () => { fireEvent.press(screen.getByText(strings('privacy_policy.continue'))); await waitFor(() => { - expect(mockMetrics.trackEvent).toHaveBeenCalledWith( + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( expect.objectContaining({ name: 'Analytics Preference Selected', }), @@ -651,7 +669,7 @@ describe('OptinMetrics', () => { fireEvent.press(screen.getByText(strings('privacy_policy.continue'))); await waitFor(() => { - expect(mockMetrics.enable).toHaveBeenCalled(); + expect(mockAnalytics.optIn).toHaveBeenCalled(); }); }); @@ -695,7 +713,7 @@ describe('OptinMetrics', () => { fireEvent.press(screen.getByText(strings('privacy_policy.continue'))); await waitFor(() => { - expect(mockMetrics.trackEvent).toHaveBeenCalledWith( + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( expect.objectContaining({ properties: expect.objectContaining({ has_marketing_consent: true, @@ -712,7 +730,7 @@ describe('OptinMetrics', () => { fireEvent.press(screen.getByText(strings('privacy_policy.continue'))); await waitFor(() => { - expect(mockMetrics.trackEvent).toHaveBeenCalledWith( + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( expect.objectContaining({ properties: expect.objectContaining({ has_marketing_consent: false, @@ -780,7 +798,7 @@ describe('OptinMetrics', () => { ); await waitFor(() => { - expect(mockMetrics.trackEvent).toHaveBeenCalledWith( + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( expect.objectContaining({ properties: expect.objectContaining({ has_marketing_consent: true, @@ -806,7 +824,7 @@ describe('OptinMetrics', () => { ); await waitFor(() => { - expect(mockMetrics.trackEvent).toHaveBeenCalledWith( + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( expect.objectContaining({ properties: expect.objectContaining({ has_marketing_consent: false, diff --git a/app/components/UI/Perps/controllers/PerpsController.test.ts b/app/components/UI/Perps/controllers/PerpsController.test.ts index d3512bfb887a..5efe36364e57 100644 --- a/app/components/UI/Perps/controllers/PerpsController.test.ts +++ b/app/components/UI/Perps/controllers/PerpsController.test.ts @@ -66,6 +66,7 @@ jest.mock('../../../../util/Logger', () => ({ const mockTrackEvent = jest.fn(); const mockMetaMetricsInstance = { trackEvent: mockTrackEvent, + updateDataRecordingFlag: jest.fn(), }; // Mock MetaMetrics diff --git a/app/components/UI/Predict/constants/flags.ts b/app/components/UI/Predict/constants/flags.ts index fac4ae442526..c985b7a46114 100644 --- a/app/components/UI/Predict/constants/flags.ts +++ b/app/components/UI/Predict/constants/flags.ts @@ -1,4 +1,4 @@ -import { PredictFeeCollection } from '../types/flags'; +import { PredictFeeCollection, PredictLiveSportsFlag } from '../types/flags'; export const DEFAULT_FEE_COLLECTION_FLAG = { enabled: true, @@ -10,3 +10,8 @@ export const DEFAULT_FEE_COLLECTION_FLAG = { providerFee: 0.02, // 2% waiveList: [], } satisfies PredictFeeCollection; + +export const DEFAULT_LIVE_SPORTS_FLAG: PredictLiveSportsFlag = { + enabled: false, + leagues: [], +}; diff --git a/app/components/UI/Predict/constants/sports.ts b/app/components/UI/Predict/constants/sports.ts new file mode 100644 index 000000000000..79d4cd9bb7a0 --- /dev/null +++ b/app/components/UI/Predict/constants/sports.ts @@ -0,0 +1,19 @@ +import { PredictSportsLeague } from '../types'; + +/** + * Leagues with live game data support. + * + * To add a new league: + * 1. Add the league to `PredictSportsLeague` type in `../types/index.ts` + * 2. Add a slug pattern regex to `LEAGUE_SLUG_PATTERNS` in `../utils/gameParser.ts` + * 3. Add the league to this array + * 4. Add tests for the new league's slug parsing + */ +export const SUPPORTED_SPORTS_LEAGUES: PredictSportsLeague[] = ['nfl']; + +export const filterSupportedLeagues = ( + leagues: string[], +): PredictSportsLeague[] => + leagues.filter((league): league is PredictSportsLeague => + SUPPORTED_SPORTS_LEAGUES.includes(league as PredictSportsLeague), + ); diff --git a/app/components/UI/Predict/controllers/PredictController.test.ts b/app/components/UI/Predict/controllers/PredictController.test.ts index bdb860d1f81c..cc34c1d9651e 100644 --- a/app/components/UI/Predict/controllers/PredictController.test.ts +++ b/app/components/UI/Predict/controllers/PredictController.test.ts @@ -104,6 +104,10 @@ jest.mock('../../../../core/Engine', () => ({ providerFee: 0.02, waiveList: [], }, + predictLiveSports: { + enabled: false, + leagues: [], + }, }, }, }, @@ -538,6 +542,7 @@ describe('PredictController', () => { expect(controller.state.lastUpdateTimestamp).toBeGreaterThan(0); expect(mockPolymarketProvider.getMarketDetails).toHaveBeenCalledWith({ marketId: 'market-1', + liveSportsLeagues: [], }); }); }); @@ -562,6 +567,7 @@ describe('PredictController', () => { expect(result).toEqual(mockMarket); expect(mockPolymarketProvider.getMarketDetails).toHaveBeenCalledWith({ marketId: 'market-2', + liveSportsLeagues: [], }); }); }); @@ -633,6 +639,7 @@ describe('PredictController', () => { expect(result).toEqual(mockMarket); expect(mockPolymarketProvider.getMarketDetails).toHaveBeenCalledWith({ marketId: '123', + liveSportsLeagues: [], }); }); }); @@ -4926,4 +4933,139 @@ describe('PredictController', () => { ); }); }); + + describe('WebSocket subscription methods', () => { + describe('subscribeToGameUpdates', () => { + it('delegates to provider and returns unsubscribe function', () => { + withController(({ controller }) => { + const mockUnsubscribe = jest.fn(); + const mockCallback = jest.fn(); + mockPolymarketProvider.subscribeToGameUpdates = jest + .fn() + .mockReturnValue(mockUnsubscribe); + + const unsubscribe = controller.subscribeToGameUpdates( + 'game123', + mockCallback, + ); + + expect( + mockPolymarketProvider.subscribeToGameUpdates, + ).toHaveBeenCalledWith('game123', mockCallback); + expect(unsubscribe).toBe(mockUnsubscribe); + }); + }); + + it('returns no-op function when provider lacks method', () => { + withController(({ controller }) => { + // @ts-expect-error Testing undefined method scenario + mockPolymarketProvider.subscribeToGameUpdates = undefined; + + const unsubscribe = controller.subscribeToGameUpdates( + 'game123', + jest.fn(), + ); + + expect(unsubscribe).toBeDefined(); + expect(unsubscribe()).toBeUndefined(); + }); + }); + + it('returns no-op function for unknown provider', () => { + withController(({ controller }) => { + const unsubscribe = controller.subscribeToGameUpdates( + 'game123', + jest.fn(), + 'unknown-provider', + ); + + expect(unsubscribe).toBeDefined(); + expect(unsubscribe()).toBeUndefined(); + }); + }); + }); + + describe('subscribeToMarketPrices', () => { + it('delegates to provider and returns unsubscribe function', () => { + withController(({ controller }) => { + const mockUnsubscribe = jest.fn(); + const mockCallback = jest.fn(); + mockPolymarketProvider.subscribeToMarketPrices = jest + .fn() + .mockReturnValue(mockUnsubscribe); + + const unsubscribe = controller.subscribeToMarketPrices( + ['token1', 'token2'], + mockCallback, + ); + + expect( + mockPolymarketProvider.subscribeToMarketPrices, + ).toHaveBeenCalledWith(['token1', 'token2'], mockCallback); + expect(unsubscribe).toBe(mockUnsubscribe); + }); + }); + + it('returns no-op function when provider lacks method', () => { + withController(({ controller }) => { + // @ts-expect-error Testing undefined method scenario + mockPolymarketProvider.subscribeToMarketPrices = undefined; + + const unsubscribe = controller.subscribeToMarketPrices( + ['token1'], + jest.fn(), + ); + + expect(unsubscribe).toBeDefined(); + expect(unsubscribe()).toBeUndefined(); + }); + }); + }); + + describe('getConnectionStatus', () => { + it('returns connection status from provider', () => { + withController(({ controller }) => { + mockPolymarketProvider.getConnectionStatus = jest + .fn() + .mockReturnValue({ + sportsConnected: true, + marketConnected: false, + }); + + const status = controller.getConnectionStatus(); + + expect(mockPolymarketProvider.getConnectionStatus).toHaveBeenCalled(); + expect(status).toEqual({ + sportsConnected: true, + marketConnected: false, + }); + }); + }); + + it('returns disconnected status when provider lacks method', () => { + withController(({ controller }) => { + // @ts-expect-error Testing undefined method scenario + mockPolymarketProvider.getConnectionStatus = undefined; + + const status = controller.getConnectionStatus(); + + expect(status).toEqual({ + sportsConnected: false, + marketConnected: false, + }); + }); + }); + + it('returns disconnected status for unknown provider', () => { + withController(({ controller }) => { + const status = controller.getConnectionStatus('unknown-provider'); + + expect(status).toEqual({ + sportsConnected: false, + marketConnected: false, + }); + }); + }); + }); + }); }); diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index 00705e0337b8..3c2d238277ab 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -44,6 +44,8 @@ import { import { PolymarketProvider } from '../providers/polymarket/PolymarketProvider'; import { AccountState, + ConnectionStatus, + GameUpdateCallback, GetAccountStateParams, GetBalanceParams, GetMarketsParams, @@ -54,6 +56,7 @@ import { PrepareDepositParams, PrepareWithdrawParams, PreviewOrderParams, + PriceUpdateCallback, Signer, } from '../providers/types'; import { @@ -80,8 +83,12 @@ import { PREDICT_CONSTANTS, PREDICT_ERROR_CODES } from '../constants/errors'; import { getEvmAccountFromSelectedAccountGroup } from '../utils/accounts'; import { GEO_BLOCKED_COUNTRIES } from '../constants/geoblock'; import { MATIC_CONTRACTS } from '../providers/polymarket/constants'; -import { DEFAULT_FEE_COLLECTION_FLAG } from '../constants/flags'; -import { PredictFeeCollection } from '../types/flags'; +import { + DEFAULT_FEE_COLLECTION_FLAG, + DEFAULT_LIVE_SPORTS_FLAG, +} from '../constants/flags'; +import { filterSupportedLeagues } from '../constants/sports'; +import { PredictFeeCollection, PredictLiveSportsFlag } from '../types/flags'; /** * State shape for PredictController @@ -460,9 +467,20 @@ export class PredictController extends BaseController< throw new Error('Provider not available'); } + const { RemoteFeatureFlagController } = Engine.context; + const liveSportsFlag = + (RemoteFeatureFlagController.state.remoteFeatureFlags + .predictLiveSports as unknown as PredictLiveSportsFlag | undefined) ?? + DEFAULT_LIVE_SPORTS_FLAG; + const liveSportsLeagues = liveSportsFlag.enabled + ? filterSupportedLeagues(liveSportsFlag.leagues ?? []) + : []; + + const paramsWithLiveSports = { ...params, liveSportsLeagues }; + const allMarkets = await Promise.all( providerIds.map((id: string) => - this.providers.get(id)?.getMarkets(params), + this.providers.get(id)?.getMarkets(paramsWithLiveSports), ), ); @@ -557,8 +575,18 @@ export class PredictController extends BaseController< throw new Error('Provider not available'); } + const { RemoteFeatureFlagController } = Engine.context; + const liveSportsFlag = + (RemoteFeatureFlagController.state.remoteFeatureFlags + .predictLiveSports as unknown as PredictLiveSportsFlag | undefined) ?? + DEFAULT_LIVE_SPORTS_FLAG; + const liveSportsLeagues = liveSportsFlag.enabled + ? filterSupportedLeagues(liveSportsFlag.leagues ?? []) + : []; + const market = await provider.getMarketDetails({ marketId: resolvedMarketId, + liveSportsLeagues, }); this.update((state) => { @@ -1707,9 +1735,59 @@ export class PredictController extends BaseController< } /** - * Test utility method to update state for testing purposes - * @param updater - Function that updates the state + * Subscribes to real-time game updates via WebSocket. + * + * @param gameId - Unique identifier of the game to subscribe to + * @param callback - Function invoked when game state changes (score, period, status) + * @param providerId - Provider to use for subscription (default: 'polymarket') + * @returns Unsubscribe function to clean up the subscription */ + public subscribeToGameUpdates( + gameId: string, + callback: GameUpdateCallback, + providerId = 'polymarket', + ): () => void { + const provider = this.providers.get(providerId); + if (!provider?.subscribeToGameUpdates) { + return () => undefined; + } + return provider.subscribeToGameUpdates(gameId, callback); + } + + /** + * Subscribes to real-time market price updates via WebSocket. + * + * @param tokenIds - Array of token IDs to subscribe to price updates for + * @param callback - Function invoked when prices change (includes bestBid/bestAsk) + * @param providerId - Provider to use for subscription (default: 'polymarket') + * @returns Unsubscribe function to clean up the subscription + */ + public subscribeToMarketPrices( + tokenIds: string[], + callback: PriceUpdateCallback, + providerId = 'polymarket', + ): () => void { + const provider = this.providers.get(providerId); + if (!provider?.subscribeToMarketPrices) { + return () => undefined; + } + return provider.subscribeToMarketPrices(tokenIds, callback); + } + + /** + * Gets the current WebSocket connection status for live data feeds. + * + * @param providerId - Provider to check connection status for (default: 'polymarket') + * @returns Connection status for sports and market data WebSocket channels + */ + public getConnectionStatus(providerId = 'polymarket'): ConnectionStatus { + const provider = this.providers.get(providerId); + if (!provider?.getConnectionStatus) { + return { sportsConnected: false, marketConnected: false }; + } + return provider.getConnectionStatus(); + } + public updateStateForTesting( updater: (state: PredictControllerState) => void, ): void { diff --git a/app/components/UI/Predict/hooks/index.ts b/app/components/UI/Predict/hooks/index.ts new file mode 100644 index 000000000000..f66187ac32be --- /dev/null +++ b/app/components/UI/Predict/hooks/index.ts @@ -0,0 +1,11 @@ +export { + useLiveGameUpdates, + type UseLiveGameUpdatesOptions, + type UseLiveGameUpdatesResult, +} from './useLiveGameUpdates'; + +export { + useLiveMarketPrices, + type UseLiveMarketPricesOptions, + type UseLiveMarketPricesResult, +} from './useLiveMarketPrices'; diff --git a/app/components/UI/Predict/hooks/useLiveGameUpdates.test.ts b/app/components/UI/Predict/hooks/useLiveGameUpdates.test.ts new file mode 100644 index 000000000000..c54f838584f9 --- /dev/null +++ b/app/components/UI/Predict/hooks/useLiveGameUpdates.test.ts @@ -0,0 +1,298 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { useLiveGameUpdates } from './useLiveGameUpdates'; +import Engine from '../../../../core/Engine'; +import { GameUpdate } from '../types'; + +jest.mock('../../../../core/Engine', () => ({ + context: { + PredictController: { + subscribeToGameUpdates: jest.fn(), + getConnectionStatus: jest.fn(), + }, + }, +})); + +describe('useLiveGameUpdates', () => { + const mockSubscribeToGameUpdates = Engine.context.PredictController + .subscribeToGameUpdates as jest.Mock; + const mockGetConnectionStatus = Engine.context.PredictController + .getConnectionStatus as jest.Mock; + const mockUnsubscribe = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + mockSubscribeToGameUpdates.mockReturnValue(mockUnsubscribe); + mockGetConnectionStatus.mockReturnValue({ + sportsConnected: true, + marketConnected: false, + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('subscription management', () => { + it('subscribes to game updates when gameId is provided', () => { + renderHook(() => useLiveGameUpdates('game123')); + + expect(mockSubscribeToGameUpdates).toHaveBeenCalledWith( + 'game123', + expect.any(Function), + ); + }); + + it('does not subscribe when gameId is null', () => { + renderHook(() => useLiveGameUpdates(null)); + + expect(mockSubscribeToGameUpdates).not.toHaveBeenCalled(); + }); + + it('does not subscribe when enabled is false', () => { + renderHook(() => useLiveGameUpdates('game123', { enabled: false })); + + expect(mockSubscribeToGameUpdates).not.toHaveBeenCalled(); + }); + + it('unsubscribes on unmount', () => { + const { unmount } = renderHook(() => useLiveGameUpdates('game123')); + + unmount(); + + expect(mockUnsubscribe).toHaveBeenCalled(); + }); + + it('resubscribes when gameId changes', () => { + const { rerender } = renderHook( + ({ gameId }) => useLiveGameUpdates(gameId), + { initialProps: { gameId: 'game123' } }, + ); + + expect(mockSubscribeToGameUpdates).toHaveBeenCalledTimes(1); + expect(mockSubscribeToGameUpdates).toHaveBeenCalledWith( + 'game123', + expect.any(Function), + ); + + rerender({ gameId: 'game456' }); + + expect(mockUnsubscribe).toHaveBeenCalled(); + expect(mockSubscribeToGameUpdates).toHaveBeenCalledTimes(2); + expect(mockSubscribeToGameUpdates).toHaveBeenLastCalledWith( + 'game456', + expect.any(Function), + ); + }); + }); + + describe('game update handling', () => { + it('updates gameUpdate state when callback is invoked', () => { + let capturedCallback: (update: GameUpdate) => void = jest.fn(); + mockSubscribeToGameUpdates.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const { result } = renderHook(() => useLiveGameUpdates('game123')); + + expect(result.current.gameUpdate).toBeNull(); + + act(() => { + capturedCallback({ + gameId: 'game123', + score: '21-14', + elapsed: '12:34', + period: 'Q2', + status: 'ongoing', + }); + }); + + expect(result.current.gameUpdate).toEqual({ + gameId: 'game123', + score: '21-14', + elapsed: '12:34', + period: 'Q2', + status: 'ongoing', + }); + }); + + it('updates lastUpdateTime when game update is received', () => { + let capturedCallback: (update: GameUpdate) => void = jest.fn(); + mockSubscribeToGameUpdates.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const mockNow = 1704067200000; + jest.spyOn(Date, 'now').mockReturnValue(mockNow); + + const { result } = renderHook(() => useLiveGameUpdates('game123')); + + expect(result.current.lastUpdateTime).toBeNull(); + + act(() => { + capturedCallback({ + gameId: 'game123', + score: '21-14', + elapsed: '12:34', + period: 'Q2', + status: 'ongoing', + }); + }); + + expect(result.current.lastUpdateTime).toBe(mockNow); + }); + + it('includes turn field when present in game update', () => { + let capturedCallback: (update: GameUpdate) => void = jest.fn(); + mockSubscribeToGameUpdates.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + const { result } = renderHook(() => useLiveGameUpdates('game123')); + + act(() => { + capturedCallback({ + gameId: 'game123', + score: '21-14', + elapsed: '12:34', + period: 'Q2', + status: 'ongoing', + turn: 'SEA', + }); + }); + + expect(result.current.gameUpdate?.turn).toBe('SEA'); + }); + + it('resets gameUpdate when gameId changes to different valid value', () => { + let capturedCallback: (update: GameUpdate) => void = jest.fn(); + mockSubscribeToGameUpdates.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const { result, rerender } = renderHook( + ({ gameId }) => useLiveGameUpdates(gameId), + { initialProps: { gameId: 'game123' } }, + ); + + act(() => { + capturedCallback({ + gameId: 'game123', + score: '21-14', + elapsed: '12:34', + period: 'Q2', + status: 'ongoing', + }); + }); + + expect(result.current.gameUpdate).not.toBeNull(); + expect(result.current.gameUpdate?.score).toBe('21-14'); + + rerender({ gameId: 'game456' }); + + expect(result.current.gameUpdate).toBeNull(); + expect(result.current.lastUpdateTime).toBeNull(); + }); + }); + + describe('connection status', () => { + it('reflects connected status from PredictController', () => { + mockGetConnectionStatus.mockReturnValue({ + sportsConnected: true, + marketConnected: false, + }); + + const { result } = renderHook(() => useLiveGameUpdates('game123')); + + expect(result.current.isConnected).toBe(true); + }); + + it('reflects disconnected status from PredictController', () => { + mockGetConnectionStatus.mockReturnValue({ + sportsConnected: false, + marketConnected: false, + }); + + const { result } = renderHook(() => useLiveGameUpdates('game123')); + + expect(result.current.isConnected).toBe(false); + }); + + it('updates connection status on interval', () => { + mockGetConnectionStatus + .mockReturnValueOnce({ sportsConnected: true, marketConnected: false }) + .mockReturnValueOnce({ + sportsConnected: false, + marketConnected: false, + }); + + const { result } = renderHook(() => useLiveGameUpdates('game123')); + + expect(result.current.isConnected).toBe(true); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(result.current.isConnected).toBe(false); + }); + + it('clears interval on unmount', () => { + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + + const { unmount } = renderHook(() => useLiveGameUpdates('game123')); + + unmount(); + + expect(clearIntervalSpy).toHaveBeenCalled(); + }); + }); + + describe('initial state', () => { + it('returns null gameUpdate initially', () => { + const { result } = renderHook(() => useLiveGameUpdates('game123')); + + expect(result.current.gameUpdate).toBeNull(); + }); + + it('returns null lastUpdateTime initially', () => { + const { result } = renderHook(() => useLiveGameUpdates('game123')); + + expect(result.current.lastUpdateTime).toBeNull(); + }); + + it('resets state when disabled', () => { + let capturedCallback: (update: GameUpdate) => void = jest.fn(); + mockSubscribeToGameUpdates.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const { result, rerender } = renderHook( + ({ enabled }) => useLiveGameUpdates('game123', { enabled }), + { initialProps: { enabled: true } }, + ); + + act(() => { + capturedCallback({ + gameId: 'game123', + score: '21-14', + elapsed: '12:34', + period: 'Q2', + status: 'ongoing', + }); + }); + + expect(result.current.gameUpdate).not.toBeNull(); + + rerender({ enabled: false }); + + expect(result.current.gameUpdate).toBeNull(); + expect(result.current.isConnected).toBe(false); + }); + }); +}); diff --git a/app/components/UI/Predict/hooks/useLiveGameUpdates.ts b/app/components/UI/Predict/hooks/useLiveGameUpdates.ts new file mode 100644 index 000000000000..c08754ff7a30 --- /dev/null +++ b/app/components/UI/Predict/hooks/useLiveGameUpdates.ts @@ -0,0 +1,80 @@ +import { useEffect, useState, useCallback, useRef } from 'react'; +import Engine from '../../../../core/Engine'; +import { GameUpdate } from '../types'; + +export interface UseLiveGameUpdatesOptions { + enabled?: boolean; +} + +export interface UseLiveGameUpdatesResult { + gameUpdate: GameUpdate | null; + isConnected: boolean; + lastUpdateTime: number | null; +} + +/** + * Hook for subscribing to real-time game updates via WebSocket. + * + * @param gameId - Game ID to subscribe to, or null to disable + * @param options - Configuration options (enabled: boolean) + * @returns Game update state, connection status, and last update timestamp + */ +export const useLiveGameUpdates = ( + gameId: string | null, + options: UseLiveGameUpdatesOptions = {}, +): UseLiveGameUpdatesResult => { + const { enabled = true } = options; + + const [gameUpdate, setGameUpdate] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const [lastUpdateTime, setLastUpdateTime] = useState(null); + + const isMountedRef = useRef(true); + + const handleGameUpdate = useCallback((update: GameUpdate) => { + if (!isMountedRef.current) return; + + setGameUpdate(update); + setLastUpdateTime(Date.now()); + }, []); + + useEffect(() => { + isMountedRef.current = true; + + // Reset state when gameId changes to avoid stale data from previous game + setGameUpdate(null); + setLastUpdateTime(null); + + if (!enabled || !gameId) { + setIsConnected(false); + return; + } + + const { PredictController } = Engine.context; + const unsubscribe = PredictController.subscribeToGameUpdates( + gameId, + handleGameUpdate, + ); + + const checkConnection = () => { + if (!isMountedRef.current) return; + const status = PredictController.getConnectionStatus(); + setIsConnected(status.sportsConnected); + }; + + checkConnection(); + const intervalId = setInterval(checkConnection, 1000); + + return () => { + isMountedRef.current = false; + unsubscribe(); + clearInterval(intervalId); + }; + }, [gameId, enabled, handleGameUpdate]); + + return { + gameUpdate, + isConnected, + lastUpdateTime, + }; +}; diff --git a/app/components/UI/Predict/hooks/useLiveMarketPrices.test.ts b/app/components/UI/Predict/hooks/useLiveMarketPrices.test.ts new file mode 100644 index 000000000000..a225f8fd1850 --- /dev/null +++ b/app/components/UI/Predict/hooks/useLiveMarketPrices.test.ts @@ -0,0 +1,351 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { useLiveMarketPrices } from './useLiveMarketPrices'; +import Engine from '../../../../core/Engine'; +import { PriceUpdate } from '../types'; + +jest.mock('../../../../core/Engine', () => ({ + context: { + PredictController: { + subscribeToMarketPrices: jest.fn(), + getConnectionStatus: jest.fn(), + }, + }, +})); + +describe('useLiveMarketPrices', () => { + const mockSubscribeToMarketPrices = Engine.context.PredictController + .subscribeToMarketPrices as jest.Mock; + const mockGetConnectionStatus = Engine.context.PredictController + .getConnectionStatus as jest.Mock; + const mockUnsubscribe = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + mockSubscribeToMarketPrices.mockReturnValue(mockUnsubscribe); + mockGetConnectionStatus.mockReturnValue({ + sportsConnected: false, + marketConnected: true, + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('subscription management', () => { + it('subscribes to price updates when tokenIds are provided', () => { + renderHook(() => useLiveMarketPrices(['token1', 'token2'])); + + expect(mockSubscribeToMarketPrices).toHaveBeenCalledWith( + ['token1', 'token2'], + expect.any(Function), + ); + }); + + it('does not subscribe when tokenIds is empty', () => { + renderHook(() => useLiveMarketPrices([])); + + expect(mockSubscribeToMarketPrices).not.toHaveBeenCalled(); + }); + + it('does not subscribe when enabled is false', () => { + renderHook(() => + useLiveMarketPrices(['token1', 'token2'], { enabled: false }), + ); + + expect(mockSubscribeToMarketPrices).not.toHaveBeenCalled(); + }); + + it('unsubscribes on unmount', () => { + const { unmount } = renderHook(() => + useLiveMarketPrices(['token1', 'token2']), + ); + + unmount(); + + expect(mockUnsubscribe).toHaveBeenCalled(); + }); + + it('resubscribes when tokenIds change', () => { + const { rerender } = renderHook( + ({ tokenIds }) => useLiveMarketPrices(tokenIds), + { initialProps: { tokenIds: ['token1'] } }, + ); + + expect(mockSubscribeToMarketPrices).toHaveBeenCalledTimes(1); + + rerender({ tokenIds: ['token1', 'token2'] }); + + expect(mockUnsubscribe).toHaveBeenCalled(); + expect(mockSubscribeToMarketPrices).toHaveBeenCalledTimes(2); + }); + + it('does not resubscribe when tokenIds order changes but content is same', () => { + const { rerender } = renderHook( + ({ tokenIds }) => useLiveMarketPrices(tokenIds), + { initialProps: { tokenIds: ['token2', 'token1'] } }, + ); + + expect(mockSubscribeToMarketPrices).toHaveBeenCalledTimes(1); + + rerender({ tokenIds: ['token1', 'token2'] }); + + expect(mockSubscribeToMarketPrices).toHaveBeenCalledTimes(1); + }); + }); + + describe('price update handling', () => { + it('updates prices map when callback is invoked', () => { + let capturedCallback: (updates: PriceUpdate[]) => void = jest.fn(); + mockSubscribeToMarketPrices.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const { result } = renderHook(() => useLiveMarketPrices(['token1'])); + + expect(result.current.prices.size).toBe(0); + + act(() => { + capturedCallback([ + { tokenId: 'token1', price: 0.75, bestBid: 0.74, bestAsk: 0.76 }, + ]); + }); + + expect(result.current.prices.get('token1')).toEqual({ + tokenId: 'token1', + price: 0.75, + bestBid: 0.74, + bestAsk: 0.76, + }); + }); + + it('accumulates prices from multiple updates', () => { + let capturedCallback: (updates: PriceUpdate[]) => void = jest.fn(); + mockSubscribeToMarketPrices.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const { result } = renderHook(() => + useLiveMarketPrices(['token1', 'token2']), + ); + + act(() => { + capturedCallback([ + { tokenId: 'token1', price: 0.75, bestBid: 0.74, bestAsk: 0.76 }, + ]); + }); + + act(() => { + capturedCallback([ + { tokenId: 'token2', price: 0.25, bestBid: 0.24, bestAsk: 0.26 }, + ]); + }); + + expect(result.current.prices.size).toBe(2); + expect(result.current.prices.get('token1')?.price).toBe(0.75); + expect(result.current.prices.get('token2')?.price).toBe(0.25); + }); + + it('overwrites previous price for same token', () => { + let capturedCallback: (updates: PriceUpdate[]) => void = jest.fn(); + mockSubscribeToMarketPrices.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const { result } = renderHook(() => useLiveMarketPrices(['token1'])); + + act(() => { + capturedCallback([ + { tokenId: 'token1', price: 0.75, bestBid: 0.74, bestAsk: 0.76 }, + ]); + }); + + act(() => { + capturedCallback([ + { tokenId: 'token1', price: 0.8, bestBid: 0.79, bestAsk: 0.81 }, + ]); + }); + + expect(result.current.prices.get('token1')?.price).toBe(0.8); + }); + + it('updates lastUpdateTime when price update is received', () => { + let capturedCallback: (updates: PriceUpdate[]) => void = jest.fn(); + mockSubscribeToMarketPrices.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const mockNow = 1704067200000; + jest.spyOn(Date, 'now').mockReturnValue(mockNow); + + const { result } = renderHook(() => useLiveMarketPrices(['token1'])); + + expect(result.current.lastUpdateTime).toBeNull(); + + act(() => { + capturedCallback([ + { tokenId: 'token1', price: 0.75, bestBid: 0.74, bestAsk: 0.76 }, + ]); + }); + + expect(result.current.lastUpdateTime).toBe(mockNow); + }); + }); + + describe('getPrice helper', () => { + it('returns price for existing token', () => { + let capturedCallback: (updates: PriceUpdate[]) => void = jest.fn(); + mockSubscribeToMarketPrices.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const { result } = renderHook(() => useLiveMarketPrices(['token1'])); + + act(() => { + capturedCallback([ + { tokenId: 'token1', price: 0.75, bestBid: 0.74, bestAsk: 0.76 }, + ]); + }); + + expect(result.current.getPrice('token1')?.price).toBe(0.75); + }); + + it('returns undefined for non-existent token', () => { + const { result } = renderHook(() => useLiveMarketPrices(['token1'])); + + expect(result.current.getPrice('token2')).toBeUndefined(); + }); + }); + + describe('connection status', () => { + it('reflects connected status from PredictController', () => { + mockGetConnectionStatus.mockReturnValue({ + sportsConnected: false, + marketConnected: true, + }); + + const { result } = renderHook(() => useLiveMarketPrices(['token1'])); + + expect(result.current.isConnected).toBe(true); + }); + + it('reflects disconnected status from PredictController', () => { + mockGetConnectionStatus.mockReturnValue({ + sportsConnected: false, + marketConnected: false, + }); + + const { result } = renderHook(() => useLiveMarketPrices(['token1'])); + + expect(result.current.isConnected).toBe(false); + }); + + it('updates connection status on interval', () => { + mockGetConnectionStatus + .mockReturnValueOnce({ sportsConnected: false, marketConnected: true }) + .mockReturnValueOnce({ + sportsConnected: false, + marketConnected: false, + }); + + const { result } = renderHook(() => useLiveMarketPrices(['token1'])); + + expect(result.current.isConnected).toBe(true); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(result.current.isConnected).toBe(false); + }); + }); + + describe('initial state', () => { + it('returns empty prices map initially', () => { + const { result } = renderHook(() => useLiveMarketPrices(['token1'])); + + expect(result.current.prices.size).toBe(0); + }); + + it('returns null lastUpdateTime initially', () => { + const { result } = renderHook(() => useLiveMarketPrices(['token1'])); + + expect(result.current.lastUpdateTime).toBeNull(); + }); + + it('resets state when disabled', () => { + let capturedCallback: (updates: PriceUpdate[]) => void = jest.fn(); + mockSubscribeToMarketPrices.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const { result, rerender } = renderHook( + ({ enabled }) => useLiveMarketPrices(['token1'], { enabled }), + { initialProps: { enabled: true } }, + ); + + act(() => { + capturedCallback([ + { tokenId: 'token1', price: 0.75, bestBid: 0.74, bestAsk: 0.76 }, + ]); + }); + + expect(result.current.prices.size).toBe(1); + + rerender({ enabled: false }); + + expect(result.current.prices.size).toBe(0); + expect(result.current.isConnected).toBe(false); + }); + + it('resets lastUpdateTime when tokenIds change to different valid value', () => { + let capturedCallback: (updates: PriceUpdate[]) => void = jest.fn(); + mockSubscribeToMarketPrices.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const mockNow = 1704067200000; + jest.spyOn(Date, 'now').mockReturnValue(mockNow); + + const { result, rerender } = renderHook( + ({ tokenIds }) => useLiveMarketPrices(tokenIds), + { initialProps: { tokenIds: ['token1'] } }, + ); + + act(() => { + capturedCallback([ + { tokenId: 'token1', price: 0.75, bestBid: 0.74, bestAsk: 0.76 }, + ]); + }); + + expect(result.current.lastUpdateTime).toBe(mockNow); + + rerender({ tokenIds: ['token2'] }); + + expect(result.current.lastUpdateTime).toBeNull(); + expect(result.current.prices.size).toBe(0); + }); + + it('differentiates tokenIds with commas that could otherwise collide', () => { + const { rerender } = renderHook( + ({ tokenIds }) => useLiveMarketPrices(tokenIds), + { initialProps: { tokenIds: ['a,b', 'c'] } }, + ); + + expect(mockSubscribeToMarketPrices).toHaveBeenCalledTimes(1); + + rerender({ tokenIds: ['a', 'b,c'] }); + + expect(mockSubscribeToMarketPrices).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/app/components/UI/Predict/hooks/useLiveMarketPrices.ts b/app/components/UI/Predict/hooks/useLiveMarketPrices.ts new file mode 100644 index 000000000000..5f05413e53b3 --- /dev/null +++ b/app/components/UI/Predict/hooks/useLiveMarketPrices.ts @@ -0,0 +1,106 @@ +import { useEffect, useState, useCallback, useRef, useMemo } from 'react'; +import Engine from '../../../../core/Engine'; +import { PriceUpdate } from '../types'; + +export interface UseLiveMarketPricesOptions { + enabled?: boolean; +} + +export interface UseLiveMarketPricesResult { + prices: Map; + getPrice: (tokenId: string) => PriceUpdate | undefined; + isConnected: boolean; + lastUpdateTime: number | null; +} + +/** + * Hook for subscribing to real-time market price updates via WebSocket. + * + * @param tokenIds - Array of token IDs to subscribe to price updates for + * @param options - Configuration options (enabled: boolean) + * @returns Price map, getPrice helper, connection status, and last update timestamp + */ +export const useLiveMarketPrices = ( + tokenIds: string[], + options: UseLiveMarketPricesOptions = {}, +): UseLiveMarketPricesResult => { + const { enabled = true } = options; + + const [prices, setPrices] = useState>(new Map()); + const [isConnected, setIsConnected] = useState(false); + const [lastUpdateTime, setLastUpdateTime] = useState(null); + + const isMountedRef = useRef(true); + const tokenIdsRef = useRef(tokenIds); + + // Use JSON.stringify to avoid key collisions if token IDs contain commas + const tokenIdsKey = useMemo( + () => JSON.stringify([...tokenIds].sort((a, b) => a.localeCompare(b))), + [tokenIds], + ); + + // Sync ref in effect to avoid render impurity (React Concurrent Mode safe) + useEffect(() => { + tokenIdsRef.current = tokenIds; + }, [tokenIds]); + + const handlePriceUpdates = useCallback((updates: PriceUpdate[]) => { + if (!isMountedRef.current) return; + + setPrices((prevPrices) => { + const newPrices = new Map(prevPrices); + updates.forEach((update) => { + newPrices.set(update.tokenId, update); + }); + return newPrices; + }); + + setLastUpdateTime(Date.now()); + }, []); + + useEffect(() => { + isMountedRef.current = true; + + // Reset state when token set changes to avoid stale data from previous subscriptions + setPrices(new Map()); + setLastUpdateTime(null); + + if (!enabled || tokenIdsRef.current.length === 0) { + setIsConnected(false); + return; + } + + const { PredictController } = Engine.context; + const unsubscribe = PredictController.subscribeToMarketPrices( + tokenIdsRef.current, + handlePriceUpdates, + ); + + const checkConnection = () => { + if (!isMountedRef.current) return; + const status = PredictController.getConnectionStatus(); + setIsConnected(status.marketConnected); + }; + + checkConnection(); + const intervalId = setInterval(checkConnection, 1000); + + return () => { + isMountedRef.current = false; + unsubscribe(); + clearInterval(intervalId); + }; + }, [tokenIdsKey, enabled, handlePriceUpdates]); + + const getPrice = useCallback( + (tokenId: string): PriceUpdate | undefined => prices.get(tokenId), + [prices], + ); + + return { + prices, + getPrice, + isConnected, + lastUpdateTime, + }; +}; diff --git a/app/components/UI/Predict/providers/polymarket/GameCache.test.ts b/app/components/UI/Predict/providers/polymarket/GameCache.test.ts index 408beb700b43..6229630bd918 100644 --- a/app/components/UI/Predict/providers/polymarket/GameCache.test.ts +++ b/app/components/UI/Predict/providers/polymarket/GameCache.test.ts @@ -42,9 +42,9 @@ const createMockMarketWithGame = ( startTime: '2025-01-12T18:00:00Z', status: 'scheduled', league: 'nfl', - elapsed: '00:00', - period: 'NS', - score: '0-0', + elapsed: null, + period: null, + score: null, homeTeam: { id: 'team-1', name: 'Seattle Seahawks', @@ -185,8 +185,8 @@ describe('GameCache', () => { const result = cache.overlayOnMarket(market); - expect(result.game?.score).toBe('0-0'); - expect(result.game?.period).toBe('NS'); + expect(result.game?.score).toBeNull(); + expect(result.game?.period).toBeNull(); }); it('merges cached data onto market.game', () => { @@ -204,7 +204,7 @@ describe('GameCache', () => { cache.updateGame('game-123', update); const result = cache.overlayOnMarket(market); - expect(result.game?.score).toBe('28-21'); + expect(result.game?.score).toEqual({ away: 28, home: 21, raw: '28-21' }); expect(result.game?.elapsed).toBe('08:42'); expect(result.game?.period).toBe('Q3'); expect(result.game?.status).toBe('ongoing'); @@ -247,7 +247,7 @@ describe('GameCache', () => { cache.updateGame('game-123', update); cache.overlayOnMarket(market); - expect(market.game?.score).toBe('0-0'); + expect(market.game?.score).toBeNull(); }); }); @@ -271,8 +271,16 @@ describe('GameCache', () => { const results = cache.overlayOnMarkets(markets); - expect(results[0].game?.score).toBe('14-7'); - expect(results[1].game?.score).toBe('21-14'); + expect(results[0].game?.score).toEqual({ + away: 14, + home: 7, + raw: '14-7', + }); + expect(results[1].game?.score).toEqual({ + away: 21, + home: 14, + raw: '21-14', + }); expect(results[2].game).toBeUndefined(); }); }); diff --git a/app/components/UI/Predict/providers/polymarket/GameCache.ts b/app/components/UI/Predict/providers/polymarket/GameCache.ts index a332905fc56e..888a88b3ee6d 100644 --- a/app/components/UI/Predict/providers/polymarket/GameCache.ts +++ b/app/components/UI/Predict/providers/polymarket/GameCache.ts @@ -1,4 +1,5 @@ import { GameUpdate, PredictMarket } from '../../types'; +import { parseScore } from '../../utils/gameParser'; const CACHE_TTL_MS = 5 * 60 * 1000; const CLEANUP_INTERVAL_MS = 60 * 1000; @@ -65,9 +66,9 @@ export class GameCache { ...market, game: { ...market.game, - score: cachedUpdate.score, - elapsed: cachedUpdate.elapsed, - period: cachedUpdate.period, + score: parseScore(cachedUpdate.score), + elapsed: cachedUpdate.elapsed || null, + period: cachedUpdate.period || null, status: cachedUpdate.status, turn: cachedUpdate.turn, }, diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts index 0d5abe80be01..01b4e0cdda03 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts @@ -141,6 +141,42 @@ jest.mock('./GameCache', () => ({ }, })); +jest.mock('../../constants/sports', () => ({ + SUPPORTED_SPORTS_LEAGUES: ['nfl'], +})); + +const mockTeamsCacheInstance = { + ensureLeagueLoaded: jest.fn().mockResolvedValue(undefined), + ensureLeaguesLoaded: jest.fn().mockResolvedValue(undefined), + getTeam: jest.fn(), + getNflTeam: jest.fn(), + isLeagueLoaded: jest.fn().mockReturnValue(true), + clear: jest.fn(), + getTeamCount: jest.fn().mockReturnValue(0), +}; + +jest.mock('./TeamsCache', () => ({ + TeamsCache: { + getInstance: jest.fn(() => mockTeamsCacheInstance), + resetInstance: jest.fn(), + }, +})); + +const mockWebSocketManagerInstance = { + subscribeToGame: jest.fn(), + subscribeToMarketPrices: jest.fn(), + getConnectionStatus: jest.fn(), + disconnect: jest.fn(), + cleanup: jest.fn(), +}; + +jest.mock('./WebSocketManager', () => ({ + WebSocketManager: { + getInstance: jest.fn(() => mockWebSocketManagerInstance), + resetInstance: jest.fn(), + }, +})); + jest.mock('../../../../../util/transactions', () => ({ generateTransferData: jest.fn(), isSmartContractAddress: jest.fn(), @@ -239,25 +275,26 @@ describe('PolymarketProvider', () => { mockGetMarketsFromPolymarketApi.mockResolvedValue(mockMarkets); - const markets = await provider.getMarkets(); + const markets = await provider.getMarkets({ liveSportsLeagues: ['nfl'] }); expect(Array.isArray(markets)).toBe(true); expect(markets.length).toBeGreaterThan(0); expect(markets.length).toBe(2); - expect(mockGetMarketsFromPolymarketApi).toHaveBeenCalledWith(undefined); + expect(mockGetMarketsFromPolymarketApi).toHaveBeenCalledWith( + expect.objectContaining({ teamLookup: expect.any(Function) }), + ); }); it('getMarkets returns empty array when API fails', async () => { - // Arrange const provider = createProvider(); const apiError = new Error('API request failed'); mockGetMarketsFromPolymarketApi.mockRejectedValue(apiError); - // Act - const result = await provider.getMarkets(); + const result = await provider.getMarkets({ liveSportsLeagues: ['nfl'] }); - // Assert expect(result).toEqual([]); - expect(mockGetMarketsFromPolymarketApi).toHaveBeenCalledWith(undefined); + expect(mockGetMarketsFromPolymarketApi).toHaveBeenCalledWith( + expect.objectContaining({ teamLookup: expect.any(Function) }), + ); }); it('getMarkets returns empty array when non-Error exception is thrown', async () => { @@ -2386,7 +2423,10 @@ describe('PolymarketProvider', () => { mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); mockParsePolymarketEvents.mockReturnValue([mockParsedMarket]); - const result = await provider.getMarketDetails({ marketId: 'market-1' }); + const result = await provider.getMarketDetails({ + marketId: 'market-1', + liveSportsLeagues: ['nfl'], + }); expect(result).toEqual(mockParsedMarket); expect(mockGetMarketDetailsFromGammaApi).toHaveBeenCalledWith({ @@ -2394,7 +2434,10 @@ describe('PolymarketProvider', () => { }); expect(mockParsePolymarketEvents).toHaveBeenCalledWith( [mockEvent], - 'trending', + expect.objectContaining({ + category: 'trending', + teamLookup: expect.any(Function), + }), ); }); @@ -6400,7 +6443,7 @@ describe('PolymarketProvider', () => { }); describe('getMarkets', () => { - it('applies GameCache overlay to fetched markets', async () => { + it('applies GameCache overlay to fetched markets when liveSportsLeagues is provided', async () => { const provider = new PolymarketProvider(); const mockMarkets = [ { id: 'market-1', title: 'Test Market 1' }, @@ -6408,14 +6451,14 @@ describe('PolymarketProvider', () => { ]; mockGetMarketsFromPolymarketApi.mockResolvedValue(mockMarkets); - await provider.getMarkets(); + await provider.getMarkets({ liveSportsLeagues: ['nfl'] }); expect(mockGameCacheInstance.overlayOnMarkets).toHaveBeenCalledWith( mockMarkets, ); }); - it('returns markets with cached game data overlay applied', async () => { + it('returns markets with cached game data overlay applied when liveSportsLeagues is provided', async () => { const provider = new PolymarketProvider(); const mockMarkets = [{ id: 'market-1', title: 'Test Market' }]; const overlaidMarkets = [ @@ -6428,7 +6471,9 @@ describe('PolymarketProvider', () => { mockGetMarketsFromPolymarketApi.mockResolvedValue(mockMarkets); mockGameCacheInstance.overlayOnMarkets.mockReturnValue(overlaidMarkets); - const result = await provider.getMarkets(); + const result = await provider.getMarkets({ + liveSportsLeagues: ['nfl'], + }); expect(result).toEqual(overlaidMarkets); }); @@ -6439,7 +6484,9 @@ describe('PolymarketProvider', () => { new Error('API error'), ); - const result = await provider.getMarkets(); + const result = await provider.getMarkets({ + liveSportsLeagues: ['nfl'], + }); expect(result).toEqual([]); expect(mockGameCacheInstance.overlayOnMarkets).not.toHaveBeenCalled(); @@ -6447,7 +6494,7 @@ describe('PolymarketProvider', () => { }); describe('getMarketDetails', () => { - it('applies GameCache overlay to fetched market details', async () => { + it('applies GameCache overlay to fetched market details when liveSportsLeagues is provided', async () => { const provider = new PolymarketProvider(); const mockEvent = { id: 'market-1', question: 'Test Market?' }; const parsedMarket = { @@ -6458,14 +6505,17 @@ describe('PolymarketProvider', () => { mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); mockParsePolymarketEvents.mockReturnValue([parsedMarket]); - await provider.getMarketDetails({ marketId: 'market-1' }); + await provider.getMarketDetails({ + marketId: 'market-1', + liveSportsLeagues: ['nfl'], + }); expect(mockGameCacheInstance.overlayOnMarket).toHaveBeenCalledWith( parsedMarket, ); }); - it('returns market with cached game data overlay applied', async () => { + it('returns market with cached game data overlay applied when liveSportsLeagues is provided', async () => { const provider = new PolymarketProvider(); const mockEvent = { id: 'market-1', question: 'Test Market?' }; const parsedMarket = { id: 'market-1', title: 'Test Market' }; @@ -6480,6 +6530,7 @@ describe('PolymarketProvider', () => { const result = await provider.getMarketDetails({ marketId: 'market-1', + liveSportsLeagues: ['nfl'], }); expect(result).toEqual(overlaidMarket); @@ -6491,10 +6542,241 @@ describe('PolymarketProvider', () => { mockParsePolymarketEvents.mockReturnValue([]); await expect( - provider.getMarketDetails({ marketId: 'market-1' }), + provider.getMarketDetails({ + marketId: 'market-1', + liveSportsLeagues: ['nfl'], + }), ).rejects.toThrow('Failed to parse market details'); expect(mockGameCacheInstance.overlayOnMarket).not.toHaveBeenCalled(); }); }); }); + + describe('WebSocket methods', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('subscribeToGameUpdates', () => { + it('delegates to WebSocketManager.subscribeToGame', () => { + const provider = new PolymarketProvider(); + const mockCallback = jest.fn(); + const mockUnsubscribe = jest.fn(); + mockWebSocketManagerInstance.subscribeToGame.mockReturnValue( + mockUnsubscribe, + ); + + const unsubscribe = provider.subscribeToGameUpdates( + 'game-123', + mockCallback, + ); + + expect( + mockWebSocketManagerInstance.subscribeToGame, + ).toHaveBeenCalledWith('game-123', mockCallback); + expect(unsubscribe).toBe(mockUnsubscribe); + }); + + it('returns unsubscribe function from WebSocketManager', () => { + const provider = new PolymarketProvider(); + const mockUnsubscribe = jest.fn(); + mockWebSocketManagerInstance.subscribeToGame.mockReturnValue( + mockUnsubscribe, + ); + + const unsubscribe = provider.subscribeToGameUpdates( + 'game-456', + jest.fn(), + ); + + unsubscribe(); + + expect(mockUnsubscribe).toHaveBeenCalled(); + }); + }); + + describe('subscribeToMarketPrices', () => { + it('delegates to WebSocketManager.subscribeToMarketPrices', () => { + const provider = new PolymarketProvider(); + const mockCallback = jest.fn(); + const mockUnsubscribe = jest.fn(); + mockWebSocketManagerInstance.subscribeToMarketPrices.mockReturnValue( + mockUnsubscribe, + ); + + const unsubscribe = provider.subscribeToMarketPrices( + ['token-1', 'token-2'], + mockCallback, + ); + + expect( + mockWebSocketManagerInstance.subscribeToMarketPrices, + ).toHaveBeenCalledWith(['token-1', 'token-2'], mockCallback); + expect(unsubscribe).toBe(mockUnsubscribe); + }); + + it('returns unsubscribe function from WebSocketManager', () => { + const provider = new PolymarketProvider(); + const mockUnsubscribe = jest.fn(); + mockWebSocketManagerInstance.subscribeToMarketPrices.mockReturnValue( + mockUnsubscribe, + ); + + const unsubscribe = provider.subscribeToMarketPrices( + ['token-1'], + jest.fn(), + ); + + unsubscribe(); + + expect(mockUnsubscribe).toHaveBeenCalled(); + }); + }); + + describe('getConnectionStatus', () => { + it('returns connection status from WebSocketManager', () => { + const provider = new PolymarketProvider(); + mockWebSocketManagerInstance.getConnectionStatus.mockReturnValue({ + sportsConnected: true, + marketConnected: false, + gameSubscriptionCount: 5, + priceSubscriptionCount: 10, + }); + + const status = provider.getConnectionStatus(); + + expect(status).toEqual({ + sportsConnected: true, + marketConnected: false, + }); + }); + + it('maps WebSocketManager status to ConnectionStatus interface', () => { + const provider = new PolymarketProvider(); + mockWebSocketManagerInstance.getConnectionStatus.mockReturnValue({ + sportsConnected: false, + marketConnected: true, + gameSubscriptionCount: 0, + priceSubscriptionCount: 3, + }); + + const status = provider.getConnectionStatus(); + + expect(status.sportsConnected).toBe(false); + expect(status.marketConnected).toBe(true); + expect(Object.keys(status)).toEqual([ + 'sportsConnected', + 'marketConnected', + ]); + }); + }); + }); + + describe('Live sports disabled (empty liveSportsLeagues)', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getMarkets', () => { + it('skips TeamsCache loading when liveSportsLeagues is empty', async () => { + const provider = new PolymarketProvider(); + mockGetMarketsFromPolymarketApi.mockResolvedValue([]); + + await provider.getMarkets({ liveSportsLeagues: [] }); + + expect( + mockTeamsCacheInstance.ensureLeaguesLoaded, + ).not.toHaveBeenCalled(); + }); + + it('skips GameCache overlay when liveSportsLeagues is empty', async () => { + const provider = new PolymarketProvider(); + const mockMarkets = [{ id: 'market-1', title: 'Test Market' }]; + mockGetMarketsFromPolymarketApi.mockResolvedValue(mockMarkets); + + const result = await provider.getMarkets({ liveSportsLeagues: [] }); + + expect(mockGameCacheInstance.overlayOnMarkets).not.toHaveBeenCalled(); + expect(result).toEqual(mockMarkets); + }); + + it('does not pass teamLookup when liveSportsLeagues is empty', async () => { + const provider = new PolymarketProvider(); + mockGetMarketsFromPolymarketApi.mockResolvedValue([]); + + await provider.getMarkets({ + category: 'sports', + liveSportsLeagues: [], + }); + + expect(mockGetMarketsFromPolymarketApi).toHaveBeenCalledWith( + expect.objectContaining({ teamLookup: undefined }), + ); + }); + + it('skips TeamsCache loading when liveSportsLeagues is undefined (default)', async () => { + const provider = new PolymarketProvider(); + mockGetMarketsFromPolymarketApi.mockResolvedValue([]); + + await provider.getMarkets(); + + expect( + mockTeamsCacheInstance.ensureLeaguesLoaded, + ).not.toHaveBeenCalled(); + }); + }); + + describe('getMarketDetails', () => { + it('skips TeamsCache loading when liveSportsLeagues is empty', async () => { + const provider = new PolymarketProvider(); + const mockEvent = { id: 'market-1', question: 'Test?' }; + const parsedMarket = { id: 'market-1', title: 'Test' }; + mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); + mockParsePolymarketEvents.mockReturnValue([parsedMarket]); + + await provider.getMarketDetails({ + marketId: 'market-1', + liveSportsLeagues: [], + }); + + expect( + mockTeamsCacheInstance.ensureLeaguesLoaded, + ).not.toHaveBeenCalled(); + }); + + it('skips GameCache overlay when liveSportsLeagues is empty', async () => { + const provider = new PolymarketProvider(); + const mockEvent = { id: 'market-1', question: 'Test?' }; + const parsedMarket = { id: 'market-1', title: 'Test' }; + mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); + mockParsePolymarketEvents.mockReturnValue([parsedMarket]); + + const result = await provider.getMarketDetails({ + marketId: 'market-1', + liveSportsLeagues: [], + }); + + expect(mockGameCacheInstance.overlayOnMarket).not.toHaveBeenCalled(); + expect(result).toEqual(parsedMarket); + }); + + it('does not pass teamLookup when liveSportsLeagues is empty', async () => { + const provider = new PolymarketProvider(); + const mockEvent = { id: 'market-1', question: 'Test?' }; + const parsedMarket = { id: 'market-1', title: 'Test' }; + mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); + mockParsePolymarketEvents.mockReturnValue([parsedMarket]); + + await provider.getMarketDetails({ + marketId: 'market-1', + liveSportsLeagues: [], + }); + + expect(mockParsePolymarketEvents).toHaveBeenCalledWith( + [mockEvent], + expect.objectContaining({ teamLookup: undefined }), + ); + }); + }); + }); }); diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index 4e45a3e0d732..3e744f35293e 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -14,6 +14,7 @@ import { isSmartContractAddress, } from '../../../../../util/transactions'; import { PREDICT_CONSTANTS, PREDICT_ERROR_CODES } from '../../constants/errors'; +import { SUPPORTED_SPORTS_LEAGUES } from '../../constants/sports'; import { GetPriceHistoryParams, GetPriceParams, @@ -32,6 +33,8 @@ import { AccountState, ClaimOrderParams, ClaimOrderResponse, + ConnectionStatus, + GameUpdateCallback, GeoBlockResponse, GetBalanceParams, GetMarketsParams, @@ -45,6 +48,7 @@ import { PrepareWithdrawParams, PrepareWithdrawResponse, PreviewOrderParams, + PriceUpdateCallback, Signer, SignWithdrawParams, SignWithdrawResponse, @@ -97,6 +101,8 @@ import { } from './utils'; import { PredictFeeCollection } from '../../types/flags'; import { GameCache } from './GameCache'; +import { TeamsCache } from './TeamsCache'; +import { WebSocketManager } from './WebSocketManager'; export type SignTypedMessageFn = ( params: TypedMessageParams, @@ -168,8 +174,10 @@ export class PolymarketProvider implements PredictProvider { public async getMarketDetails({ marketId, + liveSportsLeagues = [], }: { marketId: string; + liveSportsLeagues?: string[]; }): Promise { if (!marketId) { throw new Error('marketId is required'); @@ -180,16 +188,33 @@ export class PolymarketProvider implements PredictProvider { marketId, }); - const [parsedMarket] = parsePolymarketEvents( - [event], - PolymarketProvider.FALLBACK_CATEGORY, - ); + const liveSportsEnabled = liveSportsLeagues.length > 0; + + if (liveSportsEnabled) { + await TeamsCache.getInstance().ensureLeaguesLoaded( + liveSportsLeagues as typeof SUPPORTED_SPORTS_LEAGUES, + ); + } + + const teamLookup = liveSportsEnabled + ? ( + league: (typeof SUPPORTED_SPORTS_LEAGUES)[number], + abbreviation: string, + ) => TeamsCache.getInstance().getTeam(league, abbreviation) + : undefined; + + const [parsedMarket] = parsePolymarketEvents([event], { + category: PolymarketProvider.FALLBACK_CATEGORY, + teamLookup, + }); if (!parsedMarket) { throw new Error('Failed to parse market details'); } - return GameCache.getInstance().overlayOnMarket(parsedMarket); + return liveSportsEnabled + ? GameCache.getInstance().overlayOnMarket(parsedMarket) + : parsedMarket; } catch (error) { DevLogger.log('Error getting market details via Polymarket API:', error); throw error; @@ -234,12 +259,33 @@ export class PolymarketProvider implements PredictProvider { public async getMarkets(params?: GetMarketsParams): Promise { try { - const markets = await getParsedMarketsFromPolymarketApi(params); - return GameCache.getInstance().overlayOnMarkets(markets); + const liveSportsLeagues = params?.liveSportsLeagues ?? []; + const liveSportsEnabled = liveSportsLeagues.length > 0; + + if (liveSportsEnabled) { + await TeamsCache.getInstance().ensureLeaguesLoaded( + liveSportsLeagues as typeof SUPPORTED_SPORTS_LEAGUES, + ); + } + + const teamLookup = liveSportsEnabled + ? ( + league: (typeof SUPPORTED_SPORTS_LEAGUES)[number], + abbreviation: string, + ) => TeamsCache.getInstance().getTeam(league, abbreviation) + : undefined; + + const markets = await getParsedMarketsFromPolymarketApi({ + ...params, + teamLookup, + }); + + return liveSportsEnabled + ? GameCache.getInstance().overlayOnMarkets(markets) + : markets; } catch (error) { DevLogger.log('Error getting markets via Polymarket API:', error); - // Log to Sentry - this error is swallowed (returns []) so controller won't see it Logger.error( error instanceof Error ? error : new Error(String(error)), this.getErrorContext('getMarkets', { @@ -1565,4 +1611,29 @@ export class PolymarketProvider implements PredictProvider { amount, }; } + + public subscribeToGameUpdates( + gameId: string, + callback: GameUpdateCallback, + ): () => void { + return WebSocketManager.getInstance().subscribeToGame(gameId, callback); + } + + public subscribeToMarketPrices( + tokenIds: string[], + callback: PriceUpdateCallback, + ): () => void { + return WebSocketManager.getInstance().subscribeToMarketPrices( + tokenIds, + callback, + ); + } + + public getConnectionStatus(): ConnectionStatus { + const status = WebSocketManager.getInstance().getConnectionStatus(); + return { + sportsConnected: status.sportsConnected, + marketConnected: status.marketConnected, + }; + } } diff --git a/app/components/UI/Predict/providers/polymarket/TeamsCache.test.ts b/app/components/UI/Predict/providers/polymarket/TeamsCache.test.ts new file mode 100644 index 000000000000..d2ef3feda2de --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/TeamsCache.test.ts @@ -0,0 +1,361 @@ +import { TeamsCache } from './TeamsCache'; +import { PolymarketApiTeam } from './types'; + +jest.mock('./utils', () => ({ + getPolymarketEndpoints: jest.fn().mockReturnValue({ + GAMMA_API_ENDPOINT: 'https://gamma-api.polymarket.com', + }), +})); + +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +const createMockTeam = ( + overrides: Partial = {}, +): PolymarketApiTeam => ({ + id: 'team-1', + name: 'Seattle Seahawks', + logo: 'https://example.com/sea.png', + abbreviation: 'SEA', + color: '#002244', + alias: 'Seahawks', + ...overrides, +}); + +const mockNflTeams: PolymarketApiTeam[] = [ + createMockTeam({ + id: 'team-sea', + name: 'Seattle Seahawks', + abbreviation: 'SEA', + color: '#002244', + alias: 'Seahawks', + }), + createMockTeam({ + id: 'team-den', + name: 'Denver Broncos', + abbreviation: 'DEN', + color: '#FB4F14', + alias: 'Broncos', + }), + createMockTeam({ + id: 'team-sf', + name: 'San Francisco 49ers', + abbreviation: 'SF', + color: '#AA0000', + alias: '49ers', + }), +]; + +describe('TeamsCache', () => { + beforeEach(() => { + TeamsCache.resetInstance(); + mockFetch.mockReset(); + jest.clearAllMocks(); + }); + + describe('singleton pattern', () => { + it('returns same instance on multiple calls', () => { + const instance1 = TeamsCache.getInstance(); + const instance2 = TeamsCache.getInstance(); + + expect(instance1).toBe(instance2); + }); + + it('creates new instance after reset', () => { + const instance1 = TeamsCache.getInstance(); + TeamsCache.resetInstance(); + const instance2 = TeamsCache.getInstance(); + + expect(instance1).not.toBe(instance2); + }); + + it('clears cache data on reset', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockNflTeams, + }); + const instance1 = TeamsCache.getInstance(); + await instance1.ensureLeagueLoaded('nfl'); + + expect(instance1.isLeagueLoaded('nfl')).toBe(true); + + TeamsCache.resetInstance(); + const instance2 = TeamsCache.getInstance(); + + expect(instance2.isLeagueLoaded('nfl')).toBe(false); + }); + }); + + describe('ensureLeagueLoaded', () => { + it('fetches teams from API on first call', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockNflTeams, + }); + const cache = TeamsCache.getInstance(); + + await cache.ensureLeagueLoaded('nfl'); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + 'https://gamma-api.polymarket.com/teams?league=nfl', + ); + }); + + it('does not fetch again when already loaded', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockNflTeams, + }); + const cache = TeamsCache.getInstance(); + + await cache.ensureLeagueLoaded('nfl'); + await cache.ensureLeagueLoaded('nfl'); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('deduplicates concurrent requests for same league', async () => { + let resolvePromise: (value: unknown) => void = () => undefined; + const fetchPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + mockFetch.mockReturnValue(fetchPromise); + const cache = TeamsCache.getInstance(); + + const promise1 = cache.ensureLeagueLoaded('nfl'); + const promise2 = cache.ensureLeagueLoaded('nfl'); + const promise3 = cache.ensureLeagueLoaded('nfl'); + + resolvePromise({ + ok: true, + json: async () => mockNflTeams, + }); + + await Promise.all([promise1, promise2, promise3]); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('handles API error gracefully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + }); + const cache = TeamsCache.getInstance(); + + await cache.ensureLeagueLoaded('nfl'); + + expect(cache.isLeagueLoaded('nfl')).toBe(false); + }); + + it('handles network error gracefully', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + const cache = TeamsCache.getInstance(); + + await cache.ensureLeagueLoaded('nfl'); + + expect(cache.isLeagueLoaded('nfl')).toBe(false); + }); + + it('handles invalid API response format', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ invalid: 'response' }), + }); + const cache = TeamsCache.getInstance(); + + await cache.ensureLeagueLoaded('nfl'); + + expect(cache.isLeagueLoaded('nfl')).toBe(false); + }); + + it('skips teams without abbreviation', async () => { + const teamsWithMissingAbbr = [ + createMockTeam({ abbreviation: 'SEA' }), + createMockTeam({ abbreviation: '' }), + createMockTeam({ abbreviation: 'DEN' }), + ]; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => teamsWithMissingAbbr, + }); + const cache = TeamsCache.getInstance(); + + await cache.ensureLeagueLoaded('nfl'); + + expect(cache.getTeamCount('nfl')).toBe(2); + }); + }); + + describe('ensureLeaguesLoaded', () => { + it('loads multiple leagues in parallel', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockNflTeams, + }); + const cache = TeamsCache.getInstance(); + + await cache.ensureLeaguesLoaded(['nfl']); + + expect(cache.isLeagueLoaded('nfl')).toBe(true); + }); + + it('does not refetch already loaded leagues', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockNflTeams, + }); + const cache = TeamsCache.getInstance(); + await cache.ensureLeagueLoaded('nfl'); + mockFetch.mockClear(); + + await cache.ensureLeaguesLoaded(['nfl']); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('handles empty leagues array', async () => { + const cache = TeamsCache.getInstance(); + + await cache.ensureLeaguesLoaded([]); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('getTeam', () => { + beforeEach(async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockNflTeams, + }); + await TeamsCache.getInstance().ensureLeagueLoaded('nfl'); + }); + + it('returns team by abbreviation (lowercase)', () => { + const cache = TeamsCache.getInstance(); + + const team = cache.getTeam('nfl', 'sea'); + + expect(team?.name).toBe('Seattle Seahawks'); + }); + + it('returns team by abbreviation (uppercase)', () => { + const cache = TeamsCache.getInstance(); + + const team = cache.getTeam('nfl', 'SEA'); + + expect(team?.name).toBe('Seattle Seahawks'); + }); + + it('returns team by abbreviation (mixed case)', () => { + const cache = TeamsCache.getInstance(); + + const team = cache.getTeam('nfl', 'SeA'); + + expect(team?.name).toBe('Seattle Seahawks'); + }); + + it('returns undefined for unknown abbreviation', () => { + const cache = TeamsCache.getInstance(); + + const team = cache.getTeam('nfl', 'xyz'); + + expect(team).toBeUndefined(); + }); + + it('returns team from loaded league', () => { + const cache = TeamsCache.getInstance(); + + const team = cache.getTeam('nfl', 'sea'); + + expect(team).toBeDefined(); + expect(team?.name).toBe('Seattle Seahawks'); + }); + }); + + describe('getNflTeam', () => { + beforeEach(async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockNflTeams, + }); + await TeamsCache.getInstance().ensureLeagueLoaded('nfl'); + }); + + it('returns NFL team by abbreviation', () => { + const cache = TeamsCache.getInstance(); + + const team = cache.getNflTeam('den'); + + expect(team?.name).toBe('Denver Broncos'); + }); + + it('returns undefined for unknown NFL team', () => { + const cache = TeamsCache.getInstance(); + + const team = cache.getNflTeam('unknown'); + + expect(team).toBeUndefined(); + }); + }); + + describe('isLeagueLoaded', () => { + it('returns false for unloaded league', () => { + const cache = TeamsCache.getInstance(); + + expect(cache.isLeagueLoaded('nfl')).toBe(false); + }); + + it('returns true after successful load', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockNflTeams, + }); + const cache = TeamsCache.getInstance(); + + await cache.ensureLeagueLoaded('nfl'); + + expect(cache.isLeagueLoaded('nfl')).toBe(true); + }); + }); + + describe('clear', () => { + it('removes all cached data', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockNflTeams, + }); + const cache = TeamsCache.getInstance(); + await cache.ensureLeagueLoaded('nfl'); + + expect(cache.isLeagueLoaded('nfl')).toBe(true); + + cache.clear(); + + expect(cache.isLeagueLoaded('nfl')).toBe(false); + }); + }); + + describe('getTeamCount', () => { + it('returns 0 for unloaded league', () => { + const cache = TeamsCache.getInstance(); + + expect(cache.getTeamCount('nfl')).toBe(0); + }); + + it('returns correct count after loading', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockNflTeams, + }); + const cache = TeamsCache.getInstance(); + + await cache.ensureLeagueLoaded('nfl'); + + expect(cache.getTeamCount('nfl')).toBe(3); + }); + }); +}); diff --git a/app/components/UI/Predict/providers/polymarket/TeamsCache.ts b/app/components/UI/Predict/providers/polymarket/TeamsCache.ts new file mode 100644 index 000000000000..ae744bd73a59 --- /dev/null +++ b/app/components/UI/Predict/providers/polymarket/TeamsCache.ts @@ -0,0 +1,142 @@ +import DevLogger from '../../../../../core/SDKConnect/utils/DevLogger'; +import Logger from '../../../../../util/Logger'; +import { PredictSportsLeague } from '../../types'; +import { PolymarketApiTeam } from './types'; +import { getPolymarketEndpoints } from './utils'; + +export class TeamsCache { + private static instance: TeamsCache | null = null; + private cache: Map> = + new Map(); + private loadingPromises: Map> = new Map(); + + // eslint-disable-next-line no-empty-function + private constructor() {} + + static getInstance(): TeamsCache { + TeamsCache.instance ??= new TeamsCache(); + return TeamsCache.instance; + } + + static resetInstance(): void { + if (TeamsCache.instance) { + TeamsCache.instance.clear(); + TeamsCache.instance = null; + } + } + + async ensureLeagueLoaded(league: PredictSportsLeague): Promise { + if (this.cache.has(league)) { + return; + } + + const existingPromise = this.loadingPromises.get(league); + if (existingPromise) { + return existingPromise; + } + + const loadPromise = this.fetchAndCacheTeams(league); + this.loadingPromises.set(league, loadPromise); + + try { + await loadPromise; + } finally { + this.loadingPromises.delete(league); + } + } + + async ensureLeaguesLoaded(leagues: PredictSportsLeague[]): Promise { + await Promise.all(leagues.map((league) => this.ensureLeagueLoaded(league))); + } + + getTeam( + league: PredictSportsLeague, + abbreviation: string, + ): PolymarketApiTeam | undefined { + const leagueCache = this.cache.get(league); + if (!leagueCache) { + return undefined; + } + return leagueCache.get(abbreviation.toLowerCase()); + } + + getNflTeam(abbreviation: string): PolymarketApiTeam | undefined { + return this.getTeam('nfl', abbreviation); + } + + isLeagueLoaded(league: PredictSportsLeague): boolean { + return this.cache.has(league); + } + + clear(): void { + this.cache.clear(); + this.loadingPromises.clear(); + } + + getTeamCount(league: PredictSportsLeague): number { + return this.cache.get(league)?.size ?? 0; + } + + private async fetchAndCacheTeams(league: PredictSportsLeague): Promise { + const { GAMMA_API_ENDPOINT } = getPolymarketEndpoints(); + const url = `${GAMMA_API_ENDPOINT}/teams?league=${league}`; + + DevLogger.log(`[TeamsCache] Fetching teams for league: ${league}`); + + try { + const response = await fetch(url); + + if (!response.ok) { + const errorMessage = `Failed to fetch teams for ${league}: ${response.status}`; + DevLogger.log(`[TeamsCache] ${errorMessage}`); + Logger.error(new Error(errorMessage), { + feature: 'predict', + provider: 'polymarket', + method: 'TeamsCache.fetchAndCacheTeams', + league, + statusCode: response.status, + }); + return; + } + + const teams: PolymarketApiTeam[] = await response.json(); + + if (!Array.isArray(teams)) { + const errorMessage = `Invalid response format for ${league} teams`; + DevLogger.log(`[TeamsCache] ${errorMessage}`); + Logger.error(new Error(errorMessage), { + feature: 'predict', + provider: 'polymarket', + method: 'TeamsCache.fetchAndCacheTeams', + league, + }); + return; + } + + const leagueCache = new Map(); + + for (const team of teams) { + if (team.abbreviation) { + leagueCache.set(team.abbreviation.toLowerCase(), team); + } + } + + this.cache.set(league, leagueCache); + + DevLogger.log( + `[TeamsCache] Cached ${leagueCache.size} teams for league: ${league}`, + ); + } catch (error) { + DevLogger.log( + `[TeamsCache] Error fetching teams for ${league}:`, + error instanceof Error ? error.message : 'Unknown error', + ); + Logger.error(error instanceof Error ? error : new Error(String(error)), { + feature: 'predict', + provider: 'polymarket', + method: 'TeamsCache.fetchAndCacheTeams', + league, + }); + } + } +} diff --git a/app/components/UI/Predict/providers/polymarket/types.ts b/app/components/UI/Predict/providers/polymarket/types.ts index 2e157dcd8994..815099718bd7 100644 --- a/app/components/UI/Predict/providers/polymarket/types.ts +++ b/app/components/UI/Predict/providers/polymarket/types.ts @@ -179,6 +179,13 @@ export interface PolymarketApiEvent { liquidity: number; volume: number; sortBy?: 'price' | 'ascending' | 'descending'; + gameId?: string; + startTime?: string; + score?: string; + elapsed?: string; + period?: string; + live?: boolean; + ended?: boolean; } export interface PolymarketApiActivity { diff --git a/app/components/UI/Predict/providers/polymarket/utils.ts b/app/components/UI/Predict/providers/polymarket/utils.ts index 0783a9826e34..d3470acb9f72 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.ts @@ -10,6 +10,7 @@ import { OnchainTradeParams, PredictMarketStatus, PredictPositionStatus, + PredictSportsLeague, Side, type PredictCategory, type PredictMarket, @@ -20,6 +21,12 @@ import { PredictOutcomeToken, } from '../../types'; import { getRecurrence } from '../../utils/format'; +import { + buildGameData, + getEventLeague, + mapApiTeamToPredictTeam, + type TeamLookup, +} from '../../utils/gameParser'; import type { GetMarketsParams, OrderPreview, @@ -51,6 +58,7 @@ import { PolymarketApiEvent, PolymarketApiActivity, PolymarketApiMarket, + PolymarketApiTeam, PolymarketPosition, TickSize, OrderBook, @@ -394,7 +402,7 @@ export const isSpreadMarket = (market: PolymarketApiMarket): boolean => market.sportsMarketType?.toLowerCase().includes('spread') ?? false; export const isMoneylineMarket = (market: PolymarketApiMarket): boolean => - market.sportsMarketType?.toLowerCase().includes('moneyline') ?? false; + market.sportsMarketType?.toLowerCase() === 'moneyline'; /** * Sort markets within a sports market type group by liquidity + volume (descending) */ @@ -602,14 +610,50 @@ export const parsePolymarketMarket = ( resolutionStatus: market.umaResolutionStatus, }); +export type PolymarketTeamLookupFn = ( + league: PredictSportsLeague, + abbreviation: string, +) => PolymarketApiTeam | undefined; + +export interface ParsePolymarketEventsOptions { + category: PredictCategory; + sortMarketsBy?: 'price' | 'ascending' | 'descending'; + teamLookup?: PolymarketTeamLookupFn; +} + export const parsePolymarketEvents = ( events: PolymarketApiEvent[], - category: PredictCategory, + categoryOrOptions: PredictCategory | ParsePolymarketEventsOptions, sortMarketsBy?: 'price' | 'ascending' | 'descending', ): PredictMarket[] => { + const options: ParsePolymarketEventsOptions = + typeof categoryOrOptions === 'string' + ? { category: categoryOrOptions, sortMarketsBy } + : categoryOrOptions; + + const { category, teamLookup } = options; + const sortBy = options.sortMarketsBy ?? sortMarketsBy; + const parsedMarkets: PredictMarket[] = events.map( (event: PolymarketApiEvent) => { const tags = Array.isArray(event.tags) ? event.tags : []; + const eventLeague = getEventLeague(event); + + const markets = sortMarkets(event, sortBy).filter( + (market: PolymarketApiMarket) => market?.active !== false, + ); + + const predictTeamLookup: TeamLookup | undefined = teamLookup + ? (league, abbr) => { + const apiTeam = teamLookup(league, abbr); + return apiTeam ? mapApiTeamToPredictTeam(apiTeam) : undefined; + } + : undefined; + + const game = + eventLeague && predictTeamLookup + ? (buildGameData(event, eventLeague, predictTeamLookup) ?? undefined) + : undefined; return { id: event.id, @@ -625,13 +669,12 @@ export const parsePolymarketEvents = ( endDate: event.endDate, category, tags: tags.map((t) => t.label), - outcomes: sortMarkets(event, sortMarketsBy) - .filter((market: PolymarketApiMarket) => market?.active !== false) - .map((market: PolymarketApiMarket) => - parsePolymarketMarket(market, event), - ), + outcomes: markets.map((market: PolymarketApiMarket) => + parsePolymarketMarket(market, event), + ), liquidity: event.liquidity, volume: event.volume, + game, }; }, ); @@ -705,12 +748,22 @@ export const parsePolymarketActivity = ( return parsedActivities; }; +export interface GetParsedMarketsOptions extends GetMarketsParams { + teamLookup?: PolymarketTeamLookupFn; +} + export const getParsedMarketsFromPolymarketApi = async ( - params?: GetMarketsParams, + params?: GetParsedMarketsOptions, ): Promise => { const { GAMMA_API_ENDPOINT } = getPolymarketEndpoints(); - const { category = 'trending', q, limit = 20, offset = 0 } = params || {}; + const { + category = 'trending', + q, + limit = 20, + offset = 0, + teamLookup, + } = params || {}; DevLogger.log( 'Getting markets via Polymarket API for category:', category, @@ -752,7 +805,6 @@ export const getParsedMarketsFromPolymarketApi = async ( const queryParamsSearch = `${type}&${eventsStatus}&${sort}&${presetsTitle}&${limitPerType}&${page}`; - // Use search endpoint if q parameter is provided const endpoint = q ? `${GAMMA_API_ENDPOINT}/public-search?q=${encodeURIComponent( q, @@ -771,11 +823,11 @@ export const getParsedMarketsFromPolymarketApi = async ( ? eventsData : []; - const parsedMarkets: PredictMarket[] = parsePolymarketEvents( - events, + const parsedMarkets: PredictMarket[] = parsePolymarketEvents(events, { category, - 'price', - ); + sortMarketsBy: 'price', + teamLookup, + }); if (q) { return parsedMarkets.filter((m) => m.outcomes.length > 0); diff --git a/app/components/UI/Predict/providers/types.ts b/app/components/UI/Predict/providers/types.ts index 59647ad55d15..6185619f2f51 100644 --- a/app/components/UI/Predict/providers/types.ts +++ b/app/components/UI/Predict/providers/types.ts @@ -1,5 +1,6 @@ import { KeyringController } from '@metamask/keyring-controller'; import { + GameUpdate, GetPriceHistoryParams, GetPriceParams, GetPriceResponse, @@ -8,6 +9,7 @@ import { PredictMarket, PredictPosition, PredictPriceHistoryPoint, + PriceUpdate, Result, Side, } from '../types'; @@ -15,6 +17,14 @@ import { Hex } from '@metamask/utils'; import { TransactionType } from '@metamask/transaction-controller'; import { PredictFeeCollection } from '../types/flags'; +export type GameUpdateCallback = (update: GameUpdate) => void; +export type PriceUpdateCallback = (updates: PriceUpdate[]) => void; + +export interface ConnectionStatus { + sportsConnected: boolean; + marketConnected: boolean; +} + export interface GetMarketsParams { providerId?: string; @@ -30,6 +40,9 @@ export interface GetMarketsParams { // Pagination offset?: number; limit?: number; + + // Live sports configuration + liveSportsLeagues?: string[]; } export interface Signer { @@ -218,7 +231,10 @@ export interface PredictProvider { // Market data getMarkets(params: GetMarketsParams): Promise; - getMarketDetails(params: { marketId: string }): Promise; + getMarketDetails(params: { + marketId: string; + liveSportsLeagues?: string[]; + }): Promise; getPriceHistory( params: GetPriceHistoryParams, ): Promise; @@ -266,4 +282,16 @@ export interface PredictProvider { signWithdraw?(params: SignWithdrawParams): Promise; getBalance(params: GetBalanceParams): Promise; + + subscribeToGameUpdates?( + gameId: string, + callback: GameUpdateCallback, + ): () => void; + + subscribeToMarketPrices?( + tokenIds: string[], + callback: PriceUpdateCallback, + ): () => void; + + getConnectionStatus?(): ConnectionStatus; } diff --git a/app/components/UI/Predict/selectors/featureFlags/index.test.ts b/app/components/UI/Predict/selectors/featureFlags/index.test.ts index 06af9f0e4bdd..d1bff8d6e088 100644 --- a/app/components/UI/Predict/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Predict/selectors/featureFlags/index.test.ts @@ -1,4 +1,4 @@ -import { selectPredictEnabledFlag, selectPredictLiveNflEnabled } from '.'; +import { selectPredictEnabledFlag } from '.'; import mockedEngine from '../../../../../core/__mocks__/MockedEngine'; import { mockedState, @@ -32,7 +32,6 @@ describe('Predict Feature Flag Selectors', () => { beforeEach(() => { jest.clearAllMocks(); delete process.env.MM_PREDICT_ENABLED; - delete process.env.MM_PREDICT_LIVE_NFL_ENABLED; mockHasMinimumRequiredVersion = jest.spyOn( remoteFeatureFlagModule, 'hasMinimumRequiredVersion', @@ -42,7 +41,6 @@ describe('Predict Feature Flag Selectors', () => { afterEach(() => { delete process.env.MM_PREDICT_ENABLED; - delete process.env.MM_PREDICT_LIVE_NFL_ENABLED; mockHasMinimumRequiredVersion?.mockRestore(); }); @@ -195,174 +193,6 @@ describe('Predict Feature Flag Selectors', () => { }); }); - describe('selectPredictLiveNflEnabled', () => { - it('returns true for enabled version-gated flag with valid version', () => { - mockHasMinimumRequiredVersion.mockReturnValue(true); - const stateWithEnabledRemoteFlag = { - engine: { - backgroundState: { - RemoteFeatureFlagController: { - remoteFeatureFlags: { - predictLiveNflEnabled: { - enabled: true, - minimumVersion: '1.0.0', - }, - }, - cacheTimestamp: 0, - }, - }, - }, - }; - - const result = selectPredictLiveNflEnabled(stateWithEnabledRemoteFlag); - - expect(result).toBe(true); - }); - - describe('remote flag precedence', () => { - it('returns true when remote flag enabled overrides local flag false', () => { - mockHasMinimumRequiredVersion.mockReturnValue(true); - process.env.MM_PREDICT_LIVE_NFL_ENABLED = 'false'; - const stateWithEnabledRemoteFlag = { - engine: { - backgroundState: { - RemoteFeatureFlagController: { - remoteFeatureFlags: { - predictLiveNflEnabled: { - enabled: true, - minimumVersion: '1.0.0', - }, - }, - cacheTimestamp: 0, - }, - }, - }, - }; - - const result = selectPredictLiveNflEnabled(stateWithEnabledRemoteFlag); - - expect(result).toBe(true); - }); - - it('returns false when remote flag disabled overrides local flag true', () => { - mockHasMinimumRequiredVersion.mockReturnValue(true); - process.env.MM_PREDICT_LIVE_NFL_ENABLED = 'true'; - const stateWithDisabledRemoteFlag = { - engine: { - backgroundState: { - RemoteFeatureFlagController: { - remoteFeatureFlags: { - predictLiveNflEnabled: { - enabled: false, - minimumVersion: '1.0.0', - }, - }, - cacheTimestamp: 0, - }, - }, - }, - }; - - const result = selectPredictLiveNflEnabled(stateWithDisabledRemoteFlag); - - expect(result).toBe(false); - }); - - it('returns false when app version below minimum required version', () => { - mockHasMinimumRequiredVersion.mockReturnValue(false); - process.env.MM_PREDICT_LIVE_NFL_ENABLED = 'true'; - const stateWithVersionCheckFailure = { - engine: { - backgroundState: { - RemoteFeatureFlagController: { - remoteFeatureFlags: { - predictLiveNflEnabled: { - enabled: true, - minimumVersion: '99.0.0', - }, - }, - cacheTimestamp: 0, - }, - }, - }, - }; - - const result = selectPredictLiveNflEnabled( - stateWithVersionCheckFailure, - ); - - expect(result).toBe(false); - }); - }); - - describe('local flag fallback', () => { - it('falls back to local flag when remote flag is invalid', () => { - const stateWithInvalidRemoteFlag = { - engine: { - backgroundState: { - RemoteFeatureFlagController: { - remoteFeatureFlags: { - predictLiveNflEnabled: { - enabled: 'invalid', - minimumVersion: 123, - }, - }, - cacheTimestamp: 0, - }, - }, - }, - }; - - const result = selectPredictLiveNflEnabled(stateWithInvalidRemoteFlag); - - expect(result).toBe(false); - }); - - it('returns false from local flag when remote flag is null', () => { - process.env.MM_PREDICT_LIVE_NFL_ENABLED = 'false'; - const stateWithNullRemoteFlag = { - engine: { - backgroundState: { - RemoteFeatureFlagController: { - remoteFeatureFlags: { - predictLiveNflEnabled: null, - }, - cacheTimestamp: 0, - }, - }, - }, - }; - - const result = selectPredictLiveNflEnabled(stateWithNullRemoteFlag); - - expect(result).toBe(false); - }); - - it('falls back to local flag when remote feature flags are empty', () => { - const result = selectPredictLiveNflEnabled(mockedEmptyFlagsState); - - expect(result).toBe(false); - }); - - it('returns false from local flag when controller is undefined', () => { - process.env.MM_PREDICT_LIVE_NFL_ENABLED = 'false'; - const stateWithUndefinedController = { - engine: { - backgroundState: { - RemoteFeatureFlagController: undefined, - }, - }, - }; - - const result = selectPredictLiveNflEnabled( - stateWithUndefinedController, - ); - - expect(result).toBe(false); - }); - }); - }); - describe('predictTradingEnabled remote feature flag validation', () => { const validRemoteFlag: VersionGatedFeatureFlag = { enabled: true, diff --git a/app/components/UI/Predict/selectors/featureFlags/index.ts b/app/components/UI/Predict/selectors/featureFlags/index.ts index f07b060cbd8b..707b35dbb2a7 100644 --- a/app/components/UI/Predict/selectors/featureFlags/index.ts +++ b/app/components/UI/Predict/selectors/featureFlags/index.ts @@ -39,23 +39,3 @@ export const selectPredictGtmOnboardingModalEnabledFlag = createSelector( return validatedVersionGatedFeatureFlag(remoteFlag) ?? localFlag; }, ); - -/** - * Selector for Predict Live NFL feature enablement - * - * Uses version-gated feature flag `predictLiveNflEnabled` from remote config. - * Falls back to local environment variable MM_PREDICT_LIVE_NFL_ENABLED if remote flag - * is unavailable or invalid. - * - * @returns {boolean} True if feature is enabled and version requirement is met - */ -export const selectPredictLiveNflEnabled = createSelector( - selectRemoteFeatureFlags, - (remoteFeatureFlags) => { - const localFlag = process.env.MM_PREDICT_LIVE_NFL_ENABLED === 'true'; - const remoteFlag = - remoteFeatureFlags?.predictLiveNflEnabled as unknown as VersionGatedFeatureFlag; - - return validatedVersionGatedFeatureFlag(remoteFlag) ?? localFlag; - }, -); diff --git a/app/components/UI/Predict/types/flags.ts b/app/components/UI/Predict/types/flags.ts index 49fc4cc24862..4ff32c744b21 100644 --- a/app/components/UI/Predict/types/flags.ts +++ b/app/components/UI/Predict/types/flags.ts @@ -7,3 +7,8 @@ export interface PredictFeeCollection { providerFee: number; waiveList: string[]; } + +export interface PredictLiveSportsFlag { + enabled: boolean; + leagues: string[]; +} diff --git a/app/components/UI/Predict/types/index.ts b/app/components/UI/Predict/types/index.ts index 213ca7e74f3c..37f016c14648 100644 --- a/app/components/UI/Predict/types/index.ts +++ b/app/components/UI/Predict/types/index.ts @@ -128,15 +128,22 @@ export interface PredictSportTeam { alias: string; // Team alias (e.g., "Seahawks") } +// Parsed score data +export interface PredictGameScore { + away: number; + home: number; + raw: string; // Original "away-home" format (e.g., "21-14") +} + // Game data attached to market export interface PredictMarketGame { id: string; startTime: string; status: PredictGameStatus; league: PredictSportsLeague; - elapsed: string; // Game clock - period: string; // Current period (Q1, Q2, HT, Q3, Q4, OT, FT) - score: string; // "away-home" format (e.g., "21-14") + elapsed: string | null; // Game clock, null if not available + period: string | null; // Current period (Q1, Q2, HT, Q3, Q4, OT, FT), null if not available + score: PredictGameScore | null; // Parsed score with away/home values, null if not available homeTeam: PredictSportTeam; awayTeam: PredictSportTeam; turn?: string; // Team abbreviation with possession diff --git a/app/components/UI/Predict/utils/gameParser.test.ts b/app/components/UI/Predict/utils/gameParser.test.ts new file mode 100644 index 000000000000..7b18984e1760 --- /dev/null +++ b/app/components/UI/Predict/utils/gameParser.test.ts @@ -0,0 +1,494 @@ +import { + parseGameSlugTeams, + parseScore, + isLiveSportsEvent, + getEventLeague, + getGameStatus, + formatPeriodDisplay, + mapApiTeamToPredictTeam, + buildGameData, +} from './gameParser'; +import { + PolymarketApiEvent, + PolymarketApiTeam, +} from '../providers/polymarket/types'; +import { PredictSportTeam, PredictSportsLeague } from '../types'; + +const createMockApiTeam = ( + overrides: Partial = {}, +): PolymarketApiTeam => ({ + id: 'team-1', + name: 'Seattle Seahawks', + logo: 'https://example.com/sea.png', + abbreviation: 'SEA', + color: '#002244', + alias: 'Seahawks', + ...overrides, +}); + +const createMockEvent = ( + overrides: Partial = {}, +): PolymarketApiEvent => ({ + id: 'event-1', + slug: 'nfl-sea-den-2025-01-12', + title: 'Seattle Seahawks vs Denver Broncos', + description: 'NFL game', + icon: 'https://example.com/icon.png', + closed: false, + series: [], + markets: [], + tags: [ + { id: '1', label: 'NFL', slug: 'nfl' }, + { id: '2', label: 'Games', slug: 'games' }, + ], + liquidity: 10000, + volume: 20000, + ...overrides, +}); + +describe('gameParser', () => { + describe('getEventLeague', () => { + it('returns "nfl" for event with nfl tag, games tag, and valid slug', () => { + const event = createMockEvent(); + + const result = getEventLeague(event); + + expect(result).toBe('nfl'); + }); + + it('returns null when missing nfl tag', () => { + const event = createMockEvent({ + tags: [{ id: '2', label: 'Games', slug: 'games' }], + }); + + const result = getEventLeague(event); + + expect(result).toBeNull(); + }); + + it('returns null when missing games tag', () => { + const event = createMockEvent({ + tags: [{ id: '1', label: 'NFL', slug: 'nfl' }], + }); + + const result = getEventLeague(event); + + expect(result).toBeNull(); + }); + + it('returns null for invalid slug format', () => { + const event = createMockEvent({ + slug: 'some-other-market', + }); + + const result = getEventLeague(event); + + expect(result).toBeNull(); + }); + + it('returns null when tags is not an array', () => { + const event = createMockEvent({ + tags: undefined as unknown as [], + }); + + const result = getEventLeague(event); + + expect(result).toBeNull(); + }); + }); + + describe('isLiveSportsEvent', () => { + it('returns true when event league is in enabled leagues', () => { + const event = createMockEvent(); + + const result = isLiveSportsEvent(event, ['nfl']); + + expect(result).toBe(true); + }); + + it('returns false when event league is not in enabled leagues', () => { + const event = createMockEvent(); + + const result = isLiveSportsEvent(event, []); + + expect(result).toBe(false); + }); + + it('returns false when event is not a sports event', () => { + const event = createMockEvent({ + slug: 'some-other-market', + tags: [], + }); + + const result = isLiveSportsEvent(event, ['nfl']); + + expect(result).toBe(false); + }); + }); + + describe('parseGameSlugTeams', () => { + it('extracts team abbreviations from valid NFL slug', () => { + const result = parseGameSlugTeams('nfl-sea-den-2025-01-12', 'nfl'); + + expect(result).toEqual({ + awayAbbreviation: 'sea', + homeAbbreviation: 'den', + dateString: '2025-01-12', + }); + }); + + it('returns null for non-NFL slug', () => { + const result = parseGameSlugTeams('some-other-event', 'nfl'); + + expect(result).toBeNull(); + }); + + it('returns null for invalid date format', () => { + const result = parseGameSlugTeams('nfl-sea-den-01-12-2025', 'nfl'); + + expect(result).toBeNull(); + }); + }); + + describe('getGameStatus', () => { + it('returns "ended" when event.ended is true', () => { + const event = createMockEvent({ ended: true }); + + const result = getGameStatus(event); + + expect(result).toBe('ended'); + }); + + it('returns "ended" when event.closed is true', () => { + const event = createMockEvent({ closed: true }); + + const result = getGameStatus(event); + + expect(result).toBe('ended'); + }); + + it('returns "ended" when period is FT', () => { + const event = createMockEvent({ period: 'FT' }); + + const result = getGameStatus(event); + + expect(result).toBe('ended'); + }); + + it('returns "ended" when period is VFT', () => { + const event = createMockEvent({ period: 'VFT' }); + + const result = getGameStatus(event); + + expect(result).toBe('ended'); + }); + + it('returns "ongoing" when event.live is true', () => { + const event = createMockEvent({ live: true }); + + const result = getGameStatus(event); + + expect(result).toBe('ongoing'); + }); + + it('returns "ongoing" when score is non-zero', () => { + const event = createMockEvent({ score: '14-7' }); + + const result = getGameStatus(event); + + expect(result).toBe('ongoing'); + }); + + it('returns "ongoing" when elapsed has value', () => { + const event = createMockEvent({ elapsed: '12:34' }); + + const result = getGameStatus(event); + + expect(result).toBe('ongoing'); + }); + + it('returns "ongoing" when period is Q1', () => { + const event = createMockEvent({ period: 'Q1' }); + + const result = getGameStatus(event); + + expect(result).toBe('ongoing'); + }); + + it('returns "scheduled" for event with no game indicators', () => { + const event = createMockEvent(); + + const result = getGameStatus(event); + + expect(result).toBe('scheduled'); + }); + + it('returns "scheduled" when period is NS', () => { + const event = createMockEvent({ period: 'NS' }); + + const result = getGameStatus(event); + + expect(result).toBe('scheduled'); + }); + + it('returns "scheduled" when score is 0-0', () => { + const event = createMockEvent({ score: '0-0' }); + + const result = getGameStatus(event); + + expect(result).toBe('scheduled'); + }); + }); + + describe('formatPeriodDisplay', () => { + it('returns "Halftime" for HT', () => { + expect(formatPeriodDisplay('HT')).toBe('Halftime'); + }); + + it('returns "Overtime" for OT', () => { + expect(formatPeriodDisplay('OT')).toBe('Overtime'); + }); + + it('returns "Final" for FT', () => { + expect(formatPeriodDisplay('FT')).toBe('Final'); + }); + + it('returns "Final" for VFT', () => { + expect(formatPeriodDisplay('VFT')).toBe('Final'); + }); + + it('returns original period for Q1', () => { + expect(formatPeriodDisplay('Q1')).toBe('Q1'); + }); + + it('returns original period for Q4', () => { + expect(formatPeriodDisplay('Q4')).toBe('Q4'); + }); + + it('handles lowercase input', () => { + expect(formatPeriodDisplay('ht')).toBe('Halftime'); + }); + + it('handles whitespace', () => { + expect(formatPeriodDisplay(' HT ')).toBe('Halftime'); + }); + }); + + describe('mapApiTeamToPredictTeam', () => { + it('maps all fields from API team to domain team', () => { + const apiTeam = createMockApiTeam(); + + const result = mapApiTeamToPredictTeam(apiTeam); + + expect(result).toEqual({ + id: 'team-1', + name: 'Seattle Seahawks', + logo: 'https://example.com/sea.png', + abbreviation: 'SEA', + color: '#002244', + alias: 'Seahawks', + }); + }); + + it('preserves all API team properties', () => { + const apiTeam = createMockApiTeam({ + id: 'custom-id', + name: 'Custom Team', + logo: 'https://custom.com/logo.png', + abbreviation: 'CUS', + color: '#FFFFFF', + alias: 'Customs', + }); + + const result = mapApiTeamToPredictTeam(apiTeam); + + expect(result.id).toBe('custom-id'); + expect(result.name).toBe('Custom Team'); + expect(result.logo).toBe('https://custom.com/logo.png'); + expect(result.abbreviation).toBe('CUS'); + expect(result.color).toBe('#FFFFFF'); + expect(result.alias).toBe('Customs'); + }); + }); + + describe('buildGameData', () => { + const seaTeam: PredictSportTeam = { + id: 'team-sea', + name: 'Seattle Seahawks', + logo: 'https://example.com/sea.png', + abbreviation: 'SEA', + color: '#002244', + alias: 'Seahawks', + }; + + const denTeam: PredictSportTeam = { + id: 'team-den', + name: 'Denver Broncos', + logo: 'https://example.com/den.png', + abbreviation: 'DEN', + color: '#FB4F14', + alias: 'Broncos', + }; + + const teamLookup = ( + league: PredictSportsLeague, + abbr: string, + ): PredictSportTeam | undefined => { + if (league !== 'nfl') return undefined; + const teams: Record = { + sea: seaTeam, + den: denTeam, + }; + return teams[abbr.toLowerCase()]; + }; + + it('builds complete game data from event', () => { + const event = createMockEvent({ + gameId: 'game-123', + startTime: '2025-01-12T18:00:00Z', + score: '14-7', + elapsed: '08:42', + period: 'Q2', + live: true, + }); + + const result = buildGameData(event, 'nfl', teamLookup); + + expect(result).toEqual({ + id: 'game-123', + startTime: '2025-01-12T18:00:00Z', + status: 'ongoing', + league: 'nfl', + elapsed: '08:42', + period: 'Q2', + score: { away: 14, home: 7, raw: '14-7' }, + homeTeam: denTeam, + awayTeam: seaTeam, + }); + }); + + it('returns null when gameId is missing', () => { + const event = createMockEvent({ gameId: undefined }); + + const result = buildGameData(event, 'nfl', teamLookup); + + expect(result).toBeNull(); + }); + + it('returns null when slug cannot be parsed', () => { + const event = createMockEvent({ + gameId: 'game-123', + slug: 'invalid-slug', + }); + + const result = buildGameData(event, 'nfl', teamLookup); + + expect(result).toBeNull(); + }); + + it('returns null when away team not found', () => { + const event = createMockEvent({ + gameId: 'game-123', + slug: 'nfl-xyz-den-2025-01-12', + }); + + const result = buildGameData(event, 'nfl', teamLookup); + + expect(result).toBeNull(); + }); + + it('returns null when home team not found', () => { + const event = createMockEvent({ + gameId: 'game-123', + slug: 'nfl-sea-xyz-2025-01-12', + }); + + const result = buildGameData(event, 'nfl', teamLookup); + + expect(result).toBeNull(); + }); + + it('uses endDate as fallback for startTime', () => { + const event = createMockEvent({ + gameId: 'game-123', + startTime: undefined, + endDate: '2025-01-12T21:00:00Z', + }); + + const result = buildGameData(event, 'nfl', teamLookup); + + expect(result?.startTime).toBe('2025-01-12T21:00:00Z'); + }); + + it('uses date from slug as last resort for startTime', () => { + const event = createMockEvent({ + gameId: 'game-123', + startTime: undefined, + endDate: undefined, + }); + + const result = buildGameData(event, 'nfl', teamLookup); + + expect(result?.startTime).toBe('2025-01-12T00:00:00Z'); + }); + + it('returns null for missing game fields', () => { + const event = createMockEvent({ + gameId: 'game-123', + score: undefined, + elapsed: undefined, + period: undefined, + }); + + const result = buildGameData(event, 'nfl', teamLookup); + + expect(result?.score).toBeNull(); + expect(result?.elapsed).toBeNull(); + expect(result?.period).toBeNull(); + }); + }); + + describe('parseScore', () => { + it('parses valid score string into away and home values', () => { + const result = parseScore('14-7'); + + expect(result).toEqual({ away: 14, home: 7, raw: '14-7' }); + }); + + it('returns null for undefined score', () => { + const result = parseScore(undefined); + + expect(result).toBeNull(); + }); + + it('returns null for empty string', () => { + const result = parseScore(''); + + expect(result).toBeNull(); + }); + + it('returns null for 0-0 score', () => { + const result = parseScore('0-0'); + + expect(result).toBeNull(); + }); + + it('returns null for invalid format without hyphen', () => { + const result = parseScore('147'); + + expect(result).toBeNull(); + }); + + it('returns null for non-numeric values', () => { + const result = parseScore('abc-def'); + + expect(result).toBeNull(); + }); + + it('parses high scores correctly', () => { + const result = parseScore('42-35'); + + expect(result).toEqual({ away: 42, home: 35, raw: '42-35' }); + }); + }); +}); diff --git a/app/components/UI/Predict/utils/gameParser.ts b/app/components/UI/Predict/utils/gameParser.ts new file mode 100644 index 000000000000..1111687a66ea --- /dev/null +++ b/app/components/UI/Predict/utils/gameParser.ts @@ -0,0 +1,187 @@ +import { + PredictGameScore, + PredictGameStatus, + PredictMarketGame, + PredictSportTeam, + PredictSportsLeague, +} from '../types'; +import { + PolymarketApiEvent, + PolymarketApiTeam, +} from '../providers/polymarket/types'; + +const NFL_SLUG_PATTERN = /^nfl-([a-z]+)-([a-z]+)-(\d{4}-\d{2}-\d{2})$/; + +const LEAGUE_SLUG_PATTERNS: Record = { + nfl: NFL_SLUG_PATTERN, +}; + +export type TeamLookup = ( + league: PredictSportsLeague, + abbreviation: string, +) => PredictSportTeam | undefined; + +export interface ParsedGameSlug { + awayAbbreviation: string; + homeAbbreviation: string; + dateString: string; +} + +export function getEventLeague( + event: PolymarketApiEvent, +): PredictSportsLeague | null { + const tags = Array.isArray(event.tags) ? event.tags : []; + const hasGamesTag = tags.some((tag) => tag.slug === 'games'); + if (!hasGamesTag) { + return null; + } + + const leagues = Object.keys(LEAGUE_SLUG_PATTERNS) as PredictSportsLeague[]; + for (const league of leagues) { + const hasLeagueTag = tags.some((tag) => tag.slug === league); + const pattern = LEAGUE_SLUG_PATTERNS[league]; + const hasValidSlug = pattern.test(event.slug); + if (hasLeagueTag && hasValidSlug) { + return league; + } + } + + return null; +} + +export function isLiveSportsEvent( + event: PolymarketApiEvent, + enabledLeagues: PredictSportsLeague[], +): boolean { + const league = getEventLeague(event); + return league !== null && enabledLeagues.includes(league); +} + +export function parseGameSlugTeams( + slug: string, + league: PredictSportsLeague, +): ParsedGameSlug | null { + const pattern = LEAGUE_SLUG_PATTERNS[league]; + if (!pattern) { + return null; + } + const match = slug.match(pattern); + if (!match) { + return null; + } + return { + awayAbbreviation: match[1], + homeAbbreviation: match[2], + dateString: match[3], + }; +} + +const NOT_STARTED_PERIODS = ['NS', 'NOT_STARTED', 'PRE', 'PREGAME', '']; +const ENDED_PERIODS = ['FT', 'VFT']; + +export function formatPeriodDisplay(period: string): string { + const normalized = period.toUpperCase().trim(); + + switch (normalized) { + case 'HT': + return 'Halftime'; + case 'OT': + return 'Overtime'; + case 'FT': + case 'VFT': + return 'Final'; + default: + return period; + } +} + +export function getGameStatus(event: PolymarketApiEvent): PredictGameStatus { + const period = (event.period ?? '').toUpperCase(); + + if (event.ended || event.closed || ENDED_PERIODS.includes(period)) { + return 'ended'; + } + + if (event.live) { + return 'ongoing'; + } + + const isNotStartedPeriod = NOT_STARTED_PERIODS.includes(period); + const hasScore = event.score && event.score !== '0-0' && event.score !== ''; + const hasElapsed = event.elapsed && event.elapsed !== ''; + const hasActivePeriod = event.period && !isNotStartedPeriod; + + if (hasScore || hasElapsed || hasActivePeriod) { + return 'ongoing'; + } + + return 'scheduled'; +} + +export function mapApiTeamToPredictTeam( + apiTeam: PolymarketApiTeam, +): PredictSportTeam { + return { + id: apiTeam.id, + name: apiTeam.name, + logo: apiTeam.logo, + abbreviation: apiTeam.abbreviation, + color: apiTeam.color, + alias: apiTeam.alias, + }; +} + +export function parseScore(scoreString?: string): PredictGameScore | null { + if (!scoreString || scoreString === '0-0') { + return null; + } + + const parts = scoreString.split('-'); + if (parts.length !== 2) { + return null; + } + + const away = parseInt(parts[0], 10); + const home = parseInt(parts[1], 10); + + if (isNaN(away) || isNaN(home)) { + return null; + } + + return { away, home, raw: scoreString }; +} + +export function buildGameData( + event: PolymarketApiEvent, + league: PredictSportsLeague, + teamLookup: TeamLookup, +): PredictMarketGame | null { + if (!event.gameId) { + return null; + } + + const parsedSlug = parseGameSlugTeams(event.slug, league); + if (!parsedSlug) { + return null; + } + + const awayTeam = teamLookup(league, parsedSlug.awayAbbreviation); + const homeTeam = teamLookup(league, parsedSlug.homeAbbreviation); + + if (!awayTeam || !homeTeam) { + return null; + } + + return { + id: event.gameId, + startTime: + event.startTime ?? event.endDate ?? `${parsedSlug.dateString}T00:00:00Z`, + status: getGameStatus(event), + league, + elapsed: event.elapsed || null, + period: event.period || null, + score: parseScore(event.score), + homeTeam, + awayTeam, + }; +} diff --git a/app/components/UI/ProtectYourWalletModal/index.test.tsx b/app/components/UI/ProtectYourWalletModal/index.test.tsx index 72ba95349f16..31863f4707dc 100644 --- a/app/components/UI/ProtectYourWalletModal/index.test.tsx +++ b/app/components/UI/ProtectYourWalletModal/index.test.tsx @@ -10,18 +10,55 @@ import { ProtectWalletModalSelectorsIDs } from '../../../../e2e/selectors/Onboar const mockMetricsIsEnabled = jest.fn().mockReturnValue(true); const mockTrackEvent = jest.fn(); -jest.mock('../../../core/Analytics/MetaMetrics', () => ({ - getInstance: () => ({ - isEnabled: mockMetricsIsEnabled, +const mockCreateEventBuilder = jest.fn().mockImplementation(() => ({ + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({ + name: 'Wallet Security Reminder Engaged', + properties: { source: 'Modal', wallet_protection_required: false }, + saveDataRecording: true, + sensitiveProperties: {}, + }), +})); + +// Mock whenEngineReady to prevent Engine access after Jest teardown +jest.mock('../../../core/Analytics/whenEngineReady', () => ({ + whenEngineReady: jest.fn().mockResolvedValue(undefined), +})); + +// Mock analytics module +jest.mock('../../../util/analytics/analytics', () => ({ + analytics: { + isEnabled: jest.fn(() => false), + trackEvent: jest.fn(), + optIn: jest.fn().mockResolvedValue(undefined), + optOut: jest.fn().mockResolvedValue(undefined), + getAnalyticsId: jest.fn().mockResolvedValue('test-analytics-id'), + identify: jest.fn(), + trackView: jest.fn(), + isOptedIn: jest.fn().mockResolvedValue(false), + }, +})); + +// Mock useMetrics hook which is used by withMetricsAwareness HOC +jest.mock('../../../components/hooks/useMetrics', () => ({ + useMetrics: () => ({ trackEvent: mockTrackEvent, - createEventBuilder: jest.fn().mockReturnValue({ - addProperties: jest.fn().mockReturnValue({ - build: jest.fn().mockReturnValue({ - name: 'Test Event', - }), - }), - }), + createEventBuilder: mockCreateEventBuilder, + isEnabled: mockMetricsIsEnabled, }), + withMetricsAwareness: + (Component: React.ComponentType) => (props: Record) => ( + )} + /> + ), })); const mockStore = configureMockStore(); @@ -46,9 +83,6 @@ interface ProtectYourWalletModalProps { navigation?: { navigate: jest.Mock; }; - metrics: { - isEnabled: jest.Mock; - }; } const mockNavigation = { @@ -57,12 +91,12 @@ const mockNavigation = { const defaultProps: ProtectYourWalletModalProps = { navigation: mockNavigation, - metrics: { - isEnabled: mockMetricsIsEnabled, - }, }; describe('ProtectYourWalletModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); it('render matches snapshot', () => { const { toJSON } = render( @@ -150,7 +184,7 @@ describe('ProtectYourWalletModal', () => { }); }); - it('render confirm button and navigate to set password flow', async () => { + it('navigates to set password flow when cancel button is pressed', async () => { const { getByTestId } = render( @@ -159,13 +193,13 @@ describe('ProtectYourWalletModal', () => { , ); - const confirmButton = getByTestId( - ProtectWalletModalSelectorsIDs.CONFIRM_BUTTON, + const cancelButton = getByTestId( + ProtectWalletModalSelectorsIDs.CANCEL_BUTTON, ); - expect(confirmButton).toBeOnTheScreen(); + expect(cancelButton).toBeOnTheScreen(); await act(async () => { - fireEvent.press(confirmButton); + fireEvent.press(cancelButton); }); await waitFor(() => { diff --git a/app/components/UI/Ramp/hooks/useAnalytics.test.ts b/app/components/UI/Ramp/hooks/useAnalytics.test.ts index 10b9fda8c90a..cc8a154eb6b2 100644 --- a/app/components/UI/Ramp/hooks/useAnalytics.test.ts +++ b/app/components/UI/Ramp/hooks/useAnalytics.test.ts @@ -8,6 +8,7 @@ jest.mock('../../../../core/Analytics', () => ({ MetaMetrics: { getInstance: jest.fn().mockReturnValue({ trackEvent: jest.fn(), + updateDataRecordingFlag: jest.fn(), }), }, })); diff --git a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep.test.tsx b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep.test.tsx index d0f2fad95680..81c10183f099 100644 --- a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep.test.tsx +++ b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingStep.test.tsx @@ -85,6 +85,20 @@ jest.mock('../../../hooks/useValidateReferralCode', () => ({ useValidateReferralCode: () => mockUseValidateReferralCode, })); +// Mock useMetrics hook +const mockBuilder = { + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({}), +}; + +jest.mock('../../../../../../components/hooks/useMetrics', () => ({ + useMetrics: () => ({ + trackEvent: jest.fn(), + createEventBuilder: jest.fn(() => mockBuilder), + }), + MetaMetricsEvents: {}, +})); + // Mock Linking and PanResponder jest.mock('react-native', () => { const RN = jest.requireActual('react-native'); diff --git a/app/components/UI/SeedphraseModal/__snapshots__/index.test.tsx.snap b/app/components/UI/SeedphraseModal/__snapshots__/index.test.tsx.snap index 02b3bdfe4c66..6f2df2c3a38f 100644 --- a/app/components/UI/SeedphraseModal/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/SeedphraseModal/__snapshots__/index.test.tsx.snap @@ -7,42 +7,167 @@ exports[`SeedphraseModal renders matches snapshot 1`] = ` - + + + - What’s a Secret Recovery Phrase? - + + + What’s a Secret Recovery Phrase? + + + + + + + + + + + + @@ -50,7 +175,7 @@ exports[`SeedphraseModal renders matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#686e7d", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -71,7 +196,7 @@ exports[`SeedphraseModal renders matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#686e7d", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -84,7 +209,7 @@ exports[`SeedphraseModal renders matches snapshot 1`] = ` @@ -103,10 +228,10 @@ exports[`SeedphraseModal renders matches snapshot 1`] = ` { "color": "#121314", "fontFamily": "Geist-Regular", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, - "marginRight": 10, + "marginRight": 12, } } > @@ -116,7 +241,7 @@ exports[`SeedphraseModal renders matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#686e7d", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -142,10 +267,10 @@ exports[`SeedphraseModal renders matches snapshot 1`] = ` { "color": "#121314", "fontFamily": "Geist-Regular", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, - "marginRight": 10, + "marginRight": 12, } } > @@ -155,7 +280,7 @@ exports[`SeedphraseModal renders matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#686e7d", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -181,10 +306,10 @@ exports[`SeedphraseModal renders matches snapshot 1`] = ` { "color": "#121314", "fontFamily": "Geist-Regular", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, - "marginRight": 10, + "marginRight": 12, } } > @@ -194,7 +319,7 @@ exports[`SeedphraseModal renders matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#686e7d", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -211,7 +336,9 @@ exports[`SeedphraseModal renders matches snapshot 1`] = ` StyleSheet.create({ modalContainer: { - padding: 16, flexDirection: 'column', - rowGap: 16, - justifyContent: 'center', - alignItems: 'center', - }, - titleContainer: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', }, explanationText: { - fontSize: 14, marginTop: 16, - textAlign: 'left', - ...fontStyles.normal, - color: colors.text.default, - lineHeight: 20, }, list: { marginTop: 24, @@ -46,22 +33,20 @@ const createStyles = (colors) => justifyContent: 'flex-start', }, bullet: { - fontSize: 20, - marginRight: 10, - }, - itemText: { - fontSize: 16, + marginRight: 12, }, listContainer: { - marginLeft: 10, - }, - explanationTextContainer: { - flexDirection: 'column', + marginLeft: 12, }, buttonContainer: { - marginTop: 16, + paddingTop: 24, + paddingHorizontal: 16, + paddingBottom: Platform.OS === 'android' ? 0 : 16, width: '100%', }, + contentContainer: { + paddingHorizontal: 16, + }, }); const SeedphraseModal = () => { @@ -83,24 +68,26 @@ const SeedphraseModal = () => { return ( - - - {strings('account_backup_step_1.what_is_seedphrase_title')} - - - - + + + {strings('account_backup_step_1.what_is_seedphrase_text_1')} - + {strings('account_backup_step_1.what_is_seedphrase_text_4')} {seedPhrasePoints.map((point) => ( {'\u2022'} - + {point} @@ -110,7 +97,7 @@ const SeedphraseModal = () => {