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