diff --git a/.github/workflows/flaky-test-report.yml b/.github/workflows/flaky-test-report.yml
index ee604b335ac..66281d23ca7 100644
--- a/.github/workflows/flaky-test-report.yml
+++ b/.github/workflows/flaky-test-report.yml
@@ -4,7 +4,23 @@ on:
schedule:
# Once every day from Monday-Friday at 08:00 UTC
- cron: 0 8 * * 1-5
+ # 10:00 UTC (Europe morning), Monday–Friday — Playwright test health report
+ - cron: '0 10 * * 1-5'
+ # 15:00 UTC (US morning), Monday–Friday — Playwright test health report
+ - cron: '0 15 * * 1-5'
workflow_dispatch:
+ inputs:
+ job:
+ description: Which test report to generate
+ type: choice
+ options:
+ - detox
+ - appium
+ default: detox
+ lookback_days:
+ description: Number of days of main CI runs to scan (Appium report only)
+ type: string
+ default: '1'
permissions:
contents: read
@@ -12,6 +28,9 @@ permissions:
jobs:
flaky-test-report:
+ if: >-
+ (github.event_name == 'schedule' && github.event.schedule == '0 8 * * 1-5') ||
+ (github.event_name == 'workflow_dispatch' && inputs.job == 'detox')
runs-on: ubuntu-latest
steps:
- name: Generate flaky test report
@@ -21,3 +40,19 @@ jobs:
workflow-id: ci.yml
github-token: ${{ secrets.GITHUB_TOKEN }}
slack-webhook-flaky-tests: ${{ secrets.SLACK_WEBHOOK_FLAKY_TESTS }}
+
+ playwright-test-health-report:
+ if: >-
+ (github.event_name == 'schedule' && (github.event.schedule == '0 10 * * 1-5' || github.event.schedule == '0 15 * * 1-5')) ||
+ (github.event_name == 'workflow_dispatch' && inputs.job == 'appium')
+ runs-on: ubuntu-latest
+ steps:
+ - name: Generate Playwright test health report
+ uses: MetaMask/github-tools/.github/actions/playwright-test-health-report@4a2ddd75e9a8182261f0f07fbfc332cc856c89a0
+ with:
+ repository: ${{ github.event.repository.name }}
+ workflow-ids: ci.yml
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ slack-webhook: ${{ secrets.SLACK_WEBHOOK_PLAYWRIGHT_HEALTH_REPORT }}
+ lookback-days: ${{ github.event_name == 'workflow_dispatch' && inputs.lookback_days || '1' }}
+ report-title: Playwright Test Health Report (Mobile Appium)
diff --git a/.js.env.example b/.js.env.example
index e0e4990f340..79ee93a4ed1 100644
--- a/.js.env.example
+++ b/.js.env.example
@@ -145,9 +145,11 @@ export MM_MUSD_CONVERSION_MIN_ASSET_BALANCE_REQUIRED="0.01"
# Money Hub
export MM_MONEY_HUB_ENABLED="false"
+# Money
export MM_MONEY_PAYMENT_TOKENS_BLOCKLIST=""
export MM_MONEY_DEPOSIT_NO_FEE_TOKENS=""
export MM_MONEY_DEPOSIT_MIN_ASSET_BALANCE="0.01"
+export MM_MONEY_FIRST_TIME_DEPOSIT_ANIMATION_ENABLED="true"
# Activates remote feature flag override mode.
# Remote feature flag values won't be updated,
diff --git a/app/animations/money_account_first_time_deposit_with_parallax.riv b/app/animations/money_account_first_time_deposit_with_parallax.riv
new file mode 100644
index 00000000000..23c12a800c5
Binary files /dev/null and b/app/animations/money_account_first_time_deposit_with_parallax.riv differ
diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js
index 2ebd5812e12..bee7ae3ba07 100644
--- a/app/components/Nav/Main/MainNavigator.js
+++ b/app/components/Nav/Main/MainNavigator.js
@@ -127,6 +127,7 @@ import {
} from '../../UI/Money/routes';
import MoneyOnboardingView from '../../UI/Money/Views/MoneyOnboardingView';
import MoneyPotentialEarningsView from '../../UI/Money/Views/MoneyPotentialEarningsView';
+import MoneyFirstTimeDepositView from '../../UI/Money/Views/MoneyFirstTimeDepositView';
import { selectMoneyEnableMoneyAccountFlag } from '../../UI/Money/selectors/featureFlags';
import { selectIsMoneyAccountGeoEligible } from '../../UI/Money/selectors/eligibility';
import { BridgeTransactionDetails } from '../../UI/Bridge/components/TransactionDetails/TransactionDetails';
@@ -1221,6 +1222,15 @@ const MainNavigator = () => {
component={MoneyOnboardingView}
options={{ headerShown: false, ...fadeNativeOptions }}
/>
+
({
mockSelectMarketInsightsPerpsEnabled(state),
}));
+jest.mock('../../UI/Money/Views/MoneyFirstTimeDepositView', () => ({
+ __esModule: true,
+ default: () => null,
+}));
+
jest.mock('../../hooks/useAnalytics/useAnalytics');
jest.mock('../../UI/Money/components/MoneyTabPressTracker', () => ({
diff --git a/app/components/UI/Bridge/_mocks_/initialState.ts b/app/components/UI/Bridge/_mocks_/initialState.ts
index d93addab3e5..9492e6d277a 100644
--- a/app/components/UI/Bridge/_mocks_/initialState.ts
+++ b/app/components/UI/Bridge/_mocks_/initialState.ts
@@ -15,12 +15,9 @@ import { ethers } from 'ethers';
import { formatChainIdToCaip, StatusTypes } from '@metamask/bridge-controller';
import { AccountTreeControllerState } from '@metamask/account-tree-controller';
-jest.mock(
- '../../../../core/redux/slices/bridge/utils/hasMinimumRequiredVersion',
- () => ({
- hasMinimumRequiredVersion: jest.fn().mockReturnValue(true),
- }),
-);
+jest.mock('../../../../util/remoteFeatureFlag', () => ({
+ hasMinimumRequiredVersion: jest.fn().mockReturnValue(true),
+}));
export const ethChainId = '0x1' as Hex;
export const optimismChainId = '0xa' as Hex;
diff --git a/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx b/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx
index f5db00dd0bd..fe9516606b4 100644
--- a/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx
+++ b/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx
@@ -84,12 +84,9 @@ jest.mock('../../../../../hooks', () => ({
// Mock hasMinimumRequiredVersion so that selectBridgeFeatureFlags does not call
// compareVersions (which requires a real app version string unavailable in tests).
-jest.mock(
- '../../../../../core/redux/slices/bridge/utils/hasMinimumRequiredVersion',
- () => ({
- hasMinimumRequiredVersion: jest.fn().mockReturnValue(true),
- }),
-);
+jest.mock('../../../../../util/remoteFeatureFlag', () => ({
+ hasMinimumRequiredVersion: jest.fn().mockReturnValue(true),
+}));
// Mock navigation
const mockNavigate = jest.fn();
diff --git a/app/components/UI/Bridge/components/TokenWarningModal/TokenWarningModal.test.tsx b/app/components/UI/Bridge/components/TokenWarningModal/TokenWarningModal.test.tsx
index e58d41c394b..bbe0f7146e0 100644
--- a/app/components/UI/Bridge/components/TokenWarningModal/TokenWarningModal.test.tsx
+++ b/app/components/UI/Bridge/components/TokenWarningModal/TokenWarningModal.test.tsx
@@ -51,12 +51,9 @@ jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({ replace: mockReplace }),
}));
-jest.mock(
- '../../../../../core/redux/slices/bridge/utils/hasMinimumRequiredVersion',
- () => ({
- hasMinimumRequiredVersion: jest.fn().mockReturnValue(true),
- }),
-);
+jest.mock('../../../../../util/remoteFeatureFlag', () => ({
+ hasMinimumRequiredVersion: jest.fn().mockReturnValue(true),
+}));
import { useParams } from '../../../../../util/navigation/navUtils';
import { useLatestBalance } from '../../hooks/useLatestBalance';
diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteData/BridgeQuoteDataContext.test.tsx b/app/components/UI/Bridge/hooks/useBridgeQuoteData/BridgeQuoteDataContext.test.tsx
index a8b5ade2500..f16d2279c1c 100644
--- a/app/components/UI/Bridge/hooks/useBridgeQuoteData/BridgeQuoteDataContext.test.tsx
+++ b/app/components/UI/Bridge/hooks/useBridgeQuoteData/BridgeQuoteDataContext.test.tsx
@@ -25,12 +25,9 @@ jest.mock('../../utils/quoteUtils', () => ({
shouldRefreshQuote: jest.fn(),
}));
-jest.mock(
- '../../../../../core/redux/slices/bridge/utils/hasMinimumRequiredVersion',
- () => ({
- hasMinimumRequiredVersion: jest.fn(() => true),
- }),
-);
+jest.mock('../../../../../util/remoteFeatureFlag', () => ({
+ hasMinimumRequiredVersion: jest.fn(() => true),
+}));
jest.mock('@metamask/bridge-controller', () => {
const actual = jest.requireActual('@metamask/bridge-controller');
diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteEvents/useBridgeQuoteEvents.test.tsx b/app/components/UI/Bridge/hooks/useBridgeQuoteEvents/useBridgeQuoteEvents.test.tsx
index f3ef369cd01..3d6d571f753 100644
--- a/app/components/UI/Bridge/hooks/useBridgeQuoteEvents/useBridgeQuoteEvents.test.tsx
+++ b/app/components/UI/Bridge/hooks/useBridgeQuoteEvents/useBridgeQuoteEvents.test.tsx
@@ -14,12 +14,9 @@ jest.mock('../../../../../core/Engine', () => ({
},
}));
-jest.mock(
- '../../../../../core/redux/slices/bridge/utils/hasMinimumRequiredVersion',
- () => ({
- hasMinimumRequiredVersion: jest.fn().mockReturnValue(true),
- }),
-);
+jest.mock('../../../../../util/remoteFeatureFlag', () => ({
+ hasMinimumRequiredVersion: jest.fn().mockReturnValue(true),
+}));
describe('useBridgeQuoteEvents', () => {
beforeEach(() => {
diff --git a/app/components/UI/Bridge/hooks/useInitialBridgeTokens.test.ts b/app/components/UI/Bridge/hooks/useInitialBridgeTokens.test.ts
index 20c5721e52d..fcffaf113d5 100644
--- a/app/components/UI/Bridge/hooks/useInitialBridgeTokens.test.ts
+++ b/app/components/UI/Bridge/hooks/useInitialBridgeTokens.test.ts
@@ -19,12 +19,9 @@ jest.mock('../../../../core/Engine', () => ({
}));
const mockHasMinimumRequiredVersion = jest.fn();
-jest.mock(
- '../../../../core/redux/slices/bridge/utils/hasMinimumRequiredVersion',
- () => ({
- hasMinimumRequiredVersion: () => mockHasMinimumRequiredVersion(),
- }),
-);
+jest.mock('../../../../util/remoteFeatureFlag', () => ({
+ hasMinimumRequiredVersion: () => mockHasMinimumRequiredVersion(),
+}));
const mockPopularTokens = [
createMockPopularToken({
diff --git a/app/components/UI/Card/Views/Cashback/Cashback.test.tsx b/app/components/UI/Card/Views/Cashback/Cashback.test.tsx
index 922310e5349..a8c0ce172d0 100644
--- a/app/components/UI/Card/Views/Cashback/Cashback.test.tsx
+++ b/app/components/UI/Card/Views/Cashback/Cashback.test.tsx
@@ -10,6 +10,10 @@ const mockEventBuilder = {
build: jest.fn().mockReturnValue({ event: 'built' }),
};
+jest.mock('react-native-device-info', () => ({
+ getVersion: jest.fn(() => '99.0.0'),
+}));
+
jest.mock('../../../../../component-library/components/Toast', () => {
const React = jest.requireActual('react');
const ToastContext = React.createContext({ toastRef: null });
@@ -36,6 +40,9 @@ jest.mock('../../util/metrics', () => ({
CardActions: {
CASHBACK_BUTTON: 'CASHBACK_BUTTON',
},
+ CardEntryPoint: {
+ CASHBACK: 'CASHBACK',
+ },
}));
jest.mock('@react-navigation/native', () => {
@@ -78,6 +85,12 @@ jest.mock('../../../../../../locales/i18n', () => ({
'You need at least one approved funding source on Linea before redeeming cashback.',
'card.cashback_screen.funding_required.confirm_button_label':
'Set up funding',
+ 'card.cashback_screen.money_account_required.title':
+ 'Set up Money Account',
+ 'card.cashback_screen.money_account_required.description':
+ 'You need a linked Money Account before redeeming cashback.',
+ 'card.cashback_screen.money_account_required.confirm_button_label':
+ 'Set up Money Account',
};
return translations[key] ?? key;
},
@@ -86,6 +99,38 @@ jest.mock('../../../../../../locales/i18n', () => ({
const mockWithdraw = jest.fn();
const mockFetchEstimation = jest.fn().mockResolvedValue(undefined);
const mockResetWithdraw = jest.fn();
+const mockStartLinkFlow = jest.fn();
+
+let mockLinkageReturn = {
+ startLinkFlow: mockStartLinkFlow,
+ canLink: true,
+};
+
+jest.mock('../../hooks/useMoneyAccountCardLinkage', () => ({
+ useMoneyAccountCardLinkage: jest.fn(() => mockLinkageReturn),
+}));
+
+const mockSelectCardHasApprovedLineaFunding = jest.fn();
+const mockSelectCardHomeDataStatus = jest.fn();
+const mockSelectCardLineaUsdcToken = jest.fn();
+const mockSelectIsCardResidencyBlocked = jest.fn();
+const mockSelectIsMoneyAccountDelegatedForCard = jest.fn();
+const mockSelectMoneyEnableMoneyAccountFlag = jest.fn();
+
+jest.mock('../../../../../selectors/cardController', () => ({
+ selectCardHasApprovedLineaFunding: () =>
+ mockSelectCardHasApprovedLineaFunding(),
+ selectCardHomeDataStatus: () => mockSelectCardHomeDataStatus(),
+ selectCardLineaUsdcToken: () => mockSelectCardLineaUsdcToken(),
+ selectIsCardResidencyBlocked: () => mockSelectIsCardResidencyBlocked(),
+ selectIsMoneyAccountDelegatedForCard: () =>
+ mockSelectIsMoneyAccountDelegatedForCard(),
+}));
+
+jest.mock('../../../Money/selectors/featureFlags', () => ({
+ selectMoneyEnableMoneyAccountFlag: () =>
+ mockSelectMoneyEnableMoneyAccountFlag(),
+}));
let mockHookReturn = {
cashbackWallet: null as {
@@ -124,7 +169,8 @@ import { ToastContext } from '../../../../../component-library/components/Toast'
import Cashback from './Cashback';
import { CashbackSelectors } from './Cashback.testIds';
import Routes from '../../../../../constants/navigation/Routes';
-import { FundingAssetStatus } from '../../../../../core/Engine/controllers/card-controller/provider-types';
+import { backgroundState } from '../../../../../util/test/initial-root-state';
+import { CASHBACK_MONEY_ACCOUNT_ORIGIN } from '../../hooks/useCardPostAuthRedirect';
const mockToastRef = {
current: {
@@ -133,48 +179,12 @@ const mockToastRef = {
},
};
-const mockLineaUsdcFundingAsset = {
+const MOCK_LINEA_USDC_TOKEN = {
symbol: 'USDC',
name: 'USD Coin',
address: '0xusdc000000000000000000000000000000000001',
- walletAddress: '0xwallet000000000000000000000000000000000002',
decimals: 6,
- chainId: 'eip155:59144',
- spendableBalance: '10',
- spendingCap: '100',
- priority: 1,
- status: FundingAssetStatus.Active,
-};
-
-const mockLineaUsdcDelegationSettings = {
- networks: [
- {
- network: 'linea',
- environment: 'production',
- chainId: '59144',
- delegationContract: '0xdeleg000000000000000000000000000000000004',
- tokens: {
- USDC: {
- address: '0xusdc000000000000000000000000000000000001',
- symbol: 'USDC',
- decimals: 6,
- },
- },
- },
- ],
- count: 1,
- _links: { self: '/v1/delegation/chain/config' },
-};
-
-const mockCardHomeData = {
- primaryFundingAsset: mockLineaUsdcFundingAsset,
- fundingAssets: [mockLineaUsdcFundingAsset],
- availableFundingAssets: [mockLineaUsdcFundingAsset],
- card: null,
- account: null,
- alerts: [],
- actions: [],
- delegationSettings: mockLineaUsdcDelegationSettings,
+ caipChainId: 'eip155:59144',
};
const CashbackWithToast = () => (
@@ -183,36 +193,47 @@ const CashbackWithToast = () => (
);
-function render(cardControllerOverrides = {}) {
+interface SelectorOverrides {
+ hasApprovedLineaFunding?: boolean;
+ cardHomeDataStatus?: string;
+ lineaUsdcToken?: unknown;
+ residencyBlocked?: boolean;
+ moneyAccountDelegated?: boolean;
+ moneyAccountEnabled?: boolean;
+}
+
+function render(overrides: SelectorOverrides = {}) {
+ if (overrides.hasApprovedLineaFunding !== undefined) {
+ mockSelectCardHasApprovedLineaFunding.mockReturnValue(
+ overrides.hasApprovedLineaFunding,
+ );
+ }
+ if (overrides.cardHomeDataStatus !== undefined) {
+ mockSelectCardHomeDataStatus.mockReturnValue(overrides.cardHomeDataStatus);
+ }
+ if (overrides.lineaUsdcToken !== undefined) {
+ mockSelectCardLineaUsdcToken.mockReturnValue(overrides.lineaUsdcToken);
+ }
+ if (overrides.residencyBlocked !== undefined) {
+ mockSelectIsCardResidencyBlocked.mockReturnValue(
+ overrides.residencyBlocked,
+ );
+ }
+ if (overrides.moneyAccountDelegated !== undefined) {
+ mockSelectIsMoneyAccountDelegatedForCard.mockReturnValue(
+ overrides.moneyAccountDelegated,
+ );
+ }
+ if (overrides.moneyAccountEnabled !== undefined) {
+ mockSelectMoneyEnableMoneyAccountFlag.mockReturnValue(
+ overrides.moneyAccountEnabled,
+ );
+ }
+
return renderScreen(
CashbackWithToast,
- {
- name: 'Cashback',
- },
- {
- state: {
- engine: {
- backgroundState: {
- PreferencesController: {
- isIpfsGatewayEnabled: true,
- },
- MoneyAccountController: {
- moneyAccounts: {},
- },
- CardController: {
- selectedCountry: null,
- activeProviderId: 'baanx',
- isAuthenticated: true,
- cardholderAccounts: [],
- providerData: {},
- cardHomeData: mockCardHomeData,
- cardHomeDataStatus: 'success',
- ...cardControllerOverrides,
- },
- },
- },
- },
- },
+ { name: 'Cashback' },
+ { state: { engine: { backgroundState } } },
);
}
@@ -220,6 +241,18 @@ describe('Cashback Component', () => {
beforeEach(() => {
jest.clearAllMocks();
mockCreateEventBuilder.mockReturnValue(mockEventBuilder);
+ mockStartLinkFlow.mockReset();
+ mockLinkageReturn = {
+ startLinkFlow: mockStartLinkFlow,
+ canLink: true,
+ };
+
+ mockSelectCardHasApprovedLineaFunding.mockReturnValue(true);
+ mockSelectCardHomeDataStatus.mockReturnValue('success');
+ mockSelectCardLineaUsdcToken.mockReturnValue(MOCK_LINEA_USDC_TOKEN);
+ mockSelectIsCardResidencyBlocked.mockReturnValue(false);
+ mockSelectIsMoneyAccountDelegatedForCard.mockReturnValue(false);
+ mockSelectMoneyEnableMoneyAccountFlag.mockReturnValue(false);
mockHookReturn = {
cashbackWallet: null,
@@ -423,12 +456,7 @@ describe('Cashback Component', () => {
describe('Linea funding requirement', () => {
it('shows a warning when the user has no approved Linea funding', () => {
- render({
- cardHomeData: {
- ...mockCardHomeData,
- fundingAssets: [],
- },
- });
+ render({ hasApprovedLineaFunding: false });
expect(
screen.getByTestId(CashbackSelectors.FUNDING_WARNING),
@@ -442,12 +470,7 @@ describe('Cashback Component', () => {
});
it('navigates to Spending Limit with USDC on Linea pre-selected', () => {
- render({
- cardHomeData: {
- ...mockCardHomeData,
- fundingAssets: [],
- },
- });
+ render({ hasApprovedLineaFunding: false });
fireEvent.press(screen.getByText('Set up funding'));
@@ -474,12 +497,7 @@ describe('Cashback Component', () => {
price: '0.50',
};
- render({
- cardHomeData: {
- ...mockCardHomeData,
- fundingAssets: [],
- },
- });
+ render({ hasApprovedLineaFunding: false });
fireEvent.press(screen.getByTestId(CashbackSelectors.WITHDRAW_BUTTON));
@@ -500,10 +518,7 @@ describe('Cashback Component', () => {
price: '0.50',
};
- render({
- cardHomeData: null,
- cardHomeDataStatus: 'error',
- });
+ render({ cardHomeDataStatus: 'error' });
expect(
screen.queryByTestId(CashbackSelectors.FUNDING_WARNING),
@@ -522,6 +537,208 @@ describe('Cashback Component', () => {
});
});
+ describe('Money Account funding requirement (supported regions)', () => {
+ const setWithdrawableWallet = () => {
+ mockHookReturn.cashbackWallet = {
+ id: 'w1',
+ balance: '10.00',
+ currency: 'musd',
+ isWithdrawable: true,
+ type: 'reward',
+ };
+ mockHookReturn.estimation = {
+ wei: '100000',
+ eth: '0.0001',
+ price: '0.50',
+ };
+ };
+
+ it('shows a Money Account warning and starts link flow when no Money Account is delegated', () => {
+ setWithdrawableWallet();
+
+ render({ moneyAccountEnabled: true, hasApprovedLineaFunding: false });
+
+ expect(
+ screen.getByTestId(CashbackSelectors.FUNDING_WARNING),
+ ).toBeOnTheScreen();
+ expect(
+ screen.getAllByText('Set up Money Account').length,
+ ).toBeGreaterThan(0);
+ expect(
+ screen.getByText(
+ 'You need a linked Money Account before redeeming cashback.',
+ ),
+ ).toBeOnTheScreen();
+
+ fireEvent.press(screen.getByTestId('confirm-button'));
+
+ expect(mockStartLinkFlow).toHaveBeenCalledWith(
+ CASHBACK_MONEY_ACCOUNT_ORIGIN,
+ );
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('hides the Money Account setup CTA when the link flow cannot complete (canLink is false)', () => {
+ setWithdrawableWallet();
+ mockLinkageReturn = {
+ startLinkFlow: mockStartLinkFlow,
+ canLink: false,
+ };
+
+ render({ moneyAccountEnabled: true, hasApprovedLineaFunding: false });
+
+ expect(
+ screen.queryByTestId(CashbackSelectors.FUNDING_WARNING),
+ ).not.toBeOnTheScreen();
+ expect(mockStartLinkFlow).not.toHaveBeenCalled();
+
+ fireEvent.press(screen.getByTestId(CashbackSelectors.WITHDRAW_BUTTON));
+
+ expect(mockWithdraw).not.toHaveBeenCalled();
+ });
+
+ it('blocks withdrawal when no Money Account is delegated in a supported region', () => {
+ setWithdrawableWallet();
+
+ render({ moneyAccountEnabled: true, hasApprovedLineaFunding: false });
+
+ fireEvent.press(screen.getByTestId(CashbackSelectors.WITHDRAW_BUTTON));
+
+ expect(mockWithdraw).not.toHaveBeenCalled();
+ });
+
+ it('allows withdrawal when Money Account is delegated in a supported region', () => {
+ setWithdrawableWallet();
+
+ render({
+ moneyAccountEnabled: true,
+ moneyAccountDelegated: true,
+ hasApprovedLineaFunding: false,
+ });
+
+ expect(
+ screen.queryByTestId(CashbackSelectors.FUNDING_WARNING),
+ ).not.toBeOnTheScreen();
+
+ fireEvent.press(screen.getByTestId(CashbackSelectors.WITHDRAW_BUTTON));
+
+ expect(mockWithdraw).toHaveBeenCalledWith('9.5');
+ });
+ });
+
+ describe('UK / restricted region funding requirement', () => {
+ const setWithdrawableWallet = () => {
+ mockHookReturn.cashbackWallet = {
+ id: 'w1',
+ balance: '10.00',
+ currency: 'musd',
+ isWithdrawable: true,
+ type: 'reward',
+ };
+ mockHookReturn.estimation = {
+ wei: '100000',
+ eth: '0.0001',
+ price: '0.50',
+ };
+ };
+
+ it('allows withdrawal when only Linea funding is approved', () => {
+ setWithdrawableWallet();
+
+ render({
+ moneyAccountEnabled: true,
+ residencyBlocked: true,
+ hasApprovedLineaFunding: true,
+ moneyAccountDelegated: false,
+ });
+
+ expect(
+ screen.queryByTestId(CashbackSelectors.FUNDING_WARNING),
+ ).not.toBeOnTheScreen();
+
+ fireEvent.press(screen.getByTestId(CashbackSelectors.WITHDRAW_BUTTON));
+
+ expect(mockWithdraw).toHaveBeenCalledWith('9.5');
+ });
+
+ it('allows withdrawal when only Money Account is delegated', () => {
+ setWithdrawableWallet();
+
+ render({
+ moneyAccountEnabled: true,
+ residencyBlocked: true,
+ hasApprovedLineaFunding: false,
+ moneyAccountDelegated: true,
+ });
+
+ expect(
+ screen.queryByTestId(CashbackSelectors.FUNDING_WARNING),
+ ).not.toBeOnTheScreen();
+
+ fireEvent.press(screen.getByTestId(CashbackSelectors.WITHDRAW_BUTTON));
+
+ expect(mockWithdraw).toHaveBeenCalledWith('9.5');
+ });
+
+ it('shows Linea warning and redirects to Spending Limit when neither destination is configured', () => {
+ setWithdrawableWallet();
+
+ render({
+ moneyAccountEnabled: true,
+ residencyBlocked: true,
+ hasApprovedLineaFunding: false,
+ moneyAccountDelegated: false,
+ });
+
+ expect(
+ screen.getByTestId(CashbackSelectors.FUNDING_WARNING),
+ ).toBeOnTheScreen();
+ expect(screen.getByText('Set up Linea funding')).toBeOnTheScreen();
+
+ fireEvent.press(screen.getByText('Set up funding'));
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.SPENDING_LIMIT, {
+ flow: 'enable',
+ selectedToken: expect.objectContaining({
+ symbol: 'USDC',
+ caipChainId: 'eip155:59144',
+ }),
+ });
+ expect(mockStartLinkFlow).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Money Account feature flag disabled', () => {
+ it('uses the UK-style Linea or Money Account gate when the Money Account FF is off', () => {
+ mockHookReturn.cashbackWallet = {
+ id: 'w1',
+ balance: '10.00',
+ currency: 'musd',
+ isWithdrawable: true,
+ type: 'reward',
+ };
+ mockHookReturn.estimation = {
+ wei: '100000',
+ eth: '0.0001',
+ price: '0.50',
+ };
+
+ render({
+ moneyAccountEnabled: false,
+ moneyAccountDelegated: true,
+ hasApprovedLineaFunding: false,
+ });
+
+ expect(
+ screen.queryByTestId(CashbackSelectors.FUNDING_WARNING),
+ ).not.toBeOnTheScreen();
+
+ fireEvent.press(screen.getByTestId(CashbackSelectors.WITHDRAW_BUTTON));
+
+ expect(mockWithdraw).toHaveBeenCalledWith('9.5');
+ });
+ });
+
describe('withdraw action', () => {
it('calls withdraw with net amount on button press', () => {
mockHookReturn.cashbackWallet = {
diff --git a/app/components/UI/Card/Views/Cashback/Cashback.tsx b/app/components/UI/Card/Views/Cashback/Cashback.tsx
index f0e178854c4..0e43aa74a67 100644
--- a/app/components/UI/Card/Views/Cashback/Cashback.tsx
+++ b/app/components/UI/Card/Views/Cashback/Cashback.tsx
@@ -22,6 +22,8 @@ import {
} from '../../../../../component-library/components/Toast';
import KeyValueRow from '../../../../../component-library/components-temp/KeyValueRow/KeyValueRow';
import useCashbackWallet from '../../hooks/useCashbackWallet';
+import { useMoneyAccountCardLinkage } from '../../hooks/useMoneyAccountCardLinkage';
+import { CASHBACK_MONEY_ACCOUNT_ORIGIN } from '../../hooks/useCardPostAuthRedirect';
import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
import { MetaMetricsEvents } from '../../../../../core/Analytics';
import { CardActions } from '../../util/metrics';
@@ -32,7 +34,10 @@ import {
selectCardHasApprovedLineaFunding,
selectCardHomeDataStatus,
selectCardLineaUsdcToken,
+ selectIsCardResidencyBlocked,
+ selectIsMoneyAccountDelegatedForCard,
} from '../../../../../selectors/cardController';
+import { selectMoneyEnableMoneyAccountFlag } from '../../../Money/selectors/featureFlags';
import CardMessageBox from '../../components/CardMessageBox/CardMessageBox';
import { CardMessageBoxType } from '../../types';
import Routes from '../../../../../constants/navigation/Routes';
@@ -52,8 +57,15 @@ const Cashback: React.FC = () => {
const hasApprovedLineaFunding = useSelector(
selectCardHasApprovedLineaFunding,
);
+ const isMoneyAccountDelegated = useSelector(
+ selectIsMoneyAccountDelegatedForCard,
+ );
+ const isMoneyAccountEnabled = useSelector(selectMoneyEnableMoneyAccountFlag);
+ const isResidencyBlocked = useSelector(selectIsCardResidencyBlocked);
const lineaUsdcToken = useSelector(selectCardLineaUsdcToken);
const cardHomeDataStatus = useSelector(selectCardHomeDataStatus);
+ const { startLinkFlow, canLink: canLinkMoneyAccount } =
+ useMoneyAccountCardLinkage();
const {
cashbackWallet,
@@ -81,11 +93,16 @@ const Cashback: React.FC = () => {
cardHomeDataStatus === 'idle' || cardHomeDataStatus === 'loading';
const hasFundingStatusError = cardHomeDataStatus === 'error';
const isFundingStatusLoaded = cardHomeDataStatus === 'success';
- const requiresLineaFunding =
- isFundingStatusLoaded && !hasApprovedLineaFunding;
+ const useMoneyAccountFlow = isMoneyAccountEnabled && !isResidencyBlocked;
+ const hasApprovedRedemptionDestination = useMoneyAccountFlow
+ ? isMoneyAccountDelegated
+ : hasApprovedLineaFunding || isMoneyAccountDelegated;
+ const needsSetup = isFundingStatusLoaded && !hasApprovedRedemptionDestination;
const isFundingStatusUnavailable =
isFundingStatusLoading || hasFundingStatusError;
const showLoadingError = !!error || hasFundingStatusError;
+ const showSetupBanner =
+ needsSetup && (!useMoneyAccountFlow || canLinkMoneyAccount);
useEffect(() => {
if (cashbackWallet) {
@@ -150,7 +167,7 @@ const Cashback: React.FC = () => {
);
const handleWithdraw = useCallback(() => {
- if (requiresLineaFunding || isFundingStatusUnavailable) return;
+ if (needsSetup || isFundingStatusUnavailable) return;
trackEvent(
createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED)
@@ -166,7 +183,7 @@ const Cashback: React.FC = () => {
withdraw,
trackEvent,
createEventBuilder,
- requiresLineaFunding,
+ needsSetup,
isFundingStatusUnavailable,
]);
@@ -177,13 +194,22 @@ const Cashback: React.FC = () => {
});
}, [navigation, lineaUsdcToken]);
+ const handleSetupPress = useCallback(() => {
+ if (useMoneyAccountFlow) {
+ startLinkFlow(CASHBACK_MONEY_ACCOUNT_ORIGIN);
+ return;
+ }
+
+ handleNavigateToSpendingLimit();
+ }, [useMoneyAccountFlow, startLinkFlow, handleNavigateToSpendingLimit]);
+
const isProcessing = isWithdrawing || monitoringStatus === 'monitoring';
const buttonLabel = useMemo(() => {
if (
!isWithdrawable ||
hasInsufficientBalance ||
- requiresLineaFunding ||
+ needsSetup ||
isFundingStatusUnavailable
) {
return strings('card.cashback_screen.withdraw_unavailable');
@@ -192,10 +218,14 @@ const Cashback: React.FC = () => {
}, [
isWithdrawable,
hasInsufficientBalance,
- requiresLineaFunding,
+ needsSetup,
isFundingStatusUnavailable,
]);
+ const fundingWarningMessageType = useMoneyAccountFlow
+ ? CardMessageBoxType.CashbackMoneyAccountRequired
+ : CardMessageBoxType.CashbackFundingRequired;
+
const isButtonDisabled =
isLoading ||
!isWithdrawable ||
@@ -203,7 +233,7 @@ const Cashback: React.FC = () => {
isEstimating ||
hasInsufficientBalance ||
isFundingStatusUnavailable ||
- requiresLineaFunding;
+ needsSetup;
return (
{
{...headerHandlers}
/>
- {requiresLineaFunding ? (
+ {showSetupBanner ? (
) : null}
diff --git a/app/components/UI/Card/components/CardMessageBox/CardMessageBox.test.tsx b/app/components/UI/Card/components/CardMessageBox/CardMessageBox.test.tsx
index 7806633cef4..11e2f6061e2 100644
--- a/app/components/UI/Card/components/CardMessageBox/CardMessageBox.test.tsx
+++ b/app/components/UI/Card/components/CardMessageBox/CardMessageBox.test.tsx
@@ -29,6 +29,12 @@ jest.mock('../../../../../../locales/i18n', () => ({
'You need at least one approved funding source on Linea before redeeming cashback.',
'card.cashback_screen.funding_required.confirm_button_label':
'Set up funding',
+ 'card.cashback_screen.money_account_required.title':
+ 'Set up Money Account',
+ 'card.cashback_screen.money_account_required.description':
+ 'You need a linked Money Account before redeeming cashback.',
+ 'card.cashback_screen.money_account_required.confirm_button_label':
+ 'Set up Money Account',
};
return mockStrings[key] || key;
}),
@@ -220,6 +226,33 @@ describe('CardMessageBox', () => {
});
});
+ describe('CashbackMoneyAccountRequired warning', () => {
+ it('renders title and description', () => {
+ const { getByText } = renderWithProvider(() => (
+
+ ));
+
+ expect(getByText('Set up Money Account')).toBeOnTheScreen();
+ expect(
+ getByText('You need a linked Money Account before redeeming cashback.'),
+ ).toBeOnTheScreen();
+ });
+
+ it('calls onConfirm when the Money Account setup button is pressed', () => {
+ const { getByTestId } = renderWithProvider(() => (
+
+ ));
+
+ fireEvent.press(getByTestId('confirm-button'));
+ expect(mockOnConfirm).toHaveBeenCalledTimes(1);
+ });
+ });
+
describe('common behaviors', () => {
it('renders dismiss button when onDismiss is provided', () => {
const { getByTestId } = renderWithProvider(() => (
@@ -273,6 +306,7 @@ describe('CardMessageBox', () => {
CardMessageBoxType.CardProvisioning,
CardMessageBoxType.AuthPrompt,
CardMessageBoxType.CashbackFundingRequired,
+ CardMessageBoxType.CashbackMoneyAccountRequired,
];
allMessageTypes.forEach((messageType) => {
diff --git a/app/components/UI/Card/components/CardMessageBox/CardMessageBox.tsx b/app/components/UI/Card/components/CardMessageBox/CardMessageBox.tsx
index 8691e1a58fc..bfa155cd0d2 100644
--- a/app/components/UI/Card/components/CardMessageBox/CardMessageBox.tsx
+++ b/app/components/UI/Card/components/CardMessageBox/CardMessageBox.tsx
@@ -74,6 +74,16 @@ const CardMessageBox = ({
'card.cashback_screen.funding_required.confirm_button_label',
),
},
+ [CardMessageBoxType.CashbackMoneyAccountRequired]: {
+ variant: CardMessageBoxVariant.Warning,
+ title: strings('card.cashback_screen.money_account_required.title'),
+ description: strings(
+ 'card.cashback_screen.money_account_required.description',
+ ),
+ confirmButtonLabel: strings(
+ 'card.cashback_screen.money_account_required.confirm_button_label',
+ ),
+ },
}),
[],
);
diff --git a/app/components/UI/Card/hooks/useCardPostAuthRedirect.ts b/app/components/UI/Card/hooks/useCardPostAuthRedirect.ts
index b8efa5bca4d..929990a69f3 100644
--- a/app/components/UI/Card/hooks/useCardPostAuthRedirect.ts
+++ b/app/components/UI/Card/hooks/useCardPostAuthRedirect.ts
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { useNavigation } from '@react-navigation/native';
import Routes from '../../../../constants/navigation/Routes';
import type { LinkFlowOrigin } from './useMoneyAccountCardLinkage';
+import { CardEntryPoint } from '../util/metrics';
export const MONEY_HOME_CARD_ORIGIN: LinkFlowOrigin = {
screen: Routes.HOME_TABS,
@@ -11,6 +12,11 @@ export const MONEY_HOME_CARD_ORIGIN: LinkFlowOrigin = {
},
};
+export const CASHBACK_MONEY_ACCOUNT_ORIGIN: LinkFlowOrigin = {
+ screen: Routes.CARD.CASHBACK,
+ entrypoint: CardEntryPoint.CASHBACK,
+};
+
const isLinkFlowOrigin = (value: unknown): value is LinkFlowOrigin =>
typeof value === 'object' &&
value !== null &&
diff --git a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx
index 8cb4ff321d0..b9c54064fe7 100644
--- a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx
+++ b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx
@@ -22,6 +22,7 @@ import {
selectPendingMoneyAccountCardLink,
setPendingMoneyAccountCardLink,
} from '../../../../core/redux/slices/card';
+import { selectIsMoneyAccountGeoEligible } from '../../Money/selectors/eligibility';
import { selectMoneyEnableMoneyAccountFlag } from '../../Money/selectors/featureFlags';
import { resolveMoneyAccountCardToken } from '../../../../core/Engine/controllers/card-controller/utils/moneyAccountCardToken';
import Routes from '../../../../constants/navigation/Routes';
@@ -158,7 +159,7 @@ const buildSelectors = (
overrides: {
primaryMoneyAccount?: { address: string } | undefined;
vaultConfig?: { chainId?: string } | undefined;
- isMoneyAccountEnabled?: boolean;
+ isMoneyAccountVisible?: boolean;
isCardAuthenticated?: boolean;
isCardVerified?: boolean;
isCardholder?: boolean;
@@ -175,7 +176,7 @@ const buildSelectors = (
) => ({
primaryMoneyAccount: { address: MONEY_ACCOUNT_ADDRESS },
vaultConfig: { chainId: '0x8f' },
- isMoneyAccountEnabled: true,
+ isMoneyAccountVisible: true,
isCardAuthenticated: true,
isCardVerified: true,
isCardholder: false,
@@ -217,7 +218,8 @@ const applySelectorMocks = (state: ReturnType) => {
return state.primaryMoneyAccount;
if (selector === selectMoneyAccountVaultConfig) return state.vaultConfig;
if (selector === selectMoneyEnableMoneyAccountFlag)
- return state.isMoneyAccountEnabled;
+ return state.isMoneyAccountVisible;
+ if (selector === selectIsMoneyAccountGeoEligible) return true;
if (selector === selectIsCardAuthenticated)
return state.isCardAuthenticated;
if (selector === selectIsCardVerified) return state.isCardVerified;
@@ -319,7 +321,7 @@ describe('useMoneyAccountCardLinkage', () => {
});
it('reports canLink=false when the feature flag is off', () => {
- applySelectorMocks(buildSelectors({ isMoneyAccountEnabled: false }));
+ applySelectorMocks(buildSelectors({ isMoneyAccountVisible: false }));
const { result } = renderLinkageHook();
expect(result.current.canLink).toBe(false);
});
diff --git a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx
index 118172e1271..695b1a64bbe 100644
--- a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx
+++ b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx
@@ -44,6 +44,7 @@ import {
selectPendingMoneyAccountCardLink,
setPendingMoneyAccountCardLink,
} from '../../../../core/redux/slices/card';
+import { selectIsMoneyAccountGeoEligible } from '../../Money/selectors/eligibility';
import { selectMoneyEnableMoneyAccountFlag } from '../../Money/selectors/featureFlags';
import {
hasMoneyAccountCardRequirements,
@@ -123,6 +124,11 @@ export const useMoneyAccountCardLinkage =
const isMoneyAccountEnabled = useSelector(
selectMoneyEnableMoneyAccountFlag,
);
+ const isMoneyAccountGeoEligible = useSelector(
+ selectIsMoneyAccountGeoEligible,
+ );
+ const isMoneyAccountVisible =
+ isMoneyAccountEnabled && isMoneyAccountGeoEligible;
const isCardAuthenticated = useSelector(selectIsCardAuthenticated);
const isCardVerified = useSelector(selectIsCardVerified);
const isCardholder = useSelector(selectIsCardholder);
@@ -161,7 +167,7 @@ export const useMoneyAccountCardLinkage =
: null;
const hasMoneyAccountBaseRequirements = hasMoneyAccountCardRequirements({
- isMoneyAccountEnabled,
+ isMoneyAccountEnabled: isMoneyAccountVisible,
vaultConfig,
moneyAccountAddress: primaryMoneyAccount?.address,
});
diff --git a/app/components/UI/Card/types.ts b/app/components/UI/Card/types.ts
index 83bdc798892..aee28fa1aec 100644
--- a/app/components/UI/Card/types.ts
+++ b/app/components/UI/Card/types.ts
@@ -39,6 +39,7 @@ export enum CardMessageBoxType {
CardProvisioning = 'card_provisioning',
AuthPrompt = 'auth_prompt',
CashbackFundingRequired = 'cashback_funding_required',
+ CashbackMoneyAccountRequired = 'cashback_money_account_required',
}
export type CardUserPhase =
diff --git a/app/components/UI/Card/util/metrics.ts b/app/components/UI/Card/util/metrics.ts
index c411fe0f5ae..ab950aa75d4 100644
--- a/app/components/UI/Card/util/metrics.ts
+++ b/app/components/UI/Card/util/metrics.ts
@@ -85,6 +85,7 @@ enum CardEntryPoint {
CARD_HOME_MONEY_ACCOUNT_CARD = 'CARD_HOME_MONEY_ACCOUNT_CARD',
MONEY_LINK_CARD_SHEET = 'MONEY_LINK_CARD_SHEET',
SPENDING_LIMIT_SPEND_AND_EARN_PROMO = 'SPENDING_LIMIT_SPEND_AND_EARN_PROMO',
+ CASHBACK = 'CASHBACK',
}
enum CardFlow {
diff --git a/app/components/UI/Card/util/residency.ts b/app/components/UI/Card/util/residency.ts
index 2077566dbaf..80b842475ea 100644
--- a/app/components/UI/Card/util/residency.ts
+++ b/app/components/UI/Card/util/residency.ts
@@ -2,7 +2,7 @@
* Builds a residency region code from authenticated card KYC data.
* US cardholders with a known state use the `US-{STATE}` pattern (e.g. US-CA)
* so blocked-region lists can target specific states via
- * `selectMusdConversionBlockedCountries`.
+ * `selectMoneyAccountGeoBlockedCountries`.
*/
export const buildCardResidencyRegion = (
countryOfResidence: string | null,
diff --git a/app/components/UI/Card/util/truncateAddress.test.ts b/app/components/UI/Card/util/truncateAddress.test.ts
index 9e618b7b294..1bcaeebd078 100644
--- a/app/components/UI/Card/util/truncateAddress.test.ts
+++ b/app/components/UI/Card/util/truncateAddress.test.ts
@@ -2,17 +2,23 @@ jest.mock('@metamask/utils', () => ({
isHexAddress: jest.fn(),
}));
+jest.mock('@solana/addresses', () => ({
+ isAddress: jest.fn(),
+}));
+
jest.mock('../../../../util/address', () => ({
safeToChecksumAddress: jest.fn(),
}));
import { isHexAddress } from '@metamask/utils';
+import { isAddress } from '@solana/addresses';
import { safeToChecksumAddress } from '../../../../util/address';
import { truncateAddress } from './truncateAddress';
const mockIsHexAddress = isHexAddress as jest.MockedFunction<
typeof isHexAddress
>;
+const mockIsAddress = isAddress as jest.MockedFunction;
const mockSafeToChecksumAddress = safeToChecksumAddress as jest.MockedFunction<
typeof safeToChecksumAddress
>;
@@ -20,6 +26,30 @@ const mockSafeToChecksumAddress = safeToChecksumAddress as jest.MockedFunction<
describe('truncateAddress', () => {
beforeEach(() => {
jest.clearAllMocks();
+ mockIsAddress.mockReturnValue(false);
+ });
+
+ describe('valid Solana addresses', () => {
+ const solanaAddress = '7EcDhSYGxXyscQzY5BF8zY3j6ftUpF8E5uMEnDZmv7d';
+
+ it('truncates a Solana address with default chars without checksumming', () => {
+ mockIsAddress.mockReturnValue(true);
+
+ const result = truncateAddress(solanaAddress);
+
+ expect(mockIsAddress).toHaveBeenCalledWith(solanaAddress);
+ expect(mockIsHexAddress).not.toHaveBeenCalled();
+ expect(mockSafeToChecksumAddress).not.toHaveBeenCalled();
+ expect(result).toBe('7EcD...mv7d');
+ });
+
+ it('truncates a Solana address with custom character count', () => {
+ mockIsAddress.mockReturnValue(true);
+
+ const result = truncateAddress(solanaAddress, 6);
+
+ expect(result).toBe('7EcDhS...DZmv7d');
+ });
});
describe('valid hex addresses', () => {
@@ -82,10 +112,12 @@ describe('truncateAddress', () => {
describe('non-hex inputs (display labels)', () => {
it('returns a non-hex string verbatim (so labels like "Money account" are not mangled)', () => {
const label = 'Money account';
+ mockIsAddress.mockReturnValue(false);
mockIsHexAddress.mockReturnValue(false);
const result = truncateAddress(label);
+ expect(mockIsAddress).toHaveBeenCalledWith(label);
expect(mockIsHexAddress).toHaveBeenCalledWith(label);
expect(mockSafeToChecksumAddress).not.toHaveBeenCalled();
expect(result).toBe(label);
@@ -93,6 +125,7 @@ describe('truncateAddress', () => {
it('returns an alphanumeric string without 0x prefix unchanged', () => {
const value = '1234567890abcdef1234567890abcdef12345678';
+ mockIsAddress.mockReturnValue(false);
mockIsHexAddress.mockReturnValue(false);
const result = truncateAddress(value);
@@ -128,6 +161,7 @@ describe('truncateAddress', () => {
it('returns a single non-hex character unchanged', () => {
const value = 'x';
+ mockIsAddress.mockReturnValue(false);
mockIsHexAddress.mockReturnValue(false);
const result = truncateAddress(value);
diff --git a/app/components/UI/Card/util/truncateAddress.ts b/app/components/UI/Card/util/truncateAddress.ts
index 6a91dd35fa0..7f3cbdfacde 100644
--- a/app/components/UI/Card/util/truncateAddress.ts
+++ b/app/components/UI/Card/util/truncateAddress.ts
@@ -1,5 +1,6 @@
import { isHexAddress } from '@metamask/utils';
import { safeToChecksumAddress } from '../../../../util/address';
+import { isAddress } from '@solana/addresses';
/**
* Truncates a hex wallet address for display (e.g. `0x1234...5678`).
@@ -16,6 +17,10 @@ export const truncateAddress = (
return undefined;
}
+ if (isAddress(address)) {
+ return `${address.slice(0, chars)}...${address.slice(-chars)}`;
+ }
+
if (!isHexAddress(address)) {
return address;
}
diff --git a/app/components/UI/Carousel/types.ts b/app/components/UI/Carousel/types.ts
index 2b5404254ed..e8652b8eb36 100644
--- a/app/components/UI/Carousel/types.ts
+++ b/app/components/UI/Carousel/types.ts
@@ -1,5 +1,5 @@
import { ViewStyle } from 'react-native';
-import { WalletClientType } from '../../../core/SnapKeyring/MultichainWalletSnapClient';
+import { WalletClientType } from '../../../core/SnapKeyring/types';
import { CaipChainId } from '@metamask/utils';
interface NavigationParams {
diff --git a/app/components/UI/Money/Views/MoneyFirstTimeDepositView/MoneyFirstTimeDepositView.test.tsx b/app/components/UI/Money/Views/MoneyFirstTimeDepositView/MoneyFirstTimeDepositView.test.tsx
new file mode 100644
index 00000000000..ec864cec517
--- /dev/null
+++ b/app/components/UI/Money/Views/MoneyFirstTimeDepositView/MoneyFirstTimeDepositView.test.tsx
@@ -0,0 +1,456 @@
+import React from 'react';
+import { render, act } from '@testing-library/react-native';
+import { BackHandler } from 'react-native';
+import { Accelerometer } from 'expo-sensors';
+import MoneyFirstTimeDepositView from './MoneyFirstTimeDepositView';
+import Routes from '../../../../../constants/navigation/Routes';
+import { strings } from '../../../../../../locales/i18n';
+import { useMoneyAnalytics } from '../../hooks/useMoneyAnalytics';
+import { SCREEN_NAMES } from '../../constants/moneyEvents';
+import { MoneyFirstTimeDepositViewTestIds } from './MoneyFirstTimeDepositView.testIds';
+
+const mockNavigate = jest.fn();
+const mockTrackScreenViewed = jest.fn();
+
+jest.mock('../../hooks/useMoneyAnalytics', () => ({
+ useMoneyAnalytics: jest.fn(),
+}));
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({ navigate: mockNavigate }),
+}));
+
+jest.mock('../../hooks/useMountEffect', () => ({
+ __esModule: true,
+ default: (fn: () => void) => fn(),
+}));
+
+let mockTriggerCallbacks: Record void> = {};
+const mockSetString = jest.fn();
+const mockSetNumber = jest.fn();
+const mockRemove = jest.fn();
+let mockOnError: ((error: { message: string }) => void) | undefined;
+
+jest.mock('rive-react-native', () => {
+ const mockRiveRef = {
+ setNumber: (...args: unknown[]) => mockSetNumber(...args),
+ };
+
+ return {
+ __esModule: true,
+ default: jest.fn(({ onError, ...props }) => {
+ mockOnError = onError;
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { View } = require('react-native');
+ return ;
+ }),
+ useRive: () => [jest.fn(), mockRiveRef],
+ useRiveString: () => [undefined, mockSetString],
+ useRiveTrigger: (
+ _riveRef: unknown,
+ path: string,
+ callback?: () => void,
+ ) => {
+ if (callback) {
+ mockTriggerCallbacks[path] = callback;
+ }
+ return jest.fn();
+ },
+ AutoBind: (value: boolean) => ({ type: 'autobind', value }),
+ Fit: { Layout: 'layout' },
+ RNRiveError: class {},
+ };
+});
+
+jest.mock(
+ '../../../../../animations/money_account_first_time_deposit_with_parallax.riv',
+ () => 1,
+ { virtual: true },
+);
+
+jest.mock('expo-sensors', () => ({
+ Accelerometer: {
+ setUpdateInterval: jest.fn(),
+ addListener: jest.fn(),
+ },
+}));
+
+describe('MoneyFirstTimeDepositView', () => {
+ interface SensorData {
+ x: number;
+ y: number;
+ }
+ let sensorCallback: ((data: SensorData) => void) | undefined;
+ let rafCallback: ((time: number) => void) | undefined;
+ let originalRaf: typeof global.requestAnimationFrame;
+ let originalCaf: typeof global.cancelAnimationFrame;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockTriggerCallbacks = {};
+ mockOnError = undefined;
+ sensorCallback = undefined;
+ rafCallback = undefined;
+
+ (Accelerometer.addListener as jest.Mock).mockImplementation(
+ (cb: (data: SensorData) => void) => {
+ sensorCallback = cb;
+ return { remove: mockRemove };
+ },
+ );
+
+ originalRaf = global.requestAnimationFrame;
+ originalCaf = global.cancelAnimationFrame;
+ global.requestAnimationFrame = jest.fn((cb: (time: number) => void) => {
+ rafCallback = cb;
+ return 0;
+ });
+ global.cancelAnimationFrame = jest.fn();
+
+ (useMoneyAnalytics as jest.Mock).mockReturnValue({
+ trackScreenViewed: mockTrackScreenViewed,
+ });
+ });
+
+ afterEach(() => {
+ global.requestAnimationFrame = originalRaf;
+ global.cancelAnimationFrame = originalCaf;
+ });
+
+ describe('Rendering', () => {
+ it('renders the Rive animation component', () => {
+ const { getByTestId } = render();
+
+ expect(
+ getByTestId(MoneyFirstTimeDepositViewTestIds.RIVE_ANIMATION),
+ ).toBeOnTheScreen();
+ });
+ });
+
+ describe('Analytics', () => {
+ it('initializes useMoneyAnalytics with first-time deposit screen name', () => {
+ render();
+
+ expect(useMoneyAnalytics).toHaveBeenCalledWith({
+ screen_name: SCREEN_NAMES.MONEY_FIRST_TIME_DEPOSIT,
+ });
+ });
+
+ it('fires trackScreenViewed on mount', () => {
+ render();
+
+ expect(mockTrackScreenViewed).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Rive text initialization', () => {
+ it('sets title and content strings on mount', () => {
+ render();
+
+ expect(mockSetString).toHaveBeenCalledWith(
+ strings('money.first_time_deposit.title'),
+ );
+ expect(mockSetString).toHaveBeenCalledWith(
+ strings('money.first_time_deposit.content'),
+ );
+ });
+ });
+
+ describe('Done trigger', () => {
+ it('navigates home when done trigger fires', () => {
+ render();
+
+ mockTriggerCallbacks.done();
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.HOME_TABS, {
+ screen: Routes.MONEY.ROOT,
+ params: { screen: Routes.MONEY.HOME },
+ });
+ });
+ });
+
+ describe('Error handling', () => {
+ it('navigates home when Rive reports an error', () => {
+ render();
+
+ mockOnError?.({ message: 'test rive error' });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.HOME_TABS, {
+ screen: Routes.MONEY.ROOT,
+ params: { screen: Routes.MONEY.HOME },
+ });
+ });
+ });
+
+ describe('Android back button', () => {
+ it('registers a BackHandler listener on mount', () => {
+ const addSpy = jest.spyOn(BackHandler, 'addEventListener');
+
+ render();
+
+ expect(addSpy).toHaveBeenCalledWith(
+ 'hardwareBackPress',
+ expect.any(Function),
+ );
+
+ addSpy.mockRestore();
+ });
+
+ it('navigates home and returns true when hardware back is pressed', () => {
+ let backPressHandler: (() => boolean) | undefined;
+ jest
+ .spyOn(BackHandler, 'addEventListener')
+ .mockImplementation((_event, handler) => {
+ backPressHandler = handler as () => boolean;
+ return { remove: jest.fn() };
+ });
+
+ render();
+
+ const result = backPressHandler?.();
+
+ expect(result).toBe(true);
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.HOME_TABS, {
+ screen: Routes.MONEY.ROOT,
+ params: { screen: Routes.MONEY.HOME },
+ });
+ });
+ });
+
+ describe('Parallax', () => {
+ // Controllable clock. `mockNow` starts at 0 so `calibrationStartedAt`
+ // (captured during render) is 0; tests advance it explicitly to cross the
+ // calibration window rather than relying on Date.now() call ordering.
+ let mockNow: number;
+ let dateSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ mockNow = 0;
+ dateSpy = jest.spyOn(Date, 'now').mockImplementation(() => mockNow);
+ });
+
+ afterEach(() => {
+ dateSpy.mockRestore();
+ });
+
+ it('keeps parallax centered during calibration regardless of device tilt', () => {
+ // Clock never advances past CALIBRATION_DURATION_MS (300ms), so no
+ // baseline is ever computed and the calibration branch stays active.
+ render();
+
+ // A strong tilt arrives mid-calibration; it must be ignored.
+ act(() => {
+ sensorCallback?.({ x: 0.3, y: 0.3 });
+ });
+
+ // One render frame. Targets are pinned to 50, so EMA smoothing leaves
+ // the pushed values at the centered default: 50 + 0.04 * (50 - 50) = 50.
+ act(() => {
+ rafCallback?.(0);
+ });
+
+ expect(mockSetNumber).toHaveBeenCalledWith('xValue', 50);
+ expect(mockSetNumber).toHaveBeenCalledWith('yValue', 50);
+ });
+
+ it('measures live parallax relative to the resting baseline', () => {
+ render();
+
+ // Advance past the calibration window, then take a calibration reading.
+ // Threshold crossed, so baseline = {0.1, 0.05}.
+ mockNow = 301;
+ act(() => {
+ sensorCallback?.({ x: 0.1, y: 0.05 });
+ });
+
+ // Live reading identical to the baseline. After subtraction the g-force
+ // is 0, so gForceToParallax(0) = 50 (perfectly centered). Without baseline
+ // subtraction this would be gForceToParallax(0.1) = 60 instead.
+ act(() => {
+ sensorCallback?.({ x: 0.1, y: 0.05 });
+ });
+
+ act(() => {
+ rafCallback?.(0);
+ });
+
+ // smoothed = 50 + 0.04 * (50 - 50) = 50
+ expect(mockSetNumber).toHaveBeenCalledWith('xValue', 50);
+ expect(mockSetNumber).toHaveBeenCalledWith('yValue', 50);
+ });
+
+ it('averages multiple calibration samples into the resting baseline', () => {
+ render();
+
+ // First sample lands inside the calibration window (clock still 0).
+ act(() => {
+ sensorCallback?.({ x: 0.1, y: 0.1 });
+ });
+
+ // Advance past the window, then a second sample. It is accumulated first
+ // (sum = 0.1 + 0.3 = 0.4 over count = 2) and only then is the baseline
+ // computed: 0.4 / 2 = 0.2 on both axes (a true average, not last-wins).
+ mockNow = 301;
+ act(() => {
+ sensorCallback?.({ x: 0.3, y: 0.3 });
+ });
+
+ // Live reading. With averaged baseline 0.2:
+ // 0.4 - 0.2 = 0.2 -> gForceToParallax(0.2) = 70 on both axes
+ // If the baseline were last-wins (0.3) it would be gForceToParallax(0.1)
+ // = 60 instead, giving 50.4 below rather than 50.8.
+ act(() => {
+ sensorCallback?.({ x: 0.4, y: 0.4 });
+ });
+
+ act(() => {
+ rafCallback?.(0);
+ });
+
+ // smoothed = 50 + 0.04 * (70 - 50) = 50.8
+ expect(mockSetNumber).toHaveBeenCalledWith(
+ 'xValue',
+ expect.closeTo(50.8),
+ );
+ expect(mockSetNumber).toHaveBeenCalledWith(
+ 'yValue',
+ expect.closeTo(50.8),
+ );
+ });
+
+ it('converts tilt through gForceToParallax and EMA smoothing before pushing to Rive', () => {
+ render();
+
+ // Calibration reading establishes baseline = {0.1, 0.05}.
+ mockNow = 301;
+ act(() => {
+ sensorCallback?.({ x: 0.1, y: 0.05 });
+ });
+
+ // Live reading. Baseline-relative g-force:
+ // x: 0.3 - 0.1 = 0.2 -> gForceToParallax(0.2) = ((0.2/0.5 + 1)/2)*100 = 70
+ // y: 0.15 - 0.05 = 0.1 -> gForceToParallax(0.1) = ((0.1/0.5 + 1)/2)*100 = 60
+ act(() => {
+ sensorCallback?.({ x: 0.3, y: 0.15 });
+ });
+
+ // One render frame applies EMA smoothing (SMOOTHING_FACTOR = 0.04):
+ // x: 50 + 0.04 * (70 - 50) = 50.8
+ // y: 50 + 0.04 * (60 - 50) = 50.4
+ act(() => {
+ rafCallback?.(0);
+ });
+
+ expect(mockSetNumber).toHaveBeenCalledWith(
+ 'xValue',
+ expect.closeTo(50.8),
+ );
+ expect(mockSetNumber).toHaveBeenCalledWith(
+ 'yValue',
+ expect.closeTo(50.4),
+ );
+ });
+
+ it('clamps tilt beyond TILT_SENSITIVITY to the parallax extremes', () => {
+ render();
+
+ // Calibration reading establishes baseline = {0, 0}.
+ mockNow = 301;
+ act(() => {
+ sensorCallback?.({ x: 0, y: 0 });
+ });
+
+ // Live reading well beyond +/- TILT_SENSITIVITY (0.5):
+ // x: 0.8 clamps to 0.5 -> gForceToParallax = 100 (max extreme)
+ // y: -0.8 clamps to -0.5 -> gForceToParallax = 0 (min extreme)
+ // Without clamping these would be 130 and -30 respectively.
+ act(() => {
+ sensorCallback?.({ x: 0.8, y: -0.8 });
+ });
+
+ act(() => {
+ rafCallback?.(0);
+ });
+
+ // smoothed = 50 + 0.04 * (100 - 50) = 52 ; 50 + 0.04 * (0 - 50) = 48
+ expect(mockSetNumber).toHaveBeenCalledWith('xValue', expect.closeTo(52));
+ expect(mockSetNumber).toHaveBeenCalledWith('yValue', expect.closeTo(48));
+ });
+
+ it('pushes the centered default to Rive on the first frame before any readings', () => {
+ render();
+
+ // No sensor readings yet: targets are at their initial 50, so the first
+ // smoothed frame stays centered: 50 + 0.04 * (50 - 50) = 50.
+ act(() => {
+ rafCallback?.(0);
+ });
+
+ expect(mockSetNumber).toHaveBeenCalledWith('xValue', 50);
+ expect(mockSetNumber).toHaveBeenCalledWith('yValue', 50);
+ });
+
+ it('keeps the render loop scheduled after a setNumber error', () => {
+ mockSetNumber.mockImplementationOnce(() => {
+ throw new Error('Rive binding not ready');
+ });
+
+ render();
+
+ const rafSpy = global.requestAnimationFrame as jest.Mock;
+ // Ignore the initial schedule queued during effect setup.
+ rafSpy.mockClear();
+
+ // setNumber throws on this frame; the catch must swallow it AND the loop
+ // must still reschedule the next frame.
+ expect(() => {
+ act(() => {
+ rafCallback?.(0);
+ });
+ }).not.toThrow();
+
+ expect(rafSpy).toHaveBeenCalled();
+ });
+
+ it('still pushes the centered default when the accelerometer is unavailable', () => {
+ (Accelerometer.addListener as jest.Mock).mockImplementationOnce(() => {
+ throw new Error('Sensor unavailable');
+ });
+
+ expect(() => render()).not.toThrow();
+
+ // The render loop is set up after the sensor try/catch, so it still runs
+ // and pushes the static center even though no listener was attached.
+ act(() => {
+ rafCallback?.(0);
+ });
+
+ expect(mockSetNumber).toHaveBeenCalledWith('xValue', 50);
+ expect(mockSetNumber).toHaveBeenCalledWith('yValue', 50);
+ });
+
+ it('unsubscribes the sensor and cancels the frame on unmount', () => {
+ const { unmount } = render();
+ const capturedRafCallback = rafCallback;
+
+ // Cleanup: sets active = false, cancels the frame, removes the listener.
+ unmount();
+
+ expect(mockRemove).toHaveBeenCalled();
+ expect(global.cancelAnimationFrame).toHaveBeenCalled();
+
+ mockSetNumber.mockClear();
+ const rafSpy = global.requestAnimationFrame as jest.Mock;
+ rafSpy.mockClear();
+
+ // A stale frame firing after unmount must not push to Rive, but it does
+ // reschedule (the loop only stops once cancelAnimationFrame wins the race).
+ act(() => {
+ capturedRafCallback?.(0);
+ });
+
+ expect(mockSetNumber).not.toHaveBeenCalled();
+ expect(rafSpy).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/app/components/UI/Money/Views/MoneyFirstTimeDepositView/MoneyFirstTimeDepositView.testIds.ts b/app/components/UI/Money/Views/MoneyFirstTimeDepositView/MoneyFirstTimeDepositView.testIds.ts
new file mode 100644
index 00000000000..c7ecabdc588
--- /dev/null
+++ b/app/components/UI/Money/Views/MoneyFirstTimeDepositView/MoneyFirstTimeDepositView.testIds.ts
@@ -0,0 +1,3 @@
+export const MoneyFirstTimeDepositViewTestIds = {
+ RIVE_ANIMATION: 'money-first-time-deposit-rive-animation',
+} as const;
diff --git a/app/components/UI/Money/Views/MoneyFirstTimeDepositView/MoneyFirstTimeDepositView.tsx b/app/components/UI/Money/Views/MoneyFirstTimeDepositView/MoneyFirstTimeDepositView.tsx
new file mode 100644
index 00000000000..ff5fead573b
--- /dev/null
+++ b/app/components/UI/Money/Views/MoneyFirstTimeDepositView/MoneyFirstTimeDepositView.tsx
@@ -0,0 +1,303 @@
+import React, { useCallback, useEffect, useRef } from 'react';
+import { BackHandler } from 'react-native';
+import { useNavigation } from '@react-navigation/native';
+import { type StackNavigationProp } from '@react-navigation/stack';
+import { Accelerometer } from 'expo-sensors';
+import Rive, {
+ AutoBind,
+ useRive,
+ useRiveString,
+ useRiveTrigger,
+ Fit,
+ RNRiveError,
+} from 'rive-react-native';
+import { createProjectLogger } from '@metamask/utils';
+import { strings } from '../../../../../../locales/i18n';
+import Routes from '../../../../../constants/navigation/Routes';
+import { useMoneyAnalytics } from '../../hooks/useMoneyAnalytics';
+import { SCREEN_NAMES } from '../../constants/moneyEvents';
+import { MoneyFirstTimeDepositViewTestIds } from './MoneyFirstTimeDepositView.testIds';
+import useMountEffect from '../../hooks/useMountEffect';
+
+const log = createProjectLogger('money-first-time-deposit');
+
+// -- Rive animation assets ------------------------------------------------
+
+// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import-x/no-commonjs
+const MoneyFirstTimeDepositAnimationWithParallax = require('../../../../../animations/money_account_first_time_deposit_with_parallax.riv');
+
+// -- Rive data-binding names ----------------------------------------------
+// These MUST match the names authored in the .riv file. If the Rive designer
+// renames any of these, update the constants here to keep the binding working.
+
+/** Name of the Rive artboard that contains the first-time deposit animation. */
+const RIVE_ARTBOARD_NAME = 'Intro';
+
+/** Trigger that kicks off the intro animation sequence. Fire once on mount. */
+const RIVE_START_TRIGGER = 'start';
+
+/** Trigger fired by the Rive "Done" button. Listened to for navigating home. */
+const RIVE_DONE_TRIGGER = 'done';
+
+/** Text data-binding path for the headline shown during the animation. */
+const RIVE_TITLE_PATH = 'title';
+
+/** Text data-binding path for the body copy shown during the animation. */
+const RIVE_CONTENT_PATH = 'content';
+
+/** Number data-binding (0–100) for horizontal parallax driven by device tilt. */
+const RIVE_X_VALUE_PATH = 'xValue';
+
+/** Number data-binding (0–100) for vertical parallax driven by device tilt. */
+const RIVE_Y_VALUE_PATH = 'yValue';
+
+// -- Accelerometer / parallax tuning constants ----------------------------
+
+/**
+ * How often (ms) the device delivers a new accelerometer reading.
+ * 33 ms ≈ 30 readings/sec. Lower = smoother but more CPU/battery.
+ */
+const SENSOR_POLL_INTERVAL_MS = 33;
+
+/**
+ * Exponential Moving Average (EMA) smoothing factor applied each frame.
+ * Formula: smoothedValue += SMOOTHING_FACTOR * (targetValue - smoothedValue)
+ *
+ * Range 0 to 1:
+ * 0.05 = very smooth / floaty, significant lag behind real tilt
+ * 0.12 = balanced — absorbs hand tremor, still tracks intentional tilt
+ * 0.40 = snappy, almost raw — good for fast-moving effects
+ * 1.00 = no smoothing at all (raw sensor, jittery)
+ */
+const SMOOTHING_FACTOR = 0.04;
+
+/**
+ * Maximum accelerometer g-force that maps to the 0 or 100 parallax extreme.
+ * Anything beyond this is clamped. Determines how far you must tilt to reach
+ * full parallax range.
+ *
+ * 0.15 = ~8° tilt, very sensitive, small wrist movements register
+ * 0.30 = ~17° tilt, moderate, deliberate tilt needed
+ * 0.50 = ~30° tilt, conservative, phone must be angled significantly
+ */
+const TILT_SENSITIVITY = 0.5;
+
+/**
+ * Duration (ms) of the calibration window on mount. During this window the
+ * accelerometer averages the device's resting orientation to establish a
+ * "neutral baseline". This means parallax is relative to how the user
+ * actually holds their phone, not relative to perfectly flat.
+ *
+ * Parallax stays centered (50, 50) during calibration so the text reveal
+ * animation plays cleanly regardless of hold angle.
+ */
+const CALIBRATION_DURATION_MS = 300;
+
+/**
+ * Converts a raw accelerometer g-force value (relative to baseline) into
+ * the 0-100 parallax range expected by the Rive data bindings.
+ *
+ * -TILT_SENSITIVITY maps to 0 (tilted fully one direction)
+ * 0 maps to 50 (neutral / resting position)
+ * +TILT_SENSITIVITY maps to 100 (tilted fully the other direction)
+ *
+ * Values outside the range are clamped to prevent overshooting.
+ */
+function gForceToParallax(gForce: number): number {
+ const clamped = Math.max(
+ -TILT_SENSITIVITY,
+ Math.min(TILT_SENSITIVITY, gForce),
+ );
+ return ((clamped / TILT_SENSITIVITY + 1) / 2) * 100;
+}
+
+const MoneyFirstTimeDepositView = () => {
+ const navigation =
+ useNavigation>>();
+
+ const { trackScreenViewed } = useMoneyAnalytics({
+ screen_name: SCREEN_NAMES.MONEY_FIRST_TIME_DEPOSIT,
+ });
+
+ const goHome = useCallback(() => {
+ navigation.navigate(Routes.HOME_TABS, {
+ screen: Routes.MONEY.ROOT,
+ params: { screen: Routes.MONEY.HOME },
+ });
+ }, [navigation]);
+
+ useMountEffect(trackScreenViewed);
+
+ // Intercept Android hardware-back so it behaves like tapping "Done".
+ useEffect(() => {
+ const sub = BackHandler.addEventListener('hardwareBackPress', () => {
+ goHome();
+ return true;
+ });
+ return () => sub.remove();
+ }, [goHome]);
+
+ // -- Rive setup ---------------------------------------------------------
+
+ const [ref, riveRef] = useRive();
+
+ // Flips true once data bindings are established. Guards accelerometer
+ // pushes so they don't fire before Rive is ready to accept values.
+ const dataBindingsReady = useRef(false);
+
+ const [, setTitle] = useRiveString(riveRef, RIVE_TITLE_PATH);
+ const [, setContent] = useRiveString(riveRef, RIVE_CONTENT_PATH);
+
+ const fireStart = useRiveTrigger(riveRef, RIVE_START_TRIGGER);
+
+ // Listen for the Rive "Done" button trigger → navigate home.
+ useRiveTrigger(riveRef, RIVE_DONE_TRIGGER, () => {
+ goHome();
+ });
+
+ // Once the Rive ref is ready, inject i18n text and kick off the animation.
+ useEffect(() => {
+ if (!riveRef) return;
+
+ setTitle(strings('money.first_time_deposit.title'));
+ setContent(strings('money.first_time_deposit.content'));
+ fireStart?.();
+ dataBindingsReady.current = true;
+ }, [riveRef, setTitle, setContent, fireStart]);
+
+ // -- Accelerometer-driven parallax --------------------------------------
+ //
+ // Architecture (two decoupled loops):
+ //
+ // Accelerometer listener → writes raw target values into refs
+ // (runs at SENSOR_POLL_INTERVAL_MS)
+ //
+ // requestAnimationFrame → reads targets, applies EMA smoothing,
+ // pushes smoothed values to Rive
+ // (runs at display refresh rate)
+ //
+ // This avoids flooding the React Native bridge when the sensor fires
+ // faster than the screen can render.
+ //
+ // On mount, a short calibration phase averages the device's resting
+ // orientation so that "neutral" = however the user holds their phone.
+
+ // EMA-smoothed values that get pushed to Rive each frame. Start centered.
+ const smoothedX = useRef(50);
+ const smoothedY = useRef(50);
+
+ // Raw parallax targets written by the sensor listener.
+ const targetX = useRef(50);
+ const targetY = useRef(50);
+
+ // Resting orientation learned during calibration. Null until calibrated.
+ const restingBaseline = useRef<{ x: number; y: number } | null>(null);
+
+ // Running sum/count for the calibration window's average.
+ const calibrationAccumulator = useRef({ sumX: 0, sumY: 0, count: 0 });
+
+ useEffect(() => {
+ if (!riveRef) return;
+
+ Accelerometer.setUpdateInterval(SENSOR_POLL_INTERVAL_MS);
+
+ let active = true;
+ let rafId: number;
+ let subscription: ReturnType | null =
+ null;
+ const calibrationStartedAt = Date.now();
+
+ try {
+ subscription = Accelerometer.addListener(({ x, y }) => {
+ if (!active) return;
+
+ const baseline = restingBaseline.current;
+
+ // --- Calibration phase ---
+ // Average the accelerometer readings to learn the device's held-rest
+ // orientation. Parallax stays centered (50, 50) so the text reveal
+ // plays cleanly regardless of how the user is holding the phone.
+ if (baseline === null) {
+ const acc = calibrationAccumulator.current;
+ acc.sumX += x;
+ acc.sumY += y;
+ acc.count += 1;
+ targetX.current = 50;
+ targetY.current = 50;
+
+ if (
+ Date.now() - calibrationStartedAt >= CALIBRATION_DURATION_MS &&
+ acc.count > 0
+ ) {
+ const baselineValue = {
+ x: acc.sumX / acc.count,
+ y: acc.sumY / acc.count,
+ };
+ restingBaseline.current = baselineValue;
+ }
+ return;
+ }
+
+ // --- Live phase ---
+ // Subtract the baseline so parallax is relative to the user's
+ // resting hold, not relative to "device perfectly flat on a table".
+ targetX.current = gForceToParallax(x - baseline.x);
+ targetY.current = gForceToParallax(y - baseline.y);
+ });
+ } catch {
+ // Sensor unavailable — parallax stays at static center.
+ }
+
+ /** rAF render loop: smooth the raw targets and push to Rive. */
+ const renderParallax = () => {
+ if (!active || !dataBindingsReady.current) {
+ rafId = requestAnimationFrame(renderParallax);
+ return;
+ }
+
+ smoothedX.current +=
+ SMOOTHING_FACTOR * (targetX.current - smoothedX.current);
+ smoothedY.current +=
+ SMOOTHING_FACTOR * (targetY.current - smoothedY.current);
+
+ try {
+ riveRef.setNumber(RIVE_X_VALUE_PATH, smoothedX.current);
+ riveRef.setNumber(RIVE_Y_VALUE_PATH, smoothedY.current);
+ } catch {
+ // Data binding not ready yet — skip this frame.
+ }
+
+ rafId = requestAnimationFrame(renderParallax);
+ };
+
+ rafId = requestAnimationFrame(renderParallax);
+
+ return () => {
+ active = false;
+ cancelAnimationFrame(rafId);
+ subscription?.remove();
+ };
+ }, [riveRef]);
+
+ const handleError = useCallback(
+ (riveError: RNRiveError) => {
+ log(`Rive error: ${riveError.message}`);
+ goHome();
+ },
+ [goHome],
+ );
+
+ return (
+
+ );
+};
+
+export default MoneyFirstTimeDepositView;
diff --git a/app/components/UI/Money/Views/MoneyFirstTimeDepositView/index.ts b/app/components/UI/Money/Views/MoneyFirstTimeDepositView/index.ts
new file mode 100644
index 00000000000..7e0e1572725
--- /dev/null
+++ b/app/components/UI/Money/Views/MoneyFirstTimeDepositView/index.ts
@@ -0,0 +1 @@
+export { default } from './MoneyFirstTimeDepositView';
diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx
index 026e06f014d..427efc48462 100644
--- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx
+++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx
@@ -52,6 +52,8 @@ import {
CardEntryPoint,
CardScreens,
} from '../../../Card/util/metrics';
+import { selectIsMoneyAccountGeoEligible } from '../../selectors/eligibility';
+import { selectMoneyEnableMoneyAccountFlag } from '../../selectors/featureFlags';
const mockGoBack = jest.fn();
const mockNavigate = jest.fn();
@@ -169,6 +171,16 @@ jest.mock('../../../../../selectors/cardController', () => ({
selectIsMoneyAccountDelegatedForCard: jest.fn(() => false),
}));
+jest.mock('../../selectors/eligibility', () => ({
+ ...jest.requireActual('../../selectors/eligibility'),
+ selectIsMoneyAccountGeoEligible: jest.fn(() => true),
+}));
+
+jest.mock('../../selectors/featureFlags', () => ({
+ ...jest.requireActual('../../selectors/featureFlags'),
+ selectMoneyEnableMoneyAccountFlag: jest.fn(() => true),
+}));
+
jest.mock('../../../Card/hooks/useMoneyAccountCardLinkage', () => ({
__esModule: true,
useMoneyAccountCardLinkage: jest.fn(() => ({
@@ -244,6 +256,12 @@ jest.mock('../../hooks/useOnboardingStep', () => ({
const mockSelectIsCardholder = jest.mocked(selectIsCardholder);
const mockSelectHasMetalCard = jest.mocked(selectHasMetalCard);
const mockSelectCardHomeDataStatus = jest.mocked(selectCardHomeDataStatus);
+const mockSelectIsMoneyAccountGeoEligible = jest.mocked(
+ selectIsMoneyAccountGeoEligible,
+);
+const mockSelectMoneyEnableMoneyAccountFlag = jest.mocked(
+ selectMoneyEnableMoneyAccountFlag,
+);
const mockUseMoneyAccountCardLinkage = jest.mocked(useMoneyAccountCardLinkage);
const mockOpenLinkCardSheet = jest.fn();
const mockStartLinkFlow = jest.fn();
@@ -368,6 +386,8 @@ describe('MoneyHomeView', () => {
mockSelectIsCardholder.mockReturnValue(false);
mockSelectHasMetalCard.mockReturnValue(false);
mockSelectCardHomeDataStatus.mockReturnValue('idle');
+ mockSelectIsMoneyAccountGeoEligible.mockReturnValue(true);
+ mockSelectMoneyEnableMoneyAccountFlag.mockReturnValue(true);
mockOpenLinkCardSheet.mockReset();
mockStartLinkFlow.mockReset();
@@ -2106,6 +2126,44 @@ describe('MoneyHomeView', () => {
).toBeOnTheScreen();
});
+ it('hides the upsell MetaMask Card when the Money account is not visible', () => {
+ mockSelectIsCardholder.mockReturnValue(false);
+ mockSelectIsMoneyAccountGeoEligible.mockReturnValue(false);
+
+ const { queryByTestId } = renderWithProvider();
+
+ expect(
+ queryByTestId(MoneyMetaMaskCardTestIds.CONTAINER),
+ ).not.toBeOnTheScreen();
+ });
+
+ it('hides the verifying MetaMask Card when the Money account is not visible', () => {
+ mockSelectIsCardholder.mockReturnValue(false);
+ mockSelectIsMoneyAccountGeoEligible.mockReturnValue(false);
+ mockUseMoneyAccountCardLinkage.mockReturnValue({
+ hasMoneyAccountRequirements: true,
+ hasMoneyAccountBaseRequirements: true,
+ isCardAuthenticated: true,
+ isCardVerified: false,
+ isCardLinkedToMoneyAccount: false,
+ primaryMoneyAccount: { address: '0xabc' },
+ moneyAccountCardToken: { symbol: 'USDC' },
+ canLink: false,
+ status: 'idle',
+ isLinking: false,
+ error: null,
+ startLinkFlow: mockStartLinkFlow,
+ openLinkCardSheet: mockOpenLinkCardSheet,
+ reset: jest.fn(),
+ } as unknown as ReturnType);
+
+ const { queryByTestId } = renderWithProvider();
+
+ expect(
+ queryByTestId(MoneyMetaMaskCardTestIds.CONTAINER),
+ ).not.toBeOnTheScreen();
+ });
+
it('renders upsell MetaMask Card below Activity and Earn on your crypto', () => {
mockSelectIsCardholder.mockReturnValue(false);
@@ -2264,9 +2322,41 @@ describe('MoneyHomeView', () => {
expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, {
screen: Routes.CARD.HOME,
params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN },
- animation: 'slide_from_bottom',
});
});
+
+ it('navigates to Card root with push animation when Card button is pressed in manage mode', () => {
+ mockSelectIsCardholder.mockReturnValue(true);
+ mockUseMoneyAccountCardLinkage.mockReturnValue({
+ hasMoneyAccountRequirements: false,
+ hasMoneyAccountBaseRequirements: false,
+ isCardAuthenticated: false,
+ isCardLinkedToMoneyAccount: true,
+ primaryMoneyAccount: undefined,
+ moneyAccountCardToken: null,
+ canLink: false,
+ status: 'idle',
+ isLinking: false,
+ error: null,
+ startLinkFlow: mockStartLinkFlow,
+ openLinkCardSheet: mockOpenLinkCardSheet,
+ reset: jest.fn(),
+ } as unknown as ReturnType);
+
+ const { getByTestId } = renderWithProvider();
+ jest.clearAllMocks();
+
+ fireEvent.press(getByTestId(MoneyActionButtonRowTestIds.CARD_BUTTON));
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, {
+ screen: Routes.CARD.HOME,
+ params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN },
+ });
+ expect(mockNavigate).not.toHaveBeenCalledWith(
+ Routes.CARD.ROOT,
+ expect.objectContaining({ animation: 'slide_from_bottom' }),
+ );
+ });
});
describe('manage card balance', () => {
diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx
index 0378bea16d5..24a38b8cf67 100644
--- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx
+++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx
@@ -51,6 +51,8 @@ import {
selectHasMetalCard,
selectIsCardholder,
} from '../../../../../selectors/cardController';
+import { selectIsMoneyAccountGeoEligible } from '../../selectors/eligibility';
+import { selectMoneyEnableMoneyAccountFlag } from '../../selectors/featureFlags';
import { useMoneyAccountCardLinkage } from '../../../Card/hooks/useMoneyAccountCardLinkage';
import { useCardHomeData } from '../../../Card/hooks/useCardHomeData';
import { MONEY_HOME_CARD_ORIGIN } from '../../../Card/hooks/useCardPostAuthRedirect';
@@ -160,6 +162,12 @@ const MoneyHomeView = () => {
const isCardholder = useSelector(selectIsCardholder);
const cardHomeDataStatus = useSelector(selectCardHomeDataStatus);
const hasMetalCard = useSelector(selectHasMetalCard);
+ const isMoneyAccountEnabled = useSelector(selectMoneyEnableMoneyAccountFlag);
+ const isMoneyAccountGeoEligible = useSelector(
+ selectIsMoneyAccountGeoEligible,
+ );
+ const isMoneyAccountVisible =
+ isMoneyAccountEnabled && isMoneyAccountGeoEligible;
const {
startLinkFlow,
isCardAuthenticated,
@@ -171,6 +179,17 @@ const MoneyHomeView = () => {
isResidencyBlocked,
} = useMoneyAccountCardLinkage();
+ const metamaskCardMode = deriveMoneyMetaMaskCardMode({
+ isCardLinkedToMoneyAccount,
+ isCardholder,
+ isCardAuthenticated,
+ isCardVerified,
+ isResidencyBlocked,
+ isMoneyAccountVisible,
+ hasMoneyAccountBaseRequirements,
+ hasMoneyAccountRequirements,
+ });
+
let displayState: MoneyBalanceDisplayState;
if (!hasMoneyAccount) {
displayState = { kind: 'noAccount' };
@@ -315,12 +334,14 @@ const MoneyHomeView = () => {
}, [navigation, trackButtonClicked]);
const navigateToCardHome = useCallback(() => {
+ const isUpsell = metamaskCardMode === 'upsell';
+
navigation.navigate(Routes.CARD.ROOT, {
screen: Routes.CARD.HOME,
params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN },
- animation: 'slide_from_bottom',
+ ...(isUpsell ? { animation: 'slide_from_bottom' } : {}),
});
- }, [navigation]);
+ }, [navigation, metamaskCardMode]);
const handleCardHeaderPress = useCallback(() => {
trackSurfaceClicked({
@@ -581,16 +602,6 @@ const MoneyHomeView = () => {
[navigation, trackActivitySurfaceClicked],
);
- const metamaskCardMode = deriveMoneyMetaMaskCardMode({
- isCardLinkedToMoneyAccount,
- isCardholder,
- isCardAuthenticated,
- isCardVerified,
- isResidencyBlocked,
- hasMoneyAccountBaseRequirements,
- hasMoneyAccountRequirements,
- });
-
const { primaryToken: cardPrimaryToken } = useCardHomeData();
const cardBalance = cardPrimaryToken?.balanceFiat ?? formattedZero;
const cardState = deriveCardState({
diff --git a/app/components/UI/Money/Views/MoneyOnboardingView/MoneyOnboardingView.tsx b/app/components/UI/Money/Views/MoneyOnboardingView/MoneyOnboardingView.tsx
index 728a9dcb03c..b54862a81bf 100644
--- a/app/components/UI/Money/Views/MoneyOnboardingView/MoneyOnboardingView.tsx
+++ b/app/components/UI/Money/Views/MoneyOnboardingView/MoneyOnboardingView.tsx
@@ -239,8 +239,7 @@ const MoneyOnboardingView = () => {
artboardName={RIVE_ARTBOARD_NAME}
stateMachineName={RIVE_STATE_MACHINE_NAME}
dataBinding={AutoBind(true)}
- fit={Fit.Layout}
- layoutScaleFactor={PixelRatio.get()}
+ fit={Fit.FitWidth}
onStateChanged={handleStateChanged}
testID={MoneyOnboardingViewTestIds.RIVE_ANIMATION}
/>
diff --git a/app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.test.tsx b/app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.test.tsx
index cfad4916ba2..34a367de512 100644
--- a/app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.test.tsx
+++ b/app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.test.tsx
@@ -4,6 +4,9 @@ import MoneyTransactionMonitor from './MoneyTransactionMonitor';
import { useMoneyTransactionStatus } from '../../hooks/useMoneyTransactionStatus';
jest.mock('../../hooks/useMoneyTransactionStatus');
+jest.mock('../../hooks/useMoneyFirstTimeDepositTracker', () => ({
+ useMoneyFirstTimeDepositTracker: jest.fn(),
+}));
describe('MoneyTransactionMonitor', () => {
const mockUseMoneyTransactionStatus = jest.mocked(useMoneyTransactionStatus);
diff --git a/app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.tsx b/app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.tsx
index 93b82af3cd1..1d3e4172ac6 100644
--- a/app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.tsx
+++ b/app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.tsx
@@ -1,10 +1,12 @@
import React from 'react';
import { useRefreshMoneyBalanceOnTxConfirm } from '../../hooks/useRefreshMoneyBalanceOnTxConfirm';
import { useMoneyTransactionStatus } from '../../hooks/useMoneyTransactionStatus';
+import { useMoneyFirstTimeDepositTracker } from '../../hooks/useMoneyFirstTimeDepositTracker';
const MoneyTransactionMonitor: React.FC = () => {
useMoneyTransactionStatus();
useRefreshMoneyBalanceOnTxConfirm();
+ useMoneyFirstTimeDepositTracker();
return null;
};
diff --git a/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.test.tsx b/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.test.tsx
index 6dfaaa0cc99..4c945c3ed2c 100644
--- a/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.test.tsx
+++ b/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.test.tsx
@@ -5,6 +5,13 @@ import { UserActionType } from '../../../../actions/user/types';
import { selectMoneyOnboardingSeen } from '../../../../reducers/user/selectors';
import { selectPrimaryMoneyAccount } from '../../../../selectors/moneyAccountController';
+const mockNavigate = jest.fn();
+
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => ({ navigate: mockNavigate }),
+}));
+
const mockDispatch = jest.fn();
const mockUseSelector = jest.fn();
diff --git a/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.tsx b/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.tsx
index 0d8d96997bf..8200891eec9 100644
--- a/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.tsx
+++ b/app/components/UI/Money/components/MoneyUiDeveloperOptionsSection.tsx
@@ -1,5 +1,7 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
+import { useNavigation } from '@react-navigation/native';
+import { type StackNavigationProp } from '@react-navigation/stack';
import { useTheme } from '../../../../util/theme';
import {
@@ -21,11 +23,13 @@ import {
import styleSheet from '../../../Views/Settings/DeveloperOptions/DeveloperOptions.styles';
import ClipboardManager from '../../../../core/ClipboardManager';
import { STEPPER_IDS } from '../hooks/useOnboardingStep';
+import Routes from '../../../../constants/navigation/Routes';
export const MoneyUiDeveloperOptionsSection = () => {
const dispatch = useDispatch();
const theme = useTheme();
const { styles } = useStyles(styleSheet, { theme });
+ const navigation = useNavigation();
const hasSeenMoneyOnboarding = useSelector(selectMoneyOnboardingSeen);
const primaryMoneyAccount = useSelector(selectPrimaryMoneyAccount);
@@ -45,6 +49,10 @@ export const MoneyUiDeveloperOptionsSection = () => {
}
}, [moneyAccountAddress]);
+ const handlePreviewFirstTimeDepositAnimation = useCallback(() => {
+ navigation.navigate(Routes.MONEY.FIRST_TIME_DEPOSIT);
+ }, [navigation]);
+
return (
@@ -105,6 +113,24 @@ export const MoneyUiDeveloperOptionsSection = () => {
{'Reset onboarding stepper'}
+
+
+ {'Preview first-time deposit animation'}
+
+
+
);
};
diff --git a/app/components/UI/Money/constants/moneyEvents.ts b/app/components/UI/Money/constants/moneyEvents.ts
index 00b95adbec5..b7f9fc4bb97 100644
--- a/app/components/UI/Money/constants/moneyEvents.ts
+++ b/app/components/UI/Money/constants/moneyEvents.ts
@@ -16,6 +16,7 @@ export enum SCREEN_NAMES {
MONEY_ACTIVITY = 'money_activity',
MONEY_ACTIVITY_DETAILS = 'money_activity_details',
MONEY_POTENTIAL_EARNINGS = 'money_potential_earnings',
+ MONEY_FIRST_TIME_DEPOSIT = 'money_first_time_deposit',
}
export enum BOTTOM_SHEET_NAMES {
diff --git a/app/components/UI/Money/hooks/useMoneyFirstTimeDepositTracker.test.ts b/app/components/UI/Money/hooks/useMoneyFirstTimeDepositTracker.test.ts
new file mode 100644
index 00000000000..7996c862352
--- /dev/null
+++ b/app/components/UI/Money/hooks/useMoneyFirstTimeDepositTracker.test.ts
@@ -0,0 +1,201 @@
+import { renderHook, act } from '@testing-library/react-native';
+import {
+ TransactionMeta,
+ TransactionStatus,
+ TransactionType,
+} from '@metamask/transaction-controller';
+import { useMoneyFirstTimeDepositTracker } from './useMoneyFirstTimeDepositTracker';
+import { store } from '../../../../store';
+import { selectMoneyFirstTimeDepositAnimationEnabledFlag } from '../selectors/featureFlags';
+import { hasPriorMoneyDeposit } from '../utils/firstTimeDeposit';
+import Routes from '../../../../constants/navigation/Routes';
+import Engine from '../../../../core/Engine';
+
+const mockNavigate = jest.fn();
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({ navigate: mockNavigate }),
+}));
+
+jest.mock('../../../../store', () => ({
+ store: { getState: jest.fn() },
+}));
+
+jest.mock('../selectors/featureFlags', () => ({
+ selectMoneyFirstTimeDepositAnimationEnabledFlag: jest.fn(),
+}));
+
+jest.mock('../utils/firstTimeDeposit', () => ({
+ hasPriorMoneyDeposit: jest.fn(),
+}));
+
+jest.mock('../utils/moneyTransactionGuards', () => ({
+ isMoneyDepositTx: jest.fn(
+ (tx: { type: string; nestedTransactions?: { type: string }[] }) =>
+ tx.type === 'moneyAccountDeposit' ||
+ tx.nestedTransactions?.some((n) => n.type === 'moneyAccountDeposit'),
+ ),
+}));
+
+jest.mock('../../../../core/Engine', () => ({
+ controllerMessenger: {
+ subscribe: jest.fn(),
+ unsubscribe: jest.fn(),
+ },
+}));
+
+const mockedFlagSelector =
+ selectMoneyFirstTimeDepositAnimationEnabledFlag as unknown as jest.Mock;
+const mockedHasPrior = hasPriorMoneyDeposit as jest.Mock;
+const mockedGetState = store.getState as jest.Mock;
+const mockedSubscribe = Engine.controllerMessenger.subscribe as jest.Mock;
+const mockedUnsubscribe = Engine.controllerMessenger.unsubscribe as jest.Mock;
+
+const makeTx = (
+ id: string,
+ type: TransactionType,
+ status: TransactionStatus = TransactionStatus.confirmed,
+): TransactionMeta =>
+ ({
+ id,
+ type,
+ status,
+ time: 0,
+ txParams: {},
+ }) as unknown as TransactionMeta;
+
+const captureSubscribedCallback = () =>
+ mockedSubscribe.mock.calls[0][1] as (tx: TransactionMeta) => void;
+
+describe('useMoneyFirstTimeDepositTracker', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockedGetState.mockReturnValue({});
+ mockedFlagSelector.mockReturnValue(true);
+ mockedHasPrior.mockReturnValue(false);
+ });
+
+ it('subscribes to TransactionController:transactionConfirmed on mount', () => {
+ renderHook(() => useMoneyFirstTimeDepositTracker());
+
+ expect(mockedSubscribe).toHaveBeenCalledWith(
+ 'TransactionController:transactionConfirmed',
+ expect.any(Function),
+ );
+ });
+
+ it('unsubscribes on unmount', () => {
+ const { unmount } = renderHook(() => useMoneyFirstTimeDepositTracker());
+ const subscribedCallback = captureSubscribedCallback();
+
+ unmount();
+
+ expect(mockedUnsubscribe).toHaveBeenCalledWith(
+ 'TransactionController:transactionConfirmed',
+ subscribedCallback,
+ );
+ });
+
+ it('does not navigate when tx status is not confirmed', () => {
+ renderHook(() => useMoneyFirstTimeDepositTracker());
+ const callback = captureSubscribedCallback();
+
+ act(() => {
+ callback(
+ makeTx(
+ 'tx-1',
+ TransactionType.moneyAccountDeposit,
+ TransactionStatus.submitted,
+ ),
+ );
+ });
+
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('does not navigate when tx is not a money deposit', () => {
+ renderHook(() => useMoneyFirstTimeDepositTracker());
+ const callback = captureSubscribedCallback();
+
+ act(() => {
+ callback(makeTx('tx-1', TransactionType.contractInteraction));
+ });
+
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('does not navigate when feature flag is disabled', () => {
+ mockedFlagSelector.mockReturnValue(false);
+ renderHook(() => useMoneyFirstTimeDepositTracker());
+ const callback = captureSubscribedCallback();
+
+ act(() => {
+ callback(makeTx('tx-1', TransactionType.moneyAccountDeposit));
+ });
+
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('does not navigate when prior money deposit exists', () => {
+ mockedHasPrior.mockReturnValue(true);
+ renderHook(() => useMoneyFirstTimeDepositTracker());
+ const callback = captureSubscribedCallback();
+
+ act(() => {
+ callback(makeTx('tx-1', TransactionType.moneyAccountDeposit));
+ });
+
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('navigates directly to first-time deposit for eligible deposit', () => {
+ renderHook(() => useMoneyFirstTimeDepositTracker());
+ const callback = captureSubscribedCallback();
+
+ act(() => {
+ callback(makeTx('tx-1', TransactionType.moneyAccountDeposit));
+ });
+
+ expect(mockNavigate).toHaveBeenCalledTimes(1);
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.FIRST_TIME_DEPOSIT);
+ });
+
+ it('navigates directly to first-time deposit for eligible nested batch deposit', () => {
+ renderHook(() => useMoneyFirstTimeDepositTracker());
+ const callback = captureSubscribedCallback();
+
+ const batchTx = {
+ ...makeTx('batch-1', TransactionType.batch),
+ nestedTransactions: [{ type: TransactionType.moneyAccountDeposit }],
+ } as unknown as TransactionMeta;
+
+ act(() => {
+ callback(batchTx);
+ });
+
+ expect(mockNavigate).toHaveBeenCalledTimes(1);
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.FIRST_TIME_DEPOSIT);
+ });
+
+ it('passes current tx id to hasPriorMoneyDeposit', () => {
+ renderHook(() => useMoneyFirstTimeDepositTracker());
+ const callback = captureSubscribedCallback();
+
+ act(() => {
+ callback(makeTx('tx-42', TransactionType.moneyAccountDeposit));
+ });
+
+ expect(mockedHasPrior).toHaveBeenCalledWith(expect.anything(), 'tx-42');
+ });
+
+ it('reads state imperatively via store.getState', () => {
+ renderHook(() => useMoneyFirstTimeDepositTracker());
+ const callback = captureSubscribedCallback();
+
+ act(() => {
+ callback(makeTx('tx-1', TransactionType.moneyAccountDeposit));
+ });
+
+ expect(mockedGetState).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/app/components/UI/Money/hooks/useMoneyFirstTimeDepositTracker.ts b/app/components/UI/Money/hooks/useMoneyFirstTimeDepositTracker.ts
new file mode 100644
index 00000000000..cb1c8df8c24
--- /dev/null
+++ b/app/components/UI/Money/hooks/useMoneyFirstTimeDepositTracker.ts
@@ -0,0 +1,46 @@
+import { useEffect } from 'react';
+import { useNavigation } from '@react-navigation/native';
+import {
+ TransactionMeta,
+ TransactionStatus,
+} from '@metamask/transaction-controller';
+import Engine from '../../../../core/Engine';
+import Routes from '../../../../constants/navigation/Routes';
+import { store } from '../../../../store';
+import { selectMoneyFirstTimeDepositAnimationEnabledFlag } from '../selectors/featureFlags';
+import { isMoneyDepositTx } from '../utils/moneyTransactionGuards';
+import { hasPriorMoneyDeposit } from '../utils/firstTimeDeposit';
+
+export const useMoneyFirstTimeDepositTracker = () => {
+ const navigation = useNavigation();
+
+ useEffect(() => {
+ const handleTransactionConfirmed = (tx: TransactionMeta) => {
+ if (tx.status !== TransactionStatus.confirmed) return;
+
+ const state = store.getState();
+
+ if (
+ !isMoneyDepositTx(tx) ||
+ !selectMoneyFirstTimeDepositAnimationEnabledFlag(state) ||
+ hasPriorMoneyDeposit(state, tx.id)
+ ) {
+ return;
+ }
+
+ navigation.navigate(Routes.MONEY.FIRST_TIME_DEPOSIT);
+ };
+
+ Engine.controllerMessenger.subscribe(
+ 'TransactionController:transactionConfirmed',
+ handleTransactionConfirmed,
+ );
+
+ return () => {
+ Engine.controllerMessenger.unsubscribe(
+ 'TransactionController:transactionConfirmed',
+ handleTransactionConfirmed,
+ );
+ };
+ }, [navigation]);
+};
diff --git a/app/components/UI/Money/selectors/eligibility.test.ts b/app/components/UI/Money/selectors/eligibility.test.ts
index 3ce33c3fc33..0a25b9d9c7c 100644
--- a/app/components/UI/Money/selectors/eligibility.test.ts
+++ b/app/components/UI/Money/selectors/eligibility.test.ts
@@ -1,6 +1,24 @@
-import { selectIsMoneyAccountGeoEligible } from './eligibility';
+import {
+ selectIsMoneyAccountGeoEligible,
+ selectIsUserInUS,
+ selectIsUsUnauthenticatedNonCardholder,
+} from './eligibility';
+import {
+ selectIsCardAuthenticated,
+ selectHasCardholderAccounts,
+} from '../../../../selectors/cardController';
import type { RootState } from '../../../../reducers';
+jest.mock('../../../../selectors/cardController', () => ({
+ selectIsCardAuthenticated: jest.fn(),
+ selectHasCardholderAccounts: jest.fn(),
+}));
+
+const mockSelectIsCardAuthenticated =
+ selectIsCardAuthenticated as unknown as jest.MockedFunction<() => boolean>;
+const mockSelectHasCardholderAccounts =
+ selectHasCardholderAccounts as unknown as jest.MockedFunction<() => boolean>;
+
describe('selectIsMoneyAccountGeoEligible', () => {
const createStateWithGeolocation = (
geolocation: string | null | undefined,
@@ -153,3 +171,93 @@ describe('selectIsMoneyAccountGeoEligible', () => {
}
});
});
+
+describe('selectIsUserInUS', () => {
+ const createStateWithGeolocation = (geolocation: string | null | undefined) =>
+ ({
+ engine: {
+ backgroundState: {
+ GeolocationController: {
+ location: geolocation,
+ },
+ },
+ },
+ }) as unknown as RootState;
+
+ it('returns true for a plain US country code', () => {
+ expect(selectIsUserInUS(createStateWithGeolocation('US'))).toBe(true);
+ });
+
+ it('returns true for a US country-region code', () => {
+ expect(selectIsUserInUS(createStateWithGeolocation('US-CA'))).toBe(true);
+ });
+
+ it('is case-insensitive', () => {
+ expect(selectIsUserInUS(createStateWithGeolocation('us'))).toBe(true);
+ });
+
+ it('returns false for a non-US country', () => {
+ expect(selectIsUserInUS(createStateWithGeolocation('GB'))).toBe(false);
+ });
+
+ it('returns false when geolocation is unknown', () => {
+ expect(selectIsUserInUS(createStateWithGeolocation('UNKNOWN'))).toBe(false);
+ });
+
+ it('returns false when geolocation is undefined', () => {
+ expect(selectIsUserInUS(createStateWithGeolocation(undefined))).toBe(false);
+ });
+});
+
+describe('selectIsUsUnauthenticatedNonCardholder', () => {
+ const createState = (geolocation: string | null | undefined) =>
+ ({
+ engine: {
+ backgroundState: {
+ GeolocationController: {
+ location: geolocation,
+ },
+ },
+ },
+ }) as unknown as RootState;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns true for a US user who is not authenticated and not a cardholder', () => {
+ mockSelectIsCardAuthenticated.mockReturnValue(false);
+ mockSelectHasCardholderAccounts.mockReturnValue(false);
+
+ expect(selectIsUsUnauthenticatedNonCardholder(createState('US'))).toBe(
+ true,
+ );
+ });
+
+ it('returns false when the user is not in the US', () => {
+ mockSelectIsCardAuthenticated.mockReturnValue(false);
+ mockSelectHasCardholderAccounts.mockReturnValue(false);
+
+ expect(selectIsUsUnauthenticatedNonCardholder(createState('GB'))).toBe(
+ false,
+ );
+ });
+
+ it('returns false when the US user is authenticated', () => {
+ mockSelectIsCardAuthenticated.mockReturnValue(true);
+ mockSelectHasCardholderAccounts.mockReturnValue(false);
+
+ expect(selectIsUsUnauthenticatedNonCardholder(createState('US'))).toBe(
+ false,
+ );
+ });
+
+ it('returns false when any wallet account is a cardholder', () => {
+ mockSelectIsCardAuthenticated.mockReturnValue(false);
+ mockSelectHasCardholderAccounts.mockReturnValue(true);
+
+ expect(selectIsUsUnauthenticatedNonCardholder(createState('US'))).toBe(
+ false,
+ );
+ });
+});
diff --git a/app/components/UI/Money/selectors/eligibility.ts b/app/components/UI/Money/selectors/eligibility.ts
index 2a14aad31cb..9b6c1c3448b 100644
--- a/app/components/UI/Money/selectors/eligibility.ts
+++ b/app/components/UI/Money/selectors/eligibility.ts
@@ -7,11 +7,18 @@
*/
import { createSelector } from 'reselect';
+import type { RootState } from '../../../../reducers';
import { selectMoneyAccountGeoBlockedCountries } from './featureFlags';
import { getDetectedGeolocation } from '../../../../reducers/fiatOrders';
+import {
+ selectIsCardAuthenticated,
+ selectHasCardholderAccounts,
+} from '../../../../selectors/cardController';
+
+const US_COUNTRY_CODE = 'US';
export const selectIsMoneyAccountGeoEligible = createSelector(
- selectMoneyAccountGeoBlockedCountries,
+ (state: RootState) => selectMoneyAccountGeoBlockedCountries(state),
getDetectedGeolocation,
(blockedCountries, geolocation): boolean => {
const userCountry = geolocation?.toUpperCase().split('-')[0] ?? null;
@@ -24,3 +31,32 @@ export const selectIsMoneyAccountGeoEligible = createSelector(
);
},
);
+
+/**
+ * Whether the user's detected geolocation resolves to the United States.
+ *
+ * The Ramps geolocation API returns codes like "US" or "US-CA"
+ * (country-region), so only the leading country segment is compared.
+ * Defaults to `false` when geolocation is unknown or still loading.
+ */
+export const selectIsUserInUS = createSelector(
+ getDetectedGeolocation,
+ (geolocation): boolean =>
+ (geolocation?.toUpperCase().split('-')[0] ?? null) === US_COUNTRY_CODE,
+);
+
+/**
+ * Whether the user is in the US, not authenticated with the card service, and
+ * not a cardholder.
+ *
+ * Cardholder status is evaluated wallet-wide (any account) rather than for the
+ * currently selected account, so an existing card customer never matches this
+ * condition after switching accounts.
+ */
+export const selectIsUsUnauthenticatedNonCardholder = createSelector(
+ selectIsUserInUS,
+ selectIsCardAuthenticated,
+ selectHasCardholderAccounts,
+ (isInUS, isCardAuthenticated, hasCardholderAccounts): boolean =>
+ isInUS && !isCardAuthenticated && !hasCardholderAccounts,
+);
diff --git a/app/components/UI/Money/selectors/featureFlags.test.ts b/app/components/UI/Money/selectors/featureFlags.test.ts
index ee5e18ec4e6..20bfaa75d52 100644
--- a/app/components/UI/Money/selectors/featureFlags.test.ts
+++ b/app/components/UI/Money/selectors/featureFlags.test.ts
@@ -10,6 +10,7 @@ import {
selectMoneyTokensSortMode,
selectMoneyAccountGeoBlockedCountries,
DEFAULT_MONEY_ACCOUNT_BLOCKED_COUNTRIES,
+ selectMoneyFirstTimeDepositAnimationEnabledFlag,
} from './featureFlags';
jest.mock('../../../../core/Engine', () => ({
@@ -484,3 +485,98 @@ describe('selectMoneyAccountGeoBlockedCountries', () => {
expect(DEFAULT_MONEY_ACCOUNT_BLOCKED_COUNTRIES).toContain('GB');
});
});
+
+describe('selectMoneyFirstTimeDepositAnimationEnabledFlag', () => {
+ const originalEnv = process.env;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ process.env = { ...originalEnv };
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ });
+
+ it('returns true when remote flag is enabled and version requirement is met', () => {
+ mockedValidate.mockReturnValue(true);
+
+ const state = createState({
+ earnMoneyFirstTimeDepositAnimationEnabled: {
+ enabled: true,
+ minimumVersion: '1.0.0',
+ },
+ });
+
+ const result = selectMoneyFirstTimeDepositAnimationEnabledFlag(
+ state as never,
+ );
+
+ expect(result).toBe(true);
+ });
+
+ it('returns false when remote flag is disabled', () => {
+ mockedValidate.mockReturnValue(false);
+
+ const state = createState({
+ earnMoneyFirstTimeDepositAnimationEnabled: {
+ enabled: false,
+ minimumVersion: '1.0.0',
+ },
+ });
+
+ const result = selectMoneyFirstTimeDepositAnimationEnabledFlag(
+ state as never,
+ );
+
+ expect(result).toBe(false);
+ });
+
+ it('defaults to true when remote flag returns undefined and env is unset', () => {
+ mockedValidate.mockReturnValue(undefined);
+ delete process.env.MM_MONEY_FIRST_TIME_DEPOSIT_ANIMATION_ENABLED;
+
+ const state = createState({
+ _unique: 'first-deposit-default-on',
+ });
+
+ const result = selectMoneyFirstTimeDepositAnimationEnabledFlag(
+ state as never,
+ );
+
+ expect(result).toBe(true);
+ });
+
+ it('returns false when env var is set to false and remote is undefined', () => {
+ mockedValidate.mockReturnValue(undefined);
+ process.env.MM_MONEY_FIRST_TIME_DEPOSIT_ANIMATION_ENABLED = 'false';
+
+ const state = createState({
+ _unique: 'first-deposit-env-false',
+ });
+
+ const result = selectMoneyFirstTimeDepositAnimationEnabledFlag(
+ state as never,
+ );
+
+ expect(result).toBe(false);
+ });
+
+ it('remote flag takes precedence over env var', () => {
+ mockedValidate.mockReturnValue(false);
+ process.env.MM_MONEY_FIRST_TIME_DEPOSIT_ANIMATION_ENABLED = 'true';
+
+ const state = createState({
+ earnMoneyFirstTimeDepositAnimationEnabled: {
+ enabled: false,
+ minimumVersion: '1.0.0',
+ },
+ });
+
+ const result = selectMoneyFirstTimeDepositAnimationEnabledFlag(
+ state as never,
+ );
+
+ expect(result).toBe(false);
+ });
+});
diff --git a/app/components/UI/Money/selectors/featureFlags.ts b/app/components/UI/Money/selectors/featureFlags.ts
index 200ba522b2c..1861f801b9c 100644
--- a/app/components/UI/Money/selectors/featureFlags.ts
+++ b/app/components/UI/Money/selectors/featureFlags.ts
@@ -39,6 +39,21 @@ export const selectMoneyHubEnabledFlag = createSelector(
},
);
+/**
+ * Kill-switch for the first-time deposit Rive animation.
+ * Defaults to ON (true).
+ */
+export const selectMoneyFirstTimeDepositAnimationEnabledFlag = createSelector(
+ selectRemoteFeatureFlags,
+ (remoteFeatureFlags) => {
+ const remoteFlag =
+ remoteFeatureFlags?.earnMoneyFirstTimeDepositAnimationEnabled as unknown as VersionGatedFeatureFlag;
+ const local =
+ process.env.MM_MONEY_FIRST_TIME_DEPOSIT_ANIMATION_ENABLED !== 'false';
+ return validatedVersionGatedFeatureFlag(remoteFlag) ?? local;
+ },
+);
+
/**
* Selects the blocked payment tokens for Money surfaces from remote config or local fallback.
* Returns a wildcard blocklist mapping chain IDs (or "*") to token symbols (or ["*"]).
diff --git a/app/components/UI/Money/utils/firstTimeDeposit.test.ts b/app/components/UI/Money/utils/firstTimeDeposit.test.ts
new file mode 100644
index 00000000000..9e98f15560a
--- /dev/null
+++ b/app/components/UI/Money/utils/firstTimeDeposit.test.ts
@@ -0,0 +1,97 @@
+import {
+ TransactionMeta,
+ TransactionStatus,
+ TransactionType,
+} from '@metamask/transaction-controller';
+import { hasPriorMoneyDeposit } from './firstTimeDeposit';
+import { selectNonReplacedTransactions } from '../../../../selectors/transactionController';
+
+jest.mock('../../../../selectors/transactionController', () => ({
+ selectNonReplacedTransactions: jest.fn(),
+}));
+
+const mockedSelectNonReplacedTransactions =
+ selectNonReplacedTransactions as unknown as jest.MockedFunction<
+ (state: unknown) => TransactionMeta[]
+ >;
+
+const baseTx = {
+ status: TransactionStatus.confirmed,
+ time: 0,
+ txParams: {},
+} as unknown as TransactionMeta;
+
+const makeTx = (
+ id: string,
+ type: TransactionType,
+ nested?: { type: TransactionType }[],
+): TransactionMeta =>
+ ({
+ ...baseTx,
+ id,
+ type,
+ nestedTransactions: nested,
+ }) as unknown as TransactionMeta;
+
+const mockState = {} as never;
+
+describe('hasPriorMoneyDeposit', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns false when transaction list is empty', () => {
+ mockedSelectNonReplacedTransactions.mockReturnValue([]);
+
+ const result = hasPriorMoneyDeposit(mockState, 'current-tx');
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false when only transaction is the current one', () => {
+ mockedSelectNonReplacedTransactions.mockReturnValue([
+ makeTx('current-tx', TransactionType.moneyAccountDeposit),
+ ]);
+
+ const result = hasPriorMoneyDeposit(mockState, 'current-tx');
+
+ expect(result).toBe(false);
+ });
+
+ it('returns true when a prior top-level deposit exists', () => {
+ mockedSelectNonReplacedTransactions.mockReturnValue([
+ makeTx('current-tx', TransactionType.moneyAccountDeposit),
+ makeTx('prior-tx', TransactionType.moneyAccountDeposit),
+ ]);
+
+ const result = hasPriorMoneyDeposit(mockState, 'current-tx');
+
+ expect(result).toBe(true);
+ });
+
+ it('returns true when a prior nested batch deposit exists', () => {
+ mockedSelectNonReplacedTransactions.mockReturnValue([
+ makeTx('current-tx', TransactionType.moneyAccountDeposit),
+ makeTx('prior-batch', TransactionType.batch, [
+ { type: TransactionType.moneyAccountDeposit },
+ ]),
+ ]);
+
+ const result = hasPriorMoneyDeposit(mockState, 'current-tx');
+
+ expect(result).toBe(true);
+ });
+
+ it('returns false when prior transactions are non-deposit types', () => {
+ mockedSelectNonReplacedTransactions.mockReturnValue([
+ makeTx('current-tx', TransactionType.moneyAccountDeposit),
+ makeTx('other-1', TransactionType.moneyAccountWithdraw),
+ makeTx('other-2', TransactionType.contractInteraction),
+ makeTx('other-3', TransactionType.incoming),
+ ]);
+
+ const result = hasPriorMoneyDeposit(mockState, 'current-tx');
+
+ expect(result).toBe(false);
+ });
+});
diff --git a/app/components/UI/Money/utils/firstTimeDeposit.ts b/app/components/UI/Money/utils/firstTimeDeposit.ts
new file mode 100644
index 00000000000..0c3c9f26054
--- /dev/null
+++ b/app/components/UI/Money/utils/firstTimeDeposit.ts
@@ -0,0 +1,22 @@
+import { TransactionStatus } from '@metamask/transaction-controller';
+import { RootState } from '../../../../reducers';
+import { selectNonReplacedTransactions } from '../../../../selectors/transactionController';
+import { isMoneyDepositTx } from './moneyTransactionGuards';
+
+/**
+ * Returns true if the wallet has any prior self-initiated Money deposit
+ * transactions, excluding the transaction with `currentTxId`.
+ */
+export function hasPriorMoneyDeposit(
+ state: RootState,
+ currentTxId: string,
+): boolean {
+ const transactions = selectNonReplacedTransactions(state);
+
+ return transactions.some(
+ (tx) =>
+ tx.id !== currentTxId &&
+ isMoneyDepositTx(tx) &&
+ tx.status === TransactionStatus.confirmed,
+ );
+}
diff --git a/app/components/UI/Money/utils/moneyMetaMaskCardMode.test.ts b/app/components/UI/Money/utils/moneyMetaMaskCardMode.test.ts
index b37cfe772fc..30030665fb4 100644
--- a/app/components/UI/Money/utils/moneyMetaMaskCardMode.test.ts
+++ b/app/components/UI/Money/utils/moneyMetaMaskCardMode.test.ts
@@ -6,6 +6,7 @@ const baseInput = {
isCardAuthenticated: false,
isCardVerified: false,
isResidencyBlocked: false,
+ isMoneyAccountVisible: true,
hasMoneyAccountBaseRequirements: true,
hasMoneyAccountRequirements: true,
};
@@ -103,6 +104,49 @@ describe('deriveMoneyMetaMaskCardMode', () => {
expect(deriveMoneyMetaMaskCardMode(baseInput)).toBe('upsell');
});
+ it('returns null for upsell when the Money account is not visible', () => {
+ expect(
+ deriveMoneyMetaMaskCardMode({
+ ...baseInput,
+ isMoneyAccountVisible: false,
+ }),
+ ).toBeNull();
+ });
+
+ it('returns null for verifying when the Money account is not visible', () => {
+ expect(
+ deriveMoneyMetaMaskCardMode({
+ ...baseInput,
+ isCardAuthenticated: true,
+ isCardVerified: false,
+ isMoneyAccountVisible: false,
+ }),
+ ).toBeNull();
+ });
+
+ it('returns manage when linked even if the Money account is not visible', () => {
+ expect(
+ deriveMoneyMetaMaskCardMode({
+ ...baseInput,
+ isCardLinkedToMoneyAccount: true,
+ isMoneyAccountVisible: false,
+ }),
+ ).toBe('manage');
+ });
+
+ it('returns link for a cardholder when full requirements are met even if visibility is off', () => {
+ expect(
+ deriveMoneyMetaMaskCardMode({
+ ...baseInput,
+ isCardholder: true,
+ isCardAuthenticated: true,
+ isCardVerified: true,
+ hasMoneyAccountRequirements: true,
+ isMoneyAccountVisible: false,
+ }),
+ ).toBe('link');
+ });
+
it('prefers manage over all other states when the card is linked', () => {
expect(
deriveMoneyMetaMaskCardMode({
diff --git a/app/components/UI/Money/utils/moneyMetaMaskCardMode.ts b/app/components/UI/Money/utils/moneyMetaMaskCardMode.ts
index e38a103b108..9a4871e0802 100644
--- a/app/components/UI/Money/utils/moneyMetaMaskCardMode.ts
+++ b/app/components/UI/Money/utils/moneyMetaMaskCardMode.ts
@@ -6,6 +6,7 @@ export interface MoneyMetaMaskCardModeInput {
isCardAuthenticated: boolean;
isCardVerified: boolean;
isResidencyBlocked: boolean;
+ isMoneyAccountVisible: boolean;
hasMoneyAccountBaseRequirements: boolean;
hasMoneyAccountRequirements: boolean;
}
@@ -16,6 +17,7 @@ export const deriveMoneyMetaMaskCardMode = ({
isCardAuthenticated,
isCardVerified,
isResidencyBlocked,
+ isMoneyAccountVisible,
hasMoneyAccountBaseRequirements,
hasMoneyAccountRequirements,
}: MoneyMetaMaskCardModeInput): MoneyMetaMaskCardMode | null => {
@@ -36,8 +38,8 @@ export const deriveMoneyMetaMaskCardMode = ({
}
// Authenticated but identity not yet verified -> verification in progress.
- if (isCardAuthenticated) return 'verifying';
+ if (isCardAuthenticated) return isMoneyAccountVisible ? 'verifying' : null;
// Brand-new user -> upsell.
- return 'upsell';
+ return isMoneyAccountVisible ? 'upsell' : null;
};
diff --git a/app/components/UI/PermissionsSummary/PermissionsSummary.tsx b/app/components/UI/PermissionsSummary/PermissionsSummary.tsx
index 4ca734fefa1..4814edb59ee 100644
--- a/app/components/UI/PermissionsSummary/PermissionsSummary.tsx
+++ b/app/components/UI/PermissionsSummary/PermissionsSummary.tsx
@@ -62,7 +62,7 @@ import AvatarFavicon from '../../../component-library/components/Avatars/Avatar/
import AvatarToken from '../../../component-library/components/Avatars/Avatar/variants/AvatarToken';
import AccountConnectCreateInitialAccount from '../../Views/MultichainAccounts/shared/AccountConnectCreateInitialAccount';
import { SolScope } from '@metamask/keyring-api';
-import { WalletClientType } from '../../../core/SnapKeyring/MultichainWalletSnapClient';
+import { WalletClientType } from '../../../core/SnapKeyring/types';
import { endTrace, trace, TraceName } from '../../../util/trace';
import {
Text as TextComponent,
diff --git a/app/components/UI/PermissionsSummary/PermissionsSummary.types.ts b/app/components/UI/PermissionsSummary/PermissionsSummary.types.ts
index f85a1f205aa..8c51303a386 100644
--- a/app/components/UI/PermissionsSummary/PermissionsSummary.types.ts
+++ b/app/components/UI/PermissionsSummary/PermissionsSummary.types.ts
@@ -2,7 +2,7 @@ import { CaipAccountId } from '@metamask/utils';
import { USER_INTENT } from '../../../constants/permissions';
import { Account, EnsByAccountAddress } from '../../hooks/useAccounts';
import { NetworkAvatarProps } from '../../Views/MultichainAccounts/shared/AccountConnect.types';
-import { WalletClientType } from '../../../core/SnapKeyring/MultichainWalletSnapClient';
+import { WalletClientType } from '../../../core/SnapKeyring/types';
import { SolScope } from '@metamask/keyring-api';
export interface PermissionsSummaryProps {
currentPageInformation: {
diff --git a/app/components/UI/Predict/components/FeaturedCarousel/FeaturedCarouselSportCard.test.tsx b/app/components/UI/Predict/components/FeaturedCarousel/FeaturedCarouselSportCard.test.tsx
index 820c239d5c1..3c964d0afff 100644
--- a/app/components/UI/Predict/components/FeaturedCarousel/FeaturedCarouselSportCard.test.tsx
+++ b/app/components/UI/Predict/components/FeaturedCarousel/FeaturedCarouselSportCard.test.tsx
@@ -13,6 +13,7 @@ import {
} from '../../types';
import FeaturedCarouselSportCard from './FeaturedCarouselSportCard';
import { FEATURED_CAROUSEL_TEST_IDS } from './FeaturedCarousel.testIds';
+import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices';
jest.mock('@metamask/design-system-twrnc-preset', () => ({
useTailwind: () => ({
@@ -83,6 +84,14 @@ jest.mock('../../hooks/useLiveGameUpdates', () => ({
useLiveGameUpdates: () => ({ gameUpdate: null }),
}));
+const mockGetLivePrice = jest.fn();
+jest.mock('../../hooks/useLiveMarketPrices', () => ({
+ useLiveMarketPrices: jest.fn(() => ({
+ getPrice: mockGetLivePrice,
+ })),
+}));
+const mockUseLiveMarketPrices = jest.mocked(useLiveMarketPrices);
+
jest.mock('../../constants/sportLeagueConfigs', () => ({
getLeagueConfig: () => ({}),
}));
@@ -107,6 +116,24 @@ const initialState = {
},
};
+const stateWithSportCardLivePricesEnabled = (enabled: boolean) => ({
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+ RemoteFeatureFlagController: {
+ ...backgroundState.RemoteFeatureFlagController,
+ remoteFeatureFlags: {
+ ...backgroundState.RemoteFeatureFlagController?.remoteFeatureFlags,
+ predictSportCardLivePrices: {
+ enabled,
+ minimumVersion: '0.0.0',
+ },
+ },
+ },
+ },
+ },
+});
+
const createMockOutcome = (
tokens: PredictOutcomeToken[],
volume = 1500000,
@@ -184,6 +211,7 @@ const createMockSportMarket = (
describe('FeaturedCarouselSportCard', () => {
beforeEach(() => {
jest.clearAllMocks();
+ mockGetLivePrice.mockReturnValue(undefined);
});
it('renders league name and live indicator for ongoing games', () => {
@@ -257,7 +285,9 @@ describe('FeaturedCarouselSportCard', () => {
{ state: initialState },
);
- expect(getByText(strings('predict.outcome_draw'))).toBeOnTheScreen();
+ expect(
+ getByText(`${strings('predict.outcome_draw')} 20%`),
+ ).toBeOnTheScreen();
});
it.each(['nba', 'nfl'] as const)(
@@ -414,4 +444,60 @@ describe('FeaturedCarouselSportCard', () => {
}),
);
});
+
+ it('renders live best ask prices when available', () => {
+ mockGetLivePrice.mockImplementation((tokenId: string) => ({
+ tokenId,
+ price: 0,
+ bestBid: 0,
+ bestAsk:
+ tokenId === 'home-token'
+ ? 0.75
+ : tokenId === 'draw-token'
+ ? 0.18
+ : 0.25,
+ }));
+ const market = createMockSportMarket();
+
+ const { getByText } = renderWithProvider(
+ ,
+ { state: initialState },
+ );
+
+ expect(getByText('$133.33')).toBeOnTheScreen();
+ expect(getByText('$400.00')).toBeOnTheScreen();
+ expect(getByText('75%')).toBeOnTheScreen();
+ expect(
+ getByText(`${strings('predict.outcome_draw')} 18%`),
+ ).toBeOnTheScreen();
+ expect(getByText('25%')).toBeOnTheScreen();
+ });
+
+ it('renders static prices and disables live subscriptions when the flag is off', () => {
+ mockGetLivePrice.mockImplementation((tokenId: string) => ({
+ tokenId,
+ price: 0,
+ bestBid: 0,
+ bestAsk: 0.99,
+ }));
+ const market = createMockSportMarket();
+
+ const { getByText, queryByText } = renderWithProvider(
+ ,
+ { state: stateWithSportCardLivePricesEnabled(false) },
+ );
+
+ expect(getByText('$166.67')).toBeOnTheScreen();
+ expect(getByText('$250.00')).toBeOnTheScreen();
+ expect(getByText('60%')).toBeOnTheScreen();
+ expect(
+ getByText(`${strings('predict.outcome_draw')} 20%`),
+ ).toBeOnTheScreen();
+ expect(getByText('40%')).toBeOnTheScreen();
+ expect(queryByText('99%')).not.toBeOnTheScreen();
+ expect(mockUseLiveMarketPrices).toHaveBeenLastCalledWith(
+ ['home-token', 'draw-token', 'away-token'],
+ { enabled: false },
+ );
+ });
});
diff --git a/app/components/UI/Predict/components/FeaturedCarousel/FeaturedCarouselSportCard.tsx b/app/components/UI/Predict/components/FeaturedCarousel/FeaturedCarouselSportCard.tsx
index d5c2f779a09..b81764c3de9 100644
--- a/app/components/UI/Predict/components/FeaturedCarousel/FeaturedCarouselSportCard.tsx
+++ b/app/components/UI/Predict/components/FeaturedCarousel/FeaturedCarouselSportCard.tsx
@@ -1,6 +1,7 @@
import React, { useCallback, useMemo } from 'react';
import { TouchableOpacity } from 'react-native';
import { NavigationProp, useNavigation } from '@react-navigation/native';
+import { useSelector } from 'react-redux';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import {
Box,
@@ -19,6 +20,7 @@ import Routes from '../../../../../constants/navigation/Routes';
import {
PredictMarket,
PredictMarketGame,
+ PredictMarketStatus,
PredictOutcomeToken,
} from '../../types';
import {
@@ -30,10 +32,13 @@ import { PredictEventValues } from '../../constants/eventNames';
import { usePredictActionGuard } from '../../hooks/usePredictActionGuard';
import { usePredictPreviewSheet } from '../../contexts';
import { useLiveGameUpdates } from '../../hooks/useLiveGameUpdates';
+import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices';
import { isDrawCapableLeague } from '../../constants/sports';
+import { selectPredictSportCardLivePricesEnabledFlag } from '../../selectors/featureFlags';
import PredictSportTeamLogo from '../PredictSportTeamLogo/PredictSportTeamLogo';
import { getLeagueConfig } from '../../constants/sportLeagueConfigs';
import { parseScore } from '../../utils/gameParser';
+import { isValidPrice } from '../../utils/prices';
import FeaturedCarouselCardFooter from './FeaturedCarouselCardFooter';
import FeaturedCarouselPayoutRow from './FeaturedCarouselPayoutRow';
import { FEATURED_CAROUSEL_TEST_IDS } from './FeaturedCarousel.testIds';
@@ -62,6 +67,9 @@ const FeaturedCarouselSportCard: React.FC = ({
useNavigation>();
const { openBuySheet } = usePredictPreviewSheet();
const { executeGuardedAction } = usePredictActionGuard({ navigation });
+ const livePricesEnabled = useSelector(
+ selectPredictSportCardLivePricesEnabledFlag,
+ );
const game = market.game as PredictMarketGame;
const config = getLeagueConfig(game.league);
@@ -117,6 +125,31 @@ const FeaturedCarouselSportCard: React.FC = ({
const drawToken = showDraw
? outcome?.tokens?.find((t) => t.title?.toLowerCase() === 'draw')
: undefined;
+ const tokenIds = useMemo(
+ () =>
+ [homeToken, drawToken, awayToken]
+ .map((token) => token?.id)
+ .filter((id): id is string => Boolean(id)),
+ [awayToken, drawToken, homeToken],
+ );
+ const { getPrice } = useLiveMarketPrices(tokenIds, {
+ enabled:
+ livePricesEnabled &&
+ market.status === PredictMarketStatus.OPEN &&
+ tokenIds.length > 0,
+ });
+
+ const getDisplayPrice = useCallback(
+ (token: PredictOutcomeToken): number => {
+ if (!livePricesEnabled) {
+ return token.price;
+ }
+
+ const liveBestAsk = getPrice(token.id)?.bestAsk;
+ return isValidPrice(liveBestAsk) ? liveBestAsk : token.price;
+ },
+ [getPrice, livePricesEnabled],
+ );
const handleCardPress = useCallback(() => {
navigation.navigate(Routes.PREDICT.ROOT, {
@@ -265,7 +298,7 @@ const FeaturedCarouselSportCard: React.FC = ({
{game.homeTeam.name}
{homeToken && (
-
+
)}
@@ -279,7 +312,7 @@ const FeaturedCarouselSportCard: React.FC = ({
{game.awayTeam.name}
{awayToken && (
-
+
)}
@@ -298,7 +331,9 @@ const FeaturedCarouselSportCard: React.FC = ({
style={tw.style('font-medium')}
color={TextColor.SuccessDefault}
>
- {formatPercentage(Math.round(homeToken.price * 100))}
+ {formatPercentage(
+ Math.round(getDisplayPrice(homeToken) * 100),
+ )}
@@ -315,7 +350,10 @@ const FeaturedCarouselSportCard: React.FC = ({
style={tw.style('font-medium')}
color={TextColor.TextDefault}
>
- {strings('predict.outcome_draw')}
+ {strings('predict.outcome_draw')}{' '}
+ {formatPercentage(
+ Math.round(getDisplayPrice(drawToken) * 100),
+ )}
@@ -333,7 +371,9 @@ const FeaturedCarouselSportCard: React.FC = ({
style={tw.style('font-medium')}
color={TextColor.SuccessDefault}
>
- {formatPercentage(Math.round(awayToken.price * 100))}
+ {formatPercentage(
+ Math.round(getDisplayPrice(awayToken) * 100),
+ )}
diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomeCards.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomeCards.tsx
new file mode 100644
index 00000000000..b876b24f108
--- /dev/null
+++ b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomeCards.tsx
@@ -0,0 +1,300 @@
+import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
+import type { PredictMarketGame, PredictOutcomeGroup } from '../../types';
+import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices';
+import { isMoneylineLikeMarketType } from '../../constants/sports';
+import PredictSportOutcomeCard from '../PredictSportOutcomeCard';
+import {
+ buildButtons,
+ buildMoneylineButtons,
+ buildMoneylineSubtitle,
+ buildSubtitle,
+ type BuyHandler,
+ formatOutcomeCardTitle,
+ getDefaultLineIndex,
+ getFallbackSportsMarketTypeLabel,
+ getSportsMarketTypeLabel,
+ getTranslatedSportsMarketTypeLabel,
+ sortMoneylineOutcomes,
+} from './utils';
+
+const SimpleOutcomeCard = memo(
+ ({
+ outcome,
+ title,
+ onBuyPress,
+ game,
+ sportsMarketType,
+ testID,
+ }: {
+ outcome: PredictOutcomeGroup['outcomes'][number];
+ title: string;
+ onBuyPress: BuyHandler;
+ game?: PredictMarketGame;
+ sportsMarketType?: string;
+ testID: string;
+ }) => (
+
+ ),
+);
+
+SimpleOutcomeCard.displayName = 'SimpleOutcomeCard';
+
+const LineOutcomeCard = memo(
+ ({
+ outcomes,
+ title,
+ onBuyPress,
+ game,
+ sportsMarketType,
+ testID,
+ }: {
+ outcomes: PredictOutcomeGroup['outcomes'];
+ title?: string;
+ onBuyPress: BuyHandler;
+ game?: PredictMarketGame;
+ sportsMarketType?: string;
+ testID: string;
+ }) => {
+ const lineIndices = useMemo(
+ () =>
+ outcomes
+ .map((outcome, index) => (outcome.line != null ? index : -1))
+ .filter((index) => index !== -1),
+ [outcomes],
+ );
+
+ const lines = useMemo(
+ () => lineIndices.map((i) => Math.abs(outcomes[i].line ?? 0)),
+ [lineIndices, outcomes],
+ );
+
+ const defaultSelectedIdx = useMemo(
+ () => getDefaultLineIndex(outcomes, lineIndices),
+ [outcomes, lineIndices],
+ );
+
+ const [selectedIdx, setSelectedIdx] = useState(defaultSelectedIdx);
+
+ useEffect(() => {
+ setSelectedIdx(defaultSelectedIdx);
+ }, [defaultSelectedIdx]);
+
+ const handleSelectLine = useCallback(
+ (_line: number, indexInLines: number) => {
+ setSelectedIdx(indexInLines);
+ },
+ [],
+ );
+
+ const selectedOutcome = useMemo(
+ () => outcomes[lineIndices[selectedIdx]] ?? outcomes[0],
+ [outcomes, lineIndices, selectedIdx],
+ );
+
+ if (!selectedOutcome) return null;
+
+ return (
+
+ );
+ },
+);
+
+LineOutcomeCard.displayName = 'LineOutcomeCard';
+
+const MoneylineCard = memo(
+ ({
+ outcomes,
+ onBuyPress,
+ game,
+ title,
+ testID,
+ }: {
+ outcomes: PredictOutcomeGroup['outcomes'];
+ onBuyPress: BuyHandler;
+ game?: PredictMarketGame;
+ title: string;
+ testID: string;
+ }) => {
+ const subtitle = useMemo(
+ () => buildMoneylineSubtitle(outcomes),
+ [outcomes],
+ );
+ const tokenIds = useMemo(
+ () =>
+ sortMoneylineOutcomes(outcomes, game)
+ .map((outcome) => outcome.tokens[0]?.id)
+ .filter((id): id is string => Boolean(id)),
+ [outcomes, game],
+ );
+ const { getPrice } = useLiveMarketPrices(tokenIds);
+ const buttons = useMemo(
+ () => buildMoneylineButtons(outcomes, onBuyPress, game, getPrice),
+ [outcomes, onBuyPress, game, getPrice],
+ );
+
+ return (
+
+ );
+ },
+);
+
+MoneylineCard.displayName = 'MoneylineCard';
+
+const SubgroupCards = memo(
+ ({
+ subgroup,
+ onBuyPress,
+ game,
+ groupKey,
+ index,
+ }: {
+ subgroup: PredictOutcomeGroup;
+ onBuyPress: BuyHandler;
+ game?: PredictMarketGame;
+ groupKey: string;
+ index: number;
+ }) => {
+ const translatedTitle = getTranslatedSportsMarketTypeLabel(subgroup.key);
+ const firstOutcomeTitle = subgroup.outcomes[0]
+ ? formatOutcomeCardTitle(subgroup.outcomes[0])
+ : undefined;
+ const title = getFallbackSportsMarketTypeLabel(
+ subgroup.key,
+ translatedTitle ?? firstOutcomeTitle,
+ );
+ const testID = `${groupKey}-${subgroup.key}-${index}`;
+
+ if (
+ isMoneylineLikeMarketType(subgroup.key) &&
+ subgroup.outcomes.length > 1
+ ) {
+ return (
+
+ );
+ }
+
+ if (subgroup.outcomes.length === 1) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ },
+);
+
+SubgroupCards.displayName = 'SubgroupCards';
+
+export const OutcomesContent = memo(
+ ({
+ group,
+ onBuyPress,
+ game,
+ }: {
+ group: PredictOutcomeGroup;
+ onBuyPress: BuyHandler;
+ game?: PredictMarketGame;
+ }) => {
+ if (group.subgroups && group.subgroups.length > 0) {
+ return (
+ <>
+ {group.subgroups.map((subgroup, index) => (
+
+ ))}
+ >
+ );
+ }
+
+ const firstType = group.outcomes[0]?.sportsMarketType;
+ if (
+ firstType &&
+ isMoneylineLikeMarketType(firstType) &&
+ group.outcomes.length > 1
+ ) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+ {group.outcomes.map((outcome, index) => (
+
+ ))}
+ >
+ );
+ },
+);
+
+OutcomesContent.displayName = 'OutcomesContent';
diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.test.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.test.tsx
index 0fb0c4cb5f2..932ac2e6fa1 100644
--- a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.test.tsx
+++ b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.test.tsx
@@ -1,8 +1,7 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
-import PredictGameOutcomesTab, {
- getSportsMarketTypeLabel,
-} from './PredictGameOutcomesTab';
+import Engine from '../../../../../core/Engine';
+import PredictGameOutcomesTab from './PredictGameOutcomesTab';
import type {
PredictMarketGame,
PredictOutcome,
@@ -12,7 +11,6 @@ import type {
import type { PredictSportOutcomeButton } from '../PredictSportOutcomeCard';
import { PREDICT_GAME_DETAILS_CONTENT_TEST_IDS } from './PredictGameDetailsContent.testIds';
import { TEST_HEX_COLORS } from '../../testUtils/mockColors';
-import Logger from '../../../../../util/Logger';
jest.mock('../../../../../../locales/i18n', () => ({
strings: jest.fn((key: string) => {
@@ -40,11 +38,30 @@ jest.mock('../../../../../../locales/i18n', () => ({
}),
}));
-jest.mock('../../../../../util/Logger', () => ({
- __esModule: true,
- default: { error: jest.fn() },
+jest.mock('../../../../../core/Engine', () => {
+ const mockContext = {
+ PredictController: {
+ getPrices: jest.fn(),
+ },
+ };
+
+ return {
+ context: mockContext,
+ };
+});
+
+const mockGetPrices = jest.mocked(Engine.context.PredictController.getPrices);
+
+const mockUsePredictPreviewSheet = jest.fn(() => ({
+ isBuySheetOpen: false,
+}));
+
+jest.mock('../../contexts', () => ({
+ usePredictPreviewSheet: () => mockUsePredictPreviewSheet(),
}));
+const emptyPriceResponse = { providerId: '', results: [] };
+
const mockGetLivePrice = jest.fn();
jest.mock('../../hooks/useLiveMarketPrices', () => ({
@@ -67,6 +84,7 @@ interface CapturedCard {
buttonLayout?: string;
lines?: number[];
selectedLine?: number;
+ selectedIndex?: number;
testID?: string;
}
@@ -79,6 +97,7 @@ interface MockCardProps {
buttonLayout?: string;
lines?: number[];
selectedLine?: number;
+ selectedIndex?: number;
onSelectLine?: (line: number, index: number) => void;
testID?: string;
}
@@ -99,6 +118,7 @@ jest.mock('../PredictSportOutcomeCard', () => {
buttonLayout: props.buttonLayout,
lines: props.lines,
selectedLine: props.selectedLine,
+ selectedIndex: props.selectedIndex,
testID: props.testID,
});
return (
@@ -214,93 +234,15 @@ const mockGame: PredictMarketGame = {
describe('PredictGameOutcomesTab', () => {
beforeEach(() => {
jest.clearAllMocks();
+ mockUsePredictPreviewSheet.mockReturnValue({
+ isBuySheetOpen: false,
+ });
mockGetLivePrice.mockReturnValue(undefined);
+ mockGetPrices.mockResolvedValue(emptyPriceResponse);
mockCapturedCards = [];
});
- describe('getSportsMarketTypeLabel', () => {
- it('returns translated label for known type', () => {
- expect(getSportsMarketTypeLabel('moneyline')).toBe('Moneyline');
- });
-
- it('returns translated label for basketball market types', () => {
- expect(getSportsMarketTypeLabel('basketball_odd_even')).toBe(
- 'Odd/Even Score',
- );
- expect(getSportsMarketTypeLabel('basketball_team_to_score_first')).toBe(
- 'Team to Score First',
- );
- });
-
- it('returns translated label for tennis market types', () => {
- expect(getSportsMarketTypeLabel('tennis_match_totals')).toBe(
- 'Total Games',
- );
- expect(getSportsMarketTypeLabel('tennis_set_handicap')).toBe(
- 'Set Handicap',
- );
- expect(getSportsMarketTypeLabel('tennis_first_set_winner')).toBe(
- '1st Set Winner',
- );
- });
-
- it('returns title-cased fallback for unknown type', () => {
- expect(getSportsMarketTypeLabel('unknown_type')).toBe('Unknown Type');
- });
-
- it('returns provided fallback when translation is missing', () => {
- expect(
- getSportsMarketTypeLabel('basketball_unknown_market', 'Fallback Title'),
- ).toBe('Fallback Title');
- });
-
- it('logs missing translations only once per key', () => {
- const mockLoggerError = jest.mocked(Logger.error);
- const type = 'basketball_logged_once_market';
- const key = `predict.sports_market_types.${type}`;
- const message = `Missing Predict sports market type translation: ${key}`;
-
- expect(getSportsMarketTypeLabel(type, 'Fallback Title')).toBe(
- 'Fallback Title',
- );
- expect(getSportsMarketTypeLabel(type, 'Fallback Title')).toBe(
- 'Fallback Title',
- );
-
- expect(mockLoggerError).toHaveBeenCalledTimes(1);
- expect(mockLoggerError).toHaveBeenCalledWith(
- expect.objectContaining({
- message,
- }),
- {
- message,
- context: {
- key,
- type,
- },
- },
- );
- });
- });
-
describe('group selection', () => {
- it('renders outcomes content container', () => {
- const groups = [createGroup({ key: 'game_lines' })];
-
- const { getByTestId } = render(
- ,
- );
-
- expect(
- getByTestId(PREDICT_GAME_DETAILS_CONTENT_TEST_IDS.OUTCOMES_CONTENT),
- ).toBeOnTheScreen();
- });
-
it('renders nothing inside container when no group matches activeChipKey', () => {
const groups = [createGroup({ key: 'game_lines' })];
@@ -358,83 +300,6 @@ describe('PredictGameOutcomesTab', () => {
expect(getByTestId('points-outcome-0')).toBeOnTheScreen();
expect(getByTestId('points-outcome-1')).toBeOnTheScreen();
});
-
- it('uses formatOutcomeCardTitle to strip O/U suffix from card title', () => {
- const outcome = createOutcome({
- id: 'o-ou',
- groupItemTitle: 'O/U 45.5 : Points (Over/Under)',
- });
- const groups = [createGroup({ key: 'points', outcomes: [outcome] })];
-
- render(
- ,
- );
-
- expect(mockCapturedCards[0].title).toBe('O/U 45.5');
- });
-
- it('extracts player name from O/U title with colon separator', () => {
- const outcome = createOutcome({
- id: 'o-player-ou',
- groupItemTitle: 'Dean Wade: Points O/U 0.5',
- });
- const groups = [createGroup({ key: 'points', outcomes: [outcome] })];
-
- render(
- ,
- );
-
- expect(mockCapturedCards[0].title).toBe('Dean Wade');
- });
-
- it('preserves title when no O/U colon pattern exists', () => {
- const outcome = createOutcome({
- id: 'o-plain',
- groupItemTitle: 'Player Props',
- });
- const groups = [createGroup({ key: 'points', outcomes: [outcome] })];
-
- render(
- ,
- );
-
- expect(mockCapturedCards[0].title).toBe('Player Props');
- });
-
- it('falls back to outcome.title when groupItemTitle is missing', () => {
- const outcome = createOutcome({
- id: 'o-notitle',
- groupItemTitle: undefined,
- title: 'Fallback Title',
- });
- const groups = [createGroup({ key: 'points', outcomes: [outcome] })];
-
- render(
- ,
- );
-
- expect(mockCapturedCards[0].title).toBe('Fallback Title');
- });
});
describe('subgroups', () => {
@@ -519,6 +384,39 @@ describe('PredictGameOutcomesTab', () => {
expect(mockCapturedCards[0].selectedLine).toBe(3.5);
});
+ it('assigns first_half_moneyline variants through subgroup routing', () => {
+ const outcome = createOutcome({
+ sportsMarketType: 'first_half_moneyline',
+ tokens: [
+ createToken({ shortTitle: 'TA' }),
+ createToken({ shortTitle: 'TB' }),
+ ],
+ });
+ const subgroups: PredictOutcomeGroup[] = [
+ createGroup({
+ key: 'first_half_moneyline',
+ outcomes: [outcome],
+ }),
+ ];
+ const groups = [
+ createGroup({ key: 'first_half', outcomes: [], subgroups }),
+ ];
+
+ render(
+ ,
+ );
+
+ expect(mockCapturedCards[0].buttons[0].variant).toBe('yes');
+ expect(mockCapturedCards[0].buttons[0].teamColor).toBe(
+ TEST_HEX_COLORS.PURE_RED,
+ );
+ });
+
it('uses outcome title when subgroup market type translation is missing', () => {
const subgroups: PredictOutcomeGroup[] = [
createGroup({
@@ -593,15 +491,21 @@ describe('PredictGameOutcomesTab', () => {
});
});
- describe('button building', () => {
- it('converts token price to cents for button price', () => {
- const outcome = createOutcome({
- tokens: [
- createToken({ price: 0.65, shortTitle: 'TA' }),
- createToken({ price: 0.35, shortTitle: 'TB' }),
- ],
- });
- const groups = [createGroup({ key: 'game_lines', outcomes: [outcome] })];
+ describe('line selection', () => {
+ it('preserves incoming line order', () => {
+ const subgroups: PredictOutcomeGroup[] = [
+ createGroup({
+ key: 'spreads',
+ outcomes: [
+ createOutcome({ id: 's1', line: 7.5 }),
+ createOutcome({ id: 's2', line: 3.5 }),
+ createOutcome({ id: 's3', line: 10.5 }),
+ ],
+ }),
+ ];
+ const groups = [
+ createGroup({ key: 'game_lines', outcomes: [], subgroups }),
+ ];
render(
{
/>,
);
- expect(mockCapturedCards[0].buttons[0].price).toBe(65);
- expect(mockCapturedCards[0].buttons[1].price).toBe(35);
+ expect(mockCapturedCards[0].lines).toEqual([7.5, 3.5, 10.5]);
});
- it('uses shortTitle for button label when available', () => {
- const outcome = createOutcome({
- tokens: [createToken({ title: 'Full Team Name', shortTitle: 'FTN' })],
- });
- const groups = [createGroup({ key: 'game_lines', outcomes: [outcome] })];
+ it('defaults selected line to the highest volume outcome', () => {
+ const subgroups: PredictOutcomeGroup[] = [
+ createGroup({
+ key: 'spreads',
+ outcomes: [
+ createOutcome({
+ id: 's1',
+ line: 7.5,
+ volume: 1000,
+ liquidity: 10000,
+ }),
+ createOutcome({
+ id: 's2',
+ line: 3.5,
+ volume: 5000,
+ liquidity: 50,
+ }),
+ ],
+ }),
+ ];
+ const groups = [
+ createGroup({ key: 'game_lines', outcomes: [], subgroups }),
+ ];
render(
{
/>,
);
- expect(mockCapturedCards[0].buttons[0].label).toBe('FTN');
+ expect(mockCapturedCards[0].selectedLine).toBe(3.5);
+ expect(mockCapturedCards[0].selectedIndex).toBe(1);
});
- it('falls back to title when shortTitle is missing', () => {
- const outcome = createOutcome({
- tokens: [
- createToken({ title: 'Full Team Name', shortTitle: undefined }),
- ],
- });
- const groups = [createGroup({ key: 'game_lines', outcomes: [outcome] })];
+ it('switches displayed outcome when line is selected', () => {
+ const subgroups: PredictOutcomeGroup[] = [
+ createGroup({
+ key: 'spreads',
+ outcomes: [
+ createOutcome({
+ id: 's1',
+ line: 3.5,
+ volume: 5000,
+ tokens: [
+ createToken({ shortTitle: 'TA', price: 0.6 }),
+ createToken({ shortTitle: 'TB', price: 0.4 }),
+ ],
+ }),
+ createOutcome({
+ id: 's2',
+ line: 7.5,
+ volume: 1000,
+ tokens: [
+ createToken({ shortTitle: 'TA', price: 0.8 }),
+ createToken({ shortTitle: 'TB', price: 0.2 }),
+ ],
+ }),
+ ],
+ }),
+ ];
+ const groups = [
+ createGroup({ key: 'game_lines', outcomes: [], subgroups }),
+ ];
- render(
+ const { getByTestId } = render(
{
/>,
);
- expect(mockCapturedCards[0].buttons[0].label).toBe('Full Team Name');
+ expect(mockCapturedCards[0].buttons[0].price).toBe(60);
+
+ mockCapturedCards = [];
+ fireEvent(getByTestId('game_lines-spreads-0-line-1-7.5'), 'touchEnd');
+
+ expect(mockCapturedCards[0].buttons[0].price).toBe(80);
});
- it('calls onBuyPress with outcome and token when button is pressed', () => {
- const token = createToken({ id: 'tok-press', shortTitle: 'TA' });
- const outcome = createOutcome({ id: 'out-press', tokens: [token] });
- const groups = [createGroup({ key: 'game_lines', outcomes: [outcome] })];
+ it('selects duplicate line labels by index', () => {
+ const subgroups: PredictOutcomeGroup[] = [
+ createGroup({
+ key: 'spreads',
+ outcomes: [
+ createOutcome({
+ id: 'spread-plus-2.5',
+ line: -2.5,
+ tokens: [createToken({ shortTitle: 'TA +2.5', price: 0.9 })],
+ }),
+ createOutcome({
+ id: 'spread-plus-1.5',
+ line: -1.5,
+ tokens: [createToken({ shortTitle: 'TA +1.5', price: 0.8 })],
+ }),
+ createOutcome({
+ id: 'spread-minus-1.5',
+ line: -1.5,
+ tokens: [createToken({ shortTitle: 'TA -1.5', price: 0.6 })],
+ }),
+ createOutcome({
+ id: 'spread-minus-2.5',
+ line: -2.5,
+ tokens: [createToken({ shortTitle: 'TA -2.5', price: 0.4 })],
+ }),
+ ],
+ }),
+ ];
+ const groups = [
+ createGroup({ key: 'game_lines', outcomes: [], subgroups }),
+ ];
const { getByTestId } = render(
{
/>,
);
- fireEvent(getByTestId('game_lines-outcome-0-btn-0'), 'touchEnd');
+ expect(mockCapturedCards[0].lines).toEqual([2.5, 1.5, 1.5, 2.5]);
+
+ mockCapturedCards = [];
+ fireEvent(getByTestId('game_lines-spreads-0-line-2-1.5'), 'touchEnd');
- expect(mockOnBuyPress).toHaveBeenCalledWith(outcome, token);
+ expect(mockCapturedCards[0].selectedLine).toBe(1.5);
+ expect(mockCapturedCards[0].selectedIndex).toBe(2);
+ expect(mockCapturedCards[0].buttons[0].price).toBe(60);
});
});
- describe('moneyline variant and team colors', () => {
- it('assigns team colors for moneyline type', () => {
- const outcome = createOutcome({
- sportsMarketType: 'moneyline',
- tokens: [
- createToken({ shortTitle: 'TA', price: 0.6 }),
- createToken({ shortTitle: 'TB', price: 0.4 }),
- ],
- });
- const groups = [createGroup({ key: 'game_lines', outcomes: [outcome] })];
-
- render(
+ describe('empty states', () => {
+ it('renders container with empty groups array', () => {
+ const { getByTestId } = render(
,
- );
-
- expect(mockCapturedCards[0].buttons[0].teamColor).toBe(
- TEST_HEX_COLORS.PURE_RED,
- );
- expect(mockCapturedCards[0].buttons[1].teamColor).toBe(
- TEST_HEX_COLORS.PURE_BLUE,
- );
- });
-
- it('assigns yes/no variants for 2-button moneyline', () => {
- const outcome = createOutcome({
- sportsMarketType: 'moneyline',
- tokens: [
- createToken({ shortTitle: 'TA' }),
- createToken({ shortTitle: 'TB' }),
- ],
- });
- const groups = [createGroup({ key: 'game_lines', outcomes: [outcome] })];
-
- render(
- ,
- );
-
- expect(mockCapturedCards[0].buttons[0].variant).toBe('yes');
- expect(mockCapturedCards[0].buttons[1].variant).toBe('no');
- });
-
- it('assigns draw variant for middle button in 3-button moneyline', () => {
- const outcome = createOutcome({
- sportsMarketType: 'moneyline',
- tokens: [
- createToken({ id: 't1', shortTitle: 'TA' }),
- createToken({ id: 't2', shortTitle: 'Draw' }),
- createToken({ id: 't3', shortTitle: 'TB' }),
- ],
- });
- const groups = [createGroup({ key: 'game_lines', outcomes: [outcome] })];
-
- render(
- ,
- );
-
- expect(mockCapturedCards[0].buttons[0].variant).toBe('yes');
- expect(mockCapturedCards[0].buttons[1].variant).toBe('draw');
- expect(mockCapturedCards[0].buttons[2].variant).toBe('no');
- });
-
- it('assigns first_half_moneyline as moneyline type', () => {
- const outcome = createOutcome({
- sportsMarketType: 'first_half_moneyline',
- tokens: [
- createToken({ shortTitle: 'TA' }),
- createToken({ shortTitle: 'TB' }),
- ],
- });
- const subgroups: PredictOutcomeGroup[] = [
- createGroup({
- key: 'first_half_moneyline',
- outcomes: [outcome],
- }),
- ];
- const groups = [
- createGroup({ key: 'first_half', outcomes: [], subgroups }),
- ];
-
- render(
- ,
- );
-
- expect(mockCapturedCards[0].buttons[0].variant).toBe('yes');
- expect(mockCapturedCards[0].buttons[0].teamColor).toBe(
- TEST_HEX_COLORS.PURE_RED,
- );
- });
-
- it('assigns tennis first set winner team colors from normalized token labels', () => {
- const tennisGame: PredictMarketGame = {
- ...mockGame,
- league: 'atp',
- homeTeam: {
- ...mockGame.homeTeam,
- name: 'Ilya Ivashka',
- abbreviation: 'ivashka',
- alias: 'I. Ivashka',
- color: TEST_HEX_COLORS.PURE_RED,
- },
- awayTeam: {
- ...mockGame.awayTeam,
- name: 'Hamish Stewart',
- abbreviation: 'stewart',
- alias: 'H. Stewart',
- color: TEST_HEX_COLORS.PURE_BLUE,
- },
- };
- const outcome = createOutcome({
- sportsMarketType: 'tennis_first_set_winner',
- tokens: [
- createToken({ shortTitle: 'IVASHKA' }),
- createToken({ shortTitle: 'STEWART' }),
- ],
- });
- const subgroups: PredictOutcomeGroup[] = [
- createGroup({
- key: 'tennis_first_set_winner',
- outcomes: [outcome],
- }),
- ];
- const groups = [
- createGroup({ key: 'first_set', outcomes: [], subgroups }),
- ];
-
- render(
- ,
- );
-
- expect(mockCapturedCards[0].buttons[0]).toEqual(
- expect.objectContaining({
- label: 'IVASHKA',
- variant: 'yes',
- teamColor: TEST_HEX_COLORS.PURE_RED,
- }),
- );
- expect(mockCapturedCards[0].buttons[1]).toEqual(
- expect.objectContaining({
- label: 'STEWART',
- variant: 'no',
- teamColor: TEST_HEX_COLORS.PURE_BLUE,
- }),
- );
- });
-
- it('assigns draw variant and no team colors for non-moneyline types', () => {
- const outcome = createOutcome({
- sportsMarketType: 'spreads',
- tokens: [
- createToken({ shortTitle: 'TA' }),
- createToken({ shortTitle: 'TB' }),
- ],
- });
- const subgroups: PredictOutcomeGroup[] = [
- createGroup({ key: 'spreads', outcomes: [outcome] }),
- ];
- const groups = [
- createGroup({ key: 'game_lines', outcomes: [], subgroups }),
- ];
-
- render(
- ,
- );
-
- expect(mockCapturedCards[0].buttons[0].variant).toBe('draw');
- expect(mockCapturedCards[0].buttons[1].variant).toBe('draw');
- expect(mockCapturedCards[0].buttons[0].teamColor).toBeUndefined();
- expect(mockCapturedCards[0].buttons[1].teamColor).toBeUndefined();
- });
-
- it('returns undefined team color when abbreviation does not match any team', () => {
- const outcome = createOutcome({
- sportsMarketType: 'moneyline',
- tokens: [createToken({ shortTitle: 'UNKNOWN' })],
- });
- const groups = [createGroup({ key: 'game_lines', outcomes: [outcome] })];
-
- render(
- ,
- );
-
- expect(mockCapturedCards[0].buttons[0].teamColor).toBeUndefined();
- });
-
- it('returns undefined team color when game is not provided', () => {
- const outcome = createOutcome({
- sportsMarketType: 'moneyline',
- tokens: [createToken({ shortTitle: 'TA' })],
- });
- const groups = [createGroup({ key: 'game_lines', outcomes: [outcome] })];
-
- render(
- ,
- );
-
- expect(mockCapturedCards[0].buttons[0].teamColor).toBeUndefined();
- });
- });
-
- describe('subtitle formatting', () => {
- it('formats volume into subtitle with dollar sign and Vol suffix', () => {
- const outcome = createOutcome({ volume: 50000 });
- const groups = [createGroup({ key: 'game_lines', outcomes: [outcome] })];
-
- render(
- ,
- );
-
- expect(mockCapturedCards[0].subtitle).toBe('$50k Vol');
- });
-
- it('formats large volume with M suffix', () => {
- const outcome = createOutcome({ volume: 2500000 });
- const groups = [createGroup({ key: 'game_lines', outcomes: [outcome] })];
-
- render(
- ,
- );
-
- expect(mockCapturedCards[0].subtitle).toBe('$2.5M Vol');
- });
- });
-
- describe('line selection', () => {
- it('sorts lines numerically in ascending order', () => {
- const subgroups: PredictOutcomeGroup[] = [
- createGroup({
- key: 'spreads',
- outcomes: [
- createOutcome({ id: 's1', line: 7.5 }),
- createOutcome({ id: 's2', line: 3.5 }),
- createOutcome({ id: 's3', line: 10.5 }),
- ],
- }),
- ];
- const groups = [
- createGroup({ key: 'game_lines', outcomes: [], subgroups }),
- ];
-
- render(
- ,
- );
-
- expect(mockCapturedCards[0].lines).toEqual([3.5, 7.5, 10.5]);
- });
-
- it('defaults selected line to the first outcome line', () => {
- const subgroups: PredictOutcomeGroup[] = [
- createGroup({
- key: 'spreads',
- outcomes: [
- createOutcome({ id: 's1', line: 7.5 }),
- createOutcome({ id: 's2', line: 3.5 }),
- ],
- }),
- ];
- const groups = [
- createGroup({ key: 'game_lines', outcomes: [], subgroups }),
- ];
-
- render(
- ,
- );
-
- expect(mockCapturedCards[0].selectedLine).toBe(3.5);
- });
-
- it('switches displayed outcome when line is selected', () => {
- const subgroups: PredictOutcomeGroup[] = [
- createGroup({
- key: 'spreads',
- outcomes: [
- createOutcome({
- id: 's1',
- line: 3.5,
- volume: 1000,
- tokens: [
- createToken({ shortTitle: 'TA', price: 0.6 }),
- createToken({ shortTitle: 'TB', price: 0.4 }),
- ],
- }),
- createOutcome({
- id: 's2',
- line: 7.5,
- volume: 5000,
- tokens: [
- createToken({ shortTitle: 'TA', price: 0.8 }),
- createToken({ shortTitle: 'TB', price: 0.2 }),
- ],
- }),
- ],
- }),
- ];
- const groups = [
- createGroup({ key: 'game_lines', outcomes: [], subgroups }),
- ];
-
- const { getByTestId } = render(
- ,
- );
-
- expect(mockCapturedCards[0].buttons[0].price).toBe(60);
-
- mockCapturedCards = [];
- fireEvent(getByTestId('game_lines-spreads-0-line-1-7.5'), 'touchEnd');
-
- expect(mockCapturedCards[0].buttons[0].price).toBe(80);
- });
- });
-
- describe('empty states', () => {
- it('renders container with empty groups array', () => {
- const { getByTestId } = render(
- {
expect(mockCapturedCards).toHaveLength(1);
expect(mockCapturedCards[0].buttonLayout).toBe('inlineNoSeparator');
expect(mockCapturedCards[0].buttons[0].label).toBe('HOM');
- expect(mockCapturedCards[0].buttons[1].label).toBe('Draw');
- expect(mockCapturedCards[0].buttons[2].label).toBe('AWY');
- });
-
- it('sums volumes from all outcomes for moneyline subgroup subtitle', () => {
- const outcomes = [
- createOutcome({
- id: 'ml-a',
- groupItemThreshold: 0,
- volume: 10000,
- tokens: [createToken({ shortTitle: 'TA', price: 0.6 })],
- }),
- createOutcome({
- id: 'ml-b',
- groupItemThreshold: 1,
- volume: 20000,
- tokens: [createToken({ shortTitle: 'TB', price: 0.4 })],
- }),
- ];
- const subgroups: PredictOutcomeGroup[] = [
- createGroup({ key: 'moneyline', outcomes }),
- ];
- const groups = [
- createGroup({ key: 'game_lines', outcomes: [], subgroups }),
- ];
-
- render(
- ,
- );
-
- expect(mockCapturedCards[0].subtitle).toBe('$30k Vol');
- });
-
- it('builds moneyline buttons with correct prices and variants', () => {
- const outcomes = [
- createOutcome({
- id: 'ml-h',
- groupItemThreshold: 0,
- volume: 1000,
- tokens: [createToken({ shortTitle: 'TA', price: 0.45 })],
- }),
- createOutcome({
- id: 'ml-d',
- groupItemThreshold: 1,
- volume: 1000,
- tokens: [createToken({ shortTitle: 'Draw', price: 0.3 })],
- }),
- createOutcome({
- id: 'ml-a',
- groupItemThreshold: 2,
- volume: 1000,
- tokens: [createToken({ shortTitle: 'TB', price: 0.25 })],
- }),
- ];
- const subgroups: PredictOutcomeGroup[] = [
- createGroup({ key: 'moneyline', outcomes }),
- ];
- const groups = [
- createGroup({ key: 'game_lines', outcomes: [], subgroups }),
- ];
-
- render(
- ,
- );
-
- expect(mockCapturedCards[0].buttons[0].price).toBe(45);
+ expect(mockCapturedCards[0].buttons[0].price).toBe(55);
expect(mockCapturedCards[0].buttons[0].variant).toBe('yes');
- expect(mockCapturedCards[0].buttons[1].price).toBe(30);
+ expect(mockCapturedCards[0].buttons[1].label).toBe('Draw');
+ expect(mockCapturedCards[0].buttons[1].price).toBe(20);
expect(mockCapturedCards[0].buttons[1].variant).toBe('draw');
+ expect(mockCapturedCards[0].buttons[2].label).toBe('AWY');
expect(mockCapturedCards[0].buttons[2].price).toBe(25);
expect(mockCapturedCards[0].buttons[2].variant).toBe('no');
});
@@ -1232,39 +765,6 @@ describe('PredictGameOutcomesTab', () => {
expect(mockCapturedCards[0].buttons[1].price).toBe(24);
});
- it('falls back to static token price when live best ask is zero', () => {
- mockGetLivePrice.mockReturnValue({
- tokenId: 'tok-a',
- price: 0,
- bestBid: 0,
- bestAsk: 0,
- });
-
- const outcomes = [
- createOutcome({
- id: 'ml-a',
- tokens: [createToken({ id: 'tok-a', shortTitle: 'TA', price: 0.45 })],
- }),
- ];
- const subgroups: PredictOutcomeGroup[] = [
- createGroup({ key: 'moneyline', outcomes }),
- ];
- const groups = [
- createGroup({ key: 'game_lines', outcomes: [], subgroups }),
- ];
-
- render(
- ,
- );
-
- expect(mockCapturedCards[0].buttons[0].price).toBe(45);
- });
-
it('calls onBuyPress with correct outcome and token for moneyline button', () => {
const tokenA = createToken({ id: 'tok-a', shortTitle: 'TA', price: 0.6 });
const tokenB = createToken({ id: 'tok-b', shortTitle: 'TB', price: 0.4 });
@@ -1300,169 +800,6 @@ describe('PredictGameOutcomesTab', () => {
expect(mockOnBuyPress).toHaveBeenCalledWith(outcomeB, tokenB);
});
-
- it('skips outcomes without tokens when building moneyline buttons', () => {
- const outcomes = [
- createOutcome({
- id: 'ml-home',
- groupItemThreshold: 0,
- groupItemTitle: 'Home Win',
- tokens: [createToken({ shortTitle: 'HOM', price: 0.55 })],
- }),
- createOutcome({
- id: 'ml-empty',
- groupItemThreshold: 1,
- groupItemTitle: 'Draw',
- tokens: [],
- }),
- createOutcome({
- id: 'ml-away',
- groupItemThreshold: 2,
- groupItemTitle: 'Away Win',
- tokens: [createToken({ shortTitle: 'AWY', price: 0.45 })],
- }),
- ];
- const subgroups: PredictOutcomeGroup[] = [
- createGroup({ key: 'moneyline', outcomes }),
- ];
- const groups = [
- createGroup({ key: 'game_lines', outcomes: [], subgroups }),
- ];
-
- render(
- ,
- );
-
- expect(mockCapturedCards[0].buttons).toHaveLength(2);
- expect(mockCapturedCards[0].buttons[0].label).toBe('HOM');
- expect(mockCapturedCards[0].buttons[1].label).toBe('AWY');
- });
- });
-
- describe('moneyline sorting without thresholds', () => {
- it('places home team first, draw middle, away last when game is provided', () => {
- const outcomes = [
- createOutcome({
- id: 'ml-draw',
- groupItemTitle: 'Draw',
- volume: 1000,
- tokens: [createToken({ shortTitle: 'Draw', price: 0.2 })],
- }),
- createOutcome({
- id: 'ml-away',
- groupItemTitle: 'Away Win',
- volume: 2000,
- tokens: [createToken({ shortTitle: 'TB', price: 0.3 })],
- }),
- createOutcome({
- id: 'ml-home',
- groupItemTitle: 'Home Win',
- volume: 3000,
- tokens: [createToken({ shortTitle: 'TA', price: 0.5 })],
- }),
- ];
- const subgroups: PredictOutcomeGroup[] = [
- createGroup({ key: 'moneyline', outcomes }),
- ];
- const groups = [
- createGroup({ key: 'game_lines', outcomes: [], subgroups }),
- ];
-
- render(
- ,
- );
-
- expect(mockCapturedCards[0].buttons[0].label).toBe('TA');
- expect(mockCapturedCards[0].buttons[1].label).toBe('Draw');
- expect(mockCapturedCards[0].buttons[2].label).toBe('TB');
- });
-
- it('places draw second when no game is provided and no thresholds', () => {
- const outcomes = [
- createOutcome({
- id: 'ml-draw',
- groupItemTitle: 'Draw',
- volume: 1000,
- tokens: [createToken({ shortTitle: 'Draw', price: 0.2 })],
- }),
- createOutcome({
- id: 'ml-first',
- groupItemTitle: 'Team X',
- volume: 2000,
- tokens: [createToken({ shortTitle: 'TX', price: 0.4 })],
- }),
- createOutcome({
- id: 'ml-second',
- groupItemTitle: 'Team Y',
- volume: 3000,
- tokens: [createToken({ shortTitle: 'TY', price: 0.4 })],
- }),
- ];
- const subgroups: PredictOutcomeGroup[] = [
- createGroup({ key: 'moneyline', outcomes }),
- ];
- const groups = [
- createGroup({ key: 'game_lines', outcomes: [], subgroups }),
- ];
-
- render(
- ,
- );
-
- expect(mockCapturedCards[0].buttons[0].label).toBe('TX');
- expect(mockCapturedCards[0].buttons[1].label).toBe('Draw');
- expect(mockCapturedCards[0].buttons[2].label).toBe('TY');
- });
-
- it('preserves original order when draw is present but fewer than 2 non-draw outcomes', () => {
- const outcomes = [
- createOutcome({
- id: 'ml-draw',
- groupItemTitle: 'Draw',
- volume: 1000,
- tokens: [createToken({ shortTitle: 'Draw', price: 0.5 })],
- }),
- createOutcome({
- id: 'ml-only',
- groupItemTitle: 'Team X',
- volume: 2000,
- tokens: [createToken({ shortTitle: 'TX', price: 0.5 })],
- }),
- ];
- const subgroups: PredictOutcomeGroup[] = [
- createGroup({ key: 'moneyline', outcomes }),
- ];
- const groups = [
- createGroup({ key: 'game_lines', outcomes: [], subgroups }),
- ];
-
- render(
- ,
- );
-
- expect(mockCapturedCards[0].buttons[0].label).toBe('Draw');
- expect(mockCapturedCards[0].buttons[1].label).toBe('TX');
- });
});
describe('flat outcomes moneyline rendering', () => {
@@ -1531,25 +868,4 @@ describe('PredictGameOutcomesTab', () => {
expect(mockCapturedCards[0].buttonLayout).toBeUndefined();
});
});
-
- describe('formatOutcomeCardTitle edge cases', () => {
- it('strips O/U suffix when title has colon but no player pattern match', () => {
- const outcome = createOutcome({
- id: 'o-colon-ou',
- groupItemTitle: 'Total: O/U 2.5',
- });
- const groups = [createGroup({ key: 'totals', outcomes: [outcome] })];
-
- render(
- ,
- );
-
- expect(mockCapturedCards[0].title).toBe('Total');
- });
- });
});
diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.tsx
index 862d1c5626d..aa5ef2ca4db 100644
--- a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.tsx
+++ b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.tsx
@@ -1,4 +1,4 @@
-import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
+import React, { memo } from 'react';
import { Box } from '@metamask/design-system-react-native';
import type {
PredictMarketGame,
@@ -6,484 +6,11 @@ import type {
PredictOutcomeGroup,
PredictOutcomeToken,
} from '../../types';
-import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices';
-import type { PredictBetButtonVariant } from '../PredictActionButtons/PredictActionButtons.types';
-import PredictSportOutcomeCard, {
- type PredictSportOutcomeButton,
-} from '../PredictSportOutcomeCard';
-import { formatVolume } from '../../utils/format';
-import { isValidPrice } from '../../utils/prices';
-import { isMoneylineLikeMarketType } from '../../constants/sports';
-import { strings } from '../../../../../../locales/i18n';
-import Logger from '../../../../../util/Logger';
import { PREDICT_GAME_DETAILS_CONTENT_TEST_IDS } from './PredictGameDetailsContent.testIds';
+import { OutcomesContent } from './PredictGameOutcomeCards';
+import { usePricedOutcomeGroup } from './usePricedOutcomeGroup';
-const I18N_PREFIX = 'predict.sports_market_types';
-const MISSING_TRANSLATION_PREFIX = '[missing';
-const loggedMissingTranslationKeys = new Set();
-
-const toTitleCase = (str: string): string =>
- str
- .split('_')
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
- .join(' ');
-
-const isMissingTranslation = (value: string, key: string): boolean =>
- value === key || value.startsWith(MISSING_TRANSLATION_PREFIX);
-
-const logMissingSportsMarketTypeTranslation = (
- key: string,
- type: string,
-): void => {
- if (loggedMissingTranslationKeys.has(key)) return;
-
- loggedMissingTranslationKeys.add(key);
- const message = `Missing Predict sports market type translation: ${key}`;
- Logger.error(new Error(message), {
- message,
- context: { key, type },
- });
-};
-
-const getTranslatedSportsMarketTypeLabel = (
- type: string,
-): string | undefined => {
- const key = `${I18N_PREFIX}.${type}`;
- const label = strings(key);
- if (typeof label !== 'string' || isMissingTranslation(label, key)) {
- logMissingSportsMarketTypeTranslation(key, type);
- return undefined;
- }
- return label;
-};
-
-export const getSportsMarketTypeLabel = (
- type: string,
- fallbackTitle?: string,
-): string =>
- getTranslatedSportsMarketTypeLabel(type) ??
- fallbackTitle ??
- toTitleCase(type);
-
-type BuyHandler = (outcome: PredictOutcome, token: PredictOutcomeToken) => void;
-
-const O_U_PLAYER_PATTERN = /^(.+?):\s+\w+ O\/U/;
-
-const formatOutcomeCardTitle = (outcome: PredictOutcome): string => {
- const raw = outcome.groupItemTitle || outcome.title;
- if (!raw.includes('O/U')) return raw;
-
- const match = raw.match(O_U_PLAYER_PATTERN);
- if (match) return match[1].trim();
-
- const colonIdx = raw.indexOf(': ');
- if (colonIdx !== -1) return raw.slice(0, colonIdx).trim();
-
- return raw;
-};
-
-const getTeamColor = (
- tokenTitle: string,
- game?: PredictMarketGame,
-): string | undefined => {
- if (!game) return undefined;
-
- const normalizedTokenTitle = tokenTitle.trim().toLowerCase();
- const homeLabels = [
- game.homeTeam.abbreviation,
- game.homeTeam.name,
- game.homeTeam.alias,
- ]
- .filter((label): label is string => Boolean(label))
- .map((label) => label.trim().toLowerCase());
- const awayLabels = [
- game.awayTeam.abbreviation,
- game.awayTeam.name,
- game.awayTeam.alias,
- ]
- .filter((label): label is string => Boolean(label))
- .map((label) => label.trim().toLowerCase());
-
- if (homeLabels.includes(normalizedTokenTitle)) return game.homeTeam.color;
- if (awayLabels.includes(normalizedTokenTitle)) return game.awayTeam.color;
- return undefined;
-};
-
-const getButtonVariant = (
- index: number,
- total: number,
- moneyline: boolean,
-): PredictBetButtonVariant => {
- if (!moneyline) return 'draw';
- if (total === 3 && index === 1) return 'draw';
- return index === 0 ? 'yes' : 'no';
-};
-
-const buildButtons = (
- outcome: PredictOutcome,
- onBuyPress: BuyHandler,
- game?: PredictMarketGame,
- sportsMarketType?: string,
-): PredictSportOutcomeButton[] => {
- const moneyline = isMoneylineLikeMarketType(sportsMarketType);
- return outcome.tokens.map((token, index) => ({
- label: token.shortTitle ?? token.title,
- price: Math.round(token.price * 100),
- onPress: () => onBuyPress(outcome, token),
- variant: getButtonVariant(index, outcome.tokens.length, moneyline),
- teamColor: moneyline
- ? getTeamColor(token.shortTitle ?? token.title, game)
- : undefined,
- }));
-};
-
-const buildSubtitle = (outcome: PredictOutcome): string =>
- `$${formatVolume(outcome.volume)} Vol`;
-
-const SimpleOutcomeCard = memo(
- ({
- outcome,
- title,
- onBuyPress,
- game,
- sportsMarketType,
- testID,
- }: {
- outcome: PredictOutcome;
- title: string;
- onBuyPress: BuyHandler;
- game?: PredictMarketGame;
- sportsMarketType?: string;
- testID: string;
- }) => (
-
- ),
-);
-
-SimpleOutcomeCard.displayName = 'SimpleOutcomeCard';
-
-const LineOutcomeCard = memo(
- ({
- outcomes,
- title,
- onBuyPress,
- game,
- sportsMarketType,
- testID,
- }: {
- outcomes: PredictOutcome[];
- title?: string;
- onBuyPress: BuyHandler;
- game?: PredictMarketGame;
- sportsMarketType?: string;
- testID: string;
- }) => {
- const lineIndices = useMemo(
- () =>
- outcomes
- .map((o, i) => (o.line != null ? i : -1))
- .filter((i) => i !== -1)
- .sort((a, b) => {
- const lineA = outcomes[a].line ?? 0;
- const lineB = outcomes[b].line ?? 0;
- return lineA - lineB;
- }),
- [outcomes],
- );
-
- const lines = useMemo(
- () => lineIndices.map((i) => Math.abs(outcomes[i].line ?? 0)),
- [lineIndices, outcomes],
- );
-
- const [selectedIdx, setSelectedIdx] = useState(0);
-
- useEffect(() => {
- setSelectedIdx(0);
- }, [outcomes]);
-
- const handleSelectLine = useCallback(
- (_line: number, indexInLines: number) => {
- setSelectedIdx(indexInLines);
- },
- [],
- );
-
- const selectedOutcome = useMemo(
- () => outcomes[lineIndices[selectedIdx]] ?? outcomes[0],
- [outcomes, lineIndices, selectedIdx],
- );
-
- if (!selectedOutcome) return null;
-
- return (
-
- );
- },
-);
-
-LineOutcomeCard.displayName = 'LineOutcomeCard';
-
-const isDrawOutcome = (outcome: PredictOutcome): boolean =>
- outcome.groupItemTitle?.toLowerCase().startsWith('draw') ?? false;
-
-const sortMoneylineOutcomes = (
- outcomes: PredictOutcome[],
- game?: PredictMarketGame,
-): PredictOutcome[] => {
- const hasThresholds = outcomes.some((o) => o.groupItemThreshold != null);
- if (hasThresholds) {
- return [...outcomes].sort(
- (a, b) => (a.groupItemThreshold ?? 0) - (b.groupItemThreshold ?? 0),
- );
- }
-
- const draw = outcomes.find(isDrawOutcome);
- const nonDraw = outcomes.filter((o) => !isDrawOutcome(o));
- if (!draw || nonDraw.length < 2) {
- return [...outcomes];
- }
-
- if (game) {
- const homeAbbr = game.homeTeam.abbreviation;
- const home = nonDraw.find((o) => o.tokens[0]?.shortTitle === homeAbbr);
- const away = nonDraw.find((o) => o !== home);
- if (home && away) {
- return [home, draw, away];
- }
- }
-
- const sorted = [...nonDraw].sort((a, b) =>
- (a.groupItemTitle ?? '').localeCompare(b.groupItemTitle ?? ''),
- );
- return [sorted[0], draw, ...sorted.slice(1)];
-};
-
-const buildMoneylineButtons = (
- outcomes: PredictOutcome[],
- onBuyPress: BuyHandler,
- game?: PredictMarketGame,
- getPrice?: ReturnType['getPrice'],
-): PredictSportOutcomeButton[] => {
- const sortedWithTokens = sortMoneylineOutcomes(outcomes, game).filter(
- (outcome) => outcome.tokens[0] !== undefined,
- );
-
- return sortedWithTokens.map((outcome, i) => {
- const yesToken = outcome.tokens[0];
- const liveBestAsk = getPrice?.(yesToken.id)?.bestAsk;
- const price = isValidPrice(liveBestAsk) ? liveBestAsk : yesToken.price;
-
- return {
- label: yesToken.shortTitle ?? yesToken.title,
- price: Math.round(price * 100),
- onPress: () => onBuyPress(outcome, yesToken),
- variant: getButtonVariant(i, sortedWithTokens.length, true),
- teamColor: getTeamColor(yesToken.shortTitle ?? yesToken.title, game),
- };
- });
-};
-
-const buildMoneylineSubtitle = (outcomes: PredictOutcome[]): string => {
- const totalVolume = outcomes.reduce((sum, o) => sum + o.volume, 0);
- return `$${formatVolume(totalVolume)} Vol`;
-};
-
-const MoneylineCard = memo(
- ({
- outcomes,
- onBuyPress,
- game,
- title,
- testID,
- }: {
- outcomes: PredictOutcome[];
- onBuyPress: BuyHandler;
- game?: PredictMarketGame;
- title: string;
- testID: string;
- }) => {
- const subtitle = useMemo(
- () => buildMoneylineSubtitle(outcomes),
- [outcomes],
- );
- const tokenIds = useMemo(
- () =>
- sortMoneylineOutcomes(outcomes, game)
- .map((outcome) => outcome.tokens[0]?.id)
- .filter((id): id is string => Boolean(id)),
- [outcomes, game],
- );
- const { getPrice } = useLiveMarketPrices(tokenIds);
- const buttons = useMemo(
- () => buildMoneylineButtons(outcomes, onBuyPress, game, getPrice),
- [outcomes, onBuyPress, game, getPrice],
- );
-
- return (
-
- );
- },
-);
-
-MoneylineCard.displayName = 'MoneylineCard';
-
-const SubgroupCards = memo(
- ({
- subgroup,
- onBuyPress,
- game,
- groupKey,
- index,
- }: {
- subgroup: PredictOutcomeGroup;
- onBuyPress: BuyHandler;
- game?: PredictMarketGame;
- groupKey: string;
- index: number;
- }) => {
- const translatedTitle = getTranslatedSportsMarketTypeLabel(subgroup.key);
- const firstOutcomeTitle = subgroup.outcomes[0]
- ? formatOutcomeCardTitle(subgroup.outcomes[0])
- : undefined;
- const title =
- translatedTitle ?? firstOutcomeTitle ?? toTitleCase(subgroup.key);
- const testID = `${groupKey}-${subgroup.key}-${index}`;
-
- if (
- isMoneylineLikeMarketType(subgroup.key) &&
- subgroup.outcomes.length > 1
- ) {
- return (
-
- );
- }
-
- if (subgroup.outcomes.length === 1) {
- return (
-
- );
- }
-
- return (
-
- );
- },
-);
-
-SubgroupCards.displayName = 'SubgroupCards';
-
-const OutcomesContent = memo(
- ({
- group,
- onBuyPress,
- game,
- }: {
- group: PredictOutcomeGroup;
- onBuyPress: BuyHandler;
- game?: PredictMarketGame;
- }) => {
- if (group.subgroups && group.subgroups.length > 0) {
- return (
- <>
- {group.subgroups.map((subgroup, index) => (
-
- ))}
- >
- );
- }
-
- const firstType = group.outcomes[0]?.sportsMarketType;
- if (
- firstType &&
- isMoneylineLikeMarketType(firstType) &&
- group.outcomes.length > 1
- ) {
- return (
-
- );
- }
-
- return (
- <>
- {group.outcomes.map((outcome, index) => (
-
- ))}
- >
- );
- },
-);
-
-OutcomesContent.displayName = 'OutcomesContent';
+export { getSportsMarketTypeLabel } from './utils';
export interface OutcomesTabProps {
groupMap: Map;
@@ -495,13 +22,14 @@ export interface OutcomesTabProps {
const PredictGameOutcomesTab = memo(
({ groupMap, game, activeChipKey, onBuyPress }: OutcomesTabProps) => {
const selectedGroup = groupMap.get(activeChipKey);
+ const pricedGroup = usePricedOutcomeGroup(selectedGroup);
return (
- {selectedGroup && (
+ {pricedGroup && (
diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/usePricedOutcomeGroup.test.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/usePricedOutcomeGroup.test.tsx
new file mode 100644
index 00000000000..ceb77a94b96
--- /dev/null
+++ b/app/components/UI/Predict/components/PredictGameDetailsContent/usePricedOutcomeGroup.test.tsx
@@ -0,0 +1,288 @@
+import { renderHook } from '@testing-library/react-native';
+import type {
+ GetPriceResponse,
+ PredictOutcome,
+ PredictOutcomeGroup,
+ PredictOutcomeToken,
+} from '../../types';
+import { usePricedOutcomeGroup } from './usePricedOutcomeGroup';
+
+const mockUsePredictPreviewSheet = jest.fn(() => ({
+ isBuySheetOpen: false,
+}));
+
+jest.mock('../../contexts', () => ({
+ usePredictPreviewSheet: () => mockUsePredictPreviewSheet(),
+}));
+
+const mockUsePredictPrices = jest.fn();
+
+jest.mock('../../hooks/usePredictPrices', () => ({
+ usePredictPrices: (options: unknown) => mockUsePredictPrices(options),
+}));
+
+const emptyPriceResponse: GetPriceResponse = {
+ providerId: '',
+ results: [],
+};
+
+const createToken = (
+ overrides: Partial = {},
+): PredictOutcomeToken =>
+ ({
+ id: 'token-1',
+ title: 'Team A',
+ shortTitle: 'TA',
+ price: 0.65,
+ ...overrides,
+ }) as PredictOutcomeToken;
+
+const createOutcome = (
+ overrides: Partial = {},
+): PredictOutcome =>
+ ({
+ id: 'outcome-1',
+ marketId: 'market-1',
+ title: 'Team A vs Team B',
+ groupItemTitle: 'Team A vs Team B',
+ status: 'open',
+ volume: 50000,
+ sportsMarketType: 'points',
+ tokens: [createToken({ id: 'tok-points', price: 0.65 })],
+ ...overrides,
+ }) as PredictOutcome;
+
+const createGroup = (
+ overrides: Partial = {},
+): PredictOutcomeGroup => ({
+ key: 'points',
+ outcomes: [createOutcome()],
+ ...overrides,
+});
+
+describe('usePricedOutcomeGroup', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUsePredictPreviewSheet.mockReturnValue({ isBuySheetOpen: false });
+ mockUsePredictPrices.mockReturnValue({
+ prices: emptyPriceResponse,
+ isFetching: false,
+ error: null,
+ refetch: jest.fn(),
+ });
+ });
+
+ it('does not fetch prices while the buy sheet is open', () => {
+ mockUsePredictPreviewSheet.mockReturnValue({ isBuySheetOpen: true });
+ const group = createGroup();
+
+ renderHook(() => usePricedOutcomeGroup(group));
+
+ expect(mockUsePredictPrices).toHaveBeenCalledWith(
+ expect.objectContaining({
+ enabled: false,
+ queries: [],
+ }),
+ );
+ });
+
+ it('applies sell prices to pollable group outcomes', () => {
+ const group = createGroup({
+ key: 'points',
+ outcomes: [
+ createOutcome({
+ id: 'o-points',
+ sportsMarketType: 'points',
+ tokens: [createToken({ id: 'tok-points', price: 0.65 })],
+ }),
+ ],
+ });
+ mockUsePredictPrices.mockReturnValue({
+ prices: {
+ providerId: 'polymarket',
+ results: [
+ {
+ marketId: 'market-1',
+ outcomeId: 'o-points',
+ outcomeTokenId: 'tok-points',
+ entry: { buy: 0.71, sell: 0.72 },
+ },
+ ],
+ },
+ isFetching: false,
+ error: null,
+ refetch: jest.fn(),
+ });
+
+ const { result } = renderHook(() => usePricedOutcomeGroup(group));
+
+ expect(mockUsePredictPrices).toHaveBeenCalledWith(
+ expect.objectContaining({
+ enabled: true,
+ queries: [
+ {
+ marketId: 'market-1',
+ outcomeId: 'o-points',
+ outcomeTokenId: 'tok-points',
+ },
+ ],
+ }),
+ );
+ expect(result.current?.outcomes[0].tokens[0].price).toBe(0.72);
+ });
+
+ it('re-targets price queries when selected group changes', () => {
+ const pointsGroup = createGroup({
+ key: 'points',
+ outcomes: [
+ createOutcome({
+ id: 'o-points',
+ sportsMarketType: 'points',
+ tokens: [createToken({ id: 'tok-points', price: 0.65 })],
+ }),
+ ],
+ });
+ const touchdownsGroup = createGroup({
+ key: 'touchdowns',
+ outcomes: [
+ createOutcome({
+ id: 'o-td',
+ sportsMarketType: 'touchdowns',
+ tokens: [createToken({ id: 'tok-td', price: 0.4 })],
+ }),
+ ],
+ });
+
+ const { rerender } = renderHook(
+ ({ group }: { group: PredictOutcomeGroup | undefined }) =>
+ usePricedOutcomeGroup(group),
+ { initialProps: { group: pointsGroup } },
+ );
+
+ expect(mockUsePredictPrices).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ queries: [
+ {
+ marketId: 'market-1',
+ outcomeId: 'o-points',
+ outcomeTokenId: 'tok-points',
+ },
+ ],
+ }),
+ );
+
+ rerender({ group: touchdownsGroup });
+
+ expect(mockUsePredictPrices).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ queries: [
+ {
+ marketId: 'market-1',
+ outcomeId: 'o-td',
+ outcomeTokenId: 'tok-td',
+ },
+ ],
+ }),
+ );
+ });
+
+ it('polls spread subgroup outcomes but excludes moneyline subgroups', () => {
+ const group = createGroup({
+ key: 'game_lines',
+ outcomes: [],
+ subgroups: [
+ createGroup({
+ key: 'spreads',
+ outcomes: [
+ createOutcome({
+ id: 'sp-1',
+ sportsMarketType: 'spreads',
+ line: 3.5,
+ tokens: [
+ createToken({ id: 'tok-sp-a', shortTitle: 'TA', price: 0.6 }),
+ createToken({ id: 'tok-sp-b', shortTitle: 'TB', price: 0.4 }),
+ ],
+ }),
+ ],
+ }),
+ createGroup({
+ key: 'moneyline',
+ outcomes: [
+ createOutcome({
+ id: 'ml-home',
+ sportsMarketType: 'moneyline',
+ tokens: [
+ createToken({
+ id: 'tok-ml-home',
+ shortTitle: 'TA',
+ price: 0.6,
+ }),
+ ],
+ }),
+ createOutcome({
+ id: 'ml-away',
+ sportsMarketType: 'moneyline',
+ tokens: [
+ createToken({
+ id: 'tok-ml-away',
+ shortTitle: 'TB',
+ price: 0.4,
+ }),
+ ],
+ }),
+ ],
+ }),
+ ],
+ });
+ mockUsePredictPrices.mockReturnValue({
+ prices: {
+ providerId: 'polymarket',
+ results: [
+ {
+ marketId: 'market-1',
+ outcomeId: 'sp-1',
+ outcomeTokenId: 'tok-sp-a',
+ entry: { buy: 0.67, sell: 0.68 },
+ },
+ {
+ marketId: 'market-1',
+ outcomeId: 'sp-1',
+ outcomeTokenId: 'tok-sp-b',
+ entry: { buy: 0.31, sell: 0.32 },
+ },
+ ],
+ },
+ isFetching: false,
+ error: null,
+ refetch: jest.fn(),
+ });
+
+ const { result } = renderHook(() => usePricedOutcomeGroup(group));
+
+ expect(mockUsePredictPrices).toHaveBeenCalledWith(
+ expect.objectContaining({
+ queries: [
+ {
+ marketId: 'market-1',
+ outcomeId: 'sp-1',
+ outcomeTokenId: 'tok-sp-a',
+ },
+ {
+ marketId: 'market-1',
+ outcomeId: 'sp-1',
+ outcomeTokenId: 'tok-sp-b',
+ },
+ ],
+ }),
+ );
+ expect(result.current?.subgroups?.[0].outcomes[0].tokens[0].price).toBe(
+ 0.68,
+ );
+ expect(result.current?.subgroups?.[0].outcomes[0].tokens[1].price).toBe(
+ 0.32,
+ );
+ expect(result.current?.subgroups?.[1].outcomes[0].tokens[0].price).toBe(
+ 0.6,
+ );
+ });
+});
diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/usePricedOutcomeGroup.ts b/app/components/UI/Predict/components/PredictGameDetailsContent/usePricedOutcomeGroup.ts
new file mode 100644
index 00000000000..65d101bed2e
--- /dev/null
+++ b/app/components/UI/Predict/components/PredictGameDetailsContent/usePricedOutcomeGroup.ts
@@ -0,0 +1,211 @@
+import { useEffect, useMemo, useRef } from 'react';
+import type {
+ GetPriceResponse,
+ PredictOutcome,
+ PredictOutcomeGroup,
+ PredictOutcomeToken,
+ PriceQuery,
+} from '../../types';
+import { usePredictPrices } from '../../hooks/usePredictPrices';
+import { isMoneylineLikeMarketType } from '../../constants/sports';
+import { usePredictPreviewSheet } from '../../contexts';
+import { isValidPrice } from '../../utils/prices';
+
+const PRICE_POLL_INTERVAL_MS = 2000;
+const EMPTY_PRICE_QUERIES: PriceQuery[] = [];
+
+const flattenGroupOutcomes = (group: PredictOutcomeGroup): PredictOutcome[] => [
+ ...group.outcomes,
+ ...(group.subgroups?.flatMap((subgroup) => subgroup.outcomes) ?? []),
+];
+
+const shouldPollOutcomePrices = (
+ outcome: PredictOutcome,
+ groupKey?: string,
+): boolean => !isMoneylineLikeMarketType(groupKey ?? outcome.sportsMarketType);
+
+const getPollableOutcomes = (group: PredictOutcomeGroup): PredictOutcome[] => {
+ const groupOutcomes = group.outcomes.filter((outcome) =>
+ shouldPollOutcomePrices(outcome),
+ );
+ const subgroupOutcomes =
+ group.subgroups?.flatMap((subgroup) =>
+ isMoneylineLikeMarketType(subgroup.key)
+ ? []
+ : subgroup.outcomes.filter((outcome) =>
+ shouldPollOutcomePrices(outcome, subgroup.key),
+ ),
+ ) ?? [];
+
+ return [...groupOutcomes, ...subgroupOutcomes];
+};
+
+const buildPriceQueries = (outcomes: PredictOutcome[]): PriceQuery[] =>
+ outcomes.flatMap((outcome) =>
+ outcome.tokens.map((token) => ({
+ marketId: outcome.marketId,
+ outcomeId: outcome.id,
+ outcomeTokenId: token.id,
+ })),
+ );
+
+const buildPriceResultsByTokenId = (
+ prices: GetPriceResponse,
+): Map =>
+ new Map(prices.results.map((result) => [result.outcomeTokenId, result]));
+
+const getRestAskPriceForBuyButton = (
+ token: PredictOutcomeToken,
+ pricesByTokenId: Map,
+): number => {
+ const restEntry = pricesByTokenId.get(token.id);
+ return isValidPrice(restEntry?.entry.sell)
+ ? restEntry.entry.sell
+ : token.price;
+};
+
+const applyPricesToOutcomes = (
+ outcomes: PredictOutcome[],
+ pricesByTokenId: Map,
+ shouldApplyPrices: (outcome: PredictOutcome) => boolean,
+ previousById?: Map,
+): { outcomes: PredictOutcome[]; changed: boolean } => {
+ let changed = false;
+ const nextOutcomes = outcomes.map((outcome) => {
+ if (!shouldApplyPrices(outcome)) {
+ return outcome;
+ }
+
+ const previousOutcome = previousById?.get(outcome.id);
+ let outcomeChanged = !previousOutcome;
+
+ const tokens = outcome.tokens.map((token) => {
+ const price = getRestAskPriceForBuyButton(token, pricesByTokenId);
+ const previousToken = previousOutcome?.tokens.find(
+ (t) => t.id === token.id,
+ );
+ if (previousToken && previousToken.price === price) {
+ return previousToken;
+ }
+ outcomeChanged = true;
+ return { ...token, price };
+ });
+
+ if (previousOutcome && !outcomeChanged) {
+ changed = changed || previousOutcome !== outcome;
+ return previousOutcome;
+ }
+
+ changed = true;
+ return { ...outcome, tokens };
+ });
+
+ return { outcomes: changed ? nextOutcomes : outcomes, changed };
+};
+
+const applyPricesToGroup = (
+ group: PredictOutcomeGroup,
+ prices: GetPriceResponse,
+ previousGroup: PredictOutcomeGroup | null,
+ previousPricedOutcomes: PredictOutcome[],
+): PredictOutcomeGroup => {
+ const canReusePreviousPrices = previousGroup === group;
+ const previousById = canReusePreviousPrices
+ ? new Map(previousPricedOutcomes.map((outcome) => [outcome.id, outcome]))
+ : undefined;
+ const pricesByTokenId = buildPriceResultsByTokenId(prices);
+
+ const pricedOutcomes = applyPricesToOutcomes(
+ group.outcomes,
+ pricesByTokenId,
+ (outcome) => shouldPollOutcomePrices(outcome),
+ previousById,
+ );
+
+ let subgroupChanged = false;
+ const pricedSubgroups = group.subgroups?.map((subgroup) => {
+ if (isMoneylineLikeMarketType(subgroup.key)) {
+ return subgroup;
+ }
+
+ const result = applyPricesToOutcomes(
+ subgroup.outcomes,
+ pricesByTokenId,
+ (outcome) => shouldPollOutcomePrices(outcome, subgroup.key),
+ previousById,
+ );
+
+ if (!result.changed) {
+ return subgroup;
+ }
+
+ subgroupChanged = true;
+ return { ...subgroup, outcomes: result.outcomes };
+ });
+
+ if (!pricedOutcomes.changed && !subgroupChanged) {
+ return group;
+ }
+
+ return {
+ ...group,
+ outcomes: pricedOutcomes.outcomes,
+ ...(pricedSubgroups && { subgroups: pricedSubgroups }),
+ };
+};
+
+export const usePricedOutcomeGroup = (
+ selectedGroup?: PredictOutcomeGroup,
+): PredictOutcomeGroup | undefined => {
+ const { isBuySheetOpen } = usePredictPreviewSheet();
+
+ const pollableOutcomes = useMemo(
+ () => (selectedGroup ? getPollableOutcomes(selectedGroup) : []),
+ [selectedGroup],
+ );
+
+ const priceQueries = useMemo(
+ () => buildPriceQueries(pollableOutcomes),
+ [pollableOutcomes],
+ );
+
+ const shouldFetchPrices = priceQueries.length > 0 && !isBuySheetOpen;
+ const activePriceQueries = shouldFetchPrices
+ ? priceQueries
+ : EMPTY_PRICE_QUERIES;
+
+ const { prices } = usePredictPrices({
+ queries: activePriceQueries,
+ enabled: shouldFetchPrices,
+ pollingInterval: shouldFetchPrices ? PRICE_POLL_INTERVAL_MS : undefined,
+ });
+
+ const previousSelectedGroupRef = useRef(null);
+ const previousPricedOutcomesRef = useRef([]);
+
+ const pricedGroup = useMemo(() => {
+ if (!selectedGroup) {
+ return undefined;
+ }
+
+ return applyPricesToGroup(
+ selectedGroup,
+ prices,
+ previousSelectedGroupRef.current,
+ previousPricedOutcomesRef.current,
+ );
+ }, [selectedGroup, prices]);
+
+ useEffect(() => {
+ if (!selectedGroup || !pricedGroup) {
+ previousSelectedGroupRef.current = null;
+ previousPricedOutcomesRef.current = [];
+ return;
+ }
+
+ previousSelectedGroupRef.current = selectedGroup;
+ previousPricedOutcomesRef.current = flattenGroupOutcomes(pricedGroup);
+ }, [selectedGroup, pricedGroup]);
+
+ return pricedGroup;
+};
diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/utils.test.ts b/app/components/UI/Predict/components/PredictGameDetailsContent/utils.test.ts
new file mode 100644
index 00000000000..2980e1c4b8b
--- /dev/null
+++ b/app/components/UI/Predict/components/PredictGameDetailsContent/utils.test.ts
@@ -0,0 +1,575 @@
+import type {
+ PredictMarketGame,
+ PredictOutcome,
+ PredictOutcomeToken,
+} from '../../types';
+import { TEST_HEX_COLORS } from '../../testUtils/mockColors';
+import Logger from '../../../../../util/Logger';
+import {
+ buildButtons,
+ buildMoneylineButtons,
+ buildMoneylineSubtitle,
+ buildSubtitle,
+ formatOutcomeCardTitle,
+ getSportsMarketTypeLabel,
+ sortMoneylineOutcomes,
+} from './utils';
+
+jest.mock('../../../../../../locales/i18n', () => ({
+ strings: jest.fn((key: string) => {
+ const translations: Record = {
+ 'predict.sports_market_types.moneyline': 'Moneyline',
+ 'predict.sports_market_types.spreads': 'Spreads',
+ 'predict.sports_market_types.totals': 'Totals',
+ 'predict.sports_market_types.points': 'Points',
+ 'predict.sports_market_types.basketball_total_points': 'Totals',
+ 'predict.sports_market_types.basketball_odd_even': 'Odd/Even Score',
+ 'predict.sports_market_types.basketball_team_to_score_first':
+ 'Team to Score First',
+ 'predict.sports_market_types.tennis_set_totals': 'Total Sets',
+ 'predict.sports_market_types.tennis_set_handicap': 'Set Handicap',
+ 'predict.sports_market_types.tennis_match_totals': 'Total Games',
+ 'predict.sports_market_types.tennis_first_set_totals':
+ '1st Set Total Games',
+ 'predict.sports_market_types.tennis_first_set_winner': '1st Set Winner',
+ 'predict.sports_market_types.tennis_completed_match': 'Completed Match',
+ };
+ if (key.startsWith('predict.sports_market_types.basketball_')) {
+ return translations[key] ?? `[missing "${key}" translation]`;
+ }
+ return translations[key] ?? key;
+ }),
+}));
+
+jest.mock('../../../../../util/Logger', () => ({
+ __esModule: true,
+ default: { error: jest.fn() },
+}));
+
+const createToken = (
+ overrides: Partial = {},
+): PredictOutcomeToken =>
+ ({
+ id: 'token-1',
+ title: 'Team A',
+ shortTitle: 'TA',
+ price: 0.65,
+ ...overrides,
+ }) as PredictOutcomeToken;
+
+const createOutcome = (
+ overrides: Partial = {},
+): PredictOutcome =>
+ ({
+ id: 'outcome-1',
+ marketId: 'market-1',
+ title: 'Team A vs Team B',
+ groupItemTitle: 'Team A vs Team B',
+ status: 'open',
+ volume: 50000,
+ sportsMarketType: 'moneyline',
+ tokens: [
+ createToken({
+ id: 'token-a',
+ title: 'Team A',
+ shortTitle: 'TA',
+ price: 0.65,
+ }),
+ createToken({
+ id: 'token-b',
+ title: 'Team B',
+ shortTitle: 'TB',
+ price: 0.35,
+ }),
+ ],
+ ...overrides,
+ }) as PredictOutcome;
+
+const mockGame: PredictMarketGame = {
+ id: 'game-1',
+ homeTeam: {
+ id: 'team-home',
+ name: 'Team A',
+ abbreviation: 'TA',
+ color: TEST_HEX_COLORS.PURE_RED,
+ alias: 'Team A',
+ logo: 'https://example.com/logo-a.png',
+ },
+ awayTeam: {
+ id: 'team-away',
+ name: 'Team B',
+ abbreviation: 'TB',
+ color: TEST_HEX_COLORS.PURE_BLUE,
+ alias: 'Team B',
+ logo: 'https://example.com/logo-b.png',
+ },
+ startTime: '2024-12-31T20:00:00Z',
+ status: 'scheduled' as const,
+ league: 'nfl' as const,
+ elapsed: null,
+ period: null,
+ score: null,
+};
+
+const mockOnBuyPress = jest.fn();
+
+describe('PredictGameDetailsContent utils', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('getSportsMarketTypeLabel', () => {
+ it('returns translated label for known type', () => {
+ expect(getSportsMarketTypeLabel('moneyline')).toBe('Moneyline');
+ });
+
+ it('returns translated label for basketball and tennis market types', () => {
+ expect(getSportsMarketTypeLabel('basketball_odd_even')).toBe(
+ 'Odd/Even Score',
+ );
+ expect(getSportsMarketTypeLabel('tennis_match_totals')).toBe(
+ 'Total Games',
+ );
+ expect(getSportsMarketTypeLabel('tennis_first_set_winner')).toBe(
+ '1st Set Winner',
+ );
+ });
+
+ it('returns title-cased fallback for unknown type', () => {
+ expect(getSportsMarketTypeLabel('unknown_type')).toBe('Unknown Type');
+ });
+
+ it('returns provided fallback when translation is missing', () => {
+ expect(
+ getSportsMarketTypeLabel('basketball_unknown_market', 'Fallback Title'),
+ ).toBe('Fallback Title');
+ });
+
+ it('logs missing translations only once per key', () => {
+ const mockLoggerError = jest.mocked(Logger.error);
+ const type = 'basketball_logged_once_market';
+ const key = `predict.sports_market_types.${type}`;
+ const message = `Missing Predict sports market type translation: ${key}`;
+
+ expect(getSportsMarketTypeLabel(type, 'Fallback Title')).toBe(
+ 'Fallback Title',
+ );
+ expect(getSportsMarketTypeLabel(type, 'Fallback Title')).toBe(
+ 'Fallback Title',
+ );
+
+ expect(mockLoggerError).toHaveBeenCalledTimes(1);
+ expect(mockLoggerError).toHaveBeenCalledWith(
+ expect.objectContaining({ message }),
+ { message, context: { key, type } },
+ );
+ });
+ });
+
+ describe('formatOutcomeCardTitle', () => {
+ it('strips O/U suffix from card title', () => {
+ const outcome = createOutcome({
+ groupItemTitle: 'O/U 45.5 : Points (Over/Under)',
+ });
+
+ expect(formatOutcomeCardTitle(outcome)).toBe('O/U 45.5');
+ });
+
+ it('extracts player name from O/U title with colon separator', () => {
+ const outcome = createOutcome({
+ groupItemTitle: 'Dean Wade: Points O/U 0.5',
+ });
+
+ expect(formatOutcomeCardTitle(outcome)).toBe('Dean Wade');
+ });
+
+ it('preserves title when no O/U colon pattern exists', () => {
+ const outcome = createOutcome({ groupItemTitle: 'Player Props' });
+
+ expect(formatOutcomeCardTitle(outcome)).toBe('Player Props');
+ });
+
+ it('falls back to outcome.title when groupItemTitle is missing', () => {
+ const outcome = createOutcome({
+ groupItemTitle: undefined,
+ title: 'Fallback Title',
+ });
+
+ expect(formatOutcomeCardTitle(outcome)).toBe('Fallback Title');
+ });
+
+ it('strips O/U suffix when title has colon but no player pattern match', () => {
+ const outcome = createOutcome({ groupItemTitle: 'Total: O/U 2.5' });
+
+ expect(formatOutcomeCardTitle(outcome)).toBe('Total');
+ });
+ });
+
+ describe('buildButtons', () => {
+ it('converts token price to cents and uses shortTitle when available', () => {
+ const outcome = createOutcome({
+ tokens: [
+ createToken({ price: 0.65, shortTitle: 'TA' }),
+ createToken({ price: 0.35, shortTitle: 'TB' }),
+ ],
+ });
+
+ const buttons = buildButtons(outcome, mockOnBuyPress);
+
+ expect(buttons[0].price).toBe(65);
+ expect(buttons[0].label).toBe('TA');
+ expect(buttons[1].price).toBe(35);
+ expect(buttons[1].label).toBe('TB');
+ });
+
+ it('falls back to title when shortTitle is missing', () => {
+ const outcome = createOutcome({
+ tokens: [
+ createToken({ title: 'Full Team Name', shortTitle: undefined }),
+ ],
+ });
+
+ expect(buildButtons(outcome, mockOnBuyPress)[0].label).toBe(
+ 'Full Team Name',
+ );
+ });
+
+ it('assigns moneyline variants and team colors', () => {
+ const twoButtonOutcome = createOutcome({
+ sportsMarketType: 'moneyline',
+ tokens: [
+ createToken({ shortTitle: 'TA' }),
+ createToken({ shortTitle: 'TB' }),
+ ],
+ });
+ const threeButtonOutcome = createOutcome({
+ sportsMarketType: 'moneyline',
+ tokens: [
+ createToken({ id: 't1', shortTitle: 'TA' }),
+ createToken({ id: 't2', shortTitle: 'Draw' }),
+ createToken({ id: 't3', shortTitle: 'TB' }),
+ ],
+ });
+
+ const twoButtons = buildButtons(
+ twoButtonOutcome,
+ mockOnBuyPress,
+ mockGame,
+ 'moneyline',
+ );
+ const threeButtons = buildButtons(
+ threeButtonOutcome,
+ mockOnBuyPress,
+ mockGame,
+ 'moneyline',
+ );
+
+ expect(twoButtons[0].variant).toBe('yes');
+ expect(twoButtons[1].variant).toBe('no');
+ expect(twoButtons[0].teamColor).toBe(TEST_HEX_COLORS.PURE_RED);
+ expect(twoButtons[1].teamColor).toBe(TEST_HEX_COLORS.PURE_BLUE);
+ expect(threeButtons[1].variant).toBe('draw');
+ });
+
+ it('assigns draw variant and no team colors for non-moneyline types', () => {
+ const buttons = buildButtons(
+ createOutcome({
+ sportsMarketType: 'spreads',
+ tokens: [
+ createToken({ shortTitle: 'TA' }),
+ createToken({ shortTitle: 'TB' }),
+ ],
+ }),
+ mockOnBuyPress,
+ mockGame,
+ 'spreads',
+ );
+
+ expect(buttons[0].variant).toBe('draw');
+ expect(buttons[1].variant).toBe('draw');
+ expect(buttons[0].teamColor).toBeUndefined();
+ expect(buttons[1].teamColor).toBeUndefined();
+ });
+
+ it('returns undefined team color for unknown abbreviations or missing game', () => {
+ const unknownAbbrButtons = buildButtons(
+ createOutcome({
+ sportsMarketType: 'moneyline',
+ tokens: [createToken({ shortTitle: 'UNKNOWN' })],
+ }),
+ mockOnBuyPress,
+ mockGame,
+ 'moneyline',
+ );
+ const noGameButtons = buildButtons(
+ createOutcome({
+ sportsMarketType: 'moneyline',
+ tokens: [createToken({ shortTitle: 'TA' })],
+ }),
+ mockOnBuyPress,
+ undefined,
+ 'moneyline',
+ );
+
+ expect(unknownAbbrButtons[0].teamColor).toBeUndefined();
+ expect(noGameButtons[0].teamColor).toBeUndefined();
+ });
+
+ it('assigns tennis first set winner team colors from normalized token labels', () => {
+ const tennisGame: PredictMarketGame = {
+ ...mockGame,
+ league: 'atp',
+ homeTeam: {
+ ...mockGame.homeTeam,
+ name: 'Ilya Ivashka',
+ abbreviation: 'ivashka',
+ alias: 'I. Ivashka',
+ color: TEST_HEX_COLORS.PURE_RED,
+ },
+ awayTeam: {
+ ...mockGame.awayTeam,
+ name: 'Hamish Stewart',
+ abbreviation: 'stewart',
+ alias: 'H. Stewart',
+ color: TEST_HEX_COLORS.PURE_BLUE,
+ },
+ };
+ const buttons = buildButtons(
+ createOutcome({
+ sportsMarketType: 'tennis_first_set_winner',
+ tokens: [
+ createToken({ shortTitle: 'IVASHKA' }),
+ createToken({ shortTitle: 'STEWART' }),
+ ],
+ }),
+ mockOnBuyPress,
+ tennisGame,
+ 'tennis_first_set_winner',
+ );
+
+ expect(buttons[0].teamColor).toBe(TEST_HEX_COLORS.PURE_RED);
+ expect(buttons[1].teamColor).toBe(TEST_HEX_COLORS.PURE_BLUE);
+ });
+
+ it('calls onBuyPress with outcome and token when button is pressed', () => {
+ const token = createToken({ id: 'tok-press', shortTitle: 'TA' });
+ const outcome = createOutcome({ id: 'out-press', tokens: [token] });
+
+ buildButtons(outcome, mockOnBuyPress)[0].onPress();
+
+ expect(mockOnBuyPress).toHaveBeenCalledWith(outcome, token);
+ });
+ });
+
+ describe('buildSubtitle', () => {
+ it.each([
+ [50000, '$50k Vol'],
+ [2500000, '$2.5M Vol'],
+ ])('formats volume %i as %s', (volume, expected) => {
+ expect(buildSubtitle(createOutcome({ volume }))).toBe(expected);
+ });
+ });
+
+ describe('buildMoneylineSubtitle', () => {
+ it('sums volumes from all outcomes', () => {
+ const outcomes = [
+ createOutcome({ volume: 10000 }),
+ createOutcome({ volume: 20000 }),
+ ];
+
+ expect(buildMoneylineSubtitle(outcomes)).toBe('$30k Vol');
+ });
+ });
+
+ describe('sortMoneylineOutcomes', () => {
+ it('places home team first, draw middle, away last when game is provided', () => {
+ const outcomes = [
+ createOutcome({
+ id: 'ml-draw',
+ groupItemTitle: 'Draw',
+ tokens: [createToken({ shortTitle: 'Draw', price: 0.2 })],
+ }),
+ createOutcome({
+ id: 'ml-away',
+ groupItemTitle: 'Away Win',
+ tokens: [createToken({ shortTitle: 'TB', price: 0.3 })],
+ }),
+ createOutcome({
+ id: 'ml-home',
+ groupItemTitle: 'Home Win',
+ tokens: [createToken({ shortTitle: 'TA', price: 0.5 })],
+ }),
+ ];
+
+ const sorted = sortMoneylineOutcomes(outcomes, mockGame);
+
+ expect(sorted.map((o) => o.tokens[0]?.shortTitle)).toEqual([
+ 'TA',
+ 'Draw',
+ 'TB',
+ ]);
+ });
+
+ it('places draw second when no game is provided and no thresholds', () => {
+ const outcomes = [
+ createOutcome({
+ id: 'ml-draw',
+ groupItemTitle: 'Draw',
+ tokens: [createToken({ shortTitle: 'Draw', price: 0.2 })],
+ }),
+ createOutcome({
+ id: 'ml-first',
+ groupItemTitle: 'Team X',
+ tokens: [createToken({ shortTitle: 'TX', price: 0.4 })],
+ }),
+ createOutcome({
+ id: 'ml-second',
+ groupItemTitle: 'Team Y',
+ tokens: [createToken({ shortTitle: 'TY', price: 0.4 })],
+ }),
+ ];
+
+ const sorted = sortMoneylineOutcomes(outcomes);
+
+ expect(sorted.map((o) => o.tokens[0]?.shortTitle)).toEqual([
+ 'TX',
+ 'Draw',
+ 'TY',
+ ]);
+ });
+
+ it('preserves original order when draw is present but fewer than 2 non-draw outcomes', () => {
+ const outcomes = [
+ createOutcome({
+ id: 'ml-draw',
+ groupItemTitle: 'Draw',
+ tokens: [createToken({ shortTitle: 'Draw', price: 0.5 })],
+ }),
+ createOutcome({
+ id: 'ml-only',
+ groupItemTitle: 'Team X',
+ tokens: [createToken({ shortTitle: 'TX', price: 0.5 })],
+ }),
+ ];
+
+ const sorted = sortMoneylineOutcomes(outcomes, mockGame);
+
+ expect(sorted.map((o) => o.tokens[0]?.shortTitle)).toEqual([
+ 'Draw',
+ 'TX',
+ ]);
+ });
+
+ it('sorts by groupItemThreshold when thresholds are present', () => {
+ const outcomes = [
+ createOutcome({
+ id: 'ml-2',
+ groupItemThreshold: 2,
+ tokens: [createToken({ shortTitle: 'AWY', price: 0.25 })],
+ }),
+ createOutcome({
+ id: 'ml-0',
+ groupItemThreshold: 0,
+ tokens: [createToken({ shortTitle: 'HOM', price: 0.55 })],
+ }),
+ createOutcome({
+ id: 'ml-1',
+ groupItemThreshold: 1,
+ tokens: [createToken({ shortTitle: 'Draw', price: 0.2 })],
+ }),
+ ];
+
+ const buttons = buildMoneylineButtons(outcomes, mockOnBuyPress, mockGame);
+
+ expect(buttons.map((button) => button.label)).toEqual([
+ 'HOM',
+ 'Draw',
+ 'AWY',
+ ]);
+ expect(buttons.map((button) => button.price)).toEqual([55, 20, 25]);
+ expect(buttons.map((button) => button.variant)).toEqual([
+ 'yes',
+ 'draw',
+ 'no',
+ ]);
+ });
+ });
+
+ describe('buildMoneylineButtons', () => {
+ it('uses live best ask prices when available', () => {
+ const outcomes = [
+ createOutcome({
+ id: 'ml-a',
+ tokens: [createToken({ id: 'tok-a', shortTitle: 'TA', price: 0.45 })],
+ }),
+ createOutcome({
+ id: 'ml-b',
+ tokens: [createToken({ id: 'tok-b', shortTitle: 'TB', price: 0.55 })],
+ }),
+ ];
+ const getPrice = jest.fn((tokenId: string) => ({
+ tokenId,
+ price: 0,
+ bestBid: 0,
+ bestAsk: tokenId === 'tok-a' ? 0.76 : 0.24,
+ }));
+
+ const buttons = buildMoneylineButtons(
+ outcomes,
+ mockOnBuyPress,
+ mockGame,
+ getPrice,
+ );
+
+ expect(buttons[0].price).toBe(76);
+ expect(buttons[1].price).toBe(24);
+ });
+
+ it('falls back to static token price when live best ask is zero', () => {
+ const outcomes = [
+ createOutcome({
+ id: 'ml-a',
+ tokens: [createToken({ id: 'tok-a', shortTitle: 'TA', price: 0.45 })],
+ }),
+ ];
+ const getPrice = jest.fn(() => ({
+ tokenId: 'tok-a',
+ price: 0,
+ bestBid: 0,
+ bestAsk: 0,
+ }));
+
+ const buttons = buildMoneylineButtons(
+ outcomes,
+ mockOnBuyPress,
+ mockGame,
+ getPrice,
+ );
+
+ expect(buttons[0].price).toBe(45);
+ });
+
+ it('skips outcomes without tokens', () => {
+ const outcomes = [
+ createOutcome({
+ id: 'ml-empty',
+ tokens: [],
+ }),
+ createOutcome({
+ id: 'ml-home',
+ groupItemThreshold: 0,
+ tokens: [createToken({ shortTitle: 'HOM', price: 0.6 })],
+ }),
+ createOutcome({
+ id: 'ml-away',
+ groupItemThreshold: 1,
+ tokens: [createToken({ shortTitle: 'AWY', price: 0.4 })],
+ }),
+ ];
+
+ const buttons = buildMoneylineButtons(outcomes, mockOnBuyPress, mockGame);
+
+ expect(buttons.map((button) => button.label)).toEqual(['HOM', 'AWY']);
+ });
+ });
+});
diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/utils.ts b/app/components/UI/Predict/components/PredictGameDetailsContent/utils.ts
new file mode 100644
index 00000000000..60e71cc0741
--- /dev/null
+++ b/app/components/UI/Predict/components/PredictGameDetailsContent/utils.ts
@@ -0,0 +1,224 @@
+import type {
+ PredictMarketGame,
+ PredictOutcome,
+ PredictOutcomeToken,
+ PriceUpdate,
+} from '../../types';
+import type { PredictBetButtonVariant } from '../PredictActionButtons/PredictActionButtons.types';
+import type { PredictSportOutcomeButton } from '../PredictSportOutcomeCard';
+import { formatVolume } from '../../utils/format';
+import { isValidPrice } from '../../utils/prices';
+import { isMoneylineLikeMarketType } from '../../constants/sports';
+import { strings } from '../../../../../../locales/i18n';
+import Logger from '../../../../../util/Logger';
+
+const I18N_PREFIX = 'predict.sports_market_types';
+const MISSING_TRANSLATION_PREFIX = '[missing';
+const loggedMissingTranslationKeys = new Set();
+const O_U_PLAYER_PATTERN = /^(.+?):\s+\w+ O\/U/;
+
+export type BuyHandler = (
+ outcome: PredictOutcome,
+ token: PredictOutcomeToken,
+) => void;
+
+export type LivePriceGetter = (tokenId: string) => PriceUpdate | undefined;
+
+const toTitleCase = (str: string): string =>
+ str
+ .split('_')
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(' ');
+
+const isMissingTranslation = (value: string, key: string): boolean =>
+ value === key || value.startsWith(MISSING_TRANSLATION_PREFIX);
+
+const logMissingSportsMarketTypeTranslation = (
+ key: string,
+ type: string,
+): void => {
+ if (loggedMissingTranslationKeys.has(key)) return;
+
+ loggedMissingTranslationKeys.add(key);
+ const message = `Missing Predict sports market type translation: ${key}`;
+ Logger.error(new Error(message), {
+ message,
+ context: { key, type },
+ });
+};
+
+export const getTranslatedSportsMarketTypeLabel = (
+ type: string,
+): string | undefined => {
+ const key = `${I18N_PREFIX}.${type}`;
+ const label = strings(key);
+ if (typeof label !== 'string' || isMissingTranslation(label, key)) {
+ logMissingSportsMarketTypeTranslation(key, type);
+ return undefined;
+ }
+ return label;
+};
+
+export const getSportsMarketTypeLabel = (
+ type: string,
+ fallbackTitle?: string,
+): string =>
+ getTranslatedSportsMarketTypeLabel(type) ??
+ fallbackTitle ??
+ toTitleCase(type);
+
+export const getFallbackSportsMarketTypeLabel = (
+ type: string,
+ fallbackTitle?: string,
+): string => fallbackTitle ?? toTitleCase(type);
+
+export const formatOutcomeCardTitle = (outcome: PredictOutcome): string => {
+ const raw = outcome.groupItemTitle || outcome.title;
+ if (!raw.includes('O/U')) return raw;
+
+ const match = raw.match(O_U_PLAYER_PATTERN);
+ if (match) return match[1].trim();
+
+ const colonIdx = raw.indexOf(': ');
+ if (colonIdx !== -1) return raw.slice(0, colonIdx).trim();
+
+ return raw;
+};
+
+const getTeamColor = (
+ tokenTitle: string,
+ game?: PredictMarketGame,
+): string | undefined => {
+ if (!game) return undefined;
+
+ const normalizedTokenTitle = tokenTitle.trim().toLowerCase();
+ const homeLabels = [
+ game.homeTeam.abbreviation,
+ game.homeTeam.name,
+ game.homeTeam.alias,
+ ]
+ .filter((label): label is string => Boolean(label))
+ .map((label) => label.trim().toLowerCase());
+ const awayLabels = [
+ game.awayTeam.abbreviation,
+ game.awayTeam.name,
+ game.awayTeam.alias,
+ ]
+ .filter((label): label is string => Boolean(label))
+ .map((label) => label.trim().toLowerCase());
+
+ if (homeLabels.includes(normalizedTokenTitle)) return game.homeTeam.color;
+ if (awayLabels.includes(normalizedTokenTitle)) return game.awayTeam.color;
+ return undefined;
+};
+
+const getButtonVariant = (
+ index: number,
+ total: number,
+ moneyline: boolean,
+): PredictBetButtonVariant => {
+ if (!moneyline) return 'draw';
+ if (total === 3 && index === 1) return 'draw';
+ return index === 0 ? 'yes' : 'no';
+};
+
+export const buildButtons = (
+ outcome: PredictOutcome,
+ onBuyPress: BuyHandler,
+ game?: PredictMarketGame,
+ sportsMarketType?: string,
+): PredictSportOutcomeButton[] => {
+ const moneyline = isMoneylineLikeMarketType(sportsMarketType);
+ return outcome.tokens.map((token, index) => ({
+ label: token.shortTitle ?? token.title,
+ price: Math.round(token.price * 100),
+ onPress: () => onBuyPress(outcome, token),
+ variant: getButtonVariant(index, outcome.tokens.length, moneyline),
+ teamColor: moneyline
+ ? getTeamColor(token.shortTitle ?? token.title, game)
+ : undefined,
+ }));
+};
+
+export const buildSubtitle = (outcome: PredictOutcome): string =>
+ `$${formatVolume(outcome.volume)} Vol`;
+
+export const getDefaultLineIndex = (
+ outcomes: PredictOutcome[],
+ lineIndices: number[],
+): number => {
+ if (lineIndices.length === 0) return 0;
+
+ return lineIndices.reduce((bestIndex, outcomeIndex, lineIndex) => {
+ const bestOutcome = outcomes[lineIndices[bestIndex]];
+ const outcome = outcomes[outcomeIndex];
+ return (outcome?.volume ?? 0) > (bestOutcome?.volume ?? 0)
+ ? lineIndex
+ : bestIndex;
+ }, 0);
+};
+
+const isDrawOutcome = (outcome: PredictOutcome): boolean =>
+ outcome.groupItemTitle?.toLowerCase().startsWith('draw') ?? false;
+
+export const sortMoneylineOutcomes = (
+ outcomes: PredictOutcome[],
+ game?: PredictMarketGame,
+): PredictOutcome[] => {
+ const hasThresholds = outcomes.some((o) => o.groupItemThreshold != null);
+ if (hasThresholds) {
+ return [...outcomes].sort(
+ (a, b) => (a.groupItemThreshold ?? 0) - (b.groupItemThreshold ?? 0),
+ );
+ }
+
+ const draw = outcomes.find(isDrawOutcome);
+ const nonDraw = outcomes.filter((o) => !isDrawOutcome(o));
+ if (!draw || nonDraw.length < 2) {
+ return [...outcomes];
+ }
+
+ if (game) {
+ const homeAbbr = game.homeTeam.abbreviation;
+ const home = nonDraw.find((o) => o.tokens[0]?.shortTitle === homeAbbr);
+ const away = nonDraw.find((o) => o !== home);
+ if (home && away) {
+ return [home, draw, away];
+ }
+ }
+
+ const sorted = [...nonDraw].sort((a, b) =>
+ (a.groupItemTitle ?? '').localeCompare(b.groupItemTitle ?? ''),
+ );
+ return [sorted[0], draw, ...sorted.slice(1)];
+};
+
+export const buildMoneylineButtons = (
+ outcomes: PredictOutcome[],
+ onBuyPress: BuyHandler,
+ game?: PredictMarketGame,
+ getPrice?: LivePriceGetter,
+): PredictSportOutcomeButton[] => {
+ const sortedWithTokens = sortMoneylineOutcomes(outcomes, game).filter(
+ (outcome) => outcome.tokens[0] !== undefined,
+ );
+
+ return sortedWithTokens.map((outcome, i) => {
+ const yesToken = outcome.tokens[0];
+ const liveBestAsk = getPrice?.(yesToken.id)?.bestAsk;
+ const price = isValidPrice(liveBestAsk) ? liveBestAsk : yesToken.price;
+
+ return {
+ label: yesToken.shortTitle ?? yesToken.title,
+ price: Math.round(price * 100),
+ onPress: () => onBuyPress(outcome, yesToken),
+ variant: getButtonVariant(i, sortedWithTokens.length, true),
+ teamColor: getTeamColor(yesToken.shortTitle ?? yesToken.title, game),
+ };
+ });
+};
+
+export const buildMoneylineSubtitle = (outcomes: PredictOutcome[]): string => {
+ const totalVolume = outcomes.reduce((sum, o) => sum + o.volume, 0);
+ return `$${formatVolume(totalVolume)} Vol`;
+};
diff --git a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx
index 1e775e385ea..5d02257ff8f 100644
--- a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx
+++ b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx
@@ -11,6 +11,7 @@ import {
import { PredictEventValues } from '../../constants/eventNames';
import PredictMarketSportCard from './';
import Routes from '../../../../../constants/navigation/Routes';
+import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices';
const mockNavigate = jest.fn();
jest.mock('@react-navigation/native', () => ({
@@ -51,6 +52,14 @@ jest.mock('../../hooks/useLiveGameUpdates', () => ({
useLiveGameUpdates: () => ({ gameUpdate: mockGameUpdate }),
}));
+const mockGetLivePrice = jest.fn();
+jest.mock('../../hooks/useLiveMarketPrices', () => ({
+ useLiveMarketPrices: jest.fn(() => ({
+ getPrice: mockGetLivePrice,
+ })),
+}));
+const mockUseLiveMarketPrices = jest.mocked(useLiveMarketPrices);
+
jest.mock('../../constants/sportLeagueConfigs', () => ({
getLeagueConfig: () => ({}),
}));
@@ -126,10 +135,29 @@ const initialState = {
},
};
+const stateWithSportCardLivePricesEnabled = (enabled: boolean) => ({
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+ RemoteFeatureFlagController: {
+ ...backgroundState.RemoteFeatureFlagController,
+ remoteFeatureFlags: {
+ ...backgroundState.RemoteFeatureFlagController?.remoteFeatureFlags,
+ predictSportCardLivePrices: {
+ enabled,
+ minimumVersion: '0.0.0',
+ },
+ },
+ },
+ },
+ },
+});
+
describe('PredictMarketSportCard', () => {
beforeEach(() => {
jest.clearAllMocks();
mockIsFromTrending.mockReturnValue(false);
+ mockGetLivePrice.mockReturnValue(undefined);
mockGameUpdate = null;
});
@@ -158,6 +186,52 @@ describe('PredictMarketSportCard', () => {
expect(getByText('ENG 62¢')).toBeOnTheScreen();
});
+ it('renders live Moneyline best ask prices when available', () => {
+ mockGetLivePrice.mockImplementation((tokenId: string) => ({
+ tokenId,
+ price: 0,
+ bestBid: 0,
+ bestAsk:
+ tokenId === 'token-home'
+ ? 0.71
+ : tokenId === 'token-draw'
+ ? 0.12
+ : 0.29,
+ }));
+
+ const { getByText } = renderWithProvider(
+ ,
+ { state: initialState },
+ );
+
+ expect(getByText('SPA 71¢')).toBeOnTheScreen();
+ expect(getByText('DRAW 12¢')).toBeOnTheScreen();
+ expect(getByText('ENG 29¢')).toBeOnTheScreen();
+ });
+
+ it('renders static prices and disables live subscriptions when the flag is off', () => {
+ mockGetLivePrice.mockImplementation((tokenId: string) => ({
+ tokenId,
+ price: 0,
+ bestBid: 0,
+ bestAsk: 0.99,
+ }));
+
+ const { getByText, queryByText } = renderWithProvider(
+ ,
+ { state: stateWithSportCardLivePricesEnabled(false) },
+ );
+
+ expect(getByText('SPA 60¢')).toBeOnTheScreen();
+ expect(getByText('DRAW 15¢')).toBeOnTheScreen();
+ expect(getByText('ENG 62¢')).toBeOnTheScreen();
+ expect(queryByText('SPA 99¢')).not.toBeOnTheScreen();
+ expect(mockUseLiveMarketPrices).toHaveBeenLastCalledWith(
+ ['token-home', 'token-draw', 'token-away'],
+ { enabled: false },
+ );
+ });
+
it('uses the main moneyline outcome when extended sports markets are present', () => {
const extendedMarket: PredictMarketType = {
...mockMarket,
diff --git a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx
index b641bf2fc99..700494ce807 100644
--- a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx
+++ b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx
@@ -16,6 +16,7 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset';
import { NavigationProp, useNavigation } from '@react-navigation/native';
import React, { useCallback, useMemo } from 'react';
import { TouchableOpacity } from 'react-native';
+import { useSelector } from 'react-redux';
import Routes from '../../../../../constants/navigation/Routes';
import { useTheme } from '../../../../../util/theme';
import {
@@ -25,6 +26,7 @@ import {
import { PredictEventValues } from '../../constants/eventNames';
import { usePredictActionGuard } from '../../hooks/usePredictActionGuard';
import { useLiveGameUpdates } from '../../hooks/useLiveGameUpdates';
+import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices';
import { usePredictPreviewSheet } from '../../contexts';
import { useResolvedPredictEntryPoint } from '../../hooks/useResolvedPredictEntryPoint';
import {
@@ -42,6 +44,8 @@ import {
import type { TransactionActiveAbTestEntry } from '../../../../../util/transactions/transaction-active-ab-test-attribution-registry';
import PredictSportScoreboard from '../PredictSportScoreboard';
import { isGameEnded } from '../../utils/scoreboard';
+import { isValidPrice } from '../../utils/prices';
+import { selectPredictSportCardLivePricesEnabledFlag } from '../../selectors/featureFlags';
interface PredictMarketSportCardProps {
market: PredictMarketType;
@@ -210,6 +214,9 @@ const PredictMarketSportCard: React.FC = ({
useNavigation>();
const { openBuySheet } = usePredictPreviewSheet();
const { executeGuardedAction } = usePredictActionGuard({ navigation });
+ const livePricesEnabled = useSelector(
+ selectPredictSportCardLivePricesEnabledFlag,
+ );
const game = market.game as PredictMarketGame | undefined;
const { gameUpdate } = useLiveGameUpdates(game?.id ?? null);
@@ -294,6 +301,25 @@ const PredictMarketSportCard: React.FC = ({
market.status === PredictMarketStatus.OPEN &&
!gameEnded &&
buttonItems.length > 0;
+ const tokenIds = useMemo(
+ () => buttonItems.map((item) => item.token.id),
+ [buttonItems],
+ );
+ const { getPrice } = useLiveMarketPrices(tokenIds, {
+ enabled: showBuyButtons && livePricesEnabled,
+ });
+
+ const getDisplayPrice = useCallback(
+ (token: PredictOutcomeToken): number => {
+ if (!livePricesEnabled) {
+ return token.price;
+ }
+
+ const liveBestAsk = getPrice(token.id)?.bestAsk;
+ return isValidPrice(liveBestAsk) ? liveBestAsk : token.price;
+ },
+ [getPrice, livePricesEnabled],
+ );
const getButtonTextColorClass = (item: SportOutcomeButtonItem): string => {
if (item.teamColor) return 'text-white';
@@ -381,7 +407,8 @@ const PredictMarketSportCard: React.FC = ({
getButtonTextColorClass(item),
)}
>
- {item.label.toUpperCase()} {formatCents(item.token.price)}
+ {item.label.toUpperCase()}{' '}
+ {formatCents(getDisplayPrice(item.token))}
diff --git a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCardWrapper.test.tsx b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCardWrapper.test.tsx
index be0761b9e32..0ca912a0c1c 100644
--- a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCardWrapper.test.tsx
+++ b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCardWrapper.test.tsx
@@ -47,6 +47,13 @@ jest.mock('../../hooks/useLiveGameUpdates', () => ({
useLiveGameUpdates: () => ({ gameUpdate: null }),
}));
+const mockGetLivePrice = jest.fn();
+jest.mock('../../hooks/useLiveMarketPrices', () => ({
+ useLiveMarketPrices: jest.fn(() => ({
+ getPrice: mockGetLivePrice,
+ })),
+}));
+
jest.mock('../../constants/sportLeagueConfigs', () => ({
getLeagueConfig: () => ({}),
}));
@@ -121,6 +128,7 @@ const initialState = {
describe('PredictMarketSportCardWrapper', () => {
beforeEach(() => {
jest.clearAllMocks();
+ mockGetLivePrice.mockReturnValue(undefined);
mockUsePredictMarket.mockReturnValue({
data: null,
isLoading: false,
@@ -129,10 +137,6 @@ describe('PredictMarketSportCardWrapper', () => {
} as unknown as ReturnType);
});
- afterEach(() => {
- jest.resetAllMocks();
- });
-
describe('loading state', () => {
it('returns null when fetching market data', () => {
mockUsePredictMarket.mockReturnValue({
diff --git a/app/components/UI/Predict/constants/eventNames.ts b/app/components/UI/Predict/constants/eventNames.ts
index ee858f491b2..60baf3b5efd 100644
--- a/app/components/UI/Predict/constants/eventNames.ts
+++ b/app/components/UI/Predict/constants/eventNames.ts
@@ -112,6 +112,7 @@ export const PredictEventValues = {
HOMEPAGE_FEATURED_CAROUSEL: 'homepage_featured_carousel',
HOMEPAGE_FEATURED_LIST: 'homepage_featured_list',
MAIN_TRADE_BUTTON: 'main_trade_button',
+ HOMESCREEN_PILL: 'homescreen_pill',
REWARDS: 'rewards',
GTM_MODAL: 'gtm_modal',
BACKGROUND: 'background',
diff --git a/app/components/UI/Predict/constants/flags.ts b/app/components/UI/Predict/constants/flags.ts
index 02a24981bbf..001953efa1b 100644
--- a/app/components/UI/Predict/constants/flags.ts
+++ b/app/components/UI/Predict/constants/flags.ts
@@ -30,6 +30,7 @@ export const DEFAULT_EXTENDED_SPORTS_MARKETS_FLAG: PredictExtendedSportsMarketsF
enabled: false,
minimumVersion: '',
leagues: [],
+ enabledSportsMarketTypes: [],
};
export const DEFAULT_MARKET_HIGHLIGHTS_FLAG: PredictMarketHighlightsFlag = {
diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts
index f2532ae263a..1b6a5a03544 100644
--- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts
+++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts
@@ -352,6 +352,7 @@ const defaultFeatureFlags: PredictFeatureFlags = {
feeCollection: DEFAULT_FEE_COLLECTION_FLAG,
liveSportsLeagues: [],
extendedSportsMarketsLeagues: [],
+ enabledSportsMarketTypes: [],
marketHighlightsFlag: {
enabled: false,
highlights: [],
@@ -362,6 +363,7 @@ const defaultFeatureFlags: PredictFeatureFlags = {
predictUpDownEnabled: false,
predictPortfolioEnabled: false,
predictHomeRedesignEnabled: false,
+ predictSportCardLivePricesEnabled: true,
predictWorldCup: DEFAULT_PREDICT_WORLD_CUP_FLAG,
};
diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts
index 2787319fcdf..319bc047c8b 100644
--- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts
+++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts
@@ -333,6 +333,10 @@ export class PolymarketProvider implements PredictProvider {
return this.#getFeatureFlags().extendedSportsMarketsLeagues;
}
+ #getEnabledSportsMarketTypes(): string[] {
+ return this.#getFeatureFlags().enabledSportsMarketTypes;
+ }
+
#createTeamLookup(
enabled: boolean,
):
@@ -389,6 +393,7 @@ export class PolymarketProvider implements PredictProvider {
sortMarketsBy: 'price',
teamLookup,
extendedSportsMarketsLeagues: this.#getExtendedSportsMarketsLeagues(),
+ enabledSportsMarketTypes: this.#getEnabledSportsMarketTypes(),
});
if (filterEmptyOutcomes) {
@@ -845,6 +850,7 @@ export class PolymarketProvider implements PredictProvider {
category: PolymarketProvider.FALLBACK_CATEGORY,
teamLookup,
extendedSportsMarketsLeagues,
+ enabledSportsMarketTypes: this.#getEnabledSportsMarketTypes(),
});
if (!parsedMarket) {
@@ -1111,6 +1117,7 @@ export class PolymarketProvider implements PredictProvider {
category: PolymarketProvider.FALLBACK_CATEGORY,
teamLookup,
extendedSportsMarketsLeagues: this.#getExtendedSportsMarketsLeagues(),
+ enabledSportsMarketTypes: this.#getEnabledSportsMarketTypes(),
});
} catch (error) {
DevLogger.log('Error fetching series events via Polymarket API:', error);
@@ -1149,6 +1156,7 @@ export class PolymarketProvider implements PredictProvider {
sortMarketsBy: 'price',
teamLookup,
extendedSportsMarketsLeagues: this.#getExtendedSportsMarketsLeagues(),
+ enabledSportsMarketTypes: this.#getEnabledSportsMarketTypes(),
})
.filter((m) => m.status === 'open' && m.outcomes.length > 0)
.map((market) => {
diff --git a/app/components/UI/Predict/providers/polymarket/constants.ts b/app/components/UI/Predict/providers/polymarket/constants.ts
index 5d0114c3d7a..8b004f0da2f 100644
--- a/app/components/UI/Predict/providers/polymarket/constants.ts
+++ b/app/components/UI/Predict/providers/polymarket/constants.ts
@@ -117,6 +117,12 @@ export const SPORTS_MARKET_TYPE_TO_GROUP: Record = {
tennis_first_set_totals: 'first_set',
};
+export const SUPPORTED_SPORTS_MARKET_TYPES: ReadonlySet = new Set([
+ 'moneyline',
+ 'spreads',
+ 'totals',
+]);
+
export const GROUP_ORDER: string[] = [
'game_lines',
'first_half',
diff --git a/app/components/UI/Predict/providers/polymarket/outcomeGrouping.test.ts b/app/components/UI/Predict/providers/polymarket/outcomeGrouping.test.ts
new file mode 100644
index 00000000000..5324507915a
--- /dev/null
+++ b/app/components/UI/Predict/providers/polymarket/outcomeGrouping.test.ts
@@ -0,0 +1,156 @@
+import type { PredictOutcome } from '../../types';
+import { POLYMARKET_PROVIDER_ID } from './constants';
+import {
+ buildOutcomeGroups,
+ normalizeEnabledSportsMarketTypes,
+} from './outcomeGrouping';
+
+function createGroupingOutcome(
+ id: string,
+ sportsMarketType: string,
+ line?: number,
+ shortTitle = id,
+): PredictOutcome {
+ return {
+ id,
+ providerId: POLYMARKET_PROVIDER_ID,
+ marketId: 'market-1',
+ title: id,
+ description: id,
+ image: 'icon.png',
+ status: 'open',
+ tokens: [{ id: `${id}-token`, title: shortTitle, shortTitle, price: 0.5 }],
+ volume: 100,
+ groupItemTitle: id,
+ sportsMarketType,
+ ...(line !== undefined && { line }),
+ };
+}
+
+describe('outcomeGrouping', () => {
+ describe('normalizeEnabledSportsMarketTypes', () => {
+ it('defaults to supported market types when value is missing', () => {
+ expect(normalizeEnabledSportsMarketTypes(undefined)).toEqual([
+ 'moneyline',
+ 'spreads',
+ 'totals',
+ ]);
+ });
+
+ it('honors explicit empty array', () => {
+ expect(normalizeEnabledSportsMarketTypes([])).toEqual([]);
+ });
+
+ it('filters unsupported types and deduplicates case-insensitively', () => {
+ expect(
+ normalizeEnabledSportsMarketTypes([
+ 'moneyline',
+ 'MONEYLINE',
+ 'spreads',
+ 'totals',
+ 'first_half_moneyline',
+ 'points',
+ ]),
+ ).toEqual(['moneyline', 'spreads', 'totals']);
+ });
+ });
+
+ describe('buildOutcomeGroups', () => {
+ it('groups tennis first set markets separately from game lines', () => {
+ const groups = buildOutcomeGroups([
+ createGroupingOutcome('moneyline', 'moneyline'),
+ createGroupingOutcome('set-total', 'tennis_set_totals'),
+ createGroupingOutcome('match-total', 'tennis_match_totals'),
+ createGroupingOutcome('completed', 'tennis_completed_match'),
+ createGroupingOutcome('first-set-winner', 'tennis_first_set_winner'),
+ createGroupingOutcome('first-set-total', 'tennis_first_set_totals'),
+ ]);
+
+ expect(groups.map((group) => group.key)).toEqual([
+ 'game_lines',
+ 'first_set',
+ ]);
+ expect(groups[0].subgroups?.map((group) => group.key)).toEqual([
+ 'moneyline',
+ 'tennis_set_totals',
+ 'tennis_match_totals',
+ 'tennis_completed_match',
+ ]);
+ expect(groups[1].subgroups?.map((group) => group.key)).toEqual([
+ 'tennis_first_set_winner',
+ 'tennis_first_set_totals',
+ ]);
+ });
+
+ it('orders spread subgroup outcomes descending when first spread is team one negative', () => {
+ const groups = buildOutcomeGroups([
+ createGroupingOutcome('moneyline', 'moneyline'),
+ createGroupingOutcome('arg-minus-1.5', 'spreads', -1.5, 'ARG -1.5'),
+ createGroupingOutcome('arg-plus-1.5', 'spreads', -1.5, 'ARG +1.5'),
+ createGroupingOutcome('arg-minus-2.5', 'spreads', -2.5, 'ARG -2.5'),
+ createGroupingOutcome('arg-plus-2.5', 'spreads', -2.5, 'ARG +2.5'),
+ ]);
+
+ const spreadsSubgroup = groups[0].subgroups?.find(
+ (subgroup) => subgroup.key === 'spreads',
+ );
+
+ expect(spreadsSubgroup?.outcomes.map((outcome) => outcome.id)).toEqual([
+ 'arg-plus-2.5',
+ 'arg-plus-1.5',
+ 'arg-minus-1.5',
+ 'arg-minus-2.5',
+ ]);
+ expect(
+ spreadsSubgroup?.outcomes.map((outcome) => Math.abs(outcome.line ?? 0)),
+ ).toEqual([2.5, 1.5, 1.5, 2.5]);
+ });
+
+ it('orders spread subgroup outcomes ascending when first spread is team two negative', () => {
+ const groups = buildOutcomeGroups([
+ createGroupingOutcome('moneyline', 'moneyline'),
+ createGroupingOutcome('irq-plus-1.5', 'spreads', -1.5, 'IRQ +1.5'),
+ createGroupingOutcome('irq-plus-2.5', 'spreads', -2.5, 'IRQ +2.5'),
+ createGroupingOutcome('irq-minus-2.5', 'spreads', -2.5, 'IRQ -2.5'),
+ createGroupingOutcome('irq-minus-1.5', 'spreads', -1.5, 'IRQ -1.5'),
+ createGroupingOutcome('irq-plus-3.5', 'spreads', -3.5, 'IRQ +3.5'),
+ createGroupingOutcome('irq-minus-3.5', 'spreads', -3.5, 'IRQ -3.5'),
+ ]);
+
+ const spreadsSubgroup = groups[0].subgroups?.find(
+ (subgroup) => subgroup.key === 'spreads',
+ );
+
+ expect(spreadsSubgroup?.outcomes.map((outcome) => outcome.id)).toEqual([
+ 'irq-minus-3.5',
+ 'irq-minus-2.5',
+ 'irq-minus-1.5',
+ 'irq-plus-1.5',
+ 'irq-plus-2.5',
+ 'irq-plus-3.5',
+ ]);
+ expect(
+ spreadsSubgroup?.outcomes.map((outcome) => Math.abs(outcome.line ?? 0)),
+ ).toEqual([3.5, 2.5, 1.5, 1.5, 2.5, 3.5]);
+ });
+
+ it('orders non-spread line subgroup outcomes by line', () => {
+ const groups = buildOutcomeGroups([
+ createGroupingOutcome('moneyline', 'moneyline'),
+ createGroupingOutcome('total-216.5', 'totals', 216.5),
+ createGroupingOutcome('total-211.5', 'totals', 211.5),
+ createGroupingOutcome('total-214.5', 'totals', 214.5),
+ ]);
+
+ const totalsSubgroup = groups[0].subgroups?.find(
+ (subgroup) => subgroup.key === 'totals',
+ );
+
+ expect(totalsSubgroup?.outcomes.map((outcome) => outcome.id)).toEqual([
+ 'total-211.5',
+ 'total-214.5',
+ 'total-216.5',
+ ]);
+ });
+ });
+});
diff --git a/app/components/UI/Predict/providers/polymarket/outcomeGrouping.ts b/app/components/UI/Predict/providers/polymarket/outcomeGrouping.ts
new file mode 100644
index 00000000000..865fa5d6390
--- /dev/null
+++ b/app/components/UI/Predict/providers/polymarket/outcomeGrouping.ts
@@ -0,0 +1,248 @@
+import type { PredictOutcome, PredictOutcomeGroup } from '../../types';
+import {
+ DEFAULT_GROUP_KEY,
+ GROUP_ORDER,
+ SPORTS_MARKET_TYPE_PRIORITIES,
+ SPORTS_MARKET_TYPE_TO_GROUP,
+ SUPPORTED_SPORTS_MARKET_TYPES,
+} from './constants';
+import type { PolymarketApiMarket } from './types';
+
+const DISPLAYED_SPREAD_LINE_PATTERN = /([+-]\d+(?:\.\d+)?)$/;
+
+export const normalizeSportsMarketType = (type: string): string => {
+ const lower = type.toLowerCase();
+ if (lower.startsWith('first_half_')) {
+ return lower.slice('first_half_'.length);
+ }
+ return lower;
+};
+
+const getSportsMarketTypePriority = (type: string): number =>
+ SPORTS_MARKET_TYPE_PRIORITIES[type.toLowerCase()] ?? 3;
+
+const isSpreadOutcomeType = (sportsMarketType?: string): boolean =>
+ sportsMarketType?.toLowerCase().includes('spread') ?? false;
+
+export const isLineOutcomeType = (sportsMarketType?: string): boolean => {
+ const normalizedType = normalizeSportsMarketType(sportsMarketType ?? '');
+ return isSpreadOutcomeType(normalizedType) || normalizedType === 'totals';
+};
+
+const getDisplayedSpreadLine = (
+ outcome: PredictOutcome,
+): number | undefined => {
+ const label = outcome.tokens[0]?.shortTitle ?? outcome.tokens[0]?.title;
+ const match = label?.trim().match(DISPLAYED_SPREAD_LINE_PATTERN);
+ if (!match) return undefined;
+
+ const line = Number(match[1]);
+ return Number.isFinite(line) ? line : undefined;
+};
+
+const sortLineOutcomesForDisplay = (
+ outcomes: PredictOutcome[],
+ sportsMarketType?: string,
+): PredictOutcome[] => {
+ const hasLineOutcomes = outcomes.some((outcome) => outcome.line != null);
+ if (!hasLineOutcomes) return outcomes;
+
+ const shouldSortByDisplayedSpreadLine =
+ isSpreadOutcomeType(sportsMarketType) &&
+ outcomes.every(
+ (outcome) =>
+ outcome.line == null || getDisplayedSpreadLine(outcome) !== undefined,
+ );
+
+ const firstLineOutcome = outcomes.find((outcome) => outcome.line != null);
+ const firstDisplayedSpreadLine =
+ shouldSortByDisplayedSpreadLine && firstLineOutcome
+ ? getDisplayedSpreadLine(firstLineOutcome)
+ : undefined;
+ const sortSpreadAscending =
+ firstDisplayedSpreadLine !== undefined && firstDisplayedSpreadLine > 0;
+
+ return [...outcomes].sort((a, b) => {
+ if (shouldSortByDisplayedSpreadLine) {
+ const lineA = getDisplayedSpreadLine(a) ?? 0;
+ const lineB = getDisplayedSpreadLine(b) ?? 0;
+ return sortSpreadAscending ? lineA - lineB : lineB - lineA;
+ }
+
+ return (a.line ?? 0) - (b.line ?? 0);
+ });
+};
+
+export const normalizeEnabledSportsMarketTypes = (value: unknown): string[] => {
+ const rawTypes = Array.isArray(value)
+ ? value
+ : [...SUPPORTED_SPORTS_MARKET_TYPES];
+
+ return [
+ ...new Set(rawTypes.map((type) => String(type).toLowerCase())),
+ ].filter((type) => SUPPORTED_SPORTS_MARKET_TYPES.has(type));
+};
+
+export const filterGroupableOutcomes = (
+ outcomes: PredictOutcome[],
+ enabledSportsMarketTypes: string[],
+): PredictOutcome[] => {
+ const enabledSportsMarketTypeSet = new Set(enabledSportsMarketTypes);
+
+ return outcomes.filter((outcome) => {
+ if (!outcome.sportsMarketType) return false;
+
+ return enabledSportsMarketTypeSet.has(
+ outcome.sportsMarketType.toLowerCase(),
+ );
+ });
+};
+
+export function buildOutcomeGroups(
+ outcomes: PredictOutcome[],
+): PredictOutcomeGroup[] {
+ if (outcomes.length === 0) {
+ return [];
+ }
+
+ const groupMap = new Map();
+
+ for (const outcome of outcomes) {
+ const groupKey =
+ (outcome.sportsMarketType &&
+ SPORTS_MARKET_TYPE_TO_GROUP[outcome.sportsMarketType]) ||
+ DEFAULT_GROUP_KEY;
+
+ const bucket = groupMap.get(groupKey);
+ if (bucket) {
+ bucket.push(outcome);
+ } else {
+ groupMap.set(groupKey, [outcome]);
+ }
+ }
+
+ for (const [, groupOutcomes] of groupMap) {
+ groupOutcomes.sort((a, b) => {
+ const priorityDiff =
+ getSportsMarketTypePriority(
+ normalizeSportsMarketType(a.sportsMarketType ?? ''),
+ ) -
+ getSportsMarketTypePriority(
+ normalizeSportsMarketType(b.sportsMarketType ?? ''),
+ );
+ if (priorityDiff !== 0) {
+ return priorityDiff;
+ }
+ if (
+ a.sportsMarketType === b.sportsMarketType &&
+ isLineOutcomeType(a.sportsMarketType)
+ ) {
+ return 0;
+ }
+ const volumeDiff = b.volume - a.volume;
+ if (volumeDiff !== 0) return volumeDiff;
+ return (b.liquidity ?? 0) - (a.liquidity ?? 0);
+ });
+ }
+
+ const groupEntries = [...groupMap.entries()];
+ groupEntries.sort((a, b) => {
+ const aIndex = GROUP_ORDER.indexOf(a[0]);
+ const bIndex = GROUP_ORDER.indexOf(b[0]);
+ const aPriority = aIndex === -1 ? GROUP_ORDER.length : aIndex;
+ const bPriority = bIndex === -1 ? GROUP_ORDER.length : bIndex;
+ if (aPriority !== bPriority) {
+ return aPriority - bPriority;
+ }
+ return a[0].localeCompare(b[0]);
+ });
+
+ return groupEntries.map(([key, groupOutcomes]) => {
+ const typeMap = new Map();
+ for (const outcome of groupOutcomes) {
+ const type = outcome.sportsMarketType ?? key;
+ const bucket = typeMap.get(type);
+ if (bucket) {
+ bucket.push(outcome);
+ } else {
+ typeMap.set(type, [outcome]);
+ }
+ }
+
+ if (typeMap.size < 2) {
+ return {
+ key,
+ outcomes: sortLineOutcomesForDisplay(
+ groupOutcomes,
+ groupOutcomes[0]?.sportsMarketType ?? key,
+ ),
+ };
+ }
+
+ const subgroupEntries = [...typeMap.entries()];
+ subgroupEntries.sort(
+ (a, b) =>
+ getSportsMarketTypePriority(normalizeSportsMarketType(a[0])) -
+ getSportsMarketTypePriority(normalizeSportsMarketType(b[0])),
+ );
+
+ return {
+ key,
+ outcomes: [],
+ subgroups: subgroupEntries.map(([subKey, subOutcomes]) => ({
+ key: subKey,
+ outcomes: sortLineOutcomesForDisplay(subOutcomes, subKey),
+ })),
+ };
+ });
+}
+
+const sortByLiquidityAndVolume = (
+ markets: PolymarketApiMarket[],
+): PolymarketApiMarket[] =>
+ [...markets].sort((a, b) => {
+ const aScore = (a.liquidity ?? 0) + (a.volumeNum ?? 0);
+ const bScore = (b.liquidity ?? 0) + (b.volumeNum ?? 0);
+ return bScore - aScore;
+ });
+
+/**
+ * Sort sport markets by:
+ * 1. Group by sportsMarketType
+ * 2. Order groups: moneyline first, spreads second, totals third, then alphabetically
+ * 3. Within each group, sort by liquidity + volume (descending)
+ * 4. Return flattened array of all groups in order
+ */
+export const sortGameMarkets = (
+ markets: PolymarketApiMarket[],
+): PolymarketApiMarket[] => {
+ const groupedMarkets = markets.reduce>(
+ (acc, market) => {
+ const type = market.sportsMarketType ?? 'other';
+ if (!acc[type]) {
+ acc[type] = [];
+ }
+ acc[type].push(market);
+ return acc;
+ },
+ {},
+ );
+
+ const sortedTypes = Object.keys(groupedMarkets).sort((a, b) => {
+ const priorityA = getSportsMarketTypePriority(a);
+ const priorityB = getSportsMarketTypePriority(b);
+
+ if (priorityA === priorityB && priorityA === 3) {
+ return a.toLowerCase().localeCompare(b.toLowerCase());
+ }
+
+ return priorityA - priorityB;
+ });
+
+ // Preserve API order for line markets; their display order is derived later.
+ return sortedTypes.flatMap((type) =>
+ isLineOutcomeType(type)
+ ? groupedMarkets[type]
+ : sortByLiquidityAndVolume(groupedMarkets[type]),
+ );
+};
diff --git a/app/components/UI/Predict/providers/polymarket/utils.test.ts b/app/components/UI/Predict/providers/polymarket/utils.test.ts
index 67c054353a7..bf509e7e1a7 100644
--- a/app/components/UI/Predict/providers/polymarket/utils.test.ts
+++ b/app/components/UI/Predict/providers/polymarket/utils.test.ts
@@ -13,7 +13,6 @@ import {
} from './constants';
import {
buildMarketListQueryParams,
- buildOutcomeGroups,
calculateConservativeBuyMarketFee,
clearClobMarketInfoCache,
clearClobMarketInfoSessionState,
@@ -147,6 +146,89 @@ describe('polymarket utils', () => {
...overrides,
});
+ const createNbaTeam = (
+ abbreviation: string,
+ overrides: Partial = {},
+ ): PolymarketApiTeam => ({
+ id: `team-${abbreviation}`,
+ name: abbreviation.toUpperCase(),
+ logo: `${abbreviation}.png`,
+ abbreviation,
+ color: 'red',
+ alias: abbreviation.toUpperCase(),
+ league: 'nba',
+ ...overrides,
+ });
+
+ const nbaTeamsByAbbreviation: Record = {
+ bos: createNbaTeam('bos'),
+ nyk: createNbaTeam('nyk', { color: 'blue' }),
+ };
+
+ const createSportsMarket = ({
+ id,
+ sportsMarketType,
+ volume = 100,
+ }: {
+ id: string;
+ sportsMarketType?: string;
+ volume?: number;
+ }): PolymarketApiEvent['markets'][number] =>
+ ({
+ conditionId: id,
+ question: `${id} question`,
+ description: `${id} description`,
+ icon: 'icon.png',
+ image: 'image.png',
+ groupItemTitle: id,
+ sportsMarketType,
+ status: 'open',
+ volumeNum: volume,
+ liquidity: 100,
+ negRisk: false,
+ clobTokenIds: `["${id}-yes","${id}-no"]`,
+ outcomes: '["Yes","No"]',
+ outcomePrices: '["0.5","0.5"]',
+ closed: false,
+ active: true,
+ acceptingOrders: true,
+ resolvedBy: '',
+ orderPriceMinTickSize: 0.01,
+ umaResolutionStatus: '',
+ }) as PolymarketApiEvent['markets'][number];
+
+ const createNbaGameEvent = (
+ markets: PolymarketApiEvent['markets'],
+ ): PolymarketApiEvent => ({
+ id: 'nba-game-event',
+ slug: 'nba-bos-nyk-2026-06-12',
+ title: 'Boston Celtics vs New York Knicks',
+ description: 'NBA game',
+ icon: 'icon.png',
+ closed: false,
+ active: true,
+ series: [
+ {
+ id: 'nba-series',
+ slug: 'nba',
+ title: 'NBA',
+ recurrence: 'daily',
+ },
+ ],
+ markets,
+ tags: [
+ { id: 'games', label: 'Games', slug: 'games' },
+ { id: 'nba', label: 'NBA', slug: 'nba' },
+ ],
+ teams: Object.values(nbaTeamsByAbbreviation),
+ liquidity: 100,
+ volume: 100,
+ gameId: 'nba-game-1',
+ startTime: '2026-06-12T20:00:00.000Z',
+ live: false,
+ ended: false,
+ });
+
it('parses activity with deterministic ids when transaction hash is missing', () => {
const rawActivity = createRawActivity({
transactionHash: undefined as unknown as string,
@@ -167,47 +249,127 @@ describe('polymarket utils', () => {
expect(parsedActivity[0].id).not.toBe(parsedActivity[1].id);
});
- it('groups tennis first set markets separately from game lines', () => {
- const createOutcome = (
- id: string,
- sportsMarketType: string,
- ): PredictOutcome => ({
- id,
- providerId: POLYMARKET_PROVIDER_ID,
- marketId: 'market-1',
- title: id,
- description: id,
- image: 'icon.png',
- status: 'open',
- tokens: [{ id: `${id}-token`, title: 'Yes', price: 0.5 }],
- volume: 100,
- groupItemTitle: id,
- sportsMarketType,
+ it('builds outcome groups only from supported and enabled sports market types', () => {
+ const event = createNbaGameEvent([
+ createSportsMarket({ id: 'moneyline', sportsMarketType: 'moneyline' }),
+ createSportsMarket({ id: 'spreads', sportsMarketType: 'spreads' }),
+ createSportsMarket({ id: 'totals', sportsMarketType: 'totals' }),
+ createSportsMarket({ id: 'points', sportsMarketType: 'points' }),
+ createSportsMarket({
+ id: 'first-half-moneyline',
+ sportsMarketType: 'first_half_moneyline',
+ }),
+ ]);
+
+ const [market] = parsePolymarketEvents([event], {
+ category: 'hot',
+ teamLookup: (_league, abbreviation) =>
+ nbaTeamsByAbbreviation[abbreviation],
+ extendedSportsMarketsLeagues: ['nba'],
+ enabledSportsMarketTypes: [
+ 'moneyline',
+ 'spreads',
+ 'totals',
+ 'points',
+ 'first_half_moneyline',
+ ],
});
- const groups = buildOutcomeGroups([
- createOutcome('moneyline', 'moneyline'),
- createOutcome('set-total', 'tennis_set_totals'),
- createOutcome('match-total', 'tennis_match_totals'),
- createOutcome('completed', 'tennis_completed_match'),
- createOutcome('first-set-winner', 'tennis_first_set_winner'),
- createOutcome('first-set-total', 'tennis_first_set_totals'),
+ expect(market.outcomes).toHaveLength(5);
+ expect(market.outcomes.map((outcome) => outcome.sportsMarketType)).toEqual(
+ expect.arrayContaining([
+ 'moneyline',
+ 'spreads',
+ 'totals',
+ 'points',
+ 'first_half_moneyline',
+ ]),
+ );
+ expect(market.outcomeGroups).toEqual([
+ expect.objectContaining({
+ key: 'game_lines',
+ outcomes: [],
+ subgroups: [
+ expect.objectContaining({ key: 'moneyline' }),
+ expect.objectContaining({ key: 'spreads' }),
+ expect.objectContaining({ key: 'totals' }),
+ ],
+ }),
+ ]);
+ });
+
+ it('keeps supported but unlisted sports market types out of outcome groups', () => {
+ const event = createNbaGameEvent([
+ createSportsMarket({ id: 'moneyline', sportsMarketType: 'moneyline' }),
+ createSportsMarket({ id: 'spreads', sportsMarketType: 'spreads' }),
+ createSportsMarket({ id: 'totals', sportsMarketType: 'totals' }),
+ ]);
+
+ const [market] = parsePolymarketEvents([event], {
+ category: 'hot',
+ teamLookup: (_league, abbreviation) =>
+ nbaTeamsByAbbreviation[abbreviation],
+ extendedSportsMarketsLeagues: ['nba'],
+ enabledSportsMarketTypes: ['moneyline'],
+ });
+
+ expect(market.outcomes).toHaveLength(3);
+ expect(market.outcomes.map((outcome) => outcome.sportsMarketType)).toEqual(
+ expect.arrayContaining(['moneyline', 'spreads', 'totals']),
+ );
+ expect(market.outcomeGroups).toEqual([
+ expect.objectContaining({
+ key: 'game_lines',
+ outcomes: [expect.objectContaining({ sportsMarketType: 'moneyline' })],
+ }),
]);
+ });
- expect(groups.map((group) => group.key)).toEqual([
- 'game_lines',
- 'first_set',
+ it('defaults outcome groups to supported market types when enabledSportsMarketTypes is missing', () => {
+ const event = createNbaGameEvent([
+ createSportsMarket({ id: 'moneyline', sportsMarketType: 'moneyline' }),
+ createSportsMarket({ id: 'spreads', sportsMarketType: 'spreads' }),
]);
- expect(groups[0].subgroups?.map((group) => group.key)).toEqual([
- 'moneyline',
- 'tennis_set_totals',
- 'tennis_match_totals',
- 'tennis_completed_match',
+
+ const [market] = parsePolymarketEvents([event], {
+ category: 'hot',
+ teamLookup: (_league, abbreviation) =>
+ nbaTeamsByAbbreviation[abbreviation],
+ extendedSportsMarketsLeagues: ['nba'],
+ });
+
+ expect(market.outcomes).toHaveLength(2);
+ expect(market.outcomes.map((outcome) => outcome.sportsMarketType)).toEqual(
+ expect.arrayContaining(['moneyline', 'spreads']),
+ );
+ expect(market.outcomeGroups).toEqual([
+ expect.objectContaining({
+ key: 'game_lines',
+ outcomes: [],
+ subgroups: [
+ expect.objectContaining({ key: 'moneyline' }),
+ expect.objectContaining({ key: 'spreads' }),
+ ],
+ }),
]);
- expect(groups[1].subgroups?.map((group) => group.key)).toEqual([
- 'tennis_first_set_winner',
- 'tennis_first_set_totals',
+ });
+
+ it('does not build outcome groups when enabledSportsMarketTypes is empty', () => {
+ const event = createNbaGameEvent([
+ createSportsMarket({ id: 'moneyline', sportsMarketType: 'moneyline' }),
+ createSportsMarket({ id: 'spreads', sportsMarketType: 'spreads' }),
]);
+
+ const [market] = parsePolymarketEvents([event], {
+ category: 'hot',
+ teamLookup: (_league, abbreviation) =>
+ nbaTeamsByAbbreviation[abbreviation],
+ extendedSportsMarketsLeagues: ['nba'],
+ enabledSportsMarketTypes: [],
+ });
+
+ expect(market.outcomes).toHaveLength(2);
+ expect(market.outcomeGroups).toBeUndefined();
});
it('parses World Cup game events with game metadata when team data is available', () => {
diff --git a/app/components/UI/Predict/providers/polymarket/utils.ts b/app/components/UI/Predict/providers/polymarket/utils.ts
index d51aa008095..c21b1e0b6f1 100644
--- a/app/components/UI/Predict/providers/polymarket/utils.ts
+++ b/app/components/UI/Predict/providers/polymarket/utils.ts
@@ -18,7 +18,6 @@ import {
type PredictPosition,
PredictActivity,
PredictOutcome,
- PredictOutcomeGroup,
PredictOutcomeToken,
PredictMarketGame,
} from '../../types';
@@ -50,10 +49,8 @@ import type {
import {
ClobAuthDomain,
DEFAULT_CLOB_BASE_URL,
- DEFAULT_GROUP_KEY,
EIP712Domain,
GROUP_ORDER,
- SPORTS_MARKET_TYPE_PRIORITIES,
HASH_ZERO_BYTES32,
MATIC_CONTRACTS_V2,
MSG_TO_SIGN,
@@ -87,8 +84,15 @@ import { PREDICT_WORLD_CUP_DEFAULT_TAG_SLUG } from '../../constants/flags';
import { PredictFeeCollection } from '../../types/flags';
import { roundToFiveDecimals } from '../../utils/orders';
import { getMinAmountReceivedWithSlippage } from './protocol/slippage';
+import {
+ buildOutcomeGroups,
+ filterGroupableOutcomes,
+ normalizeEnabledSportsMarketTypes,
+ sortGameMarkets,
+} from './outcomeGrouping';
export { SPORTS_MARKET_TYPE_TO_GROUP, GROUP_ORDER } from './constants';
+export { buildOutcomeGroups, sortGameMarkets } from './outcomeGrouping';
/**
* Parse a fetch `Response` body as JSON, raising a contextual error when the
@@ -640,122 +644,12 @@ function replaceAll(s: string, search: string, replace: string) {
return s.split(search).join(replace);
}
-const normalizeSportsMarketType = (type: string): string => {
- const lower = type.toLowerCase();
- if (lower.startsWith('first_half_')) {
- return lower.slice('first_half_'.length);
- }
- return lower;
-};
-
-const getSportsMarketTypePriority = (type: string): number =>
- SPORTS_MARKET_TYPE_PRIORITIES[type.toLowerCase()] ?? 3;
-
-export function buildOutcomeGroups(
- outcomes: PredictOutcome[],
-): PredictOutcomeGroup[] {
- if (outcomes.length === 0) {
- return [];
- }
-
- const groupMap = new Map();
-
- for (const outcome of outcomes) {
- const groupKey =
- (outcome.sportsMarketType &&
- SPORTS_MARKET_TYPE_TO_GROUP[outcome.sportsMarketType]) ||
- DEFAULT_GROUP_KEY;
-
- const bucket = groupMap.get(groupKey);
- if (bucket) {
- bucket.push(outcome);
- } else {
- groupMap.set(groupKey, [outcome]);
- }
- }
-
- for (const [, groupOutcomes] of groupMap) {
- groupOutcomes.sort((a, b) => {
- const priorityDiff =
- getSportsMarketTypePriority(
- normalizeSportsMarketType(a.sportsMarketType ?? ''),
- ) -
- getSportsMarketTypePriority(
- normalizeSportsMarketType(b.sportsMarketType ?? ''),
- );
- if (priorityDiff !== 0) {
- return priorityDiff;
- }
- const volumeDiff = b.volume - a.volume;
- if (volumeDiff !== 0) return volumeDiff;
- return (b.liquidity ?? 0) - (a.liquidity ?? 0);
- });
- }
-
- const groupEntries = [...groupMap.entries()];
- groupEntries.sort((a, b) => {
- const aIndex = GROUP_ORDER.indexOf(a[0]);
- const bIndex = GROUP_ORDER.indexOf(b[0]);
- const aPriority = aIndex === -1 ? GROUP_ORDER.length : aIndex;
- const bPriority = bIndex === -1 ? GROUP_ORDER.length : bIndex;
- if (aPriority !== bPriority) {
- return aPriority - bPriority;
- }
- return a[0].localeCompare(b[0]);
- });
-
- return groupEntries.map(([key, groupOutcomes]) => {
- const typeMap = new Map();
- for (const outcome of groupOutcomes) {
- const type = outcome.sportsMarketType ?? key;
- const bucket = typeMap.get(type);
- if (bucket) {
- bucket.push(outcome);
- } else {
- typeMap.set(type, [outcome]);
- }
- }
-
- if (typeMap.size < 2) {
- return { key, outcomes: groupOutcomes };
- }
-
- const subgroupEntries = [...typeMap.entries()];
- subgroupEntries.sort(
- (a, b) =>
- getSportsMarketTypePriority(normalizeSportsMarketType(a[0])) -
- getSportsMarketTypePriority(normalizeSportsMarketType(b[0])),
- );
-
- return {
- key,
- outcomes: [],
- subgroups: subgroupEntries.map(([subKey, subOutcomes]) => ({
- key: subKey,
- outcomes: subOutcomes,
- })),
- };
- });
-}
-
export const isSpreadMarket = (market: PolymarketApiMarket): boolean =>
market.sportsMarketType?.toLowerCase().includes('spread') ?? false;
const isMoneylineLikeMarket = (market: PolymarketApiMarket): boolean =>
isMoneylineLikeMarketType(market.sportsMarketType);
-/**
- * Sort markets within a sports market type group by liquidity + volume (descending)
- */
-const sortByLiquidityAndVolume = (
- markets: PolymarketApiMarket[],
-): PolymarketApiMarket[] =>
- [...markets].sort((a, b) => {
- const aScore = (a.liquidity ?? 0) + (a.volumeNum ?? 0);
- const bScore = (b.liquidity ?? 0) + (b.volumeNum ?? 0);
- return bScore - aScore;
- });
-
const formatMarketGroupItemTitle = (market: PolymarketApiMarket): string => {
const groupItemTitle = market.groupItemTitle ?? market.question ?? '';
@@ -906,48 +800,6 @@ const parsePolymarketMarketOutcomes = (
return sortOutcomeTokens(outcomeTokens, market, event);
};
-/**
- * Sort sport markets by:
- * 1. Group by sportsMarketType
- * 2. Order groups: moneyline first, spreads second, totals third, then alphabetically
- * 3. Within each group, sort by liquidity + volume (descending)
- * 4. Return flattened array of all groups in order
- */
-export const sortGameMarkets = (
- markets: PolymarketApiMarket[],
-): PolymarketApiMarket[] => {
- // Group markets by sportsMarketType
- const groupedMarkets = markets.reduce>(
- (acc, market) => {
- const type = market.sportsMarketType ?? 'other';
- if (!acc[type]) {
- acc[type] = [];
- }
- acc[type].push(market);
- return acc;
- },
- {},
- );
-
- // Get all unique types and sort them by priority
- const sortedTypes = Object.keys(groupedMarkets).sort((a, b) => {
- const priorityA = getSportsMarketTypePriority(a);
- const priorityB = getSportsMarketTypePriority(b);
-
- // If same priority (both are "other" category), sort alphabetically
- if (priorityA === priorityB && priorityA === 3) {
- return a.toLowerCase().localeCompare(b.toLowerCase());
- }
-
- return priorityA - priorityB;
- });
-
- // Sort each group by liquidity + volume, then flatten
- return sortedTypes.flatMap((type) =>
- sortByLiquidityAndVolume(groupedMarkets[type]),
- );
-};
-
export const sortMarketsByField = (
markets: PolymarketApiMarket[],
sortBy: 'price' | 'ascending' | 'descending',
@@ -1070,6 +922,7 @@ export interface ParsePolymarketEventsOptions {
sortMarketsBy?: 'price' | 'ascending' | 'descending';
teamLookup?: PolymarketTeamLookupFn;
extendedSportsMarketsLeagues?: string[];
+ enabledSportsMarketTypes?: string[];
}
export const parsePolymarketEvents = (
@@ -1082,7 +935,12 @@ export const parsePolymarketEvents = (
? { category: categoryOrOptions, sortMarketsBy }
: categoryOrOptions;
- const { category, teamLookup, extendedSportsMarketsLeagues } = options;
+ const {
+ category,
+ teamLookup,
+ extendedSportsMarketsLeagues,
+ enabledSportsMarketTypes,
+ } = options;
const sortBy = options.sortMarketsBy ?? sortMarketsBy;
return events.flatMap((event: PolymarketApiEvent) => {
@@ -1135,10 +993,17 @@ export const parsePolymarketEvents = (
game &&
eventLeague &&
extendedSportsMarketsLeagues?.includes(eventLeague);
+ const normalizedEnabledSportsMarketTypes =
+ normalizeEnabledSportsMarketTypes(enabledSportsMarketTypes);
- const outcomeGroups = outcomeGroupingEnabled
- ? buildOutcomeGroups(outcomes)
- : undefined;
+ const outcomesForGroups = outcomeGroupingEnabled
+ ? filterGroupableOutcomes(outcomes, normalizedEnabledSportsMarketTypes)
+ : [];
+
+ const outcomeGroups =
+ outcomesForGroups.length > 0
+ ? buildOutcomeGroups(outcomesForGroups)
+ : undefined;
const priceToBeat = parseEventPriceToBeat(event);
diff --git a/app/components/UI/Predict/selectors/featureFlags/index.test.ts b/app/components/UI/Predict/selectors/featureFlags/index.test.ts
index 2ede9695501..c939676167f 100644
--- a/app/components/UI/Predict/selectors/featureFlags/index.test.ts
+++ b/app/components/UI/Predict/selectors/featureFlags/index.test.ts
@@ -11,6 +11,7 @@ import {
selectPredictHomeRedesignEnabledFlag,
selectPredictHotTabFlag,
selectPredictPortfolioEnabledFlag,
+ selectPredictSportCardLivePricesEnabledFlag,
selectPredictUpDownEnabledFlag,
selectPredictWithAnyTokenEnabledFlag,
selectPredictWorldCupConfig,
@@ -1782,6 +1783,35 @@ describe('Predict Feature Flag Selectors', () => {
});
});
+ describe('selectPredictSportCardLivePricesEnabledFlag', () => {
+ it('defaults to true when the remote flag is missing', () => {
+ expect(
+ selectPredictSportCardLivePricesEnabledFlag(mockedEmptyFlagsState),
+ ).toBe(true);
+ });
+
+ it('returns false when the remote flag is disabled', () => {
+ mockHasMinimumRequiredVersion.mockReturnValue(true);
+ const state = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ predictSportCardLivePrices: {
+ enabled: false,
+ minimumVersion: '1.0.0',
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ expect(selectPredictSportCardLivePricesEnabledFlag(state)).toBe(false);
+ });
+ });
+
describe('selectPredictFeaturedCarouselEnabledFlag', () => {
it('returns true when flag is enabled and version requirement is met', () => {
mockHasMinimumRequiredVersion.mockReturnValue(true);
diff --git a/app/components/UI/Predict/selectors/featureFlags/index.ts b/app/components/UI/Predict/selectors/featureFlags/index.ts
index 3bf603691a4..f4c2369317e 100644
--- a/app/components/UI/Predict/selectors/featureFlags/index.ts
+++ b/app/components/UI/Predict/selectors/featureFlags/index.ts
@@ -182,6 +182,11 @@ export const selectPredictHomeRedesignEnabledFlag = createSelector(
(flags) => flags.predictHomeRedesignEnabled,
);
+export const selectPredictSportCardLivePricesEnabledFlag = createSelector(
+ selectPredictFeatureFlags,
+ (flags) => flags.predictSportCardLivePricesEnabled,
+);
+
export const selectPredictFeaturedCarouselEnabledFlag = createSelector(
selectRemoteFeatureFlags,
(remoteFeatureFlags) =>
diff --git a/app/components/UI/Predict/types/flags.ts b/app/components/UI/Predict/types/flags.ts
index 9ec21374123..6fd42af8f67 100644
--- a/app/components/UI/Predict/types/flags.ts
+++ b/app/components/UI/Predict/types/flags.ts
@@ -22,6 +22,7 @@ export interface PredictMarketHighlightsFlag extends VersionGatedFeatureFlag {
export interface PredictExtendedSportsMarketsFlag
extends VersionGatedFeatureFlag {
leagues: string[];
+ enabledSportsMarketTypes: string[];
}
export interface PredictWorldCupStageConfig {
@@ -49,6 +50,7 @@ export interface PredictFeatureFlags {
feeCollection: PredictFeeCollection;
liveSportsLeagues: string[];
extendedSportsMarketsLeagues: string[];
+ enabledSportsMarketTypes: string[];
marketHighlightsFlag: PredictMarketHighlightsFlag;
fakOrdersEnabled: boolean;
predictWithAnyTokenEnabled: boolean;
@@ -56,6 +58,7 @@ export interface PredictFeatureFlags {
predictWorldCup: PredictWorldCupConfig;
predictPortfolioEnabled: boolean;
predictHomeRedesignEnabled: boolean;
+ predictSportCardLivePricesEnabled: boolean;
}
export interface PredictHotTabFlag extends VersionGatedFeatureFlag {
diff --git a/app/components/UI/Predict/types/navigation.ts b/app/components/UI/Predict/types/navigation.ts
index c491caf1147..cab38b4d691 100644
--- a/app/components/UI/Predict/types/navigation.ts
+++ b/app/components/UI/Predict/types/navigation.ts
@@ -28,6 +28,7 @@ export type PredictEntryPoint =
| typeof PredictEventValues.ENTRY_POINT.HOMEPAGE_FEATURED_CAROUSEL
| typeof PredictEventValues.ENTRY_POINT.HOMEPAGE_FEATURED_LIST
| typeof PredictEventValues.ENTRY_POINT.MAIN_TRADE_BUTTON
+ | typeof PredictEventValues.ENTRY_POINT.HOMESCREEN_PILL
| typeof PredictEventValues.ENTRY_POINT.BACKGROUND
| typeof PredictEventValues.ENTRY_POINT.TRENDING_SEARCH
| typeof PredictEventValues.ENTRY_POINT.TRENDING
diff --git a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts
index d42dbd867ea..c1e0f22fde7 100644
--- a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts
+++ b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts
@@ -28,12 +28,14 @@ describe('resolvePredictFeatureFlags', () => {
feeCollection: DEFAULT_FEE_COLLECTION_FLAG,
liveSportsLeagues: [],
extendedSportsMarketsLeagues: [],
+ enabledSportsMarketTypes: [],
marketHighlightsFlag: DEFAULT_MARKET_HIGHLIGHTS_FLAG,
fakOrdersEnabled: false,
predictWithAnyTokenEnabled: false,
predictUpDownEnabled: false,
predictPortfolioEnabled: false,
predictHomeRedesignEnabled: false,
+ predictSportCardLivePricesEnabled: true,
predictWorldCup: DEFAULT_PREDICT_WORLD_CUP_FLAG,
});
});
@@ -216,6 +218,39 @@ describe('resolvePredictFeatureFlags', () => {
expect(result.predictWithAnyTokenEnabled).toBe(false);
});
+ describe('predictSportCardLivePricesEnabled', () => {
+ it('defaults to true so sport cards fetch live prices by default', () => {
+ const result = resolvePredictFeatureFlags({});
+
+ expect(result.predictSportCardLivePricesEnabled).toBe(true);
+ });
+
+ it('returns false when the remote flag is disabled and version gate passes', () => {
+ mockValidatedVersionGatedFeatureFlag.mockImplementation((flag) => {
+ if (
+ flag &&
+ typeof flag === 'object' &&
+ 'enabled' in flag &&
+ 'minimumVersion' in flag
+ ) {
+ return (flag as { enabled: boolean }).enabled;
+ }
+ return undefined;
+ });
+
+ const result = resolvePredictFeatureFlags({
+ remoteFeatureFlags: {
+ predictSportCardLivePrices: {
+ enabled: false,
+ minimumVersion: '1.0.0',
+ },
+ },
+ });
+
+ expect(result.predictSportCardLivePricesEnabled).toBe(false);
+ });
+ });
+
describe('predictWorldCup', () => {
it('returns default disabled config when flag is missing', () => {
const result = resolvePredictFeatureFlags({});
@@ -690,4 +725,162 @@ describe('resolvePredictFeatureFlags', () => {
expect(result.extendedSportsMarketsLeagues).toEqual([]);
});
});
+
+ describe('enabledSportsMarketTypes', () => {
+ it('returns empty array when flag is missing', () => {
+ const result = resolvePredictFeatureFlags({});
+
+ expect(result.enabledSportsMarketTypes).toEqual([]);
+ });
+
+ it('returns supported market types when enabledSportsMarketTypes is missing', () => {
+ mockValidatedVersionGatedFeatureFlag.mockImplementation((flag) => {
+ if (flag && typeof flag === 'object' && 'leagues' in flag) {
+ return true;
+ }
+ return undefined;
+ });
+
+ const result = resolvePredictFeatureFlags({
+ remoteFeatureFlags: {
+ predictExtendedSportsMarkets: {
+ enabled: true,
+ minimumVersion: '1.0.0',
+ leagues: ['nba'],
+ },
+ },
+ });
+
+ expect(result.enabledSportsMarketTypes).toEqual([
+ 'moneyline',
+ 'spreads',
+ 'totals',
+ ]);
+ });
+
+ it('returns empty array when enabledSportsMarketTypes is explicit empty array', () => {
+ mockValidatedVersionGatedFeatureFlag.mockImplementation((flag) => {
+ if (flag && typeof flag === 'object' && 'leagues' in flag) {
+ return true;
+ }
+ return undefined;
+ });
+
+ const result = resolvePredictFeatureFlags({
+ remoteFeatureFlags: {
+ predictExtendedSportsMarkets: {
+ enabled: true,
+ minimumVersion: '1.0.0',
+ leagues: ['nba'],
+ enabledSportsMarketTypes: [],
+ },
+ },
+ });
+
+ expect(result.enabledSportsMarketTypes).toEqual([]);
+ });
+
+ it('returns empty array when flag is disabled', () => {
+ const result = resolvePredictFeatureFlags({
+ remoteFeatureFlags: {
+ predictExtendedSportsMarkets: {
+ ...DEFAULT_EXTENDED_SPORTS_MARKETS_FLAG,
+ enabled: false,
+ leagues: ['nba'],
+ enabledSportsMarketTypes: ['moneyline', 'spreads', 'totals'],
+ },
+ },
+ });
+
+ expect(result.enabledSportsMarketTypes).toEqual([]);
+ });
+
+ it('returns empty array when version check fails', () => {
+ mockValidatedVersionGatedFeatureFlag.mockImplementation((flag) => {
+ if (flag && typeof flag === 'object' && 'leagues' in flag) {
+ return false;
+ }
+ return undefined;
+ });
+
+ const result = resolvePredictFeatureFlags({
+ remoteFeatureFlags: {
+ predictExtendedSportsMarkets: {
+ enabled: true,
+ minimumVersion: '99.0.0',
+ leagues: ['nba'],
+ enabledSportsMarketTypes: ['moneyline', 'spreads', 'totals'],
+ },
+ },
+ });
+
+ expect(result.enabledSportsMarketTypes).toEqual([]);
+ });
+
+ it('returns filtered market types when enabled and version check passes', () => {
+ mockValidatedVersionGatedFeatureFlag.mockImplementation((flag) => {
+ if (flag && typeof flag === 'object' && 'leagues' in flag) {
+ return true;
+ }
+ return undefined;
+ });
+
+ const result = resolvePredictFeatureFlags({
+ remoteFeatureFlags: {
+ predictExtendedSportsMarkets: {
+ enabled: true,
+ minimumVersion: '1.0.0',
+ leagues: ['nba'],
+ enabledSportsMarketTypes: [
+ 'moneyline',
+ 'MONEYLINE',
+ 'spreads',
+ 'totals',
+ 'first_half_moneyline',
+ 'points',
+ ],
+ },
+ },
+ });
+
+ expect(result.enabledSportsMarketTypes).toEqual([
+ 'moneyline',
+ 'spreads',
+ 'totals',
+ ]);
+ });
+
+ it('applies local override over remote flag', () => {
+ mockValidatedVersionGatedFeatureFlag.mockImplementation((flag) =>
+ Boolean(
+ flag &&
+ typeof flag === 'object' &&
+ 'enabled' in flag &&
+ 'leagues' in flag &&
+ (flag as { enabled: boolean }).enabled,
+ ),
+ );
+
+ const result = resolvePredictFeatureFlags({
+ remoteFeatureFlags: {
+ predictExtendedSportsMarkets: {
+ enabled: true,
+ minimumVersion: '1.0.0',
+ leagues: ['nba'],
+ enabledSportsMarketTypes: ['moneyline', 'spreads'],
+ },
+ },
+ localOverrides: {
+ predictExtendedSportsMarkets: {
+ enabled: true,
+ minimumVersion: '1.0.0',
+ leagues: ['nba'],
+ enabledSportsMarketTypes: ['totals'],
+ },
+ },
+ });
+
+ expect(result.enabledSportsMarketTypes).toEqual(['totals']);
+ });
+ });
});
diff --git a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts
index 29d30fefc42..8ea70c602ee 100644
--- a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts
+++ b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts
@@ -10,6 +10,7 @@ import {
DEFAULT_PREDICT_WORLD_CUP_FLAG,
} from '../constants/flags';
import { filterSupportedLeagues } from '../constants/sports';
+import { normalizeEnabledSportsMarketTypes } from '../providers/polymarket/outcomeGrouping';
import {
parse,
PredictFeeCollectionSchema,
@@ -85,11 +86,16 @@ export function resolvePredictFeatureFlags(
unwrapRemoteFeatureFlag(
flags.predictExtendedSportsMarkets,
) ?? DEFAULT_EXTENDED_SPORTS_MARKETS_FLAG;
- const extendedSportsMarketsLeagues = validatedVersionGatedFeatureFlag(
- extendedSportsFlag,
- )
+ const extendedSportsMarketsEnabled =
+ validatedVersionGatedFeatureFlag(extendedSportsFlag);
+ const extendedSportsMarketsLeagues = extendedSportsMarketsEnabled
? filterSupportedLeagues(extendedSportsFlag.leagues ?? [])
: [];
+ const enabledSportsMarketTypes = extendedSportsMarketsEnabled
+ ? normalizeEnabledSportsMarketTypes(
+ extendedSportsFlag.enabledSportsMarketTypes,
+ )
+ : [];
const fakOrdersEnabled = resolveVersionGatedBooleanFlag(
flags.predictFakOrders,
);
@@ -105,6 +111,10 @@ export function resolvePredictFeatureFlags(
const predictHomeRedesignEnabled = resolveVersionGatedBooleanFlag(
flags.predictHomeRedesign,
);
+ const predictSportCardLivePricesEnabled = resolveVersionGatedBooleanFlag(
+ flags.predictSportCardLivePrices,
+ true,
+ );
const parsedPredictWorldCup = parse(
unwrapRemoteFeatureFlag(
flags.predictWorldCup,
@@ -122,12 +132,14 @@ export function resolvePredictFeatureFlags(
feeCollection,
liveSportsLeagues,
extendedSportsMarketsLeagues,
+ enabledSportsMarketTypes,
marketHighlightsFlag,
fakOrdersEnabled,
predictWithAnyTokenEnabled,
predictUpDownEnabled,
predictPortfolioEnabled,
predictHomeRedesignEnabled,
+ predictSportCardLivePricesEnabled,
predictWorldCup,
};
}
diff --git a/app/components/UI/Predict/views/PredictHome/PredictHome.view.test.tsx b/app/components/UI/Predict/views/PredictHome/PredictHome.view.test.tsx
index 45444d4d9ab..c14d44f9ee9 100644
--- a/app/components/UI/Predict/views/PredictHome/PredictHome.view.test.tsx
+++ b/app/components/UI/Predict/views/PredictHome/PredictHome.view.test.tsx
@@ -21,7 +21,7 @@ import {
PredictSearchSelectorsIDs,
} from '../../Predict.testIds';
import { PREDICT_HEADER_STACKED_TEST_IDS } from '../../components/PredictHeaderStacked';
-import { MOCK_PREDICT_MARKET } from '../../../../../../tests/component-view/fixtures/predict';
+import { MOCK_PREDICT_LIVE_SPORT_MARKET } from '../../../../../../tests/component-view/fixtures/predict';
const SEARCH_PLACEHOLDER = 'Search prediction markets';
const PREDICTIONS_TITLE = 'Predictions';
@@ -47,11 +47,15 @@ describe('PredictHome', () => {
(
Engine.context.PredictController.getMarkets as jest.Mock
).mockResolvedValue({ markets: [], nextCursor: null });
- // The Live Now section hides itself when there is no data, so it needs at
- // least one live market to render for the composition/order assertion.
+ // Live Now hides when empty after load and only keeps scoreboard-capable
+ // markets (`game` present). The sport card also needs full team/league data
+ // once the carousel renders loaded cards (see MOCK_PREDICT_LIVE_SPORT_MARKET).
(
Engine.context.PredictController.listMarkets as jest.Mock
- ).mockResolvedValue({ markets: [MOCK_PREDICT_MARKET], nextCursor: null });
+ ).mockResolvedValue({
+ markets: [MOCK_PREDICT_LIVE_SPORT_MARKET],
+ nextCursor: null,
+ });
(
Engine.context.PredictController.listFilterOptions as jest.Mock
).mockResolvedValue([
@@ -111,26 +115,15 @@ describe('PredictHome', () => {
});
it('composes the section placeholders in Figma order', async () => {
- const { findByTestId, getByTestId } = renderPredictHomeView();
+ const { findByTestId } = renderPredictHomeView();
- // Wait for the shell to mount.
+ // Wait for the shell and async sections (Live Now loads via React Query).
await findByTestId(PredictHomeSelectorsIDs.CONTAINER);
-
- expect(
- getByTestId(PredictHomeSelectorsIDs.PORTFOLIO_MODULE),
- ).toBeOnTheScreen();
- expect(
- getByTestId(PredictHomeSelectorsIDs.LIVE_NOW_SECTION),
- ).toBeOnTheScreen();
- expect(
- getByTestId(PredictHomeSelectorsIDs.CATEGORIES_SECTION),
- ).toBeOnTheScreen();
- expect(
- getByTestId(PredictHomeSelectorsIDs.POPULAR_TODAY_SECTION),
- ).toBeOnTheScreen();
- expect(
- getByTestId(PredictHomeSelectorsIDs.TRENDING_SECTION),
- ).toBeOnTheScreen();
+ await findByTestId(PredictHomeSelectorsIDs.PORTFOLIO_MODULE);
+ await findByTestId(PredictHomeSelectorsIDs.LIVE_NOW_SECTION);
+ await findByTestId(PredictHomeSelectorsIDs.CATEGORIES_SECTION);
+ await findByTestId(PredictHomeSelectorsIDs.POPULAR_TODAY_SECTION);
+ await findByTestId(PredictHomeSelectorsIDs.TRENDING_SECTION);
});
});
diff --git a/app/components/UI/Rewards/RewardsNavigator.tsx b/app/components/UI/Rewards/RewardsNavigator.tsx
index f57bea9716a..568ec33b0e6 100644
--- a/app/components/UI/Rewards/RewardsNavigator.tsx
+++ b/app/components/UI/Rewards/RewardsNavigator.tsx
@@ -30,6 +30,7 @@ import PredictThePitchCampaignDetailsView from './Views/PredictThePitchCampaignD
import PredictThePitchCampaignWinningView from './Views/PredictThePitchCampaignWinningView';
import PredictThePitchCampaignLeaderboardView from './Views/PredictThePitchCampaignLeaderboardView';
import PredictThePitchCampaignPortfolioView from './Views/PredictThePitchCampaignPortfolioView';
+import PredictThePitchCampaignStatsView from './Views/PredictThePitchCampaignStatsView';
import { useSelector } from 'react-redux';
import { selectRewardsSubscriptionId } from '../../../selectors/rewards';
import { selectIsRewardsVersionBlocked } from '../../../reducers/rewards/selectors';
@@ -277,6 +278,11 @@ const RewardsNavigator: React.FC = () => {
component={PredictThePitchCampaignLeaderboardView}
options={{ headerShown: false }}
/>
+
{
PREDICT_THE_PITCH_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_VOLUME,
),
).toBeDefined();
+ expect(
+ getByTestId(
+ PREDICT_THE_PITCH_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARKETS_TRADED,
+ ),
+ ).toBeDefined();
+ });
+
+ it('shows x/y markets when below minimum', () => {
+ mockUseGetPosition.mockReturnValue({
+ position: {
+ ...basePosition,
+ marketsTraded: 2,
+ minimumMarketsTraded: 3,
+ },
+ isLoading: false,
+ hasError: false,
+ hasFetched: true,
+ refetch: jest.fn(),
+ });
+
+ const { getByTestId } = render();
+
+ expect(
+ getByTestId(
+ PREDICT_THE_PITCH_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARKETS_TRADED,
+ ).props.children,
+ ).toBe('2/3');
+ });
+
+ it('shows count only when markets traded meets minimum', () => {
+ mockUseGetPosition.mockReturnValue({
+ position: {
+ ...basePosition,
+ marketsTraded: 5,
+ minimumMarketsTraded: 3,
+ },
+ isLoading: false,
+ hasError: false,
+ hasFetched: true,
+ refetch: jest.fn(),
+ });
+
+ const { getByTestId } = render();
+
+ expect(
+ getByTestId(
+ PREDICT_THE_PITCH_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARKETS_TRADED,
+ ).props.children,
+ ).toBe('5');
+ });
+
+ it('hides the markets traded cell when marketsTraded is null', () => {
+ mockUseGetPosition.mockReturnValue({
+ position: {
+ ...basePosition,
+ marketsTraded: null,
+ minimumMarketsTraded: 3,
+ } as unknown as PredictThePitchLeaderboardPositionDto,
+ isLoading: false,
+ hasError: false,
+ hasFetched: true,
+ refetch: jest.fn(),
+ });
+
+ const { queryByTestId } = render();
+
+ expect(
+ queryByTestId(
+ PREDICT_THE_PITCH_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARKETS_TRADED,
+ ),
+ ).toBeNull();
+ });
+
+ it('hides the markets traded cell when position is null', () => {
+ mockUseGetPosition.mockReturnValue({
+ position: null,
+ isLoading: false,
+ hasError: false,
+ hasFetched: true,
+ refetch: jest.fn(),
+ });
+
+ const { queryByTestId } = render();
+
+ expect(
+ queryByTestId(
+ PREDICT_THE_PITCH_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARKETS_TRADED,
+ ),
+ ).toBeNull();
});
it('shows fallback performance values when position numeric fields are invalid', () => {
diff --git a/app/components/UI/Rewards/Views/PredictThePitchCampaignStatsView.tsx b/app/components/UI/Rewards/Views/PredictThePitchCampaignStatsView.tsx
index b8898bf7f27..299df7d6d4d 100644
--- a/app/components/UI/Rewards/Views/PredictThePitchCampaignStatsView.tsx
+++ b/app/components/UI/Rewards/Views/PredictThePitchCampaignStatsView.tsx
@@ -42,6 +42,8 @@ export const PREDICT_THE_PITCH_CAMPAIGN_STATS_VIEW_TEST_IDS = {
PERFORMANCE_PNL: 'predict-the-pitch-campaign-stats-view-performance-pnl',
PERFORMANCE_VOLUME:
'predict-the-pitch-campaign-stats-view-performance-volume',
+ PERFORMANCE_MARKETS_TRADED:
+ 'predict-the-pitch-campaign-stats-view-performance-markets-traded',
QUALIFIED_CARD: 'predict-the-pitch-campaign-stats-view-qualified-card',
QUALIFY_FOR_RANK_CARD:
'predict-the-pitch-campaign-stats-view-qualify-for-rank-card',
@@ -98,6 +100,16 @@ const PredictThePitchCampaignStatsView: React.FC = () => {
const pnlDisplay = pnl != null ? formatSignedUsd(pnl) : '-';
const volumeDisplay = volume != null ? formatUsd(volume) : '-';
+ const marketsTraded = position?.marketsTraded ?? null;
+ const minimumMarketsTraded = position?.minimumMarketsTraded ?? null;
+ const showMarketsTraded =
+ marketsTraded != null && minimumMarketsTraded != null;
+ const marketsDisplay = showMarketsTraded
+ ? marketsTraded < minimumMarketsTraded
+ ? `${marketsTraded}/${minimumMarketsTraded}`
+ : String(marketsTraded)
+ : '';
+
const isPending = position != null && !position.eligible;
const isEligible = position != null && position.eligible;
@@ -185,7 +197,20 @@ const PredictThePitchCampaignStatsView: React.FC = () => {
PREDICT_THE_PITCH_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_VOLUME
}
/>
-
+ {showMarketsTraded ? (
+
+ ) : (
+
+ )}
{showQualifiedCard && (
diff --git a/app/components/UI/Rewards/Views/RewardsVipRefereeSplashView.test.tsx b/app/components/UI/Rewards/Views/RewardsVipRefereeSplashView.test.tsx
index 3e1f21a7870..1defeacad1b 100644
--- a/app/components/UI/Rewards/Views/RewardsVipRefereeSplashView.test.tsx
+++ b/app/components/UI/Rewards/Views/RewardsVipRefereeSplashView.test.tsx
@@ -5,7 +5,7 @@ import { useSelector } from 'react-redux';
import Routes from '../../../../constants/navigation/Routes';
import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram';
-import { VIP_REFEREE_SPLASH_SCREEN_TEST_IDS } from '../components/Vip/VipRefereeSplashScreen';
+import { VIP_REFEREE_SPLASH_SCREEN_TEST_IDS } from '../components/Vip/VipSplashScreenLayout';
import RewardsVipRefereeSplashView from './RewardsVipRefereeSplashView';
const mockNavigateDispatch = jest.fn();
@@ -102,11 +102,11 @@ jest.mock('../../../../images/rewards/vip_splash.png', () => 1);
jest.mock('../../../../../locales/i18n', () => ({
strings: jest.fn((key: string, opts?: Record) => {
const translations: Record = {
- 'rewards.vip.referee_splash_title': 'WELCOME\nTO MOCK\nFOX VIP',
- 'rewards.vip.referee_splash_description':
+ 'rewards.vip.splash_title': 'WELCOME\nTO MOCK\nFOX COLLECTIVE',
+ 'rewards.vip.splash_description':
'Placeholder referee splash copy for tests only.',
'rewards.vip.referee_splash_continue': 'Continue',
- 'rewards.vip.referee_splash_not_now': 'Not now',
+ 'rewards.vip.splash_not_now': 'Not now',
};
if (key === 'rewards.vip.referee_referred_by') {
return `Referred by ${opts?.code ?? ''}`;
@@ -165,8 +165,42 @@ describe('RewardsVipRefereeSplashView', () => {
expect(
getByTestId(VIP_REFEREE_SPLASH_SCREEN_TEST_IDS.CONTAINER),
).toBeOnTheScreen();
- expect(getAllByText('WELCOME\nTO MOCK\nFOX VIP')[0]).toBeOnTheScreen();
+ expect(
+ getAllByText('WELCOME\nTO MOCK\nFOX COLLECTIVE')[0],
+ ).toBeOnTheScreen();
expect(getByText('Referred by TESTCODE')).toBeOnTheScreen();
+ expect(
+ getByTestId(VIP_REFEREE_SPLASH_SCREEN_TEST_IDS.REFERRED_BY),
+ ).toBeOnTheScreen();
+ });
+
+ it('omits the referred-by footer when no referral code is available', () => {
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === selectRewardsSubscriptionId) return mockSubscriptionId;
+ if (selector === selectVipProgramEnabled) return mockIsVipProgramEnabled;
+
+ return (
+ selector as (state: {
+ rewards: {
+ isVipReferee: boolean;
+ referredByVipCode: string | null;
+ vipRefereeSplashAccepted: Record;
+ };
+ }) => unknown
+ )({
+ rewards: {
+ isVipReferee: mockIsVipReferee,
+ referredByVipCode: null,
+ vipRefereeSplashAccepted: mockVipRefereeSplashAccepted,
+ },
+ });
+ });
+
+ const { queryByTestId } = render();
+
+ expect(
+ queryByTestId(VIP_REFEREE_SPLASH_SCREEN_TEST_IDS.REFERRED_BY),
+ ).toBeNull();
});
it('replaces with the referee view when continue is pressed', () => {
diff --git a/app/components/UI/Rewards/Views/RewardsVipRefereeSplashView.tsx b/app/components/UI/Rewards/Views/RewardsVipRefereeSplashView.tsx
index a01ede5df7b..1221ebef505 100644
--- a/app/components/UI/Rewards/Views/RewardsVipRefereeSplashView.tsx
+++ b/app/components/UI/Rewards/Views/RewardsVipRefereeSplashView.tsx
@@ -1,6 +1,12 @@
import React, { useCallback, useEffect } from 'react';
import { StackActions, useNavigation } from '@react-navigation/native';
import { useSelector } from 'react-redux';
+import {
+ FontWeight,
+ Text,
+ TextVariant,
+} from '@metamask/design-system-react-native';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
import Routes from '../../../../constants/navigation/Routes';
import {
selectHasAcceptedVipRefereeInvite,
@@ -9,11 +15,16 @@ import {
} from '../../../../reducers/rewards/selectors';
import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram';
+import { strings } from '../../../../../locales/i18n';
import ErrorBoundary from '../../../Views/ErrorBoundary';
-import VipRefereeSplashScreen from '../components/Vip/VipRefereeSplashScreen';
+import { VIP_GOLD_TEXT_MUTED } from '../components/Vip/Vip.constants';
+import VipSplashScreenLayout, {
+ VIP_REFEREE_SPLASH_SCREEN_TEST_IDS,
+} from '../components/Vip/VipSplashScreenLayout';
const RewardsVipRefereeSplashViewContent: React.FC = () => {
const navigation = useNavigation();
+ const tw = useTailwind();
const subscriptionId = useSelector(selectRewardsSubscriptionId);
const isVipProgramEnabled = useSelector(selectVipProgramEnabled);
const isVipReferee = useSelector(selectIsVipReferee);
@@ -62,10 +73,32 @@ const RewardsVipRefereeSplashViewContent: React.FC = () => {
}
return (
-
+ {strings('rewards.vip.referee_referred_by', {
+ code: referredByVipCode,
+ })}
+
+ ) : null
+ }
/>
);
};
diff --git a/app/components/UI/Rewards/Views/RewardsVipRefereeView.test.tsx b/app/components/UI/Rewards/Views/RewardsVipRefereeView.test.tsx
index c98c2de0a43..66cb05465da 100644
--- a/app/components/UI/Rewards/Views/RewardsVipRefereeView.test.tsx
+++ b/app/components/UI/Rewards/Views/RewardsVipRefereeView.test.tsx
@@ -135,11 +135,13 @@ jest.mock('../../../../../locales/i18n', () => ({
if (key === 'rewards.vip.referee_referred_by') {
return `Referred by ${params?.code ?? ''}`;
}
+ if (key === 'rewards.vip.referee_points_to_label') {
+ return `Points to ${params?.code ?? ''}`;
+ }
const translations: Record = {
'rewards.vip.referee_page_title': 'VIP Pilot',
'rewards.vip.referee_stats_title': 'Stats',
'rewards.vip.referee_period_last_30d': 'Last 30d',
- 'rewards.vip.referee_points_label': 'Points',
'rewards.vip.referee_swaps_volume_label': 'Swaps volume',
'rewards.vip.referee_perps_volume_label': 'Perps volume',
'rewards.vip.referee_error_title': 'Error title',
@@ -280,21 +282,57 @@ describe('RewardsVipRefereeView', () => {
).toBeOnTheScreen();
expect(getByText('VIP Pilot')).toBeOnTheScreen();
expect(getByText('Referred by TESTCODE')).toBeOnTheScreen();
- expect(
- getByTestId(REWARDS_VIP_REFEREE_VIEW_TEST_IDS.POINTS),
- ).toBeOnTheScreen();
+ expect(getByText('Swaps volume')).toBeOnTheScreen();
+ expect(getByText('Perps volume')).toBeOnTheScreen();
+ expect(getByText('Points to TESTCODE')).toBeOnTheScreen();
expect(
getByTestId(REWARDS_VIP_REFEREE_VIEW_TEST_IDS.SWAPS_VOLUME),
).toBeOnTheScreen();
expect(
getByTestId(REWARDS_VIP_REFEREE_VIEW_TEST_IDS.PERPS_VOLUME),
).toBeOnTheScreen();
+ expect(
+ getByTestId(REWARDS_VIP_REFEREE_VIEW_TEST_IDS.POINTS_TO),
+ ).toBeOnTheScreen();
expect(mockUseTrack).toHaveBeenCalledWith({
page_type: 'vip_referee',
enabled: true,
});
});
+ it('displays swaps and perps volume in separate stat cells', () => {
+ const { getByTestId } = render();
+
+ expect(
+ getByTestId(REWARDS_VIP_REFEREE_VIEW_TEST_IDS.SWAPS_VOLUME),
+ ).toHaveTextContent(/\$1,000/);
+ expect(
+ getByTestId(REWARDS_VIP_REFEREE_VIEW_TEST_IDS.PERPS_VOLUME),
+ ).toHaveTextContent(/\$2,000/);
+ });
+
+ it('displays dashboard points in the points-to stat cell', () => {
+ const { getByTestId } = render();
+
+ expect(
+ getByTestId(REWARDS_VIP_REFEREE_VIEW_TEST_IDS.POINTS_TO),
+ ).toHaveTextContent(/1,234/);
+ });
+
+ it('renders an empty points-to label when referredByCode is missing', () => {
+ mockUseVipRefereeDashboard.mockReturnValue({
+ dashboard: { ...defaultDashboard, referredByCode: null },
+ isLoading: false,
+ hasError: false,
+ hasAttemptedFetch: true,
+ fetchVipRefereeDashboard: mockFetch,
+ });
+
+ const { getByText } = render();
+
+ expect(getByText('Points to ')).toBeOnTheScreen();
+ });
+
it('renders the "Last updated" row when computedAt is present', () => {
const { getByTestId } = render();
@@ -405,7 +443,7 @@ describe('RewardsVipRefereeView', () => {
screen: Routes.WEBVIEW.SIMPLE,
params: {
url: expect.stringContaining(
- `priority=vip&account=${encodeURIComponent(mockAccountAddress)}`,
+ `priority=vip&address=${encodeURIComponent(mockAccountAddress)}`,
),
title: 'Contact support',
},
diff --git a/app/components/UI/Rewards/Views/RewardsVipRefereeView.tsx b/app/components/UI/Rewards/Views/RewardsVipRefereeView.tsx
index f2e91e42ab7..3901fb58485 100644
--- a/app/components/UI/Rewards/Views/RewardsVipRefereeView.tsx
+++ b/app/components/UI/Rewards/Views/RewardsVipRefereeView.tsx
@@ -56,9 +56,9 @@ export const REWARDS_VIP_REFEREE_VIEW_TEST_IDS = {
SKELETON: 'rewards-vip-referee-view-skeleton',
ERROR: 'rewards-vip-referee-view-error',
REFERRED_BY_CARD: 'rewards-vip-referee-view-referred-by-card',
- POINTS: 'rewards-vip-referee-view-points',
SWAPS_VOLUME: 'rewards-vip-referee-view-swaps-volume',
PERPS_VOLUME: 'rewards-vip-referee-view-perps-volume',
+ POINTS_TO: 'rewards-vip-referee-view-points-to',
LAST_UPDATED: 'rewards-vip-referee-view-last-updated',
CONTACT_SUPPORT_BUTTON: 'rewards-vip-referee-view-contact-support-button',
} as const;
@@ -197,8 +197,8 @@ const RewardsVipRefereeViewContent: React.FC = () => {
-
-
+
+
@@ -207,8 +207,8 @@ const RewardsVipRefereeViewContent: React.FC = () => {
-
-
+
+
@@ -269,55 +269,57 @@ const RewardsVipRefereeViewContent: React.FC = () => {
- {strings('rewards.vip.referee_points_label')}
+ {strings('rewards.vip.referee_swaps_volume_label')}
- {formatNumber(dashboard.points)}
+ {formatUsd(dashboard.swapsVolume)}
- {strings('rewards.vip.referee_swaps_volume_label')}
+ {strings('rewards.vip.referee_perps_volume_label')}
- {formatUsd(dashboard.swapsVolume)}
+ {formatUsd(dashboard.perpsVolume)}
- {strings('rewards.vip.referee_perps_volume_label')}
+ {strings('rewards.vip.referee_points_to_label', {
+ code: dashboard.referredByCode ?? '',
+ })}
- {formatUsd(dashboard.perpsVolume)}
+ {formatNumber(dashboard.points)}
diff --git a/app/components/UI/Rewards/Views/RewardsVipSplashView.test.tsx b/app/components/UI/Rewards/Views/RewardsVipSplashView.test.tsx
index 1ad36d44110..f59398c3a26 100644
--- a/app/components/UI/Rewards/Views/RewardsVipSplashView.test.tsx
+++ b/app/components/UI/Rewards/Views/RewardsVipSplashView.test.tsx
@@ -8,7 +8,7 @@ import {
selectRewardsSubscriptionId,
} from '../../../../selectors/rewards';
import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram';
-import { VIP_SPLASH_SCREEN_TEST_IDS } from '../components/Vip/VipSplashScreen';
+import { VIP_SPLASH_SCREEN_TEST_IDS } from '../components/Vip/VipSplashScreenLayout';
import { useVipDashboard } from '../hooks/useVipDashboard';
import RewardsVipSplashView from './RewardsVipSplashView';
diff --git a/app/components/UI/Rewards/Views/RewardsVipSplashView.tsx b/app/components/UI/Rewards/Views/RewardsVipSplashView.tsx
index 8c4f109fdf3..dca3c2f27bc 100644
--- a/app/components/UI/Rewards/Views/RewardsVipSplashView.tsx
+++ b/app/components/UI/Rewards/Views/RewardsVipSplashView.tsx
@@ -8,8 +8,11 @@ import {
selectRewardsSubscriptionId,
} from '../../../../selectors/rewards';
import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram';
+import { strings } from '../../../../../locales/i18n';
import ErrorBoundary from '../../../Views/ErrorBoundary';
-import VipSplashScreen from '../components/Vip/VipSplashScreen';
+import VipSplashScreenLayout, {
+ VIP_SPLASH_SCREEN_TEST_IDS,
+} from '../components/Vip/VipSplashScreenLayout';
import { useVipDashboard } from '../hooks/useVipDashboard';
const RewardsVipSplashViewContent: React.FC = () => {
@@ -59,9 +62,18 @@ const RewardsVipSplashViewContent: React.FC = () => {
}
return (
-
);
};
diff --git a/app/components/UI/Rewards/components/Vip/VipRefereeSplashScreen.tsx b/app/components/UI/Rewards/components/Vip/VipRefereeSplashScreen.tsx
deleted file mode 100644
index 8b16ffc6ab4..00000000000
--- a/app/components/UI/Rewards/components/Vip/VipRefereeSplashScreen.tsx
+++ /dev/null
@@ -1,184 +0,0 @@
-import MaskedView from '@react-native-masked-view/masked-view';
-import React from 'react';
-import { Image, Pressable } from 'react-native';
-import LinearGradient from 'react-native-linear-gradient';
-import { SafeAreaView } from 'react-native-safe-area-context';
-import {
- Box,
- FontWeight,
- Text,
- TextVariant,
-} from '@metamask/design-system-react-native';
-import { useTailwind } from '@metamask/design-system-twrnc-preset';
-import { strings } from '../../../../../../locales/i18n';
-import VipSplashFox from '../../../../../images/rewards/vip_splash.png';
-import {
- VIP_GOLD_BORDER_DEFAULT,
- VIP_GOLD_TEXT_DEFAULT,
- VIP_GOLD_TEXT_MUTED,
- VIP_SPLASH_ACCEPT_BUTTON_BACKGROUND,
- VIP_SPLASH_BACKGROUND_GRADIENT_COLORS,
- VIP_SPLASH_TITLE_GRADIENT_COLORS,
-} from './Vip.constants';
-
-export const VIP_REFEREE_SPLASH_SCREEN_TEST_IDS = {
- CONTAINER: 'rewards-vip-referee-splash-screen',
- TITLE: 'rewards-vip-referee-splash-title',
- DESCRIPTION: 'rewards-vip-referee-splash-description',
- FOX: 'rewards-vip-referee-splash-fox',
- REFERRED_BY: 'rewards-vip-referee-splash-referred-by',
- CONTINUE_BUTTON: 'rewards-vip-referee-splash-continue-button',
- NOT_NOW_BUTTON: 'rewards-vip-referee-splash-not-now-button',
-} as const;
-
-interface VipRefereeSplashScreenProps {
- onContinue: () => void;
- onNotNow: () => void;
- referredByCode?: string | null;
-}
-
-const titleColorStyle = { color: VIP_SPLASH_TITLE_GRADIENT_COLORS[0] };
-const titleFontStyle = {
- fontFamily: 'MMPoly-Regular',
- fontWeight: '400' as const,
- includeFontPadding: false,
- letterSpacing: 0,
-};
-const descriptionColorStyle = { color: VIP_GOLD_TEXT_MUTED };
-const referredByColorStyle = { color: VIP_GOLD_TEXT_MUTED };
-const continueButtonBorderStyle = { borderColor: VIP_GOLD_BORDER_DEFAULT };
-const continueButtonBackgroundStyle = {
- backgroundColor: VIP_SPLASH_ACCEPT_BUTTON_BACKGROUND,
-};
-const continueButtonTextStyle = { color: VIP_GOLD_TEXT_DEFAULT };
-const notNowButtonTextStyle = { color: VIP_GOLD_TEXT_MUTED };
-
-const VipRefereeGradientTitle = () => {
- const tw = useTailwind();
- const title = strings('rewards.vip.referee_splash_title');
- const titleStyle = tw.style(
- 'text-center text-[42px] leading-[42px] pt-[6px]',
- titleFontStyle,
- titleColorStyle,
- );
-
- return (
-
- {title}
-
- }
- style={tw.style('self-stretch')}
- >
-
-
- {title}
-
-
-
- );
-};
-
-const VipRefereeSplashScreen: React.FC = ({
- onContinue,
- onNotNow,
- referredByCode,
-}) => {
- const tw = useTailwind();
-
- return (
-
-
-
-
-
- {strings('rewards.vip.referee_splash_description')}
-
-
-
-
-
- {referredByCode ? (
-
- {strings('rewards.vip.referee_referred_by', {
- code: referredByCode,
- })}
-
- ) : null}
-
-
- {strings('rewards.vip.referee_splash_continue')}
-
-
-
-
- {strings('rewards.vip.referee_splash_not_now')}
-
-
-
-
-
- );
-};
-
-export default VipRefereeSplashScreen;
diff --git a/app/components/UI/Rewards/components/Vip/VipSplashGradientTitle.test.tsx b/app/components/UI/Rewards/components/Vip/VipSplashGradientTitle.test.tsx
new file mode 100644
index 00000000000..7959f07831c
--- /dev/null
+++ b/app/components/UI/Rewards/components/Vip/VipSplashGradientTitle.test.tsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import { render } from '@testing-library/react-native';
+import VipSplashGradientTitle from './VipSplashGradientTitle';
+
+jest.mock('@metamask/design-system-react-native', () => {
+ const ReactActual = jest.requireActual('react');
+ const { Text } = jest.requireActual('react-native');
+
+ return {
+ Text: ({
+ children,
+ testID,
+ }: {
+ children?: React.ReactNode;
+ testID?: string;
+ }) => ReactActual.createElement(Text, { testID }, children),
+ TextVariant: { DisplayMd: 'displayMd' },
+ };
+});
+
+jest.mock('@metamask/design-system-twrnc-preset', () => ({
+ useTailwind: () => ({ style: (...args: unknown[]) => args }),
+}));
+
+jest.mock('react-native-linear-gradient', () => {
+ const ReactActual = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return function MockLinearGradient({
+ children,
+ }: {
+ children?: React.ReactNode;
+ }) {
+ return ReactActual.createElement(View, null, children);
+ };
+});
+
+jest.mock('@react-native-masked-view/masked-view', () => {
+ const ReactActual = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return function MockMaskedView({
+ children,
+ maskElement,
+ }: {
+ children?: React.ReactNode;
+ maskElement?: React.ReactNode;
+ }) {
+ return ReactActual.createElement(View, null, maskElement, children);
+ };
+});
+
+jest.mock('../../../../../../locales/i18n', () => ({
+ strings: jest.fn((key: string) => {
+ if (key === 'rewards.vip.splash_title') {
+ return 'WELCOME\nTO GOLD FOX\nCOLLECTIVE';
+ }
+ return key;
+ }),
+}));
+
+describe('VipSplashGradientTitle', () => {
+ it('renders the shared splash title with the provided testID', () => {
+ const { getAllByText, getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('vip-splash-title')).toBeOnTheScreen();
+ expect(getAllByText('WELCOME\nTO GOLD FOX\nCOLLECTIVE')).toHaveLength(2);
+ });
+});
diff --git a/app/components/UI/Rewards/components/Vip/VipSplashGradientTitle.tsx b/app/components/UI/Rewards/components/Vip/VipSplashGradientTitle.tsx
new file mode 100644
index 00000000000..d715110c006
--- /dev/null
+++ b/app/components/UI/Rewards/components/Vip/VipSplashGradientTitle.tsx
@@ -0,0 +1,62 @@
+import MaskedView from '@react-native-masked-view/masked-view';
+import React from 'react';
+import LinearGradient from 'react-native-linear-gradient';
+import { Text, TextVariant } from '@metamask/design-system-react-native';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import { strings } from '../../../../../../locales/i18n';
+import { VIP_SPLASH_TITLE_GRADIENT_COLORS } from './Vip.constants';
+
+const titleColorStyle = { color: VIP_SPLASH_TITLE_GRADIENT_COLORS[0] };
+const titleFontStyle = {
+ fontFamily: 'MMPoly-Regular',
+ fontWeight: '400' as const,
+ includeFontPadding: false,
+ letterSpacing: 0,
+};
+
+interface VipSplashGradientTitleProps {
+ testID: string;
+}
+
+const VipSplashGradientTitle: React.FC = ({
+ testID,
+}) => {
+ const tw = useTailwind();
+ const title = strings('rewards.vip.splash_title');
+ const titleStyle = tw.style(
+ 'text-center text-[38px] leading-[38px] pt-[6px]',
+ titleFontStyle,
+ titleColorStyle,
+ );
+
+ return (
+
+ {title}
+
+ }
+ style={tw.style('self-stretch')}
+ >
+
+
+ {title}
+
+
+
+ );
+};
+
+export default VipSplashGradientTitle;
diff --git a/app/components/UI/Rewards/components/Vip/VipSplashScreenLayout.test.tsx b/app/components/UI/Rewards/components/Vip/VipSplashScreenLayout.test.tsx
new file mode 100644
index 00000000000..bb0d962d7cb
--- /dev/null
+++ b/app/components/UI/Rewards/components/Vip/VipSplashScreenLayout.test.tsx
@@ -0,0 +1,157 @@
+import React from 'react';
+import { fireEvent, render } from '@testing-library/react-native';
+import { Text } from 'react-native';
+import VipSplashScreenLayout, {
+ VIP_SPLASH_SCREEN_TEST_IDS,
+} from './VipSplashScreenLayout';
+
+jest.mock('@metamask/design-system-react-native', () => {
+ const ReactActual = jest.requireActual('react');
+ const { View, Text: RNText } = jest.requireActual('react-native');
+
+ return {
+ Box: ({
+ children,
+ testID,
+ }: {
+ children?: React.ReactNode;
+ testID?: string;
+ }) => ReactActual.createElement(View, { testID }, children),
+ FontWeight: { Bold: 'bold', Medium: 'medium' },
+ Text: ({
+ children,
+ testID,
+ }: {
+ children?: React.ReactNode;
+ testID?: string;
+ }) => ReactActual.createElement(RNText, { testID }, children),
+ TextVariant: { BodyMd: 'bodyMd' },
+ };
+});
+
+jest.mock('@metamask/design-system-twrnc-preset', () => ({
+ useTailwind: () => ({ style: (...args: unknown[]) => args }),
+}));
+
+jest.mock('react-native-linear-gradient', () => {
+ const ReactActual = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return function MockLinearGradient({
+ children,
+ testID,
+ }: {
+ children?: React.ReactNode;
+ testID?: string;
+ }) {
+ return ReactActual.createElement(View, { testID }, children);
+ };
+});
+
+jest.mock('@react-native-masked-view/masked-view', () => {
+ const ReactActual = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return function MockMaskedView({
+ children,
+ maskElement,
+ }: {
+ children?: React.ReactNode;
+ maskElement?: React.ReactNode;
+ }) {
+ return ReactActual.createElement(View, null, maskElement, children);
+ };
+});
+
+jest.mock('react-native-safe-area-context', () => {
+ const ReactActual = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return {
+ SafeAreaView: ({
+ children,
+ testID,
+ }: {
+ children?: React.ReactNode;
+ testID?: string;
+ }) => ReactActual.createElement(View, { testID }, children),
+ };
+});
+
+jest.mock('../../../../../images/rewards/vip_splash.png', () => 1);
+
+jest.mock('../../../../../../locales/i18n', () => ({
+ strings: jest.fn((key: string) => {
+ const translations: Record = {
+ 'rewards.vip.splash_title': 'WELCOME\nTO GOLD FOX\nCOLLECTIVE',
+ 'rewards.vip.splash_description': 'Exclusive perks by invitation only.',
+ 'rewards.vip.splash_not_now': 'Not now',
+ };
+ return translations[key] ?? key;
+ }),
+}));
+
+const defaultProps = {
+ testIDs: {
+ container: VIP_SPLASH_SCREEN_TEST_IDS.CONTAINER,
+ title: VIP_SPLASH_SCREEN_TEST_IDS.TITLE,
+ description: VIP_SPLASH_SCREEN_TEST_IDS.DESCRIPTION,
+ fox: VIP_SPLASH_SCREEN_TEST_IDS.FOX,
+ primaryButton: VIP_SPLASH_SCREEN_TEST_IDS.ACCEPT_BUTTON,
+ notNowButton: VIP_SPLASH_SCREEN_TEST_IDS.NOT_NOW_BUTTON,
+ },
+ onPrimaryPress: jest.fn(),
+ onNotNow: jest.fn(),
+ primaryButtonLabel: 'Accept invite',
+};
+
+describe('VipSplashScreenLayout', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the shared splash content and primary button label', () => {
+ const { getByTestId, getByText } = render(
+ ,
+ );
+
+ expect(getByTestId(VIP_SPLASH_SCREEN_TEST_IDS.CONTAINER)).toBeOnTheScreen();
+ expect(getByTestId(VIP_SPLASH_SCREEN_TEST_IDS.TITLE)).toBeOnTheScreen();
+ expect(
+ getByTestId(VIP_SPLASH_SCREEN_TEST_IDS.DESCRIPTION),
+ ).toHaveTextContent('Exclusive perks by invitation only.');
+ expect(getByTestId(VIP_SPLASH_SCREEN_TEST_IDS.FOX)).toBeOnTheScreen();
+ expect(getByText('Accept invite')).toBeOnTheScreen();
+ expect(getByText('Not now')).toBeOnTheScreen();
+ });
+
+ it('invokes the primary and not-now callbacks when their buttons are pressed', () => {
+ const onPrimaryPress = jest.fn();
+ const onNotNow = jest.fn();
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId(VIP_SPLASH_SCREEN_TEST_IDS.ACCEPT_BUTTON));
+ fireEvent.press(getByTestId(VIP_SPLASH_SCREEN_TEST_IDS.NOT_NOW_BUTTON));
+
+ expect(onPrimaryPress).toHaveBeenCalledTimes(1);
+ expect(onNotNow).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders optional footer content above the primary button', () => {
+ const { getByTestId, getByText } = render(
+ Referred by TESTCODE
+ }
+ />,
+ );
+
+ expect(getByTestId('vip-splash-footer')).toBeOnTheScreen();
+ expect(getByText('Referred by TESTCODE')).toBeOnTheScreen();
+ });
+});
diff --git a/app/components/UI/Rewards/components/Vip/VipSplashScreen.tsx b/app/components/UI/Rewards/components/Vip/VipSplashScreenLayout.tsx
similarity index 58%
rename from app/components/UI/Rewards/components/Vip/VipSplashScreen.tsx
rename to app/components/UI/Rewards/components/Vip/VipSplashScreenLayout.tsx
index 0ae150e4df1..21957098a6d 100644
--- a/app/components/UI/Rewards/components/Vip/VipSplashScreen.tsx
+++ b/app/components/UI/Rewards/components/Vip/VipSplashScreenLayout.tsx
@@ -1,5 +1,4 @@
-import MaskedView from '@react-native-masked-view/masked-view';
-import React from 'react';
+import React, { type ReactNode } from 'react';
import { Image, Pressable } from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import { SafeAreaView } from 'react-native-safe-area-context';
@@ -18,8 +17,8 @@ import {
VIP_GOLD_TEXT_MUTED,
VIP_SPLASH_ACCEPT_BUTTON_BACKGROUND,
VIP_SPLASH_BACKGROUND_GRADIENT_COLORS,
- VIP_SPLASH_TITLE_GRADIENT_COLORS,
} from './Vip.constants';
+import VipSplashGradientTitle from './VipSplashGradientTitle';
export const VIP_SPLASH_SCREEN_TEST_IDS = {
CONTAINER: 'rewards-vip-splash-screen',
@@ -30,68 +29,47 @@ export const VIP_SPLASH_SCREEN_TEST_IDS = {
NOT_NOW_BUTTON: 'rewards-vip-splash-not-now-button',
} as const;
-interface VipSplashScreenProps {
- onAcceptInvite: () => void;
+export const VIP_REFEREE_SPLASH_SCREEN_TEST_IDS = {
+ CONTAINER: 'rewards-vip-referee-splash-screen',
+ TITLE: 'rewards-vip-referee-splash-title',
+ DESCRIPTION: 'rewards-vip-referee-splash-description',
+ FOX: 'rewards-vip-referee-splash-fox',
+ REFERRED_BY: 'rewards-vip-referee-splash-referred-by',
+ CONTINUE_BUTTON: 'rewards-vip-referee-splash-continue-button',
+ NOT_NOW_BUTTON: 'rewards-vip-referee-splash-not-now-button',
+} as const;
+
+export interface VipSplashScreenLayoutTestIDs {
+ container: string;
+ title: string;
+ description: string;
+ fox: string;
+ primaryButton: string;
+ notNowButton: string;
+}
+
+interface VipSplashScreenLayoutProps {
+ testIDs: VipSplashScreenLayoutTestIDs;
+ onPrimaryPress: () => void;
onNotNow: () => void;
+ primaryButtonLabel: string;
+ footerContent?: ReactNode;
}
-const titleColorStyle = { color: VIP_SPLASH_TITLE_GRADIENT_COLORS[0] };
-const titleFontStyle = {
- fontFamily: 'MMPoly-Regular',
- fontWeight: '400' as const,
- includeFontPadding: false,
- letterSpacing: 0,
-};
const descriptionColorStyle = { color: VIP_GOLD_TEXT_MUTED };
-const acceptButtonBorderStyle = { borderColor: VIP_GOLD_BORDER_DEFAULT };
-const acceptButtonBackgroundStyle = {
+const primaryButtonBorderStyle = { borderColor: VIP_GOLD_BORDER_DEFAULT };
+const primaryButtonBackgroundStyle = {
backgroundColor: VIP_SPLASH_ACCEPT_BUTTON_BACKGROUND,
};
-const acceptButtonTextStyle = { color: VIP_GOLD_TEXT_DEFAULT };
+const primaryButtonTextStyle = { color: VIP_GOLD_TEXT_DEFAULT };
const notNowButtonTextStyle = { color: VIP_GOLD_TEXT_MUTED };
-const VipGradientTitle = () => {
- const tw = useTailwind();
- const title = strings('rewards.vip.splash_title');
- const titleStyle = tw.style(
- 'text-center text-[42px] leading-[42px] pt-[6px]',
- titleFontStyle,
- titleColorStyle,
- );
-
- return (
-
- {title}
-
- }
- style={tw.style('self-stretch')}
- >
-
-
- {title}
-
-
-
- );
-};
-
-const VipSplashScreen: React.FC = ({
- onAcceptInvite,
+const VipSplashScreenLayout: React.FC = ({
+ testIDs,
+ onPrimaryPress,
onNotNow,
+ primaryButtonLabel,
+ footerContent,
}) => {
const tw = useTailwind();
@@ -101,11 +79,11 @@ const VipSplashScreen: React.FC = ({
start={{ x: 0.1, y: 0 }}
end={{ x: 0.5, y: 1 }}
style={tw.style('flex-1')}
- testID={VIP_SPLASH_SCREEN_TEST_IDS.CONTAINER}
+ testID={testIDs.container}
>
-
+
= ({
'mt-[18px] max-w-[326px] text-center leading-[22px]',
descriptionColorStyle,
)}
- testID={VIP_SPLASH_SCREEN_TEST_IDS.DESCRIPTION}
+ testID={testIDs.description}
>
{strings('rewards.vip.splash_description')}
+ {footerContent}
- {strings('rewards.vip.splash_accept_invite')}
+ {primaryButtonLabel}
= ({
);
};
-export default VipSplashScreen;
+export default VipSplashScreenLayout;
diff --git a/app/components/UI/Trending/components/PillScrollList/PillScrollList.tsx b/app/components/UI/Trending/components/PillScrollList/PillScrollList.tsx
index 7a7aec0d583..41c3715ad5b 100644
--- a/app/components/UI/Trending/components/PillScrollList/PillScrollList.tsx
+++ b/app/components/UI/Trending/components/PillScrollList/PillScrollList.tsx
@@ -63,7 +63,7 @@ export interface PillScrollListProps {
* Multi-row horizontal scroll of pill-shaped items. Used for compact movers sections.
* Splits incoming data evenly between rows.
*/
-const DEFAULT_WRAPPER_TW = '-mx-4 bg-transparent mt-3 mb-9' as const;
+const DEFAULT_WRAPPER_TW = '-mx-4 bg-transparent' as const;
function PillScrollList({
data,
diff --git a/app/components/UI/WhatsHappening/WhatsHappeningSection.test.tsx b/app/components/UI/WhatsHappening/WhatsHappeningSection.test.tsx
index 774b93d9628..7cc13a1d737 100644
--- a/app/components/UI/WhatsHappening/WhatsHappeningSection.test.tsx
+++ b/app/components/UI/WhatsHappening/WhatsHappeningSection.test.tsx
@@ -33,6 +33,7 @@ jest.mock('../../../selectors/featureFlagController/whatsHappening', () => ({
}));
jest.mock('./hooks', () => ({
+ ...jest.requireActual('./hooks'),
useWhatsHappening: jest.fn(() => ({
items: [],
isLoading: false,
diff --git a/app/components/UI/WhatsHappening/WhatsHappeningSection.tsx b/app/components/UI/WhatsHappening/WhatsHappeningSection.tsx
index a2dfce3c5ab..5a0801b4a7b 100644
--- a/app/components/UI/WhatsHappening/WhatsHappeningSection.tsx
+++ b/app/components/UI/WhatsHappening/WhatsHappeningSection.tsx
@@ -8,17 +8,15 @@ import {
NativeScrollEvent,
NativeSyntheticEvent,
ScrollView,
+ StyleSheet,
View,
} from 'react-native';
import { useSelector } from 'react-redux';
import { useNavigation } from '@react-navigation/native';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
-import {
- Box,
- SectionDivider,
- SectionHeader,
- TextVariant,
-} from '@metamask/design-system-react-native';
+import { Box, TextVariant } from '@metamask/design-system-react-native';
+import SectionHeader from '../../Views/TrendingView/components/SectionHeader';
+import TempSectionHeader from '../../../component-library/components-temp/SectionHeader';
import ErrorState from '../../Views/Homepage/components/ErrorState';
import ViewMoreCard from '../../Views/Homepage/components/ViewMoreCard';
import { SectionRefreshHandle } from '../../Views/Homepage/types';
@@ -30,9 +28,14 @@ import {
MAX_ITEMS_DISPLAYED,
WhatsHappeningInteractionType,
WhatsHappeningView,
+ WhatsHappeningSource,
type WhatsHappeningSourceValue,
} from './constants';
-import { useWhatsHappening } from './hooks';
+import {
+ useWhatsHappening,
+ isWhatsHappeningSectionVisible,
+ type UseWhatsHappeningResult,
+} from './hooks';
import type { WhatsHappeningItem } from './types';
import { WhatsHappeningCard, WhatsHappeningCardSkeleton } from './components';
import { WhatsHappeningSelectorsIDs } from './WhatsHappening.testIds';
@@ -54,14 +57,22 @@ const SKELETON_KEYS = Array.from(
(__, i) => `skeleton-${i}`,
);
+const styles = StyleSheet.create({
+ sectionGap: { gap: 12 },
+});
+
interface WhatsHappeningSectionProps {
source: WhatsHappeningSourceValue;
+ /** When true, the parent Explore feed supplies the section header. */
+ hideHeader?: boolean;
+ /** Optional pre-fetched feed state (avoids duplicate requests in Explore). */
+ feed?: UseWhatsHappeningResult;
}
const WhatsHappeningSection = forwardRef<
SectionRefreshHandle,
WhatsHappeningSectionProps
->(({ source }, ref) => {
+>(({ source, hideHeader = false, feed }, ref) => {
const currentIndexRef = useRef(0);
const tw = useTailwind();
const navigation = useNavigation();
@@ -69,8 +80,10 @@ const WhatsHappeningSection = forwardRef<
const isEnabled = useSelector(selectWhatsHappeningEnabled);
const title = strings('whats_happening.title');
- const { items, isLoading, error, refresh } =
- useWhatsHappening(MAX_ITEMS_DISPLAYED);
+ const internalFeed = useWhatsHappening(MAX_ITEMS_DISPLAYED, {
+ enabled: feed === undefined,
+ });
+ const { items, isLoading, error, refresh } = feed ?? internalFeed;
useImperativeHandle(ref, () => ({ refresh }), [refresh]);
@@ -124,6 +137,17 @@ const WhatsHappeningSection = forwardRef<
return null;
}
+ const useExploreLayout =
+ hideHeader && source === WhatsHappeningSource.Explore;
+
+ const header = hideHeader ? null : (
+
+ );
+
const carouselContent = hasError ? (
);
- if (hasError) {
+ if (!isWhatsHappeningSectionVisible({ isLoading, items, error })) {
+ return null;
+ }
+
+ if (useExploreLayout) {
return (
-
-
+
- {carouselContent}
+ {carouselContent}
);
}
- if (!isLoading && items.length === 0) {
- return null;
- }
-
return (
-
-
-
+
+ {header}
{carouselContent}
-
+
);
});
diff --git a/app/components/UI/WhatsHappening/hooks/index.ts b/app/components/UI/WhatsHappening/hooks/index.ts
index 47bd130d8df..5f23138f658 100644
--- a/app/components/UI/WhatsHappening/hooks/index.ts
+++ b/app/components/UI/WhatsHappening/hooks/index.ts
@@ -1,2 +1,8 @@
-export { useWhatsHappening } from './useWhatsHappening';
-export type { UseWhatsHappeningResult } from './useWhatsHappening';
+export {
+ useWhatsHappening,
+ isWhatsHappeningSectionVisible,
+} from './useWhatsHappening';
+export type {
+ UseWhatsHappeningResult,
+ UseWhatsHappeningOptions,
+} from './useWhatsHappening';
diff --git a/app/components/UI/WhatsHappening/hooks/useWhatsHappening.test.ts b/app/components/UI/WhatsHappening/hooks/useWhatsHappening.test.ts
index 9373d059a9b..b29f73394d5 100644
--- a/app/components/UI/WhatsHappening/hooks/useWhatsHappening.test.ts
+++ b/app/components/UI/WhatsHappening/hooks/useWhatsHappening.test.ts
@@ -1,6 +1,9 @@
import { renderHook, waitFor } from '@testing-library/react-native';
import { useSelector } from 'react-redux';
-import { useWhatsHappening } from './useWhatsHappening';
+import {
+ isWhatsHappeningSectionVisible,
+ useWhatsHappening,
+} from './useWhatsHappening';
const mockFetchMarketOverview = jest.fn();
@@ -199,4 +202,58 @@ describe('useWhatsHappening', () => {
expect(result.current.items).toHaveLength(0);
expect(result.current.error).toBeNull();
});
+
+ it('does not fetch when enabled option is false', async () => {
+ const { result } = renderHook(() =>
+ useWhatsHappening(5, { enabled: false }),
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(mockFetchMarketOverview).not.toHaveBeenCalled();
+ expect(result.current.items).toHaveLength(0);
+ expect(result.current.error).toBeNull();
+ });
+});
+
+describe('isWhatsHappeningSectionVisible', () => {
+ it('returns true while loading', () => {
+ expect(
+ isWhatsHappeningSectionVisible({
+ isLoading: true,
+ items: [],
+ error: null,
+ }),
+ ).toBe(true);
+ });
+
+ it('returns true when items are available', () => {
+ expect(
+ isWhatsHappeningSectionVisible({
+ isLoading: false,
+ items: [{ id: 'trend-0' } as never],
+ error: null,
+ }),
+ ).toBe(true);
+ });
+
+ it('returns true when an error is present', () => {
+ expect(
+ isWhatsHappeningSectionVisible({
+ isLoading: false,
+ items: [],
+ error: 'Network error',
+ }),
+ ).toBe(true);
+ });
+
+ it('returns false for an empty loaded feed without error', () => {
+ expect(
+ isWhatsHappeningSectionVisible({
+ isLoading: false,
+ items: [],
+ error: null,
+ }),
+ ).toBe(false);
+ });
});
diff --git a/app/components/UI/WhatsHappening/hooks/useWhatsHappening.ts b/app/components/UI/WhatsHappening/hooks/useWhatsHappening.ts
index a29c743e98c..bdf0978ad54 100644
--- a/app/components/UI/WhatsHappening/hooks/useWhatsHappening.ts
+++ b/app/components/UI/WhatsHappening/hooks/useWhatsHappening.ts
@@ -15,6 +15,18 @@ export interface UseWhatsHappeningResult {
refresh: () => Promise;
}
+export interface UseWhatsHappeningOptions {
+ /** When false, skips fetching (for parents that supply their own feed). */
+ enabled?: boolean;
+}
+
+export const isWhatsHappeningSectionVisible = ({
+ isLoading,
+ items,
+ error,
+}: Pick): boolean =>
+ isLoading || items.length > 0 || Boolean(error);
+
const mapTrendsToItems = (
overview: MarketOverview,
limit: number,
@@ -40,15 +52,20 @@ const mapTrendsToItems = (
* @param limit - Maximum number of items to return (default: 5)
* @returns Object with items, isLoading, error, refresh
*/
-export const useWhatsHappening = (limit = 5): UseWhatsHappeningResult => {
- const isEnabled = useSelector(selectWhatsHappeningEnabled);
+export const useWhatsHappening = (
+ limit = 5,
+ options?: UseWhatsHappeningOptions,
+): UseWhatsHappeningResult => {
+ const isFeatureEnabled = useSelector(selectWhatsHappeningEnabled);
+ const isHookEnabled = options?.enabled ?? true;
+ const isActive = isFeatureEnabled && isHookEnabled;
const [items, setItems] = useState([]);
- const [isLoading, setIsLoading] = useState(!!isEnabled);
+ const [isLoading, setIsLoading] = useState(isActive);
const [error, setError] = useState(null);
const fetchItems = useCallback(async () => {
- if (!isEnabled) {
+ if (!isActive) {
setItems([]);
setIsLoading(false);
setError(null);
@@ -75,7 +92,7 @@ export const useWhatsHappening = (limit = 5): UseWhatsHappeningResult => {
} finally {
setIsLoading(false);
}
- }, [isEnabled, limit]);
+ }, [isActive, limit]);
const refresh = useCallback(async () => {
await fetchItems();
diff --git a/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.test.tsx b/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.test.tsx
index 402702a81f1..47babfaf6c7 100644
--- a/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.test.tsx
+++ b/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.test.tsx
@@ -25,13 +25,12 @@ describe('TopTraderCard', () => {
jest.clearAllMocks();
});
- it('renders username and 7D PnL', () => {
+ it('renders username and PnL', () => {
renderWithProvider(
,
);
expect(screen.getByText('alpha.eth')).toBeOnTheScreen();
expect(screen.getByText('+$963.1K')).toBeOnTheScreen();
- expect(screen.getByText(/7D/)).toBeOnTheScreen();
});
it('does not display ROI percentage on the card', () => {
diff --git a/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.tsx b/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.tsx
index 49832504d52..516f3d7a18c 100644
--- a/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.tsx
+++ b/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.tsx
@@ -1,6 +1,7 @@
import {
Box,
BoxAlignItems,
+ BoxFlexDirection,
Button,
ButtonSize,
ButtonVariant,
@@ -34,15 +35,15 @@ export interface TopTraderCardProps {
testID?: string;
}
-// Fixed dimensions for every tile in the homepage Top Traders carousel
-const AVATAR_SIZE = 60;
-export const TOP_TRADER_CARD_WIDTH = 200;
+// Fixed dimensions for every tile in the homepage Top Traders carousel.
+const AVATAR_SIZE = 40;
+export const TOP_TRADER_CARD_WIDTH = 155;
/**
* TopTraderCard -- compact card for the homepage horizontal scroll.
*
- * Displays a trader's avatar (top-left), username, 7D PnL stats, and a
- * Follow/Following toggle pinned to the bottom.
+ * Lays out the trader's avatar with the username + 7D PnL stacked to its
+ * right, and a small Follow / Following toggle pinned below.
*/
const TopTraderCard: React.FC = ({
trader,
@@ -55,7 +56,7 @@ const TopTraderCard: React.FC = ({
return (
= ({
disabled={!onTraderPress}
testID={`top-trader-card-pressable-${trader.id}`}
>
-
+
= ({
testID={`top-trader-avatar-${trader.id}`}
/>
-
+
{trader.username}
-
-
- {pnlText}
-
-
- {' 7D'}
-
+ {pnlText}
@@ -119,8 +110,7 @@ const TopTraderCard: React.FC = ({
variant={
trader.isFollowing ? ButtonVariant.Secondary : ButtonVariant.Primary
}
- size={ButtonSize.Md}
- twClassName="mt-2"
+ size={ButtonSize.Sm}
isFullWidth
onPress={() => onFollowPress(trader.id)}
>
diff --git a/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCardSkeleton.tsx b/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCardSkeleton.tsx
index 49129af2672..ec404fce9ab 100644
--- a/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCardSkeleton.tsx
+++ b/app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCardSkeleton.tsx
@@ -18,22 +18,22 @@ const TopTraderCardSkeleton: React.FC = () => {
return (
-
-
-
-
-
-
+
+
+
+
+
+
-
+
diff --git a/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.test.tsx b/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.test.tsx
index c69fd826b5d..5329698b331 100644
--- a/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.test.tsx
+++ b/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.test.tsx
@@ -27,14 +27,38 @@ describe('TraderRow', () => {
jest.clearAllMocks();
});
- it('renders rank, username, ROI, and PnL', () => {
+ it('renders username and the full (non-abbreviated) PnL', () => {
renderWithProvider(
,
);
- expect(screen.getByText('1')).toBeOnTheScreen();
expect(screen.getByText('alpha.eth')).toBeOnTheScreen();
- expect(screen.getByText('+43.0%')).toBeOnTheScreen();
- expect(screen.getByText('+$963.1K')).toBeOnTheScreen();
+ expect(screen.getByText('+$963,146.80')).toBeOnTheScreen();
+ });
+
+ it('does not render the abbreviated PnL, ROI %, or timeframe suffix', () => {
+ renderWithProvider(
+ ,
+ );
+ expect(screen.queryByText('+$963.1K')).toBeNull();
+ expect(screen.queryByText('+43.0%')).toBeNull();
+ expect(screen.queryByText(/30D/)).toBeNull();
+ expect(screen.queryByText(/7D/)).toBeNull();
+ });
+
+ it('renders a podium medal for ranks 1-3', () => {
+ renderWithProvider(
+ ,
+ );
+ expect(screen.getByTestId('rank-medal-1')).toBeOnTheScreen();
+ });
+
+ it('does not render a podium medal for ranks outside 1-3', () => {
+ const offPodium = { ...baseTrader, rank: 4 };
+ renderWithProvider(
+ ,
+ );
+ expect(screen.queryByTestId('rank-medal-4')).toBeNull();
+ expect(screen.queryByTestId('rank-medal-1')).toBeNull();
});
it('renders avatar image when avatarUri is present', () => {
@@ -114,7 +138,7 @@ describe('TraderRow', () => {
expect(mockOnTraderPress).not.toHaveBeenCalled();
});
- it('displays negative ROI and PnL values', () => {
+ it('displays negative PnL values with a leading minus', () => {
const negativeTrader: TopTrader = {
...baseTrader,
percentageChange: -15.3,
@@ -123,7 +147,6 @@ describe('TraderRow', () => {
renderWithProvider(
,
);
- expect(screen.getByText('-15.3%')).toBeOnTheScreen();
expect(screen.getByText('-$500.00')).toBeOnTheScreen();
});
@@ -168,16 +191,15 @@ describe('TraderRow', () => {
it('renders the stats line with numberOfLines=1 so it does not wrap when the button grows', () => {
const trader: TopTrader = {
...baseTrader,
- percentageChange: 28233,
pnlValue: 407000,
};
renderWithProvider(
,
);
- const roiSegment = screen.getByText('+28233.0%');
+ const pnlSegment = screen.getByText('+$407,000.00');
const statsText = findAncestor(
- roiSegment,
+ pnlSegment,
(node) => node.props?.numberOfLines === 1,
);
@@ -199,24 +221,6 @@ describe('TraderRow', () => {
expect(resolveMinWidth(buttonWithMinWidth as ReactTestInstance)).toBe(96);
});
- it('renders the rank on a single line so the trailing dot does not wrap for double-digit ranks', () => {
- const doubleDigitTrader: TopTrader = {
- ...baseTrader,
- rank: 20,
- overallRank: 20,
- };
- renderWithProvider(
- ,
- );
-
- const rankText = screen.getByText('20');
-
- expect(rankText.props.numberOfLines).toBe(1);
- });
-
it('vertically centers the Follow button so it sits in the middle of the row (overrides ButtonBase self-start default)', () => {
renderWithProvider(
,
diff --git a/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.tsx b/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.tsx
index 9ed45045aa0..f019e79e5d1 100644
--- a/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.tsx
+++ b/app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.tsx
@@ -13,12 +13,12 @@ import {
} from '@metamask/design-system-react-native';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import React from 'react';
-import { TouchableOpacity } from 'react-native';
+import { TouchableOpacity, View } from 'react-native';
import { strings } from '../../../../../../../locales/i18n';
-import { TopRankAvatar, TopRankIndicator } from '../topRank';
+import { RankMedal, isTopRank } from '../topRank';
import type { TopTrader } from '../types';
// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog
-import { formatSignedAbbreviatedUsd } from '../../../../SocialLeaderboard/utils/formatters';
+import { formatSignedUsd } from '../../../../SocialLeaderboard/utils/formatters';
import TraderAvatar from './TraderAvatar';
const AVATAR_SIZE = 40;
@@ -41,8 +41,8 @@ export interface TraderRowProps {
/**
* TraderRow -- a single row in the Top Traders leaderboard.
*
- * Displays the trader's rank, avatar, username, performance stats,
- * and a Follow / Following toggle button.
+ * Displays the trader's avatar (with a podium medal badge for ranks 1–3),
+ * username, 30D PnL, and a Follow / Following toggle button.
*/
const TraderRow: React.FC = ({
trader,
@@ -52,11 +52,9 @@ const TraderRow: React.FC = ({
}) => {
const tw = useTailwind();
- const roiSign = trader.percentageChange >= 0 ? '+' : '';
- const roiText = `${roiSign}${trader.percentageChange.toFixed(1)}%`;
- const pnlText = formatSignedAbbreviatedUsd(trader.pnlValue);
+ const pnlText = formatSignedUsd(trader.pnlValue);
const isPnlPositive = trader.pnlValue >= 0;
- const isRoiPositive = trader.percentageChange >= 0;
+ const showMedal = isTopRank(trader.rank);
return (
= ({
-
-
-
+
-
+ {showMedal ? (
+ // Offset so the medal bottom (incl. its 2px border) sits ~10px
+ // below the avatar's bottom edge.
+
+
+
+ ) : null}
+
= ({
variant={TextVariant.BodySm}
fontWeight={FontWeight.Medium}
numberOfLines={1}
+ twClassName={
+ isPnlPositive ? 'text-success-default' : 'text-error-default'
+ }
>
-
- {roiText}
-
-
- {' \u00B7 '}
-
-
- {pnlText}
-
-
- {' 7D'}
-
+ {pnlText}
diff --git a/app/components/Views/Homepage/Sections/TopTraders/components/TraderRowSkeleton.tsx b/app/components/Views/Homepage/Sections/TopTraders/components/TraderRowSkeleton.tsx
index dab0631b403..f5d29ee1c15 100644
--- a/app/components/Views/Homepage/Sections/TopTraders/components/TraderRowSkeleton.tsx
+++ b/app/components/Views/Homepage/Sections/TopTraders/components/TraderRowSkeleton.tsx
@@ -27,15 +27,6 @@ const TraderRowSkeleton: React.FC = () => {
highlightColor={colors.background.subsection}
>
- {/* Rank placeholder — outer cell mirrors the rendered rank's
- `w-8 text-right` (32px container, content right-aligned) so the
- bar's visible right edge lines up with where the loaded digit
- sits. The inner bar's width (`w-4`) approximates a 1–2 digit
- rank glyph. */}
-
-
-
-
{/* Avatar placeholder */}
diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/CrownEmoji.tsx b/app/components/Views/Homepage/Sections/TopTraders/topRank/CrownEmoji.tsx
deleted file mode 100644
index feeb81040c5..00000000000
--- a/app/components/Views/Homepage/Sections/TopTraders/topRank/CrownEmoji.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { useTailwind } from '@metamask/design-system-twrnc-preset';
-import React from 'react';
-import { Text, View } from 'react-native';
-import { isTopRank, PODIUM_EMOJI } from './topRank.colors';
-
-interface CrownEmojiProps {
- rank: number;
- children: React.ReactNode;
-}
-
-/**
- * Floating medal emoji anchored above the top-right corner of the avatar
- * (rank 1 = 🥇, rank 2 = 🥈, rank 3 = 🥉).
- *
- * Slight rotation makes the emoji look like it is sitting on the avatar's
- * shoulder rather than perfectly aligned, which reads better at this size.
- */
-const CrownEmoji: React.FC = ({ rank, children }) => {
- const tw = useTailwind();
-
- if (!isTopRank(rank)) {
- return <>{children}>;
- }
-
- const emoji = PODIUM_EMOJI[rank as 1 | 2 | 3];
-
- return (
-
- {children}
-
- {emoji}
-
-
- );
-};
-
-export default CrownEmoji;
diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/GradientRing.tsx b/app/components/Views/Homepage/Sections/TopTraders/topRank/GradientRing.tsx
deleted file mode 100644
index ac14f3a3587..00000000000
--- a/app/components/Views/Homepage/Sections/TopTraders/topRank/GradientRing.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import React from 'react';
-import { View } from 'react-native';
-import LinearGradient from 'react-native-linear-gradient';
-import { getMedalColors } from './topRank.colors';
-
-interface GradientRingProps {
- rank: number;
- children: React.ReactNode;
-}
-
-const RING_WIDTH = 4;
-const AVATAR_SIZE = 40;
-const OUTER_SIZE = AVATAR_SIZE + RING_WIDTH * 2;
-const INSET = -RING_WIDTH;
-const OUTER_RADIUS = OUTER_SIZE / 2;
-
-// Narrow diagonal sheen band rendered on top of the base gradient. The
-// transparent → bright-white → transparent ramp simulates a polished-metal
-// specular highlight; running it perpendicular to the base gradient maximises
-// the cross-pattern that reads as "shiny chrome".
-const SHEEN_COLORS = [
- 'rgba(255, 255, 255, 0)',
- 'rgba(255, 255, 255, 0.65)',
- 'rgba(255, 255, 255, 0)',
-] as const;
-const SHEEN_LOCATIONS = [0.38, 0.5, 0.62] as const;
-
-/**
- * Chrome-like multi-stop gradient ring with a perpendicular specular sheen
- * overlay for a polished-metal look around an avatar.
- *
- * The wrapper keeps the avatar's exact 40x40 footprint so the row's flex
- * layout is identical to a non-podium row (no horizontal shift). The
- * gradient (and its sheen overlay) bleeds 4px outside the footprint on every
- * side, with the avatar (an opaque round image) sitting on top to mask the
- * center, leaving only the visible ring.
- */
-const GradientRing: React.FC = ({ rank, children }) => {
- const colors = getMedalColors(rank);
-
- if (!colors) {
- return <>{children}>;
- }
-
- const ringStyle = {
- position: 'absolute' as const,
- top: INSET,
- left: INSET,
- right: INSET,
- bottom: INSET,
- borderRadius: OUTER_RADIUS,
- };
-
- return (
-
-
-
- {children}
-
- );
-};
-
-export default GradientRing;
diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/RankMedal.test.tsx b/app/components/Views/Homepage/Sections/TopTraders/topRank/RankMedal.test.tsx
new file mode 100644
index 00000000000..e8f6a5a3c6c
--- /dev/null
+++ b/app/components/Views/Homepage/Sections/TopTraders/topRank/RankMedal.test.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react-native';
+import RankMedal, { isTopRank } from './RankMedal';
+
+describe('isTopRank', () => {
+ it.each([1, 2, 3])('returns true for podium rank %s', (rank) => {
+ expect(isTopRank(rank)).toBe(true);
+ });
+
+ it.each([0, 4, 5, 100, -1])('returns false for rank %s', (rank) => {
+ expect(isTopRank(rank)).toBe(false);
+ });
+});
+
+describe('RankMedal', () => {
+ it.each([1, 2, 3])('renders a medal for podium rank %s', (rank) => {
+ render();
+
+ expect(screen.getByTestId(`rank-medal-${rank}`)).toBeOnTheScreen();
+ });
+
+ it('sizes the medal from the size prop', () => {
+ render();
+
+ expect(screen.getByTestId('rank-medal-2').props.height).toBe(30);
+ });
+
+ it.each([0, 4, 5, 100, -1])(
+ 'renders nothing for non-podium rank %s',
+ (rank) => {
+ render();
+
+ expect(screen.queryByTestId('rank-medal-test')).toBeNull();
+ },
+ );
+
+ it('honors a custom testID', () => {
+ render();
+
+ expect(screen.getByTestId('custom-medal')).toBeOnTheScreen();
+ });
+});
diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/RankMedal.tsx b/app/components/Views/Homepage/Sections/TopTraders/topRank/RankMedal.tsx
new file mode 100644
index 00000000000..333cdcb79e0
--- /dev/null
+++ b/app/components/Views/Homepage/Sections/TopTraders/topRank/RankMedal.tsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import type { SvgProps } from 'react-native-svg';
+import RankBadge1 from './assets/rank-badge-1.svg';
+import RankBadge2 from './assets/rank-badge-2.svg';
+import RankBadge3 from './assets/rank-badge-3.svg';
+
+type SvgComponent = React.FC;
+
+/** Whether `rank` is a podium position (1, 2 or 3). */
+export const isTopRank = (rank: number): boolean => rank >= 1 && rank <= 3;
+
+/**
+ * RankMedal — the leaderboard podium badge (medallion + ribbon + rank numeral)
+ * for ranks 1–3.
+ *
+ * Renders the exact Figma exports (nodes `2521:11941/11960/11978`): the medal
+ * shape, the baked-in rank number, and the 2px backing border are all part of
+ * the vector. Ranks outside 1–3 render nothing.
+ */
+
+// Source artboard for a single medal export. It is 24x30 because the 2px
+// border wraps the 20x26 medallion on every side, so rendering at the natural
+// artboard size displays the medallion at its intended 20x26.
+const VIEW_BOX_WIDTH = 24;
+const VIEW_BOX_HEIGHT = 30;
+const ASPECT_RATIO = VIEW_BOX_WIDTH / VIEW_BOX_HEIGHT;
+
+const BADGE_BY_RANK: Record<1 | 2 | 3, SvgComponent> = {
+ 1: RankBadge1,
+ 2: RankBadge2,
+ 3: RankBadge3,
+};
+
+export interface RankMedalProps {
+ /** Podium rank; only 1, 2 and 3 render a medal. */
+ rank: number;
+ /**
+ * Rendered height in px; width is derived from the medal aspect ratio.
+ * Defaults to the export artboard height so the medallion renders at 20x26.
+ */
+ size?: number;
+ testID?: string;
+}
+
+const RankMedal: React.FC = ({
+ rank,
+ size = VIEW_BOX_HEIGHT,
+ testID,
+}) => {
+ if (!isTopRank(rank)) {
+ return null;
+ }
+
+ const Badge = BADGE_BY_RANK[rank as 1 | 2 | 3];
+ const width = size * ASPECT_RATIO;
+
+ return (
+
+ );
+};
+
+export default RankMedal;
diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankAvatar.test.tsx b/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankAvatar.test.tsx
deleted file mode 100644
index cf1f7ab8445..00000000000
--- a/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankAvatar.test.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { screen } from '@testing-library/react-native';
-import React from 'react';
-import { Text, View } from 'react-native';
-import renderWithProvider from '../../../../../../util/test/renderWithProvider';
-import TopRankAvatar from './TopRankAvatar';
-
-const renderWithRank = (rank: number) =>
- renderWithProvider(
-
-
- A
-
- ,
- );
-
-describe('TopRankAvatar', () => {
- it.each([1, 2, 3])(
- 'wraps the avatar with a gradient ring and floating crown for rank %s',
- (rank) => {
- renderWithRank(rank);
-
- expect(
- screen.getByTestId(`top-rank-gradient-ring-${rank}`),
- ).toBeOnTheScreen();
- expect(screen.getByTestId(`top-rank-crown-${rank}`)).toBeOnTheScreen();
- expect(screen.getByTestId('avatar-child')).toBeOnTheScreen();
- },
- );
-
- it.each([0, 4, 5, 19, 100])(
- 'passes children through unchanged for non-podium rank %s',
- (rank) => {
- renderWithRank(rank);
-
- expect(screen.getByTestId('avatar-child')).toBeOnTheScreen();
- expect(screen.queryByTestId(/top-rank-/)).not.toBeOnTheScreen();
- },
- );
-});
diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankAvatar.tsx b/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankAvatar.tsx
deleted file mode 100644
index 0c4699847e9..00000000000
--- a/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankAvatar.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import React from 'react';
-import GradientRing from './GradientRing';
-import CrownEmoji from './CrownEmoji';
-import { isTopRank } from './topRank.colors';
-
-interface TopRankAvatarProps {
- /**
- * Rank used to decide whether to decorate the avatar. Pass the trader's
- * overall (unfiltered) rank so the gold/silver/bronze treatment only fires
- * for true top-3 traders.
- */
- rank: number;
- children: React.ReactNode;
-}
-
-/**
- * Wraps an avatar with the top-rank decoration for podium ranks (1–3): a
- * chrome gradient ring around the avatar plus a floating crown / medal emoji
- * above its top-right corner.
- *
- * For ranks > 3, `children` is rendered unchanged.
- */
-const TopRankAvatar: React.FC = ({ rank, children }) => {
- if (!isTopRank(rank)) {
- return <>{children}>;
- }
-
- return (
-
- {children}
-
- );
-};
-
-export default TopRankAvatar;
diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankIndicator.test.tsx b/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankIndicator.test.tsx
deleted file mode 100644
index c03837e554a..00000000000
--- a/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankIndicator.test.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import React from 'react';
-import { screen } from '@testing-library/react-native';
-import renderWithProvider from '../../../../../../util/test/renderWithProvider';
-import TopRankIndicator from './TopRankIndicator';
-
-describe('TopRankIndicator', () => {
- it.each([1, 2, 3])(
- 'colors the rank digit with the medal color for podium rank %s',
- (rank) => {
- renderWithProvider();
-
- expect(
- screen.getByTestId(`top-rank-medal-color-${rank}`),
- ).toBeOnTheScreen();
- expect(screen.getByText(String(rank))).toBeOnTheScreen();
- },
- );
-
- it.each([0, 4, 19, 100])(
- 'renders the plain rank digit for non-podium rank %s',
- (rank) => {
- renderWithProvider();
-
- expect(screen.queryByTestId(/top-rank-medal-color-/)).toBeNull();
- expect(screen.getByText(String(rank))).toBeOnTheScreen();
- },
- );
-
- describe('podiumRank gating', () => {
- it('does not apply the podium color when the overall rank is outside the top 3 even if the displayed rank is 1', () => {
- renderWithProvider();
-
- expect(screen.queryByTestId('top-rank-medal-color-1')).toBeNull();
- expect(screen.queryByTestId('top-rank-medal-color-19')).toBeNull();
- expect(screen.getByText('1')).toBeOnTheScreen();
- });
-
- it('applies the podium color when the overall rank is in the top 3 even if the displayed rank is larger', () => {
- renderWithProvider();
-
- expect(screen.getByTestId('top-rank-medal-color-2')).toBeOnTheScreen();
- expect(screen.getByText('5')).toBeOnTheScreen();
- });
- });
-});
diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankIndicator.tsx b/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankIndicator.tsx
deleted file mode 100644
index ea2d7b7763f..00000000000
--- a/app/components/Views/Homepage/Sections/TopTraders/topRank/TopRankIndicator.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import {
- FontWeight,
- Text,
- TextColor,
- TextVariant,
-} from '@metamask/design-system-react-native';
-import React from 'react';
-import {
- getMedalColors,
- getPodiumRankIndicatorStyle,
- isTopRank,
-} from './topRank.colors';
-
-interface TopRankIndicatorProps {
- /** The rank digit to display in the cell (e.g. position within a filter). */
- rank: number;
- /**
- * Optional gating rank used to decide whether podium styling applies.
- * Defaults to `rank`. Pass the trader's overall (unfiltered) rank when the
- * displayed rank is a filtered position so the gold/silver/bronze treatment
- * only fires for true top-3 traders.
- */
- podiumRank?: number;
-}
-
-/**
- * Renders the rank cell for a `TraderRow`. For podium ranks (1–3), the digit
- * is rendered bold and tinted with the matching medal color so it visually
- * echoes the avatar's gradient ring. For other ranks it renders as the
- * secondary (muted) body text so podium ranks read as the focal ranks.
- */
-const TopRankIndicator: React.FC = ({
- rank,
- podiumRank = rank,
-}) => {
- if (isTopRank(podiumRank)) {
- const colors = getMedalColors(podiumRank);
- return (
-
- {rank}
-
- );
- }
-
- return (
-
- {rank}
-
- );
-};
-
-export default TopRankIndicator;
diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/assets/rank-badge-1.svg b/app/components/Views/Homepage/Sections/TopTraders/topRank/assets/rank-badge-1.svg
new file mode 100644
index 00000000000..69416d04611
--- /dev/null
+++ b/app/components/Views/Homepage/Sections/TopTraders/topRank/assets/rank-badge-1.svg
@@ -0,0 +1,12 @@
+
diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/assets/rank-badge-2.svg b/app/components/Views/Homepage/Sections/TopTraders/topRank/assets/rank-badge-2.svg
new file mode 100644
index 00000000000..2ba0516cc91
--- /dev/null
+++ b/app/components/Views/Homepage/Sections/TopTraders/topRank/assets/rank-badge-2.svg
@@ -0,0 +1,12 @@
+
diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/assets/rank-badge-3.svg b/app/components/Views/Homepage/Sections/TopTraders/topRank/assets/rank-badge-3.svg
new file mode 100644
index 00000000000..bae1e111279
--- /dev/null
+++ b/app/components/Views/Homepage/Sections/TopTraders/topRank/assets/rank-badge-3.svg
@@ -0,0 +1,12 @@
+
diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/index.ts b/app/components/Views/Homepage/Sections/TopTraders/topRank/index.ts
index 30d68639a29..884b79fb13a 100644
--- a/app/components/Views/Homepage/Sections/TopTraders/topRank/index.ts
+++ b/app/components/Views/Homepage/Sections/TopTraders/topRank/index.ts
@@ -1,2 +1 @@
-export { default as TopRankAvatar } from './TopRankAvatar';
-export { default as TopRankIndicator } from './TopRankIndicator';
+export { default as RankMedal, isTopRank } from './RankMedal';
diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/topRank.colors.test.ts b/app/components/Views/Homepage/Sections/TopTraders/topRank/topRank.colors.test.ts
deleted file mode 100644
index cf8078da692..00000000000
--- a/app/components/Views/Homepage/Sections/TopTraders/topRank/topRank.colors.test.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-/* eslint-disable @metamask/design-tokens/color-no-hex */
-import { getMedalColors, isTopRank, PODIUM_EMOJI } from './topRank.colors';
-
-describe('isTopRank', () => {
- it.each([1, 2, 3])('returns true for podium rank %s', (rank) => {
- expect(isTopRank(rank)).toBe(true);
- });
-
- it.each([0, 4, 5, 100, -1])('returns false for rank %s', (rank) => {
- expect(isTopRank(rank)).toBe(false);
- });
-});
-
-describe('getMedalColors', () => {
- it('returns the gold palette for rank 1', () => {
- const colors = getMedalColors(1);
-
- expect(colors).not.toBeNull();
- expect(colors?.rankDigit).toBe('#FFEE58');
- expect(colors?.gradient.length).toBeGreaterThanOrEqual(3);
- expect(colors?.gradientLocations.length).toBe(colors?.gradient.length);
- });
-
- it('returns the silver palette for rank 2', () => {
- expect(getMedalColors(2)?.rankDigit).toBe('#ECEFF1');
- });
-
- it('returns the bronze palette for rank 3', () => {
- expect(getMedalColors(3)?.rankDigit).toBe('#FFAB40');
- });
-
- it.each([0, 4, 5, 100, -1])('returns null for rank %s', (rank) => {
- expect(getMedalColors(rank)).toBeNull();
- });
-});
-
-describe('PODIUM_EMOJI', () => {
- it('uses gold/silver/bronze medal emojis for ranks 1, 2 and 3', () => {
- expect(PODIUM_EMOJI[1]).toBe('🥇');
- expect(PODIUM_EMOJI[2]).toBe('🥈');
- expect(PODIUM_EMOJI[3]).toBe('🥉');
- });
-});
diff --git a/app/components/Views/Homepage/Sections/TopTraders/topRank/topRank.colors.ts b/app/components/Views/Homepage/Sections/TopTraders/topRank/topRank.colors.ts
deleted file mode 100644
index 185ba98fffb..00000000000
--- a/app/components/Views/Homepage/Sections/TopTraders/topRank/topRank.colors.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-/* eslint-disable @metamask/design-tokens/color-no-hex -- Decorative podium medal chrome; no DS tokens for multi-stop metallic gradients. */
-/**
- * Metallic palette for the top-rank decorations.
- *
- * Each medal exposes a bright `rankDigit` color for legible rank numerals on
- * dark backgrounds and a multi-stop chrome gradient used by the avatar ring.
- */
-
-export interface MedalColors {
- /**
- * High-luminance tint for the rank numeral on dark backgrounds. Kept separate
- * from the chrome `gradient` palette so the digit stays as legible and vivid
- * as default body text (ranks 4+), which muted metallics alone do not achieve.
- */
- rankDigit: string;
- /**
- * Multi-stop chrome gradient (dark rim → bright peak → mid → deep → bright
- * peak → mid → dark rim). The alternating peaks fake the polished-metal
- * look of a real coin/medal rim. Must contain at least 3 stops.
- */
- gradient: readonly string[];
- /**
- * Stop positions (0–1) for `gradient`. Length must equal `gradient.length`.
- */
- gradientLocations: readonly number[];
-}
-
-// Stop positions shared by all chrome palettes. Two narrow bright peaks at
-// ~15% and ~65% create the alternating highlight/shadow bands that give the
-// ring its specular "shiny" appearance.
-const CHROME_LOCATIONS: readonly number[] = [0, 0.15, 0.3, 0.5, 0.65, 0.85, 1];
-
-const GOLD: MedalColors = {
- rankDigit: '#FFEE58',
- gradient: [
- '#5C4400',
- '#FFF8DC',
- '#FFC400',
- '#8B6508',
- '#FFE066',
- '#D4AF37',
- '#3D2C00',
- ],
- gradientLocations: CHROME_LOCATIONS,
-};
-
-const SILVER: MedalColors = {
- rankDigit: '#ECEFF1',
- gradient: [
- '#2A2A2A',
- '#FFFFFF',
- '#D8D8D8',
- '#5A5A5A',
- '#F0F0F0',
- '#A0A0A0',
- '#1F1F1F',
- ],
- gradientLocations: CHROME_LOCATIONS,
-};
-
-const BRONZE: MedalColors = {
- rankDigit: '#FFAB40',
- gradient: [
- '#3D2410',
- '#FFD7A8',
- '#CD7F32',
- '#5C3712',
- '#F4A460',
- '#A0612A',
- '#2D1A08',
- ],
- gradientLocations: CHROME_LOCATIONS,
-};
-
-/** Whether `rank` is a podium position (1, 2 or 3). */
-export const isTopRank = (rank: number): boolean => rank >= 1 && rank <= 3;
-
-/**
- * Returns the metallic color set for a given rank, or `null` if the rank is
- * not a podium position (rank > 3).
- */
-export const getMedalColors = (rank: number): MedalColors | null => {
- switch (rank) {
- case 1:
- return GOLD;
- case 2:
- return SILVER;
- case 3:
- return BRONZE;
- default:
- return null;
- }
-};
-
-/**
- * Medal emoji that floats above each podium avatar. The same glyph family
- * (gold / silver / bronze medal) is used for all three ranks so they share
- * a consistent visual footprint.
- */
-export const PODIUM_EMOJI: Readonly> = {
- 1: '🥇',
- 2: '🥈',
- 3: '🥉',
-};
-
-/**
- * Subtle shadow under podium rank numerals for contrast on varied backgrounds.
- */
-export const PODIUM_RANK_TEXT_SHADOW_COLOR = 'rgba(0, 0, 0, 0.35)';
-
-/** Style fragment shared by all podium rank digits (shadow only; color from medal). */
-const PODIUM_RANK_TEXT_SHADOW_STYLE = {
- textShadowColor: PODIUM_RANK_TEXT_SHADOW_COLOR,
- textShadowOffset: { width: 0, height: 1 },
- textShadowRadius: 2,
-} as const;
-
-/**
- * Combined podium rank numeral style for a medal `rankDigit` color.
- */
-export const getPodiumRankIndicatorStyle = (rankDigit: string) => ({
- color: rankDigit,
- ...PODIUM_RANK_TEXT_SHADOW_STYLE,
-});
diff --git a/app/components/Views/Homepage/abTestConfig.ts b/app/components/Views/Homepage/abTestConfig.ts
index 4e10961dfd1..e278d8ec728 100644
--- a/app/components/Views/Homepage/abTestConfig.ts
+++ b/app/components/Views/Homepage/abTestConfig.ts
@@ -241,3 +241,75 @@ export const PREDICT_HOMEPAGE_DISCOVERY_AB_KEY =
PREDICT_POSITIONS_EMPTY_STATE_AB_KEY;
export const PREDICT_HOMEPAGE_DISCOVERY_VARIANTS =
PREDICT_POSITIONS_EMPTY_STATE_VARIANTS;
+
+// ─── Homepage discovery pills (TMCU-926) ─────────────────────────────────────
+
+/**
+ * LaunchDarkly / remote flag key. Pattern: `{team}{TICKET}Abtest{Name}` — keep in
+ * sync with the flag in LD (team `home`, ticket TMCU-926).
+ */
+export const HOMEPAGE_DISCOVERY_PILLS_AB_KEY =
+ 'homeTMCU926AbtestDiscoveryPills';
+
+export enum HomepageDiscoveryPillsVariant {
+ Control = 'control',
+ GrayIcons = 'grayIcons',
+ ColorIcons = 'colorIcons',
+}
+
+export type HomepageDiscoveryPillIconStyle = 'gray' | 'color';
+
+interface HomepageDiscoveryPillsVariantConfig {
+ showPills: boolean;
+ iconStyle: HomepageDiscoveryPillIconStyle | null;
+}
+
+export const HOMEPAGE_DISCOVERY_PILLS_VARIANTS: Record<
+ HomepageDiscoveryPillsVariant,
+ HomepageDiscoveryPillsVariantConfig
+> = {
+ [HomepageDiscoveryPillsVariant.Control]: {
+ showPills: false,
+ iconStyle: null,
+ },
+ [HomepageDiscoveryPillsVariant.GrayIcons]: {
+ showPills: true,
+ iconStyle: 'gray',
+ },
+ [HomepageDiscoveryPillsVariant.ColorIcons]: {
+ showPills: true,
+ iconStyle: 'color',
+ },
+};
+
+export const HOMEPAGE_DISCOVERY_PILLS_AB_TEST_EXPOSURE_OPTIONS = {
+ experimentName: 'Homepage discovery pills',
+ variationNames: {
+ control: 'Current homepage without discovery pills',
+ grayIcons: 'Discovery pills with gray icons',
+ colorIcons: 'Discovery pills with color icons',
+ },
+} as const;
+
+export const HOMEPAGE_DISCOVERY_PILLS_AB_TEST_ANALYTICS_MAPPING: ABTestAnalyticsMapping =
+ {
+ flagKey: HOMEPAGE_DISCOVERY_PILLS_AB_KEY,
+ validVariants: Object.values(HomepageDiscoveryPillsVariant),
+ eventNames: [EVENT_NAME.HOME_VIEWED],
+ };
+
+/**
+ * Builds `active_ab_tests` entries for swap / perps / predict transaction flows
+ * when the homepage discovery-pills experiment assignment is active.
+ */
+export function getHomepageDiscoveryPillsTransactionActiveAbTests(
+ isAssignmentActive: boolean,
+ variantName: string,
+): TransactionActiveAbTestEntry[] | undefined {
+ if (!isAssignmentActive) {
+ return undefined;
+ }
+ return [
+ createActiveABTestAssignment(HOMEPAGE_DISCOVERY_PILLS_AB_KEY, variantName),
+ ];
+}
diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPillIcon.tsx b/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPillIcon.tsx
new file mode 100644
index 00000000000..d3f7d346ae2
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPillIcon.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import { Image } from 'react-native';
+import {
+ Icon,
+ IconColor,
+ IconSize,
+} from '@metamask/design-system-react-native';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import type { HomepageDiscoveryPillIconStyle } from '../../abTestConfig';
+import {
+ HOMEPAGE_DISCOVERY_PILL_COLOR_ICON_SOURCES,
+ HOMEPAGE_DISCOVERY_PILL_GRAY_ICONS,
+ type HomepageDiscoveryPillId,
+} from './homepageDiscoveryPills.constants';
+
+interface HomepageDiscoveryPillIconProps {
+ pillId: HomepageDiscoveryPillId;
+ iconStyle: HomepageDiscoveryPillIconStyle;
+}
+
+const HomepageDiscoveryPillIcon: React.FC = ({
+ pillId,
+ iconStyle,
+}) => {
+ const tw = useTailwind();
+ if (iconStyle === 'color') {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default HomepageDiscoveryPillIcon;
diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.test.tsx b/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.test.tsx
new file mode 100644
index 00000000000..93bd93b59b4
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.test.tsx
@@ -0,0 +1,190 @@
+import React from 'react';
+import { fireEvent, render } from '@testing-library/react-native';
+import Routes from '../../../../../constants/navigation/Routes';
+import { selectPerpsEnabledFlag } from '../../../../UI/Perps/selectors/featureFlags';
+import { selectPredictEnabledFlag } from '../../../../UI/Predict/selectors/featureFlags';
+import { HomepageDiscoveryPillsTestIds } from './HomepageDiscoveryPills.testIds';
+import { HOMESCREEN_PILL_SOURCE } from './useHomepageDiscoveryPillsNavigation';
+
+const mockNavigate = jest.fn();
+const mockTrackPillTapped = jest.fn();
+
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => ({ navigate: mockNavigate }),
+}));
+
+jest.mock('../../hooks/usePillViewedEvent', () => ({
+ __esModule: true,
+ default: () => ({ trackPillTapped: mockTrackPillTapped }),
+}));
+
+jest.mock('../../../../../constants/navigation/exploreTabIndices', () => ({
+ EXPLORE_TAB_INDEX: {
+ NOW: 0,
+ MACRO: 1,
+ RWAS: 2,
+ CRYPTO: 3,
+ SPORTS: 4,
+ SITES: 5,
+ },
+}));
+
+const mockUseSelector = jest.fn();
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useSelector: (selector: unknown) => mockUseSelector(selector),
+}));
+
+import HomepageDiscoveryPills from './HomepageDiscoveryPills';
+
+const mockSelectorState = ({
+ isPerpsEnabled = true,
+ isPredictEnabled = true,
+}: {
+ isPerpsEnabled?: boolean;
+ isPredictEnabled?: boolean;
+} = {}) => {
+ mockUseSelector.mockImplementation((selector: unknown) => {
+ if (selector === selectPerpsEnabledFlag) {
+ return isPerpsEnabled;
+ }
+ if (selector === selectPredictEnabledFlag) {
+ return isPredictEnabled;
+ }
+ return undefined;
+ });
+};
+
+describe('HomepageDiscoveryPills', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockSelectorState();
+ });
+
+ it('renders crypto and stocks when perps and predict are disabled', () => {
+ mockSelectorState({ isPerpsEnabled: false, isPredictEnabled: false });
+
+ const { queryByTestId } = render(
+ ,
+ );
+
+ expect(
+ queryByTestId(HomepageDiscoveryPillsTestIds.pill('perpetuals')),
+ ).toBeNull();
+ expect(
+ queryByTestId(HomepageDiscoveryPillsTestIds.pill('predictions')),
+ ).toBeNull();
+ expect(
+ queryByTestId(HomepageDiscoveryPillsTestIds.pill('crypto')),
+ ).toBeOnTheScreen();
+ expect(
+ queryByTestId(HomepageDiscoveryPillsTestIds.pill('stocks')),
+ ).toBeOnTheScreen();
+ });
+
+ it('renders enabled discovery pills', () => {
+ const { getByTestId } = render();
+
+ expect(
+ getByTestId(HomepageDiscoveryPillsTestIds.CONTAINER),
+ ).toBeOnTheScreen();
+ expect(
+ getByTestId(HomepageDiscoveryPillsTestIds.pill('perpetuals')),
+ ).toBeOnTheScreen();
+ expect(
+ getByTestId(HomepageDiscoveryPillsTestIds.pill('predictions')),
+ ).toBeOnTheScreen();
+ expect(
+ getByTestId(HomepageDiscoveryPillsTestIds.pill('crypto')),
+ ).toBeOnTheScreen();
+ expect(
+ getByTestId(HomepageDiscoveryPillsTestIds.pill('stocks')),
+ ).toBeOnTheScreen();
+ });
+
+ it('omits perps pill when perps flag is disabled', () => {
+ mockSelectorState({ isPerpsEnabled: false });
+
+ const { queryByTestId } = render(
+ ,
+ );
+
+ expect(
+ queryByTestId(HomepageDiscoveryPillsTestIds.pill('perpetuals')),
+ ).toBeNull();
+ expect(
+ queryByTestId(HomepageDiscoveryPillsTestIds.pill('crypto')),
+ ).toBeOnTheScreen();
+ });
+
+ it('navigates to perps home when perpetuals pill is pressed', () => {
+ const { getByTestId } = render();
+
+ fireEvent.press(
+ getByTestId(HomepageDiscoveryPillsTestIds.pill('perpetuals')),
+ );
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, {
+ screen: Routes.PERPS.PERPS_HOME,
+ params: { source: HOMESCREEN_PILL_SOURCE },
+ });
+ });
+
+ it('navigates to predict feed when predictions pill is pressed', () => {
+ const { getByTestId } = render();
+
+ fireEvent.press(
+ getByTestId(HomepageDiscoveryPillsTestIds.pill('predictions')),
+ );
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_LIST,
+ params: { entryPoint: HOMESCREEN_PILL_SOURCE },
+ });
+ });
+
+ it('navigates to explore crypto tab when crypto pill is pressed', () => {
+ const { getByTestId } = render();
+
+ fireEvent.press(getByTestId(HomepageDiscoveryPillsTestIds.pill('crypto')));
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.TRENDING_VIEW, {
+ screen: Routes.TRENDING_FEED,
+ params: { initialTab: 3, source: HOMESCREEN_PILL_SOURCE },
+ });
+ });
+
+ it('navigates to explore RWAs tab when stocks pill is pressed', () => {
+ const { getByTestId } = render();
+
+ fireEvent.press(getByTestId(HomepageDiscoveryPillsTestIds.pill('stocks')));
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.TRENDING_VIEW, {
+ screen: Routes.TRENDING_FEED,
+ params: { initialTab: 2, source: HOMESCREEN_PILL_SOURCE },
+ });
+ });
+
+ it('tracks pill_tapped before navigation when a pill is pressed', () => {
+ const onPillPress = jest.fn();
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(getByTestId(HomepageDiscoveryPillsTestIds.pill('crypto')));
+
+ expect(mockTrackPillTapped).toHaveBeenCalledWith('crypto', 3);
+ expect(onPillPress).toHaveBeenCalledWith('crypto', 3);
+ expect(mockNavigate).toHaveBeenCalled();
+ });
+
+ it('reports fixed pill position when perps and predict pills are hidden', () => {
+ mockSelectorState({ isPerpsEnabled: false, isPredictEnabled: false });
+ const { getByTestId } = render();
+
+ fireEvent.press(getByTestId(HomepageDiscoveryPillsTestIds.pill('crypto')));
+
+ expect(mockTrackPillTapped).toHaveBeenCalledWith('crypto', 3);
+ });
+});
diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.testIds.ts b/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.testIds.ts
new file mode 100644
index 00000000000..2704acead42
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.testIds.ts
@@ -0,0 +1,7 @@
+import type { HomepageDiscoveryPillId } from './homepageDiscoveryPills.constants';
+
+export const HomepageDiscoveryPillsTestIds = {
+ CONTAINER: 'homepage-discovery-pills',
+ pill: (pillId: HomepageDiscoveryPillId) =>
+ `homepage-discovery-pill-${pillId}`,
+} as const;
diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.tsx b/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.tsx
new file mode 100644
index 00000000000..9e412c81ca2
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.tsx
@@ -0,0 +1,123 @@
+import React, { useCallback, useMemo } from 'react';
+import { useSelector } from 'react-redux';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import {
+ Box,
+ BoxAlignItems,
+ BoxFlexDirection,
+ FontWeight,
+ Text,
+ TextColor,
+ TextVariant,
+} from '@metamask/design-system-react-native';
+import { ScrollView } from 'react-native-gesture-handler';
+import { strings } from '../../../../../../locales/i18n';
+import ButtonToggle from '../../../../../component-library/components-temp/Buttons/ButtonToggle';
+import { ButtonSize } from '../../../../../component-library/components/Buttons/Button';
+import { selectPerpsEnabledFlag } from '../../../../UI/Perps/selectors/featureFlags';
+import { selectPredictEnabledFlag } from '../../../../UI/Predict/selectors/featureFlags';
+import HomepageDiscoveryPillIcon from './HomepageDiscoveryPillIcon';
+import {
+ HOMEPAGE_DISCOVERY_PILL_IDS,
+ type HomepageDiscoveryPillId,
+} from './homepageDiscoveryPills.constants';
+import { HomepageDiscoveryPillsTestIds } from './HomepageDiscoveryPills.testIds';
+import type { HomepageDiscoveryPillsProps } from './HomepageDiscoveryPills.types';
+import usePillViewedEvent from '../../hooks/usePillViewedEvent';
+import { useHomepageDiscoveryPillsNavigation } from './useHomepageDiscoveryPillsNavigation';
+
+const PILL_LABEL_KEYS: Record<
+ HomepageDiscoveryPillId,
+ | 'homepage.sections.perpetuals'
+ | 'homepage.sections.predictions'
+ | 'trending.stocks'
+ | 'trending.crypto'
+> = {
+ perpetuals: 'homepage.sections.perpetuals',
+ predictions: 'homepage.sections.predictions',
+ stocks: 'trending.stocks',
+ crypto: 'trending.crypto',
+};
+
+const HomepageDiscoveryPills: React.FC = ({
+ iconStyle,
+ onPillPress,
+}) => {
+ const tw = useTailwind();
+ const { navigateToPill } = useHomepageDiscoveryPillsNavigation();
+ const { trackPillTapped } = usePillViewedEvent();
+ const isPerpsEnabled = useSelector(selectPerpsEnabledFlag);
+ const isPredictEnabled = useSelector(selectPredictEnabledFlag);
+
+ const visiblePillIds = useMemo(
+ () =>
+ HOMEPAGE_DISCOVERY_PILL_IDS.filter((pillId) => {
+ if (pillId === 'perpetuals') {
+ return isPerpsEnabled;
+ }
+ if (pillId === 'predictions') {
+ return isPredictEnabled;
+ }
+ return true;
+ }),
+ [isPerpsEnabled, isPredictEnabled],
+ );
+
+ const handlePillPress = useCallback(
+ (pillId: HomepageDiscoveryPillId, position: number) => {
+ trackPillTapped(pillId, position);
+ onPillPress?.(pillId, position);
+ navigateToPill(pillId);
+ },
+ [navigateToPill, onPillPress, trackPillTapped],
+ );
+
+ if (visiblePillIds.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {visiblePillIds.map((pillId) => (
+
+
+
+ {strings(PILL_LABEL_KEYS[pillId])}
+
+
+ }
+ isActive={false}
+ onPress={() =>
+ handlePillPress(pillId, HOMEPAGE_DISCOVERY_PILL_IDS.indexOf(pillId))
+ }
+ size={ButtonSize.Md}
+ style={tw.style('rounded-xl py-2 px-3')}
+ testID={HomepageDiscoveryPillsTestIds.pill(pillId)}
+ accessibilityLabel={strings(PILL_LABEL_KEYS[pillId])}
+ />
+ ))}
+
+ );
+};
+
+export default HomepageDiscoveryPills;
diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.types.ts b/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.types.ts
new file mode 100644
index 00000000000..78e2a8e4c90
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageDiscoveryPills/HomepageDiscoveryPills.types.ts
@@ -0,0 +1,7 @@
+import type { HomepageDiscoveryPillIconStyle } from '../../abTestConfig';
+import type { HomepageDiscoveryPillId } from './homepageDiscoveryPills.constants';
+
+export interface HomepageDiscoveryPillsProps {
+ iconStyle: HomepageDiscoveryPillIconStyle;
+ onPillPress?: (pillId: HomepageDiscoveryPillId, position: number) => void;
+}
diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryPills/homepageDiscoveryPills.constants.ts b/app/components/Views/Homepage/components/HomepageDiscoveryPills/homepageDiscoveryPills.constants.ts
new file mode 100644
index 00000000000..beba18cc730
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageDiscoveryPills/homepageDiscoveryPills.constants.ts
@@ -0,0 +1,43 @@
+import { IconName } from '@metamask/design-system-react-native';
+import discoveryPillCrypto from '../../../../../images/discovery_pill_crypto.png';
+import discoveryPillPerps from '../../../../../images/discovery_pill_perps.png';
+import discoveryPillPredictions from '../../../../../images/discovery_pill_predictions.png';
+import discoveryPillStocks from '../../../../../images/discovery_pill_stocks.png';
+
+export const HOMEPAGE_DISCOVERY_PILL_IDS = [
+ 'perpetuals',
+ 'predictions',
+ 'stocks',
+ 'crypto',
+] as const;
+
+export type HomepageDiscoveryPillId =
+ (typeof HOMEPAGE_DISCOVERY_PILL_IDS)[number];
+
+/** Segment `section_name` values for `Home Viewed` (`interaction_type: pill_tapped`). */
+export const HOMEPAGE_DISCOVERY_PILL_SECTION_NAMES = {
+ perpetuals: 'perps',
+ predictions: 'predict',
+ stocks: 'stocks',
+ crypto: 'crypto',
+} as const satisfies Record;
+
+export type HomepageDiscoveryPillSectionName =
+ (typeof HOMEPAGE_DISCOVERY_PILL_SECTION_NAMES)[HomepageDiscoveryPillId];
+
+export const HOMEPAGE_DISCOVERY_PILL_GRAY_ICONS: Record<
+ HomepageDiscoveryPillId,
+ IconName
+> = {
+ perpetuals: IconName.Candlestick,
+ predictions: IconName.Predictions,
+ stocks: IconName.Chart,
+ crypto: IconName.Coin,
+} as const;
+
+export const HOMEPAGE_DISCOVERY_PILL_COLOR_ICON_SOURCES = {
+ perpetuals: discoveryPillPerps,
+ predictions: discoveryPillPredictions,
+ stocks: discoveryPillStocks,
+ crypto: discoveryPillCrypto,
+} as const;
diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryPills/index.ts b/app/components/Views/Homepage/components/HomepageDiscoveryPills/index.ts
new file mode 100644
index 00000000000..b3358a513a6
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageDiscoveryPills/index.ts
@@ -0,0 +1,11 @@
+export { default as HomepageDiscoveryPills } from './HomepageDiscoveryPills';
+export { HomepageDiscoveryPillsTestIds } from './HomepageDiscoveryPills.testIds';
+export {
+ HOMEPAGE_DISCOVERY_PILL_IDS,
+ type HomepageDiscoveryPillId,
+} from './homepageDiscoveryPills.constants';
+export {
+ HOMESCREEN_PILL_SOURCE,
+ useHomepageDiscoveryPillsNavigation,
+} from './useHomepageDiscoveryPillsNavigation';
+export type { HomepageDiscoveryPillsProps } from './HomepageDiscoveryPills.types';
diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryPills/useHomepageDiscoveryPillsNavigation.ts b/app/components/Views/Homepage/components/HomepageDiscoveryPills/useHomepageDiscoveryPillsNavigation.ts
new file mode 100644
index 00000000000..77cfa399357
--- /dev/null
+++ b/app/components/Views/Homepage/components/HomepageDiscoveryPills/useHomepageDiscoveryPillsNavigation.ts
@@ -0,0 +1,59 @@
+import { useCallback } from 'react';
+import { useNavigation } from '@react-navigation/native';
+import Routes from '../../../../../constants/navigation/Routes';
+import { EXPLORE_TAB_INDEX } from '../../../../../constants/navigation/exploreTabIndices';
+import { PredictEventValues } from '../../../../UI/Predict/constants/eventNames';
+import type { HomepageDiscoveryPillId } from './homepageDiscoveryPills.constants';
+
+export const HOMESCREEN_PILL_SOURCE =
+ PredictEventValues.ENTRY_POINT.HOMESCREEN_PILL;
+
+export function useHomepageDiscoveryPillsNavigation() {
+ const navigation = useNavigation();
+
+ const navigateToPill = useCallback(
+ (pillId: HomepageDiscoveryPillId) => {
+ switch (pillId) {
+ case 'perpetuals':
+ navigation.navigate(Routes.PERPS.ROOT, {
+ screen: Routes.PERPS.PERPS_HOME,
+ params: { source: HOMESCREEN_PILL_SOURCE },
+ });
+ break;
+ case 'predictions':
+ navigation.navigate(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_LIST,
+ params: {
+ entryPoint: HOMESCREEN_PILL_SOURCE,
+ },
+ });
+ break;
+ case 'crypto':
+ navigation.navigate(Routes.TRENDING_VIEW, {
+ screen: Routes.TRENDING_FEED,
+ params: {
+ initialTab: EXPLORE_TAB_INDEX.CRYPTO,
+ source: HOMESCREEN_PILL_SOURCE,
+ },
+ });
+ break;
+ case 'stocks':
+ navigation.navigate(Routes.TRENDING_VIEW, {
+ screen: Routes.TRENDING_FEED,
+ params: {
+ initialTab: EXPLORE_TAB_INDEX.RWAS,
+ source: HOMESCREEN_PILL_SOURCE,
+ },
+ });
+ break;
+ default: {
+ const exhaustiveCheck: never = pillId;
+ return exhaustiveCheck;
+ }
+ }
+ },
+ [navigation],
+ );
+
+ return { navigateToPill };
+}
diff --git a/app/components/Views/Homepage/hooks/usePillViewedEvent.test.ts b/app/components/Views/Homepage/hooks/usePillViewedEvent.test.ts
new file mode 100644
index 00000000000..cb30f684e76
--- /dev/null
+++ b/app/components/Views/Homepage/hooks/usePillViewedEvent.test.ts
@@ -0,0 +1,180 @@
+import { renderHook, act } from '@testing-library/react-hooks';
+import usePillViewedEvent from './usePillViewedEvent';
+import { MetaMetricsEvents } from '../../../../core/Analytics';
+
+const mockTrackEvent = jest.fn();
+const mockBuild = jest.fn(() => ({ builtEvent: true }));
+const mockAddProperties = jest.fn(() => ({ build: mockBuild }));
+const mockCreateEventBuilder = jest.fn(() => ({
+ addProperties: mockAddProperties,
+}));
+
+jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ }),
+}));
+
+let mockContextValue = {
+ subscribeToScroll: jest.fn(() => jest.fn()),
+ viewportHeight: 800,
+ containerScreenY: 0,
+ entryPoint: 'app_opened',
+ visitId: 1,
+ notifySectionViewed: jest.fn(),
+ getViewedSectionCount: jest.fn(() => 0),
+ getVisitMaxDepth: jest.fn(() => -1),
+ appSessionId: 'test-session-id',
+};
+
+jest.mock('../context/HomepageScrollContext', () => ({
+ useHomepageScrollContext: () => mockContextValue,
+}));
+
+describe('usePillViewedEvent', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockContextValue = {
+ subscribeToScroll: jest.fn(() => jest.fn()),
+ viewportHeight: 800,
+ containerScreenY: 0,
+ entryPoint: 'app_opened',
+ visitId: 1,
+ notifySectionViewed: jest.fn(),
+ getViewedSectionCount: jest.fn(() => 0),
+ getVisitMaxDepth: jest.fn(() => -1),
+ appSessionId: 'test-session-id',
+ };
+ });
+
+ describe('event shape', () => {
+ it('uses the HOME_VIEWED MetaMetrics event', () => {
+ const { result } = renderHook(() => usePillViewedEvent());
+
+ act(() => {
+ result.current.trackPillTapped('crypto', 2);
+ });
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.HOME_VIEWED,
+ );
+ });
+
+ it('fires with interaction_type: pill_tapped', () => {
+ const { result } = renderHook(() => usePillViewedEvent());
+
+ act(() => {
+ result.current.trackPillTapped('crypto', 2);
+ });
+
+ expect(mockAddProperties).toHaveBeenCalledWith(
+ expect.objectContaining({ interaction_type: 'pill_tapped' }),
+ );
+ });
+
+ it('fires with location: home', () => {
+ const { result } = renderHook(() => usePillViewedEvent());
+
+ act(() => {
+ result.current.trackPillTapped('crypto', 2);
+ });
+
+ expect(mockAddProperties).toHaveBeenCalledWith(
+ expect.objectContaining({ location: 'home' }),
+ );
+ });
+
+ it('maps pill id to section_name', () => {
+ const { result } = renderHook(() => usePillViewedEvent());
+
+ act(() => {
+ result.current.trackPillTapped('perpetuals', 0);
+ });
+
+ expect(mockAddProperties).toHaveBeenCalledWith(
+ expect.objectContaining({ section_name: 'perps' }),
+ );
+ });
+
+ it('includes position', () => {
+ const { result } = renderHook(() => usePillViewedEvent());
+
+ act(() => {
+ result.current.trackPillTapped('stocks', 1);
+ });
+
+ expect(mockAddProperties).toHaveBeenCalledWith(
+ expect.objectContaining({ position: 1 }),
+ );
+ });
+
+ it('includes entry_point from context', () => {
+ mockContextValue = { ...mockContextValue, entryPoint: 'home_tab' };
+ const { result } = renderHook(() => usePillViewedEvent());
+
+ act(() => {
+ result.current.trackPillTapped('predictions', 1);
+ });
+
+ expect(mockAddProperties).toHaveBeenCalledWith(
+ expect.objectContaining({ entry_point: 'home_tab' }),
+ );
+ });
+
+ it('passes the built event to trackEvent', () => {
+ const builtEvent = { builtEvent: true };
+ mockBuild.mockReturnValue(builtEvent);
+ const { result } = renderHook(() => usePillViewedEvent());
+
+ act(() => {
+ result.current.trackPillTapped('crypto', 3);
+ });
+
+ expect(mockTrackEvent).toHaveBeenCalledWith(builtEvent);
+ });
+
+ it('fires with all required properties', () => {
+ mockContextValue = {
+ ...mockContextValue,
+ entryPoint: 'navigated_back',
+ appSessionId: 'full-props-session',
+ visitId: 3,
+ };
+ const { result } = renderHook(() => usePillViewedEvent());
+
+ act(() => {
+ result.current.trackPillTapped('predictions', 1);
+ });
+
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ interaction_type: 'pill_tapped',
+ location: 'home',
+ section_name: 'predict',
+ position: 1,
+ entry_point: 'navigated_back',
+ app_session_id: 'full-props-session',
+ visit_number: 3,
+ });
+ });
+ });
+
+ describe('section names', () => {
+ it.each([
+ ['perpetuals', 'perps'],
+ ['predictions', 'predict'],
+ ['stocks', 'stocks'],
+ ['crypto', 'crypto'],
+ ] as const)('maps %s to section_name %s', (pillId, sectionName) => {
+ const { result } = renderHook(() => usePillViewedEvent());
+
+ act(() => {
+ result.current.trackPillTapped(pillId, 0);
+ });
+
+ expect(mockAddProperties).toHaveBeenCalledWith(
+ expect.objectContaining({ section_name: sectionName }),
+ );
+ });
+ });
+});
diff --git a/app/components/Views/Homepage/hooks/usePillViewedEvent.ts b/app/components/Views/Homepage/hooks/usePillViewedEvent.ts
new file mode 100644
index 00000000000..57deafd89e2
--- /dev/null
+++ b/app/components/Views/Homepage/hooks/usePillViewedEvent.ts
@@ -0,0 +1,52 @@
+import { useCallback, useRef } from 'react';
+import { MetaMetricsEvents } from '../../../../core/Analytics';
+import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics';
+import {
+ HOMEPAGE_DISCOVERY_PILL_SECTION_NAMES,
+ type HomepageDiscoveryPillId,
+} from '../components/HomepageDiscoveryPills/homepageDiscoveryPills.constants';
+import { useHomepageScrollContext } from '../context/HomepageScrollContext';
+
+/**
+ * Fires a `Home Viewed` Segment event with `interaction_type: 'pill_tapped'`
+ * when a homepage discovery pill is tapped.
+ *
+ * - `visit_number` increments with each homepage visit via `visitId` from HomepageScrollContext.
+ * - `active_ab_tests` is injected automatically by the analytics registry (`HOMEPAGE_DISCOVERY_PILLS_AB_TEST_ANALYTICS_MAPPING` in abTestConfig.ts).
+ */
+const usePillViewedEvent = () => {
+ const { entryPoint, visitId, appSessionId } = useHomepageScrollContext();
+ const { trackEvent, createEventBuilder } = useAnalytics();
+
+ const entryPointRef = useRef(entryPoint);
+ entryPointRef.current = entryPoint;
+
+ const appSessionIdRef = useRef(appSessionId);
+ appSessionIdRef.current = appSessionId;
+
+ const visitIdRef = useRef(visitId);
+ visitIdRef.current = visitId;
+
+ const trackPillTapped = useCallback(
+ (pillId: HomepageDiscoveryPillId, position: number) => {
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.HOME_VIEWED)
+ .addProperties({
+ interaction_type: 'pill_tapped',
+ location: 'home',
+ section_name: HOMEPAGE_DISCOVERY_PILL_SECTION_NAMES[pillId],
+ position,
+ entry_point: entryPointRef.current,
+ app_session_id: appSessionIdRef.current,
+ visit_number: visitIdRef.current,
+ })
+ .build(),
+ );
+ },
+ [trackEvent, createEventBuilder],
+ );
+
+ return { trackPillTapped };
+};
+
+export default usePillViewedEvent;
diff --git a/app/components/Views/MultichainAccounts/WalletDetails/BaseWalletDetails/index.test.tsx b/app/components/Views/MultichainAccounts/WalletDetails/BaseWalletDetails/index.test.tsx
index 7d408f123bd..baa717cf3c4 100644
--- a/app/components/Views/MultichainAccounts/WalletDetails/BaseWalletDetails/index.test.tsx
+++ b/app/components/Views/MultichainAccounts/WalletDetails/BaseWalletDetails/index.test.tsx
@@ -116,15 +116,6 @@ jest.mock(
}),
);
-jest.mock('../../../../../core/SnapKeyring/MultichainWalletSnapClient', () => ({
- MultichainWalletSnapFactory: {
- createClient: jest.fn(),
- },
- WalletClientType: {
- Solana: 'solana',
- },
-}));
-
const mockGetInternalAccountsFromWallet =
getInternalAccountsFromWallet as jest.Mock;
const mockUseWalletBalances = useWalletBalances as jest.Mock;
diff --git a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx
index 7f5b855e618..91572508324 100644
--- a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx
+++ b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx
@@ -227,14 +227,14 @@ describe('TopTradersView', () => {
});
});
- it('renders the rank for the top trader', () => {
+ it('renders a podium medal for the top trader', () => {
renderWithProvider();
- expect(screen.getByText('1')).toBeOnTheScreen();
+ expect(screen.getByTestId('rank-medal-1')).toBeOnTheScreen();
});
- it('renders the ROI for the first trader', () => {
+ it('renders the full PnL for the first trader', () => {
renderWithProvider();
- expect(screen.getByText('+43.0%')).toBeOnTheScreen();
+ expect(screen.getByText('+$963,146.80')).toBeOnTheScreen();
});
it('calls toggleFollow with analytics context when Follow button is pressed', () => {
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx
index 4e96242dc95..3c885544d6d 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx
@@ -382,7 +382,6 @@ const TraderPositionView = () => {
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.test.tsx
index c09857509a9..5fc0127abd1 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.test.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.test.tsx
@@ -1,116 +1,40 @@
-import React, { createRef } from 'react';
+import React from 'react';
import { render, screen } from '@testing-library/react-native';
-import { TextInput } from 'react-native';
import QuickBuyAmountSection from './QuickBuyAmountSection';
-const defaultProps = {
- amountDisplayMode: 'fiat' as const,
- fiatAmountLabel: '$0',
- destSymbol: 'ETH',
- estimatedReceiveAmount: undefined,
- isQuoteLoading: false,
- hiddenInputRef: createRef(),
- onAmountAreaPress: jest.fn(),
- onAmountChange: jest.fn(),
-};
-
describe('QuickBuyAmountSection', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- it('renders the fiat amount as primary in fiat mode', () => {
- render(
- ,
- );
- expect(screen.getByText('$50')).toBeOnTheScreen();
- });
-
- it('renders a non-USD preformatted fiat label as primary in fiat mode', () => {
- render(
- ,
- );
- expect(screen.getByText('50 €')).toBeOnTheScreen();
- });
-
- it('shows the zero placeholder label when no amount is entered', () => {
- render();
- expect(screen.getByText('$0')).toBeOnTheScreen();
- });
-
- it('renders the crypto amount as primary in crypto mode', () => {
- render(
- ,
- );
- expect(screen.getByText('0.025 ETH')).toBeOnTheScreen();
- });
-
- it('shows 0 crypto placeholder when estimatedCryptoAmount is undefined', () => {
- render(
- ,
- );
- expect(screen.getByText('0 ETH')).toBeOnTheScreen();
+ const baseProps = {
+ amountDisplayMode: 'fiat' as const,
+ fiatAmountLabel: '$2.55',
+ destSymbol: 'GIGA',
+ estimatedReceiveAmount: '56.52037',
+ isQuoteLoading: false,
+ };
+
+ it('renders the secondary amount label when not loading', () => {
+ render();
+
+ expect(screen.getByText('$2.55')).toBeOnTheScreen();
+ expect(screen.getByText('56.52037 GIGA')).toBeOnTheScreen();
+ expect(
+ screen.queryByTestId('quick-buy-amount-loading'),
+ ).not.toBeOnTheScreen();
});
- it('shows the estimated crypto amount with destSymbol as the secondary label', () => {
- render(
- ,
- );
- expect(screen.getByText('123.45 ETH')).toBeOnTheScreen();
- });
+ it('shows skeleton and token symbol while quote is loading', () => {
+ render();
- it('replaces the secondary label with a spinner when isQuoteLoading', () => {
- render(
- ,
- );
- expect(screen.getByTestId('quick-buy-amount-area')).toBeOnTheScreen();
+ expect(screen.getByText('$2.55')).toBeOnTheScreen();
+ expect(screen.getByTestId('quick-buy-amount-loading')).toBeOnTheScreen();
expect(
- screen.getByTestId('quick-buy-amount-loading-spinner'),
+ screen.getByTestId('quick-buy-amount-loading-skeleton'),
).toBeOnTheScreen();
- // Secondary label is replaced by spinner — crypto label should NOT be present
- expect(screen.queryByText('0 ETH')).not.toBeOnTheScreen();
- });
-
- it('shows the secondary label when NOT loading', () => {
- render();
- expect(screen.getByText('0 ETH')).toBeOnTheScreen();
- });
-
- it('does not render a toggle button', () => {
- render();
expect(
- screen.queryByTestId('quick-buy-toggle-amount-display'),
+ screen.getByTestId('quick-buy-amount-loading-symbol'),
+ ).toHaveTextContent('GIGA');
+ expect(
+ screen.queryByTestId('quick-buy-amount-loading-icon'),
).not.toBeOnTheScreen();
- });
-
- it('does not render available balance text', () => {
- render();
- expect(screen.queryByText(/available/)).not.toBeOnTheScreen();
+ expect(screen.queryByText('56.52037 GIGA')).not.toBeOnTheScreen();
});
});
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx
index 03e593a0488..f6efc4fd4ee 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import { TextInput } from 'react-native';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
import {
Box,
Text,
@@ -7,11 +8,10 @@ import {
TextColor,
FontWeight,
BoxAlignItems,
+ BoxFlexDirection,
BoxJustifyContent,
- IconColor,
- IconSize,
- Spinner,
} from '@metamask/design-system-react-native';
+import { Skeleton } from '../../../../../../../component-library/components-temp/Skeleton';
import type { QuickBuyAmountDisplayMode } from '../types';
import { formatTokenAmount } from '../../../../utils/formatters';
@@ -52,6 +52,8 @@ const QuickBuyAmountSection: React.FC = ({
sourceCryptoAmount,
sourceSymbol,
}) => {
+ const tw = useTailwind();
+
const cryptoAmountLabel = estimatedReceiveAmount
? `${formatTokenAmount(parseFloat(estimatedReceiveAmount))} ${destSymbol}`
: `0 ${destSymbol}`;
@@ -90,11 +92,26 @@ const QuickBuyAmountSection: React.FC = ({
{isQuoteLoading ? (
-
+
+
+
+ {destSymbol}
+
+
) : (
{
it('prefers formattedRate (quote-based) over formattedExchangeRate when a quote is available', () => {
(useQuickBuyContext as jest.Mock).mockReturnValue({
...baseContext,
- formattedRate: '1 ETH = 4381.23 REPPO',
- formattedExchangeRate: '1 ETH = 1000 USDC',
+ formattedRate: '1 SOL = 25,738.44 GIGA',
+ formattedExchangeRate: '1 SOL = 23,529 GIGA',
setActiveScreen,
});
render();
- expect(screen.getByText('1 ETH = 4381.23 REPPO')).toBeOnTheScreen();
- expect(screen.queryByText('1 ETH = 1000 USDC')).not.toBeOnTheScreen();
+ expect(screen.getByText('1 SOL = 25,738.44 GIGA')).toBeOnTheScreen();
+ expect(screen.queryByText('1 SOL = 23,529 GIGA')).not.toBeOnTheScreen();
});
it('shows formattedRate even when formattedExchangeRate is undefined', () => {
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts
index e5196eac4c3..ca864f66b64 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts
@@ -879,7 +879,7 @@ export function useQuickBuyController(
() =>
tradeMode === 'sell'
? formatExchangeRate(sourceToken, destToken)
- : formatExchangeRate(destTokenForRate, sourceToken),
+ : formatExchangeRate(sourceToken, destTokenForRate),
[destToken, destTokenForRate, sourceToken, tradeMode],
);
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts
index e3a608cb8aa..996d9d15484 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts
@@ -1471,6 +1471,41 @@ describe('useQuickBuyController', () => {
});
});
+ describe('formattedExchangeRate', () => {
+ it('shows pay-with token on the left in buy mode pre-quote', () => {
+ const solToken = createSourceToken({
+ symbol: 'SOL',
+ currencyExchangeRate: 150,
+ });
+ (usePayWithTokens as jest.Mock).mockReturnValue({
+ options: [solToken],
+ isLoading: false,
+ });
+ (useQuickBuySetup as jest.Mock).mockReturnValue({
+ chainId: '0x1',
+ destToken: {
+ address: '0xDEST',
+ chainId: '0x1',
+ decimals: 18,
+ symbol: 'GIGA',
+ name: 'Gigachad',
+ },
+ isLoading: false,
+ isUnsupportedChain: false,
+ });
+ (usePositionTokenBalance as jest.Mock).mockReturnValue({
+ currencyExchangeRate: 0.006375,
+ });
+
+ const { result } = renderHook(() =>
+ useQuickBuyController(createTarget({ tokenSymbol: 'GIGA' }), jest.fn()),
+ );
+
+ expect(result.current.formattedExchangeRate).toMatch(/^1 SOL = /);
+ expect(result.current.formattedExchangeRate).not.toMatch(/^1 GIGA = /);
+ });
+ });
+
describe('source token auto-selection', () => {
it('auto-selects the first option when options load (legacy — native on dest chain matches priority 1)', () => {
const firstToken = createSourceToken({ symbol: 'ETH' });
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TradeRow.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TradeRow.test.tsx
index 2d3790e210a..c0ba6e62d27 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TradeRow.test.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TradeRow.test.tsx
@@ -19,30 +19,24 @@ const REAL_AVATAR_URL = 'https://example.com/avatar.png';
const TRADER_ADDRESS = '0x0000000000000000000000000000000000000001';
describe('TradeRow', () => {
- it('renders the bought label for an enter trade', () => {
- renderWithProvider(
- ,
- );
+ it('renders the "Bought" label for an enter trade (no trader name)', () => {
+ renderWithProvider();
- expect(screen.getByText('nodestack.eth bought')).toBeOnTheScreen();
+ expect(screen.getByText('Bought')).toBeOnTheScreen();
+ expect(screen.queryByText(/nodestack\.eth/)).toBeNull();
});
- it('renders the sold label for an exit trade', () => {
- renderWithProvider(
- ,
- );
+ it('renders the "Sold" label for an exit trade (no trader name)', () => {
+ renderWithProvider();
- expect(screen.getByText('nodestack.eth sold')).toBeOnTheScreen();
+ expect(screen.getByText('Sold')).toBeOnTheScreen();
+ expect(screen.queryByText(/nodestack\.eth/)).toBeNull();
});
it('renders the avatar image when a real image url is provided', () => {
renderWithProvider(
,
@@ -54,11 +48,7 @@ describe('TradeRow', () => {
it('renders the Maskicon fallback when the image url is absent', () => {
renderWithProvider(
- ,
+ ,
);
expect(screen.UNSAFE_queryByType(AvatarAccount)).not.toBeNull();
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TradeRow.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TradeRow.tsx
index eccc0346057..3a0a6da30ff 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TradeRow.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TradeRow.tsx
@@ -20,14 +20,12 @@ const AVATAR_SIZE = 32;
export interface TradeRowProps {
trade: Trade;
- traderName: string;
traderImageUrl?: string;
traderAddress?: string;
}
const TradeRow: React.FC = ({
trade,
- traderName,
traderImageUrl,
traderAddress,
}) => {
@@ -38,19 +36,11 @@ const TradeRow: React.FC = ({
// Perp fills read as "opened"/"closed" (vs spot "bought"/"sold").
const actionLabel = isPerp
? isEntry
- ? strings('social_leaderboard.trader_position.opened', {
- name: traderName,
- })
- : strings('social_leaderboard.trader_position.closed_action', {
- name: traderName,
- })
+ ? strings('social_leaderboard.trader_position.opened')
+ : strings('social_leaderboard.trader_position.closed_action')
: isEntry
- ? strings('social_leaderboard.trader_position.bought', {
- name: traderName,
- })
- : strings('social_leaderboard.trader_position.sold', {
- name: traderName,
- });
+ ? strings('social_leaderboard.trader_position.bought')
+ : strings('social_leaderboard.trader_position.sold');
return (
= ({
)}
- {/* The absolute amount stays neutral; the percentage carries the
- red/green so coloring is consistent with the positions list and
- with spot positions. */}
+ {/* The absolute amount carries the red/green; the percentage below
+ stays neutral (white). */}
- {pnlValue != null ? formatPnl(pnlValue) : '\u2014'}
-
-
+ {pnlValue != null ? formatPnl(pnlValue) : '\u2014'}
+
+
{formatPercent(pnlPercent)}
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderPriceChart.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderPriceChart.tsx
index 6bc066fd955..5e9ff2f1c6f 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderPriceChart.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderPriceChart.tsx
@@ -40,13 +40,13 @@ interface TooltipProps {
/** Smaller than TRADE_MARKER_RADIUS so the end-dot doesn't look like a trade marker. */
const END_DOT_DIAMETER = 5;
/**
- * Trade marker geometry — the inner colored disk is 16x16 with a 4px ring
+ * Trade marker geometry — the inner colored disk is 10x10 with a 2px ring
* matching the chart background. SVG strokes are centered on the circumference,
- * so we set r = innerRadius + strokeWidth/2 (8 + 2 = 10) to make the ring
+ * so we set r = innerRadius + strokeWidth/2 (5 + 1 = 6) to make the ring
* sit fully outside the visible inner disk.
*/
-const TRADE_MARKER_INNER_RADIUS = 8;
-const TRADE_MARKER_BORDER_WIDTH = 4;
+const TRADE_MARKER_INNER_RADIUS = 5;
+const TRADE_MARKER_BORDER_WIDTH = 2;
const TRADE_MARKER_RADIUS =
TRADE_MARKER_INNER_RADIUS + TRADE_MARKER_BORDER_WIDTH / 2;
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderTradesSection.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderTradesSection.test.tsx
new file mode 100644
index 00000000000..77a9d65eb62
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderTradesSection.test.tsx
@@ -0,0 +1,59 @@
+import React from 'react';
+import { screen } from '@testing-library/react-native';
+import { StyleSheet } from 'react-native';
+import type { ReactTestInstance } from 'react-test-renderer';
+import type { Trade } from '@metamask/social-controllers';
+import renderWithProvider from '../../../../../util/test/renderWithProvider';
+import TraderTradesSection from './TraderTradesSection';
+
+const makeTrade = (overrides: Partial = {}): Trade => ({
+ intent: 'enter',
+ direction: 'buy',
+ tokenAmount: 1,
+ usdCost: 23155.92,
+ timestamp: 1_700_000_000_000,
+ transactionHash: '0xabc',
+ ...overrides,
+});
+
+describe('TraderTradesSection', () => {
+ it('renders the Trades title', () => {
+ renderWithProvider();
+
+ expect(screen.getByText('Trades')).toBeOnTheScreen();
+ });
+
+ it('does not render a white underline below the title', () => {
+ renderWithProvider();
+
+ let node: ReactTestInstance | null = screen.getByText('Trades');
+ while (node) {
+ const flat = StyleSheet.flatten(node.props.style) as
+ | { borderBottomWidth?: number }
+ | undefined;
+ expect(flat?.borderBottomWidth).toBeFalsy();
+ node = node.parent;
+ }
+ });
+
+ it('renders a TradeRow for each trade', () => {
+ renderWithProvider(
+ ,
+ );
+
+ expect(screen.getByText('Bought')).toBeOnTheScreen();
+ expect(screen.getByText('Sold')).toBeOnTheScreen();
+ });
+
+ it('renders the empty state when there are no trades', () => {
+ renderWithProvider();
+
+ expect(screen.queryByText('Bought')).toBeNull();
+ expect(screen.queryByText('Sold')).toBeNull();
+ });
+});
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderTradesSection.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderTradesSection.tsx
index 28629cacc49..69c31ef500e 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderTradesSection.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/TraderTradesSection.tsx
@@ -12,20 +12,18 @@ import TradeRow from './TradeRow';
export interface TraderTradesSectionProps {
trades: Trade[];
- traderName: string;
traderImageUrl?: string;
traderAddress?: string;
}
const TraderTradesSection: React.FC = ({
trades,
- traderName,
traderImageUrl,
traderAddress,
}) => (
<>
-
+
= ({
{strings('social_leaderboard.trader_position.trades')}
-
+
{trades.length > 0 ? (
@@ -42,7 +40,6 @@ const TraderTradesSection: React.FC = ({
diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx
index 0f817ddf218..4b36a138ea4 100644
--- a/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx
@@ -824,11 +824,11 @@ describe('TraderProfileView', () => {
renderWithProvider();
- // Sum is 1,000,000 — hyperliquid is included; rendered with the shared
- // abbreviated formatter (main): 1,000,000 → +$1M
- expect(screen.getByText('+$1M')).toBeOnTheScreen();
+ // Sum is 1,000,000 — hyperliquid is included; rendered with the full
+ // no-decimals formatter: 1,000,000 → +$1,000,000
+ expect(screen.getByText('+$1,000,000')).toBeOnTheScreen();
// And the trader's global pnl7d (999,999) is NOT what we display
- expect(screen.queryByText('+$999,999.00')).not.toBeOnTheScreen();
+ expect(screen.queryByText('+$999,999')).not.toBeOnTheScreen();
});
it('shows the Hyperliquid PnL for a perps-only trader (regression: was 0)', () => {
@@ -847,9 +847,9 @@ describe('TraderProfileView', () => {
renderWithProvider();
- // 1,474,000 rendered with the shared abbreviated formatter (main) → +$1.5M
- expect(screen.getByText('+$1.5M')).toBeOnTheScreen();
- expect(screen.queryByText('+$0.00')).not.toBeOnTheScreen();
+ // 1,474,000 rendered with the full no-decimals formatter → +$1,474,000
+ expect(screen.getByText('+$1,474,000')).toBeOnTheScreen();
+ expect(screen.queryByText('$0')).not.toBeOnTheScreen();
});
it('sums negative per-chain values (incl. hyperliquid) into a negative total', () => {
@@ -872,8 +872,8 @@ describe('TraderProfileView', () => {
renderWithProvider();
// -1000 + -2500 + 500 + -50000 = -53000 (hyperliquid included);
- // rendered with the shared abbreviated formatter (main): -53,000 → -$53K
- expect(screen.getByText('-$53K')).toBeOnTheScreen();
+ // rendered with the full no-decimals formatter: -53,000 → -$53,000
+ expect(screen.getByText('-$53,000')).toBeOnTheScreen();
});
it('treats a missing chain entry as 0', () => {
@@ -890,7 +890,7 @@ describe('TraderProfileView', () => {
renderWithProvider();
- expect(screen.getByText('+$7.5K')).toBeOnTheScreen();
+ expect(screen.getByText('+$7,500')).toBeOnTheScreen();
});
it('falls back to the global stats.pnl7d when perChainPnl7d is empty', () => {
@@ -907,7 +907,7 @@ describe('TraderProfileView', () => {
renderWithProvider();
- expect(screen.getByText('+$20.6K')).toBeOnTheScreen();
+ expect(screen.getByText('+$20,610')).toBeOnTheScreen();
});
});
});
diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.tsx
index 31ad3e30e98..5daa7ae7bb5 100644
--- a/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.tsx
@@ -388,7 +388,6 @@ const TraderProfileView = () => {
profile={profile.profile}
followerCount={profile.followerCount}
twitterHandle={profile.socialHandles?.twitter}
- rank={traderRank}
/>
)}
diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.test.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.test.tsx
index 5a7bc0d321d..57acd28292b 100644
--- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.test.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.test.tsx
@@ -70,12 +70,14 @@ describe('PositionRow', () => {
expect(screen.getByText('$2,259.96')).toBeOnTheScreen();
});
- it('renders unrealized $ PnL alongside the percent on the bottom-right', () => {
+ it('renders only the bare percent on the bottom-right (no absolute PnL)', () => {
renderWithProvider();
- expect(screen.getByText('+$1,059.96 (+182%)')).toBeOnTheScreen();
+ expect(screen.getByText('+182%')).toBeOnTheScreen();
+ expect(screen.queryByText('+$1,059.96 (+182%)')).toBeNull();
+ expect(screen.queryByText('+$1,059.96')).toBeNull();
});
- it('renders negative unrealized $ PnL with the percent', () => {
+ it('renders the negative percent without the absolute PnL', () => {
const position = {
...basePosition,
pnlValueUsd: -250,
@@ -83,10 +85,11 @@ describe('PositionRow', () => {
};
renderWithProvider();
- expect(screen.getByText('-$250.00 (-25%)')).toBeOnTheScreen();
+ expect(screen.getByText('-25%')).toBeOnTheScreen();
+ expect(screen.queryByText('-$250.00 (-25%)')).toBeNull();
});
- it('falls back to just the percent when pnlValueUsd is missing', () => {
+ it('renders the percent even when pnlValueUsd is missing', () => {
const position = {
...basePosition,
pnlValueUsd: null,
@@ -119,7 +122,7 @@ describe('PositionRow', () => {
expect(dashes.length).toBeGreaterThanOrEqual(1);
});
- it('renders zero unrealized $ PnL with zero percent', () => {
+ it('renders zero percent when unrealized PnL is zero', () => {
const position = {
...basePosition,
pnlValueUsd: 0,
@@ -127,7 +130,8 @@ describe('PositionRow', () => {
};
renderWithProvider();
- expect(screen.getByText('$0.00 (+0%)')).toBeOnTheScreen();
+ expect(screen.getByText('+0%')).toBeOnTheScreen();
+ expect(screen.queryByText('$0.00 (+0%)')).toBeNull();
});
it('renders negative USD value', () => {
diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.tsx
index 2b28662fb00..1adb8f795e2 100644
--- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/PositionRow.tsx
@@ -125,9 +125,7 @@ const PositionRow: React.FC = ({
twClassName={pnlColorClass}
color={pnlColorClass ? undefined : TextColor.TextAlternative}
>
- {position.pnlValueUsd != null
- ? `${formatSignedUsd(position.pnlValueUsd)} (${formatPercent(displayPnlPercent)})`
- : formatPercent(displayPnlPercent)}
+ {formatPercent(displayPnlPercent)}
) : (
{
).toBeOnTheScreen();
});
+ it('does not render a podium medal badge on the profile avatar', () => {
+ renderWithProvider(
+ ,
+ );
+ expect(screen.queryByTestId('rank-medal-1')).toBeNull();
+ expect(screen.queryByTestId('rank-medal-2')).toBeNull();
+ expect(screen.queryByTestId('rank-medal-3')).toBeNull();
+ });
+
it('renders Maskicon fallback when imageUrl is falsy', () => {
const profileNoImage = { ...baseProfile, imageUrl: '' };
renderWithProvider(
diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/ProfileHeader.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/components/ProfileHeader.tsx
index 875c708d149..834e0d6a669 100644
--- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/ProfileHeader.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/ProfileHeader.tsx
@@ -17,8 +17,6 @@ import { strings } from '../../../../../../locales/i18n';
import type { TraderProfile } from '@metamask/social-controllers';
import { TraderProfileViewSelectorsIDs } from '../TraderProfileView.testIds';
// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog
-import { TopRankAvatar } from '../../../Homepage/Sections/TopTraders/topRank';
-// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog
import TraderAvatar from '../../../Homepage/Sections/TopTraders/components/TraderAvatar';
const AVATAR_SIZE = 40;
@@ -27,18 +25,12 @@ export interface ProfileHeaderProps {
profile: TraderProfile;
followerCount: number;
twitterHandle?: string | null;
- /**
- * Optional leaderboard rank used to render the top-rank decoration
- * (gradient ring + crown emoji) around the avatar for ranks 1-3.
- */
- rank?: number;
}
const ProfileHeader: React.FC = ({
profile,
followerCount,
twitterHandle,
- rank,
}) => {
const handleTwitterPress = useCallback(() => {
if (twitterHandle) {
@@ -54,13 +46,11 @@ const ProfileHeader: React.FC = ({
gap={4}
testID={TraderProfileViewSelectorsIDs.HEADER}
>
-
-
-
+
{
expect(screen.getByText('0%')).toBeOnTheScreen();
});
- it('abbreviates positive PnL with K suffix', () => {
+ it('renders the full positive PnL with no decimals (no abbreviation)', () => {
renderWithProvider();
- expect(screen.getByText('+$20.6K')).toBeOnTheScreen();
+ expect(screen.getByText('+$20,610')).toBeOnTheScreen();
});
- it('abbreviates large positive PnL with M suffix', () => {
+ it('renders large positive PnL in full with no decimals', () => {
const stats = { ...baseStats, pnl7d: 1_170_000 };
renderWithProvider();
- expect(screen.getByText('+$1.2M')).toBeOnTheScreen();
+ expect(screen.getByText('+$1,170,000')).toBeOnTheScreen();
});
it('renders dash when pnl7d is null', () => {
@@ -57,22 +57,22 @@ describe('StatsRow', () => {
expect(dashes.length).toBeGreaterThanOrEqual(2);
});
- it('abbreviates negative PnL with K suffix', () => {
+ it('renders negative PnL in full with a leading minus', () => {
const stats = { ...baseStats, pnl7d: -5000 };
renderWithProvider();
- expect(screen.getByText('-$5K')).toBeOnTheScreen();
+ expect(screen.getByText('-$5,000')).toBeOnTheScreen();
});
- it('renders sub-$1K PnL with two decimal places (no abbreviation)', () => {
+ it('renders sub-$1K PnL without decimals', () => {
const stats = { ...baseStats, pnl7d: 500 };
renderWithProvider();
- expect(screen.getByText('+$500.00')).toBeOnTheScreen();
+ expect(screen.getByText('+$500')).toBeOnTheScreen();
});
- it('rounds sub-$1K PnL to two decimal places', () => {
+ it('rounds fractional PnL to a whole dollar', () => {
const stats = { ...baseStats, pnl7d: 500.236 };
renderWithProvider();
- expect(screen.getByText('+$500.24')).toBeOnTheScreen();
+ expect(screen.getByText('+$500')).toBeOnTheScreen();
});
it('renders both null winRate and null pnl as dashes', () => {
@@ -115,21 +115,21 @@ describe('StatsRow', () => {
});
});
- it('renders win rate label', () => {
+ it('renders the win rate label', () => {
renderWithProvider();
- expect(screen.getByText('win rate')).toBeOnTheScreen();
+ expect(screen.getByText('Win rate')).toBeOnTheScreen();
});
- it('renders 7D Return label', () => {
+ it('renders the 7D P&L label', () => {
renderWithProvider();
- expect(screen.getByText('7D Return')).toBeOnTheScreen();
+ expect(screen.getByText('7D P&L')).toBeOnTheScreen();
});
- it('renders hold time label', () => {
+ it('renders the avg. hold label', () => {
renderWithProvider();
- expect(screen.getByText('hold time')).toBeOnTheScreen();
+ expect(screen.getByText('Avg. hold')).toBeOnTheScreen();
});
});
diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/StatsRow.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/components/StatsRow.tsx
index 28945448a3d..76cd512d9e4 100644
--- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/StatsRow.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/StatsRow.tsx
@@ -12,7 +12,7 @@ import {
} from '@metamask/design-system-react-native';
import { strings } from '../../../../../../locales/i18n';
import type { TraderStats } from '@metamask/social-controllers';
-import { formatSignedAbbreviatedUsd } from '../../utils/formatters';
+import { formatSignedFullUsdNoDecimals } from '../../utils/formatters';
import { TraderProfileViewSelectorsIDs } from '../TraderProfileView.testIds';
export interface StatsRowProps {
@@ -50,7 +50,7 @@ const StatsRow: React.FC = ({ stats, holdTimeMinutes }) => {
const isWinRatePositive = (stats.winRate7d ?? 0) > 0;
const hasPnl = stats.pnl7d != null;
- const pnl = formatSignedAbbreviatedUsd(stats.pnl7d);
+ const pnl = formatSignedFullUsdNoDecimals(stats.pnl7d);
const isPnlPositive = stats.pnl7d != null && stats.pnl7d >= 0;
return (
diff --git a/app/components/Views/SocialLeaderboard/utils/formatters.test.ts b/app/components/Views/SocialLeaderboard/utils/formatters.test.ts
index 3ae3a5b8499..97df2dbe6b0 100644
--- a/app/components/Views/SocialLeaderboard/utils/formatters.test.ts
+++ b/app/components/Views/SocialLeaderboard/utils/formatters.test.ts
@@ -2,6 +2,7 @@ import {
formatUsd,
formatSignedUsd,
formatSignedAbbreviatedUsd,
+ formatSignedFullUsdNoDecimals,
formatTokenAmount,
formatPercent,
formatTradeDate,
@@ -90,6 +91,29 @@ describe('formatSignedAbbreviatedUsd', () => {
});
});
+describe('formatSignedFullUsdNoDecimals', () => {
+ it('formats the full number with commas and no decimals, signed', () => {
+ expect(formatSignedFullUsdNoDecimals(82610666)).toBe('+$82,610,666');
+ });
+
+ it('rounds away fractional digits', () => {
+ expect(formatSignedFullUsdNoDecimals(782360.66)).toBe('+$782,361');
+ });
+
+ it('prefixes negative values with -', () => {
+ expect(formatSignedFullUsdNoDecimals(-1234)).toBe('-$1,234');
+ });
+
+ it('renders zero without a sign', () => {
+ expect(formatSignedFullUsdNoDecimals(0)).toBe('$0');
+ });
+
+ it('returns an em dash for null and undefined', () => {
+ expect(formatSignedFullUsdNoDecimals(null)).toBe('\u2014');
+ expect(formatSignedFullUsdNoDecimals(undefined)).toBe('\u2014');
+ });
+});
+
describe('formatTokenAmount', () => {
it('abbreviates billions (e.g. 1.5B)', () => {
expect(formatTokenAmount(1500000000)).toBe('1.50B');
diff --git a/app/components/Views/SocialLeaderboard/utils/formatters.ts b/app/components/Views/SocialLeaderboard/utils/formatters.ts
index 294395bde7c..5acad3b20a4 100644
--- a/app/components/Views/SocialLeaderboard/utils/formatters.ts
+++ b/app/components/Views/SocialLeaderboard/utils/formatters.ts
@@ -33,6 +33,28 @@ export function formatSignedUsd(value: number | null | undefined): string {
return sign + formatPerpsFiat(Math.abs(value), { stripTrailingZeros: false });
}
+/**
+ * Signed USD with the full (non-abbreviated) number, thousands separators and
+ * no fractional digits. Used for the profile 30D P&L headline
+ * (e.g. `+$82,610,666`). Zero renders without a sign.
+ */
+export function formatSignedFullUsdNoDecimals(
+ value: number | null | undefined,
+): string {
+ if (value == null) return EM_DASH;
+ if (value === 0) {
+ return formatPerpsFiat(0, { minimumDecimals: 0, maximumDecimals: 0 });
+ }
+ const sign = value > 0 ? '+' : '-';
+ return (
+ sign +
+ formatPerpsFiat(Math.abs(value), {
+ minimumDecimals: 0,
+ maximumDecimals: 0,
+ })
+ );
+}
+
// Ordered largest → smallest. Walk down and promote when rounding pushes a
// value past the bucket boundary (e.g. `999_999` rounds to `1000K`, which
// we want as `$1M`, not `$1000K`).
diff --git a/app/components/Views/TrendingView/TrendingView.tsx b/app/components/Views/TrendingView/TrendingView.tsx
index 0863486494a..2d23dca50f4 100644
--- a/app/components/Views/TrendingView/TrendingView.tsx
+++ b/app/components/Views/TrendingView/TrendingView.tsx
@@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { TouchableOpacity } from 'react-native';
-import { SafeAreaView } from 'react-native-safe-area-context';
import {
RouteProp,
useFocusEffect,
@@ -43,6 +42,7 @@ import {
trackExploreInteracted,
type ExploreTabName,
} from './search/analytics';
+import { EXPLORE_TAB_INDEX } from '../../../constants/navigation/exploreTabIndices';
const TAB_NAMES: ExploreTabName[] = [
'Now',
@@ -53,29 +53,32 @@ const TAB_NAMES: ExploreTabName[] = [
'Sites',
];
-export const EXPLORE_TAB_INDEX = {
- NOW: 0,
- MACRO: 1,
- RWAS: 2,
- CRYPTO: 3,
- SPORTS: 4,
- SITES: 5,
-} as const;
+export { EXPLORE_TAB_INDEX } from '../../../constants/navigation/exploreTabIndices';
interface ExploreFeedRouteParams {
initialTab?: number | null;
+ source?: string;
}
const useExploreTabNavigationEffect = (opts: {
tabsListRef: React.RefObject;
+ tabNames: ExploreTabName[];
+ pendingExploreEntrySourceRef: React.MutableRefObject;
+ previousTabRef: React.MutableRefObject;
}) => {
- const { tabsListRef } = opts;
+ const {
+ tabsListRef,
+ tabNames,
+ pendingExploreEntrySourceRef,
+ previousTabRef,
+ } = opts;
const route =
useRoute>();
const { setParams } = useNavigation();
const initialTabIndex = Object.values(EXPLORE_TAB_INDEX).find(
(tab) => tab === route.params?.initialTab,
);
+ const entrySource = route.params?.source;
useFocusEffect(
useCallback(() => {
@@ -83,9 +86,41 @@ const useExploreTabNavigationEffect = (opts: {
return;
}
+ const destinationTab = tabNames[initialTabIndex];
+ if (entrySource) {
+ pendingExploreEntrySourceRef.current = entrySource;
+ }
+
+ const currentIndex = tabsListRef.current?.getCurrentIndex();
tabsListRef.current?.goToTabIndex(initialTabIndex);
- setParams?.({ initialTab: null });
- }, [initialTabIndex, setParams, tabsListRef]),
+
+ // goToTabIndex only invokes onChangeTab when the tab actually changes.
+ if (entrySource && destinationTab && currentIndex === initialTabIndex) {
+ pendingExploreEntrySourceRef.current = undefined;
+ trackExploreInteracted({
+ interaction_type: 'tab_switched',
+ tab_name: destinationTab,
+ previous_tab: previousTabRef.current,
+ source: entrySource,
+ });
+ } else if (
+ entrySource &&
+ pendingExploreEntrySourceRef.current === entrySource
+ ) {
+ // Tab switch did not run (e.g. tabs not mounted) — discard stale source.
+ pendingExploreEntrySourceRef.current = undefined;
+ }
+
+ setParams?.({ initialTab: null, source: undefined });
+ }, [
+ entrySource,
+ initialTabIndex,
+ pendingExploreEntrySourceRef,
+ previousTabRef,
+ setParams,
+ tabNames,
+ tabsListRef,
+ ]),
);
};
@@ -96,9 +131,30 @@ export const ExploreFeed: React.FC = () => {
const tabProps = useExploreRefresh();
const tabsListRef = useRef(null);
const sessionManager = TrendingFeedSessionManager.getInstance();
+ const previousTabRef = useRef('Now');
+ const pendingExploreEntrySourceRef = useRef(undefined);
+
+ const handleTabChange = useCallback(({ i }: { i: number }) => {
+ const destinationTab = TAB_NAMES[i];
+ if (!destinationTab) return;
+ const source = pendingExploreEntrySourceRef.current;
+ pendingExploreEntrySourceRef.current = undefined;
+ trackExploreInteracted({
+ interaction_type: 'tab_switched',
+ tab_name: destinationTab,
+ previous_tab: previousTabRef.current,
+ ...(source ? { source } : {}),
+ });
+ previousTabRef.current = destinationTab;
+ }, []);
// Handle tab navigation from route params
- useExploreTabNavigationEffect({ tabsListRef });
+ useExploreTabNavigationEffect({
+ tabsListRef,
+ tabNames: TAB_NAMES,
+ pendingExploreEntrySourceRef,
+ previousTabRef,
+ });
// Initialize session and enable AppState listener on mount
useEffect(() => {
@@ -146,22 +202,8 @@ export const ExploreFeed: React.FC = () => {
navigation.navigate(Routes.EXPLORE_SEARCH);
}, [navigation]);
- const previousTabRef = useRef('Now');
-
- const handleTabChange = useCallback(({ i }: { i: number }) => {
- const destinationTab = TAB_NAMES[i];
- if (!destinationTab) return;
- trackExploreInteracted({
- interaction_type: 'tab_switched',
- tab_name: destinationTab,
- previous_tab: previousTabRef.current,
- });
- previousTabRef.current = destinationTab;
- }, []);
-
return (
-
@@ -253,6 +295,6 @@ export const ExploreFeed: React.FC = () => {
)}
-
+
);
};
diff --git a/app/components/Views/TrendingView/components/CardList.tsx b/app/components/Views/TrendingView/components/CardList.tsx
index ec0c06c3ad1..722abad7a95 100644
--- a/app/components/Views/TrendingView/components/CardList.tsx
+++ b/app/components/Views/TrendingView/components/CardList.tsx
@@ -8,7 +8,6 @@ const DEFAULT_MAX_ITEMS = 3;
const styles = StyleSheet.create({
cardContainer: {
padding: 0,
- marginBottom: 28,
borderWidth: 0,
},
});
diff --git a/app/components/Views/TrendingView/components/ExploreScroll.tsx b/app/components/Views/TrendingView/components/ExploreScroll.tsx
index 51bcd00357e..d26afeb6dfc 100644
--- a/app/components/Views/TrendingView/components/ExploreScroll.tsx
+++ b/app/components/Views/TrendingView/components/ExploreScroll.tsx
@@ -11,8 +11,8 @@ interface ExploreScrollProps {
}
/**
- * Vertical ScrollView wrapper for an Explore tab body. Owns horizontal and top
- * padding and pull-to-refresh wiring.
+ * Vertical ScrollView wrapper for an Explore tab body. Owns horizontal, top, and
+ * bottom padding and pull-to-refresh wiring.
*/
const ExploreScroll: React.FC = ({
refreshing,
@@ -27,6 +27,7 @@ const ExploreScroll: React.FC = ({
{
+ it('does not render a divider before the first section', () => {
+ render(
+ First },
+ { key: 'second', content: Second },
+ ]}
+ />,
+ );
+
+ expect(screen.getByText('First')).toBeOnTheScreen();
+ expect(screen.getByText('Second')).toBeOnTheScreen();
+ expect(screen.getAllByTestId('explore-section-divider')).toHaveLength(1);
+ });
+
+ it('renders no dividers for a single section', () => {
+ render(
+ Only }]}
+ />,
+ );
+
+ expect(screen.queryByTestId('explore-section-divider')).toBeNull();
+ });
+});
diff --git a/app/components/Views/TrendingView/components/ExploreSectionList.tsx b/app/components/Views/TrendingView/components/ExploreSectionList.tsx
new file mode 100644
index 00000000000..b4564291ac6
--- /dev/null
+++ b/app/components/Views/TrendingView/components/ExploreSectionList.tsx
@@ -0,0 +1,44 @@
+import React, { Fragment } from 'react';
+import { Box, SectionDivider } from '@metamask/design-system-react-native';
+
+export interface ExploreSectionItem {
+ key: string;
+ content: React.ReactNode;
+ /** Vertical card lists omit pb-3 before the next section. */
+ isVerticalList?: boolean;
+}
+
+interface ExploreSectionListProps {
+ sections: ExploreSectionItem[];
+}
+
+/**
+ * Renders Explore tab sections with dividers between visible blocks.
+ * The tab owns section order and visibility; sections render content only.
+ */
+const ExploreSectionList: React.FC = ({
+ sections,
+}) => (
+ <>
+ {sections.map((section, index) => (
+
+ {index > 0 ? (
+
+
+
+ ) : null}
+
+ {section.content}
+
+
+ ))}
+ >
+);
+
+export default ExploreSectionList;
diff --git a/app/components/Views/TrendingView/components/HorizontalCarousel.tsx b/app/components/Views/TrendingView/components/HorizontalCarousel.tsx
index b417808a30d..1ce718469fc 100644
--- a/app/components/Views/TrendingView/components/HorizontalCarousel.tsx
+++ b/app/components/Views/TrendingView/components/HorizontalCarousel.tsx
@@ -44,7 +44,7 @@ function HorizontalCarousel({
const displayData = isLoading ? skeletonData : data;
return (
-
+
= ({
const tw = useTailwind();
return (
-
+
({
};
return (
-
+
= ({
@@ -30,6 +35,8 @@ const SectionHeader: React.FC = ({
testID,
tabName,
sectionName,
+ titleTwClassName,
+ subtitleTwClassName,
}) => {
const handleViewAll = useCallback(() => {
if (tabName && sectionName) {
@@ -39,23 +46,24 @@ const SectionHeader: React.FC = ({
}, [onViewAll, tabName, sectionName]);
return (
- <>
-
+
- {subtitle && (
+ {subtitle ? (
{subtitle}
- )}
- >
+ ) : null}
+
);
};
diff --git a/app/components/Views/TrendingView/components/TileCarousel.tsx b/app/components/Views/TrendingView/components/TileCarousel.tsx
index 14f45cfa920..749ea42068a 100644
--- a/app/components/Views/TrendingView/components/TileCarousel.tsx
+++ b/app/components/Views/TrendingView/components/TileCarousel.tsx
@@ -20,12 +20,6 @@ export interface TileCarouselProps {
max?: number;
testID?: string;
viewMoreTestID?: string;
- /**
- * When true, uses `mb-7` after the carousel so spacing matches {@link CardList}
- * section tails (~28px). Use for the first tile strip on Sites (Recents) to
- * mirror Crypto "Trending" → next section rhythm.
- */
- compactSectionTail?: boolean;
}
/**
@@ -42,13 +36,12 @@ function TileCarousel({
max = TILE_CAROUSEL_DEFAULT_MAX_TILES,
testID,
viewMoreTestID,
- compactSectionTail = false,
}: TileCarouselProps) {
const tw = useTailwind();
const displayItems = useMemo(() => data.slice(0, max), [data, max]);
return (
-
+
{isLoading ? (
diff --git a/app/components/Views/TrendingView/search/ExploreSearchResults.tsx b/app/components/Views/TrendingView/search/ExploreSearchResults.tsx
index d2f6a2c68ba..7dda22b128d 100644
--- a/app/components/Views/TrendingView/search/ExploreSearchResults.tsx
+++ b/app/components/Views/TrendingView/search/ExploreSearchResults.tsx
@@ -14,7 +14,8 @@ import {
IconColor,
BoxFlexDirection,
BoxAlignItems,
- BoxJustifyContent,
+ SectionDivider,
+ SectionHeader as MMDSSectionHeader,
} from '@metamask/design-system-react-native';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import { FlashList, FlashListRef, ListRenderItem } from '@shopify/flash-list';
@@ -131,43 +132,42 @@ const ExploreSearchResults: React.FC = ({
section.total,
);
return (
-
-
- {item.title}
-
- {viewMoreLabel !== null && (
- handleViewMore(section)}
- hitSlop={8}
- accessibilityRole="button"
- accessibilityLabel={`${viewMoreLabel} ${item.title}`}
- style={({ pressed }) => [
- pressedStyle.pressable,
- pressed && { opacity: 0.5 },
- ]}
- >
-
- {viewMoreLabel}
-
-
-
- )}
+
+ {!item.isFirstHeader ? : null}
+ handleViewMore(section)}
+ hitSlop={8}
+ accessibilityRole="button"
+ accessibilityLabel={`${viewMoreLabel} ${item.title}`}
+ style={({ pressed }) => [
+ pressedStyle.pressable,
+ pressed && { opacity: 0.5 },
+ ]}
+ >
+
+ {viewMoreLabel}
+
+
+
+ ) : undefined
+ }
+ />
);
},
@@ -182,12 +182,19 @@ const ExploreSearchResults: React.FC = ({
const flatData = useMemo(() => {
const result: FlatListItem[] = [];
const visibleSections = isBasicFunctionalityEnabled ? sections : [];
+ let headerCount = 0;
visibleSections.forEach((section) => {
const { feedId, title, items, isLoading } = section;
if (!isLoading && items.length === 0) return;
- result.push({ type: 'header', feedId, title });
+ result.push({
+ type: 'header',
+ feedId,
+ title,
+ isFirstHeader: headerCount === 0,
+ });
+ headerCount += 1;
if (isLoading) {
for (let i = 0; i < MAX_ITEMS_PER_SECTION; i++) {
diff --git a/app/components/Views/TrendingView/search/analytics.ts b/app/components/Views/TrendingView/search/analytics.ts
index 733a145fe05..bda5c92e3d4 100644
--- a/app/components/Views/TrendingView/search/analytics.ts
+++ b/app/components/Views/TrendingView/search/analytics.ts
@@ -100,6 +100,8 @@ export interface ExploreInteractedProperties {
token_symbol?: string;
chain_id?: string;
item_clicked?: string;
+ /** Entry surface when the user arrived on Explore (e.g. `homescreen_pill`). */
+ source?: string;
}
export const trackExploreInteracted = (
diff --git a/app/components/Views/TrendingView/search/searchTypes.ts b/app/components/Views/TrendingView/search/searchTypes.ts
index 7ab3f505a84..c65b522b029 100644
--- a/app/components/Views/TrendingView/search/searchTypes.ts
+++ b/app/components/Views/TrendingView/search/searchTypes.ts
@@ -4,6 +4,7 @@ export interface ListItemHeader {
type: 'header';
feedId: SearchFeedId;
title: string;
+ isFirstHeader: boolean;
}
export interface ListItemData {
diff --git a/app/components/Views/TrendingView/tabs/CryptoTab.tsx b/app/components/Views/TrendingView/tabs/CryptoTab.tsx
index 55c57b1f4bc..c9c3dcc1b6f 100644
--- a/app/components/Views/TrendingView/tabs/CryptoTab.tsx
+++ b/app/components/Views/TrendingView/tabs/CryptoTab.tsx
@@ -1,7 +1,6 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useMemo } from 'react';
import { useNavigation, NavigationProp } from '@react-navigation/native';
import { useSelector } from 'react-redux';
-import { Box } from '@metamask/design-system-react-native';
import type { ListRenderItem } from '@shopify/flash-list';
import type { TrendingAsset } from '@metamask/assets-controllers';
import type { PerpsNavigationParamList } from '../../../UI/Perps/types/navigation';
@@ -26,6 +25,9 @@ import PredictionsCarouselSection from '../feeds/predictions/PredictionsCarousel
import { navigateToExplorePredictionsList } from '../feeds/predictions/predictionsNavigation';
import CardList from '../components/CardList';
import ExploreScroll from '../components/ExploreScroll';
+import ExploreSectionList, {
+ type ExploreSectionItem,
+} from '../components/ExploreSectionList';
import SectionHeader from '../components/SectionHeader';
import TileCarousel from '../components/TileCarousel';
import type { TabProps } from '../hooks/useExploreRefresh';
@@ -50,7 +52,7 @@ const CryptoPerpsBlock: React.FC = ({
if (!perps.isLoading && perps.data.length === 0) return null;
return (
-
+ <>
onViewAll(perps.defaultSortOptionId)}
@@ -83,11 +85,15 @@ const CryptoPerpsBlock: React.FC = ({
testID="explore-crypto_perps-carousel"
viewMoreTestID="crypto_perps-view-more-card"
/>
-
+ >
);
};
-const CryptoTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
+const CryptoTabContent: React.FC = ({
+ refresh,
+ refreshing,
+ onRefresh,
+}) => {
const navigation = useNavigation();
const perpsNavigation =
useNavigation>();
@@ -98,6 +104,11 @@ const CryptoTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
variant: 'crypto',
refresh,
});
+ const cryptoPerps = usePerpsFeed({
+ variant: 'crypto',
+ refresh,
+ withTileExtras: true,
+ });
const renderTokenItem: ListRenderItem = useCallback(
({ item, index }) => (
@@ -123,56 +134,103 @@ const CryptoTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
);
const showTokens = tokens.isLoading || tokens.data.length > 0;
+ const showCryptoPerps =
+ isPerpsEnabled && (cryptoPerps.isLoading || cryptoPerps.data.length > 0);
+ const showPredictions =
+ cryptoPredictions.isLoading || cryptoPredictions.data.length > 0;
- return (
-
- {showTokens && (
-
-
- navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW)
- }
- testID="section-header-view-all-tokens"
- tabName="Crypto"
- sectionName="tokens_trending"
- />
-
- data={tokens.data}
- isLoading={tokens.isLoading}
- renderItem={renderTokenItem}
- Skeleton={TrendingTokensSkeleton}
- idPrefix="tokens"
- />
-
- )}
+ const sections = useMemo((): ExploreSectionItem[] => {
+ const items: ExploreSectionItem[] = [];
- {isPerpsEnabled && (
-
+ if (showTokens) {
+ items.push({
+ key: 'tokens',
+ isVerticalList: true,
+ content: (
+ <>
+
+ navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW)
+ }
+ testID="section-header-view-all-tokens"
+ tabName="Crypto"
+ sectionName="tokens_trending"
+ />
+
+ data={tokens.data}
+ isLoading={tokens.isLoading}
+ renderItem={renderTokenItem}
+ Skeleton={TrendingTokensSkeleton}
+ idPrefix="tokens"
+ />
+ >
+ ),
+ });
+ }
+
+ if (showCryptoPerps) {
+ items.push({
+ key: 'crypto_perps',
+ content: (
navigateToPerpsMarketList(perpsNavigation, 'crypto', sortOptionId)
}
/>
-
- )}
+ ),
+ });
+ }
- navigateToExplorePredictionsList(navigation, 'crypto')}
- />
+ if (showPredictions) {
+ items.push({
+ key: 'predictions',
+ content: (
+
+ navigateToExplorePredictionsList(navigation, 'crypto')
+ }
+ />
+ ),
+ });
+ }
+
+ return items;
+ }, [
+ showTokens,
+ showCryptoPerps,
+ showPredictions,
+ navigation,
+ tokens.data,
+ tokens.isLoading,
+ renderTokenItem,
+ refresh,
+ perpsNavigation,
+ cryptoPredictions,
+ ]);
+
+ return (
+
+
);
};
+const CryptoTab: React.FC = (props) => (
+
+
+
+);
+
export default CryptoTab;
diff --git a/app/components/Views/TrendingView/tabs/DappsTab.tsx b/app/components/Views/TrendingView/tabs/DappsTab.tsx
index 2f5d2e9e515..947a1f9e973 100644
--- a/app/components/Views/TrendingView/tabs/DappsTab.tsx
+++ b/app/components/Views/TrendingView/tabs/DappsTab.tsx
@@ -1,6 +1,5 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useMemo } from 'react';
import { useNavigation } from '@react-navigation/native';
-import { Box } from '@metamask/design-system-react-native';
import type { ListRenderItem } from '@shopify/flash-list';
import type { AppNavigationProp } from '../../../../core/NavigationService/types';
import type { SiteData } from '../../../UI/Sites/components/SiteRowItem/SiteRowItem';
@@ -16,6 +15,9 @@ import SiteSkeleton from '../../../UI/Sites/components/SiteSkeleton/SiteSkeleton
import { useSitesFeed } from '../feeds/sites/useSitesFeed';
import CardList from '../components/CardList';
import ExploreScroll from '../components/ExploreScroll';
+import ExploreSectionList, {
+ type ExploreSectionItem,
+} from '../components/ExploreSectionList';
import SectionHeader from '../components/SectionHeader';
import TileCarousel from '../components/TileCarousel';
import type { TabProps } from '../hooks/useExploreRefresh';
@@ -72,21 +74,88 @@ const DappsTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
const showFavorites = favorites.isLoading || favorites.data.length > 0;
const showSites = sites.isLoading || sites.data.length > 0;
- return (
-
- {showRecents && (
-
+ const sections = useMemo((): ExploreSectionItem[] => {
+ const items: ExploreSectionItem[] = [];
+
+ if (showRecents) {
+ items.push({
+ key: 'recents',
+ content: (
+ <>
+
+
+ data={recents.data}
+ isLoading={recents.isLoading}
+ renderItem={(site, index) => (
+
+ trackExploreInteracted({
+ interaction_type: 'section_item_tapped',
+ tab_name: 'Sites',
+ section_name: 'sites_recents',
+ asset_type: 'dapp',
+ position: index,
+ item_clicked: site.url,
+ })
+ }
+ />
+ )}
+ keyExtractor={(site) => site.url}
+ Skeleton={SiteTileSkeleton}
+ testID="explore-dapps_recents-carousel"
+ />
+ >
+ ),
+ });
+ }
+
+ if (showFavorites) {
+ items.push({
+ key: 'favorites',
+ isVerticalList: true,
+ content: (
+ <>
+
+ navigation.navigate(Routes.SITES_FULL_VIEW, {
+ mode: 'favorites',
+ })
+ }
+ testID="section-header-view-all-dapps_favorites"
+ tabName="Sites"
+ sectionName="sites_favorites"
+ />
+
+ data={favorites.data}
+ isLoading={favorites.isLoading}
+ renderItem={renderFavorite}
+ Skeleton={SiteSkeleton}
+ idPrefix="dapps_favorites"
+ />
+ >
+ ),
+ });
+ }
+
+ items.push({
+ key: 'networks',
+ content: (
+ <>
- data={recents.data}
- isLoading={recents.isLoading}
+ data={networks.data}
+ isLoading={false}
renderItem={(site, index) => (
= ({ refresh, refreshing, onRefresh }) => {
trackExploreInteracted({
interaction_type: 'section_item_tapped',
tab_name: 'Sites',
- section_name: 'sites_recents',
+ section_name: 'sites_ecosystems',
asset_type: 'dapp',
position: index,
item_clicked: site.url,
@@ -104,81 +173,61 @@ const DappsTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
)}
keyExtractor={(site) => site.url}
Skeleton={SiteTileSkeleton}
- compactSectionTail
- testID="explore-dapps_recents-carousel"
- />
-
- )}
-
- {showFavorites && (
-
-
- navigation.navigate(Routes.SITES_FULL_VIEW, { mode: 'favorites' })
- }
- testID="section-header-view-all-dapps_favorites"
- tabName="Sites"
- sectionName="sites_favorites"
+ testID="explore-dapps_networks-carousel"
/>
-
- data={favorites.data}
- isLoading={favorites.isLoading}
- renderItem={renderFavorite}
- Skeleton={SiteSkeleton}
- idPrefix="dapps_favorites"
- />
-
- )}
+ >
+ ),
+ });
-
-
-
- data={networks.data}
- isLoading={false}
- renderItem={(site, index) => (
-
- trackExploreInteracted({
- interaction_type: 'section_item_tapped',
- tab_name: 'Sites',
- section_name: 'sites_ecosystems',
- asset_type: 'dapp',
- position: index,
- item_clicked: site.url,
- })
- }
+ if (showSites) {
+ items.push({
+ key: 'sites',
+ isVerticalList: true,
+ content: (
+ <>
+ navigation.navigate(Routes.SITES_FULL_VIEW)}
+ testID="section-header-view-all-sites"
+ tabName="Sites"
+ sectionName="sites_popular"
+ />
+
+ data={sites.data}
+ isLoading={sites.isLoading}
+ renderItem={renderSite}
+ Skeleton={SiteSkeleton}
+ idPrefix="sites"
/>
- )}
- keyExtractor={(site) => site.url}
- Skeleton={SiteTileSkeleton}
- testID="explore-dapps_networks-carousel"
- />
-
+ >
+ ),
+ });
+ }
- {showSites && (
-
- navigation.navigate(Routes.SITES_FULL_VIEW)}
- testID="section-header-view-all-sites"
- tabName="Sites"
- sectionName="sites_popular"
- />
-
- data={sites.data}
- isLoading={sites.isLoading}
- renderItem={renderSite}
- Skeleton={SiteSkeleton}
- idPrefix="sites"
- />
-
- )}
+ return items;
+ }, [
+ showRecents,
+ showFavorites,
+ showSites,
+ recents.data,
+ recents.isLoading,
+ favorites.data,
+ favorites.isLoading,
+ networks.data,
+ navigation,
+ renderFavorite,
+ sites.data,
+ sites.isLoading,
+ renderSite,
+ ]);
+
+ return (
+
+
);
};
diff --git a/app/components/Views/TrendingView/tabs/MacroTab.tsx b/app/components/Views/TrendingView/tabs/MacroTab.tsx
index 74e8e957dec..366adc635eb 100644
--- a/app/components/Views/TrendingView/tabs/MacroTab.tsx
+++ b/app/components/Views/TrendingView/tabs/MacroTab.tsx
@@ -22,6 +22,9 @@ import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed';
import PredictionsCarouselSection from '../feeds/predictions/PredictionsCarouselSection';
import { navigateToExplorePredictionsList } from '../feeds/predictions/predictionsNavigation';
import ExploreScroll from '../components/ExploreScroll';
+import ExploreSectionList, {
+ type ExploreSectionItem,
+} from '../components/ExploreSectionList';
import type { PillToggleCardListTab } from '../components/PillToggleCardList';
import type { TabProps } from '../hooks/useExploreRefresh';
@@ -79,7 +82,11 @@ const MacroPerpsBlock: React.FC = ({
);
};
-const MacroTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
+const MacroTabContent: React.FC = ({
+ refresh,
+ refreshing,
+ onRefresh,
+}) => {
const appNavigation = useNavigation();
const perpsNavigation =
useNavigation>();
@@ -87,34 +94,73 @@ const MacroTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
const isPredictEnabled = useSelector(selectPredictEnabledFlag);
const politics = usePredictionsFeed({ variant: 'politics', refresh });
+ const macroPerps = usePerpsFeed({ variant: 'macro', refresh });
- return (
-
-
- navigateToExplorePredictionsList(appNavigation, 'politics')
- }
- isEnabled={isPredictEnabled}
- />
-
- {isPerpsEnabled && (
-
+ const showPredictions =
+ isPredictEnabled && (politics.isLoading || politics.data.length > 0);
+ const showPerps =
+ isPerpsEnabled && (macroPerps.isLoading || macroPerps.data.length > 0);
+
+ const sections = useMemo((): ExploreSectionItem[] => {
+ const items: ExploreSectionItem[] = [];
+
+ if (showPredictions) {
+ items.push({
+ key: 'predictions',
+ content: (
+
+ navigateToExplorePredictionsList(appNavigation, 'politics')
+ }
+ isEnabled={isPredictEnabled}
+ />
+ ),
+ });
+ }
+
+ if (showPerps) {
+ items.push({
+ key: 'perps',
+ isVerticalList: true,
+ content: (
navigateToPerpsMarketList(perpsNavigation, filter, sortOptionId)
}
/>
-
- )}
+ ),
+ });
+ }
+
+ return items;
+ }, [
+ showPredictions,
+ showPerps,
+ politics,
+ isPredictEnabled,
+ appNavigation,
+ refresh,
+ perpsNavigation,
+ ]);
+
+ return (
+
+
);
};
+const MacroTab: React.FC = (props) => (
+
+
+
+);
+
export default MacroTab;
diff --git a/app/components/Views/TrendingView/tabs/NowTab.test.tsx b/app/components/Views/TrendingView/tabs/NowTab.test.tsx
index 7891d5dfbbc..83343aa9b24 100644
--- a/app/components/Views/TrendingView/tabs/NowTab.test.tsx
+++ b/app/components/Views/TrendingView/tabs/NowTab.test.tsx
@@ -154,13 +154,72 @@ const mockWhatsHappeningImpl = jest.fn(
() => null,
);
+const mockWhatsHappeningRefresh = jest.fn();
+const mockUseWhatsHappening = jest.fn(() => ({
+ items: [] as { id: string }[],
+ isLoading: false,
+ error: null as string | null,
+ refresh: mockWhatsHappeningRefresh,
+}));
+
+jest.mock('../../../UI/WhatsHappening/hooks', () => ({
+ ...jest.requireActual('../../../UI/WhatsHappening/hooks'),
+ useWhatsHappening: () => mockUseWhatsHappening(),
+}));
+
+const mockPredictionsCarouselSection = jest.fn(
+ (props: {
+ idPrefix?: string;
+ title?: string;
+ onViewAll?: () => void;
+ isEnabled?: boolean;
+ feed?: { isLoading: boolean; data: unknown[] };
+ }) => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { createElement, Fragment } = require('react');
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { Pressable, Text } = require('react-native');
+
+ if (
+ !props.isEnabled ||
+ (!props.feed?.isLoading && props.feed?.data.length === 0)
+ ) {
+ return null;
+ }
+
+ return createElement(
+ Fragment,
+ null,
+ createElement(
+ Pressable,
+ {
+ testID: `section-header-view-all-${props.idPrefix}`,
+ onPress: props.onViewAll,
+ },
+ createElement(Text, null, props.title),
+ ),
+ );
+ },
+);
+
+jest.mock('../feeds/predictions/PredictionsCarouselSection', () => ({
+ __esModule: true,
+ default: (props: {
+ idPrefix?: string;
+ title?: string;
+ onViewAll?: () => void;
+ isEnabled?: boolean;
+ feed?: { isLoading: boolean; data: unknown[] };
+ }) => mockPredictionsCarouselSection(props),
+}));
+
jest.mock('../../../UI/WhatsHappening', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { forwardRef } = require('react');
return {
__esModule: true,
- default: forwardRef((_props: unknown, ref: unknown) =>
- mockWhatsHappeningImpl(ref),
+ default: forwardRef((props: unknown, ref: unknown) =>
+ mockWhatsHappeningImpl({ ...(props as object), ref }),
),
};
});
@@ -256,6 +315,12 @@ beforeEach(() => {
isLoading: false,
isEnabled: false,
});
+ mockUseWhatsHappening.mockReturnValue({
+ items: [],
+ isLoading: false,
+ error: null,
+ refresh: mockWhatsHappeningRefresh,
+ });
mockWhatsHappeningImpl.mockReturnValue(null);
});
@@ -283,6 +348,12 @@ describe('NowTab — WhatsHappeningSection integration', () => {
if (selector === selectWhatsHappeningEnabled) return true;
return mockSelectorBase(selector);
});
+ mockUseWhatsHappening.mockReturnValue({
+ items: [{ id: 'trend-0' }],
+ isLoading: false,
+ error: null,
+ refresh: mockWhatsHappeningRefresh,
+ });
(mockWhatsHappeningImpl as jest.Mock).mockReturnValue(
React.createElement('View', {
testID: 'whats-happening-carousel',
@@ -307,19 +378,26 @@ describe('NowTab — WhatsHappeningSection integration', () => {
expect(screen.queryByTestId('whats-happening-carousel')).toBeNull();
});
- it('passes a ref to WhatsHappeningSection so pull-to-refresh can trigger it', () => {
+ it('calls whatsHappening.refresh when pull-to-refresh is triggered', () => {
mockUseSelector.mockImplementation((selector) => {
if (selector === selectWhatsHappeningEnabled) return true;
return mockSelectorBase(selector);
});
- renderNowTab();
+ const { rerender } = renderNowTab();
+
+ expect(mockWhatsHappeningRefresh).not.toHaveBeenCalled();
+
+ rerender(
+
+
+ ,
+ );
- // The mock's first argument is the forwarded ref (we dropped props in the mock).
- // It should be a React ref object so the useEffect bridge can call .refresh().
- expect(mockWhatsHappeningImpl).toHaveBeenCalled();
- const [forwardedRef] = mockWhatsHappeningImpl.mock.calls[0];
- expect(forwardedRef).not.toBeNull();
+ expect(mockWhatsHappeningRefresh).toHaveBeenCalledTimes(1);
});
it('renders Predict before Whats Happening for the control variant', () => {
@@ -332,6 +410,12 @@ describe('NowTab — WhatsHappeningSection integration', () => {
data: [{ id: 'market-1' }],
isLoading: false,
});
+ mockUseWhatsHappening.mockReturnValue({
+ items: [{ id: 'trend-0' }],
+ isLoading: false,
+ error: null,
+ refresh: mockWhatsHappeningRefresh,
+ });
mockWhatsHappeningImpl.mockReturnValue(
React.createElement('View', {
testID: whatsHappeningSectionTestId,
@@ -357,6 +441,12 @@ describe('NowTab — WhatsHappeningSection integration', () => {
data: [{ id: 'market-1' }],
isLoading: false,
});
+ mockUseWhatsHappening.mockReturnValue({
+ items: [{ id: 'trend-0' }],
+ isLoading: false,
+ error: null,
+ refresh: mockWhatsHappeningRefresh,
+ });
mockWhatsHappeningImpl.mockReturnValue(
React.createElement('View', {
testID: whatsHappeningSectionTestId,
@@ -370,6 +460,57 @@ describe('NowTab — WhatsHappeningSection integration', () => {
predictSectionTestId,
]);
});
+
+ it('does not add a divider before Predict when Whats Happening feed is empty', () => {
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === selectWhatsHappeningEnabled) return true;
+ if (selector === selectPredictEnabledFlag) return true;
+ return mockSelectorBase(selector);
+ });
+ mockTreatmentAbTest();
+ mockUsePredictionsFeed.mockReturnValue({
+ data: [{ id: 'market-1' }],
+ isLoading: false,
+ });
+ mockUseWhatsHappening.mockReturnValue({
+ items: [],
+ isLoading: false,
+ error: null,
+ refresh: mockWhatsHappeningRefresh,
+ });
+
+ renderNowTab();
+
+ expect(screen.queryByTestId('explore-section-divider')).toBeNull();
+ });
+
+ it('adds a divider before Predict when Whats Happening has content', () => {
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === selectWhatsHappeningEnabled) return true;
+ if (selector === selectPredictEnabledFlag) return true;
+ return mockSelectorBase(selector);
+ });
+ mockTreatmentAbTest();
+ mockUsePredictionsFeed.mockReturnValue({
+ data: [{ id: 'market-1' }],
+ isLoading: false,
+ });
+ mockUseWhatsHappening.mockReturnValue({
+ items: [{ id: 'trend-0' }],
+ isLoading: false,
+ error: null,
+ refresh: mockWhatsHappeningRefresh,
+ });
+ mockWhatsHappeningImpl.mockReturnValue(
+ React.createElement('View', {
+ testID: whatsHappeningSectionTestId,
+ }),
+ );
+
+ renderNowTab();
+
+ expect(screen.getByTestId('explore-section-divider')).toBeOnTheScreen();
+ });
});
describe('NowTab — Perps Movers "View All" navigation', () => {
diff --git a/app/components/Views/TrendingView/tabs/NowTab.tsx b/app/components/Views/TrendingView/tabs/NowTab.tsx
index cba004d7279..5846fb9b7ad 100644
--- a/app/components/Views/TrendingView/tabs/NowTab.tsx
+++ b/app/components/Views/TrendingView/tabs/NowTab.tsx
@@ -1,13 +1,6 @@
-import React, {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from 'react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigation, NavigationProp } from '@react-navigation/native';
import { useSelector } from 'react-redux';
-import { Box } from '@metamask/design-system-react-native';
import type { ListRenderItem } from '@shopify/flash-list';
import type { TrendingAsset } from '@metamask/assets-controllers';
import type { AppNavigationProp } from '../../../../core/NavigationService/types';
@@ -50,15 +43,23 @@ import {
import { getCaipChainIdFromAssetId } from '../../../UI/Trending/components/TrendingTokenRowItem/utils';
import CardList from '../components/CardList';
import ExploreScroll from '../components/ExploreScroll';
+import ExploreSectionList, {
+ type ExploreSectionItem,
+} from '../components/ExploreSectionList';
+import SectionHeader from '../components/SectionHeader';
import PillScrollList from '../components/PillScrollList';
import PillRow, { type PillOption } from '../components/PillRow';
-import SectionHeader from '../components/SectionHeader';
import type { TabProps } from '../hooks/useExploreRefresh';
import { trackExploreInteracted } from '../search/analytics';
import WhatsHappeningSection from '../../../UI/WhatsHappening';
-import { WhatsHappeningSource } from '../../../UI/WhatsHappening/constants';
-// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog
-import type { SectionRefreshHandle } from '../../Homepage/types';
+import {
+ MAX_ITEMS_DISPLAYED,
+ WhatsHappeningSource,
+} from '../../../UI/WhatsHappening/constants';
+import {
+ isWhatsHappeningSectionVisible,
+ useWhatsHappening,
+} from '../../../UI/WhatsHappening/hooks';
import { selectWhatsHappeningEnabled } from '../../../../selectors/featureFlagController/whatsHappening';
import { useABTest } from '../../../../hooks';
import {
@@ -146,7 +147,7 @@ const PerpsBlock: React.FC = ({ refresh, navigation }) => {
if (!perps.isLoading && perps.data.length === 0) return null;
return (
-
+ <>
@@ -192,7 +193,7 @@ const PerpsBlock: React.FC = ({ refresh, navigation }) => {
Skeleton={CryptoMoversSkeleton}
listTestId="explore-perps-pills-list"
/>
-
+ >
);
};
@@ -200,7 +201,11 @@ const CRYPTO_MOVERS_TIME_OPTION = TimeOption.OneHour;
const CRYPTO_MOVERS_ROW_COUNT = 3;
const CRYPTO_MOVERS_MAX_PILLS = 18;
-const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
+const NowTabContent: React.FC = ({
+ refresh,
+ refreshing,
+ onRefresh,
+}) => {
const navigation = useNavigation();
const perpsNavigation =
useNavigation>();
@@ -212,12 +217,13 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
WHATS_HAPPENING_EXPLORE_VARIANTS,
);
- const whatsHappeningRef = useRef(null);
+ const whatsHappening = useWhatsHappening(MAX_ITEMS_DISPLAYED);
+ const refreshWhatsHappening = whatsHappening.refresh;
useEffect(() => {
if (refresh.trigger === 0) return;
- whatsHappeningRef.current?.refresh();
- }, [refresh.trigger]);
+ void refreshWhatsHappening();
+ }, [refresh.trigger, refreshWhatsHappening]);
const worldCupPredictions = useWorldCupPredictionsFeed({
enabled: isPredictEnabled,
@@ -230,6 +236,11 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
const displayedPredictions = worldCupPredictions.isEnabled
? worldCupPredictions
: predictions;
+ const perpsFeed = usePerpsFeed({
+ variant: 'all',
+ refresh,
+ withTileExtras: false,
+ });
const cryptoMovers = useTokensFeed({
refresh,
hideRiskyTokens: true,
@@ -263,45 +274,168 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
[],
);
+ const showWhatsHappening =
+ isWhatsHappeningEnabled && isWhatsHappeningSectionVisible(whatsHappening);
+ const showPredictions =
+ isPredictEnabled &&
+ (displayedPredictions.isLoading || displayedPredictions.data.length > 0);
const showCryptoMovers =
cryptoMovers.isLoading || cryptoMovers.data.length > 0;
+ const showPerps =
+ isPerpsEnabled && (perpsFeed.isLoading || perpsFeed.data.length > 0);
const showStocks = stocks.isLoading || stocks.data.length > 0;
- const whatsHappeningSection = isWhatsHappeningEnabled ? (
-
-
-
- ) : null;
+ const sections = useMemo((): ExploreSectionItem[] => {
+ const items: ExploreSectionItem[] = [];
+ const whFirst = whatsHappeningExploreVariant.whatsHappeningBeforePredict;
- const predictionsSection = (
-
- worldCupPredictions.isEnabled
- ? navigateToExploreWorldCupPredictions(navigation)
- : navigateToExplorePredictionsList(navigation, 'trending')
- }
- isEnabled={isPredictEnabled}
- />
- );
+ const whatsHappeningSection: ExploreSectionItem = {
+ key: 'wh',
+ content: (
+
+ ),
+ };
+
+ const predictionsSection: ExploreSectionItem = {
+ key: 'predict',
+ content: (
+
+ worldCupPredictions.isEnabled
+ ? navigateToExploreWorldCupPredictions(navigation)
+ : navigateToExplorePredictionsList(navigation, 'trending')
+ }
+ isEnabled={isPredictEnabled}
+ />
+ ),
+ };
- const orderedIntroSections =
- whatsHappeningExploreVariant.whatsHappeningBeforePredict
- ? [whatsHappeningSection, predictionsSection]
- : [predictionsSection, whatsHappeningSection];
+ if (whFirst) {
+ if (showWhatsHappening) items.push(whatsHappeningSection);
+ if (showPredictions) items.push(predictionsSection);
+ } else {
+ if (showPredictions) items.push(predictionsSection);
+ if (showWhatsHappening) items.push(whatsHappeningSection);
+ }
+
+ if (showCryptoMovers) {
+ items.push({
+ key: 'crypto_movers',
+ content: (
+ <>
+
+ navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW, {
+ initialTimeOption: CRYPTO_MOVERS_TIME_OPTION,
+ })
+ }
+ testID="section-header-view-all-crypto_movers"
+ tabName="Now"
+ sectionName="tokens_movers"
+ />
+
+ data={cryptoMovers.data}
+ isLoading={cryptoMovers.isLoading}
+ renderItem={(token, index) => (
+
+ trackExploreInteracted({
+ interaction_type: 'section_item_tapped',
+ tab_name: 'Now',
+ section_name: 'tokens_movers',
+ asset_type: 'token',
+ position: index,
+ token_symbol: token.symbol,
+ chain_id: getCaipChainIdFromAssetId(token.assetId),
+ item_clicked: token.assetId,
+ })
+ }
+ />
+ )}
+ keyExtractor={(token) => token.assetId ?? ''}
+ Skeleton={CryptoMoversSkeleton}
+ listTestId="explore-crypto_movers-pills-list"
+ rowCount={CRYPTO_MOVERS_ROW_COUNT}
+ maxPills={CRYPTO_MOVERS_MAX_PILLS}
+ />
+ >
+ ),
+ });
+ }
+
+ if (showPerps) {
+ items.push({
+ key: 'perps',
+ content: ,
+ });
+ }
+
+ if (showStocks) {
+ items.push({
+ key: 'stocks',
+ isVerticalList: true,
+ content: (
+ <>
+
+ navigation.navigate(Routes.WALLET.RWA_TOKENS_FULL_VIEW)
+ }
+ testID="section-header-view-all-stocks"
+ tabName="Now"
+ sectionName="stocks"
+ />
+
+ data={stocks.data}
+ isLoading={stocks.isLoading}
+ renderItem={renderTokenItem}
+ Skeleton={TrendingTokensSkeleton}
+ idPrefix="stocks"
+ />
+ >
+ ),
+ });
+ }
+
+ return items;
+ }, [
+ whatsHappeningExploreVariant.whatsHappeningBeforePredict,
+ showWhatsHappening,
+ showPredictions,
+ showCryptoMovers,
+ showPerps,
+ showStocks,
+ whatsHappening,
+ displayedPredictions,
+ worldCupPredictions.isEnabled,
+ isPredictEnabled,
+ navigation,
+ cryptoMovers.data,
+ cryptoMovers.isLoading,
+ refresh,
+ perpsNavigation,
+ stocks.data,
+ stocks.isLoading,
+ renderTokenItem,
+ ]);
return (
= ({ refresh, refreshing, onRefresh }) => {
onRefresh={onRefresh}
testID={TrendingViewSelectorsIDs.EXPLORE_NOW_SCROLL_VIEW}
>
- {orderedIntroSections}
-
- {showCryptoMovers && (
-
-
- navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW, {
- initialTimeOption: CRYPTO_MOVERS_TIME_OPTION,
- })
- }
- testID="section-header-view-all-crypto_movers"
- tabName="Now"
- sectionName="tokens_movers"
- />
-
- data={cryptoMovers.data}
- isLoading={cryptoMovers.isLoading}
- renderItem={(token, index) => (
-
- trackExploreInteracted({
- interaction_type: 'section_item_tapped',
- tab_name: 'Now',
- section_name: 'tokens_movers',
- asset_type: 'token',
- position: index,
- token_symbol: token.symbol,
- chain_id: getCaipChainIdFromAssetId(token.assetId),
- item_clicked: token.assetId,
- })
- }
- />
- )}
- keyExtractor={(token) => token.assetId ?? ''}
- Skeleton={CryptoMoversSkeleton}
- listTestId="explore-crypto_movers-pills-list"
- rowCount={CRYPTO_MOVERS_ROW_COUNT}
- maxPills={CRYPTO_MOVERS_MAX_PILLS}
- />
-
- )}
-
- {isPerpsEnabled && (
-
-
-
- )}
-
- {showStocks && (
-
-
- navigation.navigate(Routes.WALLET.RWA_TOKENS_FULL_VIEW)
- }
- testID="section-header-view-all-stocks"
- tabName="Now"
- sectionName="stocks"
- />
-
- data={stocks.data}
- isLoading={stocks.isLoading}
- renderItem={renderTokenItem}
- Skeleton={TrendingTokensSkeleton}
- idPrefix="stocks"
- />
-
- )}
+
);
};
+const NowTab: React.FC = (props) => (
+
+
+
+);
+
export default NowTab;
diff --git a/app/components/Views/TrendingView/tabs/RwasTab.tsx b/app/components/Views/TrendingView/tabs/RwasTab.tsx
index d5dde08ffc3..c8140a21b4d 100644
--- a/app/components/Views/TrendingView/tabs/RwasTab.tsx
+++ b/app/components/Views/TrendingView/tabs/RwasTab.tsx
@@ -1,7 +1,6 @@
import React, { useCallback, useMemo } from 'react';
import { useNavigation, NavigationProp } from '@react-navigation/native';
import { useSelector } from 'react-redux';
-import { Box } from '@metamask/design-system-react-native';
import type { ListRenderItem } from '@shopify/flash-list';
import type { TrendingAsset } from '@metamask/assets-controllers';
import {
@@ -35,8 +34,11 @@ import PredictionsCarouselSection from '../feeds/predictions/PredictionsCarousel
import { navigateToExplorePredictionsList } from '../feeds/predictions/predictionsNavigation';
import CardList from '../components/CardList';
import ExploreScroll from '../components/ExploreScroll';
-import type { PillToggleCardListTab } from '../components/PillToggleCardList';
+import ExploreSectionList, {
+ type ExploreSectionItem,
+} from '../components/ExploreSectionList';
import SectionHeader from '../components/SectionHeader';
+import type { PillToggleCardListTab } from '../components/PillToggleCardList';
import type { TabProps } from '../hooks/useExploreRefresh';
import { trackExploreInteracted } from '../search/analytics';
import { TrendingViewSelectorsIDs } from '../TrendingView.testIds';
@@ -95,7 +97,11 @@ const RwaPerpsBlock: React.FC = ({
);
};
-const RwasTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
+const RwasTabContent: React.FC = ({
+ refresh,
+ refreshing,
+ onRefresh,
+}) => {
const appNavigation = useNavigation();
const perpsNavigation =
useNavigation>();
@@ -107,6 +113,7 @@ const RwasTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
refresh,
pageSize: STOCKS_FEED_PREVIEW_PAGE_SIZE,
});
+ const rwaPerps = usePerpsFeed({ variant: 'rwa', refresh });
const renderStockItem: ListRenderItem = useCallback(
({ item, index }) => (
@@ -132,59 +139,106 @@ const RwasTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
);
const showStocks = stocks.isLoading || stocks.data.length > 0;
+ const showPredictions =
+ isPredictEnabled && (politics.isLoading || politics.data.length > 0);
+ const showPerps =
+ isPerpsEnabled && (rwaPerps.isLoading || rwaPerps.data.length > 0);
- return (
-
-
- navigateToExplorePredictionsList(appNavigation, 'politics')
- }
- isEnabled={isPredictEnabled}
- />
+ const sections = useMemo((): ExploreSectionItem[] => {
+ const items: ExploreSectionItem[] = [];
- {showStocks && (
-
-
- appNavigation.navigate(Routes.WALLET.RWA_TOKENS_FULL_VIEW)
+ navigateToExplorePredictionsList(appNavigation, 'politics')
}
- testID="section-header-view-all-stocks"
- tabName="RWAs"
- sectionName="stocks"
- />
-
- data={stocks.data}
- isLoading={stocks.isLoading}
- renderItem={renderStockItem}
- Skeleton={TrendingTokensSkeleton}
- idPrefix="stocks"
+ isEnabled={isPredictEnabled}
/>
-
- )}
+ ),
+ });
+ }
- {isPerpsEnabled && (
-
+ if (showStocks) {
+ items.push({
+ key: 'stocks',
+ isVerticalList: true,
+ content: (
+ <>
+
+ appNavigation.navigate(Routes.WALLET.RWA_TOKENS_FULL_VIEW)
+ }
+ testID="section-header-view-all-stocks"
+ tabName="RWAs"
+ sectionName="stocks"
+ />
+
+ data={stocks.data}
+ isLoading={stocks.isLoading}
+ renderItem={renderStockItem}
+ Skeleton={TrendingTokensSkeleton}
+ idPrefix="stocks"
+ />
+ >
+ ),
+ });
+ }
+
+ if (showPerps) {
+ items.push({
+ key: 'perps',
+ isVerticalList: true,
+ content: (
navigateToPerpsMarketList(perpsNavigation, filter, sortOptionId)
}
/>
-
- )}
+ ),
+ });
+ }
+
+ return items;
+ }, [
+ showPredictions,
+ showStocks,
+ showPerps,
+ politics,
+ isPredictEnabled,
+ appNavigation,
+ stocks.data,
+ stocks.isLoading,
+ renderStockItem,
+ refresh,
+ perpsNavigation,
+ ]);
+
+ return (
+
+
);
};
+const RwasTab: React.FC = (props) => (
+
+
+
+);
+
export default RwasTab;
diff --git a/app/components/Views/TrendingView/tabs/SportsTab.tsx b/app/components/Views/TrendingView/tabs/SportsTab.tsx
index 41fb0dcaa16..f7367a83903 100644
--- a/app/components/Views/TrendingView/tabs/SportsTab.tsx
+++ b/app/components/Views/TrendingView/tabs/SportsTab.tsx
@@ -12,6 +12,7 @@ import {
BoxFlexDirection,
BoxJustifyContent,
FontWeight,
+ SectionDivider,
TabEmptyState,
Text,
TextColor,
@@ -36,8 +37,8 @@ import {
navigateToExplorePredictionsList,
navigateToExploreWorldCupPredictions,
} from '../feeds/predictions/predictionsNavigation';
-import PillRow from '../components/PillRow';
import SectionHeader from '../components/SectionHeader';
+import PillRow from '../components/PillRow';
import type { TabProps } from '../hooks/useExploreRefresh';
import {
trackExploreInteracted,
@@ -72,42 +73,43 @@ const SportsListHeader: React.FC = ({
showAllSportsEmpty,
navigation,
}) => (
-
-
- showWorldCupPredictions
- ? navigateToExploreWorldCupPredictions(navigation)
- : navigateToExplorePredictionsList(navigation, 'sports')
- }
- isEnabled={showSportsPredictions}
- />
+ <>
+
+
+ showWorldCupPredictions
+ ? navigateToExploreWorldCupPredictions(navigation)
+ : navigateToExplorePredictionsList(navigation, 'sports')
+ }
+ isEnabled={showSportsPredictions}
+ />
+
+ {showSportsPredictions ? : null}
-
-
-
+
{showAllSportsSkeleton && (
@@ -130,7 +132,7 @@ const SportsListHeader: React.FC = ({
)}
-
+ >
);
const SportsTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
@@ -240,7 +242,6 @@ const SportsTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
);
- // When loading or empty, data is empty — header renders those states.
const listData =
showAllSportsSkeleton || showAllSportsEmpty ? [] : active.marketData;
@@ -254,7 +255,7 @@ const SportsTab: React.FC = ({ refresh, refreshing, onRefresh }) => {
ListFooterComponent={listFooter}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
- contentContainerStyle={tw.style('px-4')}
+ contentContainerStyle={tw.style('px-4 pt-3 pb-4')}
testID={`all-sports-list-${activeKey}`}
refreshControl={
{
it('renders wallet home with minimal state and shows key UI elements', () => {
@@ -135,4 +139,103 @@ describeForPlatforms('Wallet', () => {
expect(accountPicker).toBeOnTheScreen();
fireEvent.press(accountPicker);
});
+
+ const walletStateOverrides = {
+ settings: {
+ basicFunctionalityEnabled: true,
+ },
+ engine: {
+ backgroundState: {
+ MultichainNetworkController: {
+ isEvmSelected: true,
+ },
+ RewardsController: {
+ activeAccount: null,
+ },
+ PreferencesController: {
+ tokenSortConfig: {
+ key: 'tokenFiatAmount',
+ order: 'dsc',
+ sortCallback: 'stringNumeric',
+ },
+ },
+ },
+ },
+ };
+
+ const renderWalletWithState = (
+ configure: (
+ builder: ReturnType,
+ ) => ReturnType,
+ ) => {
+ const state = configure(initialStateWallet()).build();
+
+ return renderComponentViewScreen(
+ Wallet as unknown as React.ComponentType,
+ { name: Routes.WALLET_VIEW },
+ { state },
+ );
+ };
+
+ describe('card header button', () => {
+ it('hides the card button when the Money account is visible', () => {
+ const { queryByTestId } = renderWalletWithState((builder) =>
+ builder
+ .withRemoteFeatureFlags({
+ moneyEnableMoneyAccount: {
+ enabled: true,
+ minimumVersion: '0.0.0',
+ },
+ moneyAccountGeoBlockedCountries: { blockedRegions: ['GB'] },
+ })
+ .withOverrides({
+ ...walletStateOverrides,
+ engine: {
+ backgroundState: {
+ ...walletStateOverrides.engine.backgroundState,
+ GeolocationController: {
+ location: 'US',
+ },
+ },
+ },
+ } as unknown as Record),
+ );
+
+ expect(
+ queryByTestId(WalletViewSelectorsIDs.CARD_BUTTON),
+ ).not.toBeOnTheScreen();
+ });
+
+ it('shows the card button when Money account is disabled', () => {
+ const { getByTestId } = renderWalletView(defaultWalletOverrides);
+
+ expect(getByTestId(WalletViewSelectorsIDs.CARD_BUTTON)).toBeOnTheScreen();
+ });
+
+ it('shows the card button when Money account is enabled but geo-ineligible', () => {
+ const { getByTestId } = renderWalletWithState((builder) =>
+ builder
+ .withRemoteFeatureFlags({
+ moneyEnableMoneyAccount: {
+ enabled: true,
+ minimumVersion: '0.0.0',
+ },
+ moneyAccountGeoBlockedCountries: { blockedRegions: ['GB'] },
+ })
+ .withOverrides({
+ ...walletStateOverrides,
+ engine: {
+ backgroundState: {
+ ...walletStateOverrides.engine.backgroundState,
+ GeolocationController: {
+ location: 'GB',
+ },
+ },
+ },
+ } as unknown as Record),
+ );
+
+ expect(getByTestId(WalletViewSelectorsIDs.CARD_BUTTON)).toBeOnTheScreen();
+ });
+ });
});
diff --git a/app/components/Views/Wallet/index.test.tsx b/app/components/Views/Wallet/index.test.tsx
index 569940b275a..56f5a457185 100644
--- a/app/components/Views/Wallet/index.test.tsx
+++ b/app/components/Views/Wallet/index.test.tsx
@@ -112,16 +112,49 @@ jest.mock('../../UI/NetworkConnectionBanner', () => () => null);
// Control discovery tabs AB test variant per test (default control so existing tests are unaffected)
let mockDiscoveryTabsVariantName = 'control';
+let mockDiscoveryPillsVariantName = 'control';
jest.mock('../../../hooks', () => ({
...jest.requireActual('../../../hooks'),
- useABTest: jest.fn(() => ({
- variantName: mockDiscoveryTabsVariantName,
- variant: {
- discoveryTabsEnabled: mockDiscoveryTabsVariantName === 'treatment',
- },
- })),
+ useABTest: jest.fn((flagKey: string) => {
+ if (flagKey === 'homeTMCU926AbtestDiscoveryPills') {
+ const isGrayIcons = mockDiscoveryPillsVariantName === 'grayIcons';
+ const isColorIcons = mockDiscoveryPillsVariantName === 'colorIcons';
+ const showPills = isGrayIcons || isColorIcons;
+
+ return {
+ variantName: mockDiscoveryPillsVariantName,
+ variant: {
+ showPills,
+ iconStyle: isGrayIcons ? 'gray' : isColorIcons ? 'color' : null,
+ },
+ isActive: showPills,
+ };
+ }
+
+ return {
+ variantName: mockDiscoveryTabsVariantName,
+ variant: {
+ discoveryTabsEnabled: mockDiscoveryTabsVariantName === 'treatment',
+ },
+ isActive: mockDiscoveryTabsVariantName === 'treatment',
+ };
+ }),
}));
+const mockHomepageDiscoveryPills = jest.fn();
+jest.mock('../Homepage/components/HomepageDiscoveryPills', () => {
+ const React = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return {
+ HomepageDiscoveryPills: (props: { iconStyle: string }) => {
+ mockHomepageDiscoveryPills(props);
+ return React.createElement(View, {
+ testID: 'homepage-discovery-pills-mock',
+ });
+ },
+ };
+});
+
// Track HomepageDiscoveryTabs renders
const mockHomepageDiscoveryTabs = jest.fn();
const mockHomepageDiscoveryTabsRefresh = jest.fn(async () => undefined);
@@ -1695,6 +1728,90 @@ describe('HomepageDiscoveryTabs AB test', () => {
});
});
+describe('HomepageDiscoveryPills AB test', () => {
+ let mockNavigation: NavigationProp;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockDiscoveryTabsVariantName = 'control';
+ mockDiscoveryPillsVariantName = 'control';
+ mockHomepageDiscoveryPills.mockClear();
+
+ mockNavigation = {
+ navigate: mockNavigate,
+ setOptions: mockSetOptions,
+ addListener: jest.fn(() => jest.fn()),
+ isFocused: jest.fn(() => false),
+ dangerouslyGetParent: jest.fn(() => ({
+ dangerouslyGetState: jest.fn(() => ({ type: 'stack' })),
+ addListener: jest.fn(() => jest.fn()),
+ dangerouslyGetParent: jest.fn(() => ({
+ dangerouslyGetState: jest.fn(() => ({ type: 'tab' })),
+ addListener: jest.fn(() => jest.fn()),
+ dangerouslyGetParent: jest.fn(() => undefined),
+ })),
+ })),
+ } as unknown as NavigationProp;
+
+ jest
+ .mocked(useSelector)
+ .mockImplementation((callback: (state: unknown) => unknown) =>
+ callback(mockInitialState),
+ );
+ });
+
+ afterEach(() => {
+ mockDiscoveryTabsVariantName = 'control';
+ mockDiscoveryPillsVariantName = 'control';
+ jest.clearAllMocks();
+ });
+
+ it('renders discovery pills on legacy homepage when grayIcons variant is active', () => {
+ mockDiscoveryPillsVariantName = 'grayIcons';
+
+ renderWithProvider(
+ ,
+ { state: mockInitialState },
+ );
+
+ expect(mockHomepageDiscoveryPills).toHaveBeenCalledWith(
+ expect.objectContaining({ iconStyle: 'gray' }),
+ );
+ });
+
+ it('does not render discovery pills when control variant is active', () => {
+ mockDiscoveryPillsVariantName = 'control';
+
+ renderWithProvider(
+ ,
+ { state: mockInitialState },
+ );
+
+ expect(mockHomepageDiscoveryPills).not.toHaveBeenCalled();
+ });
+
+ it('does not render discovery pills when discovery tabs treatment is active', () => {
+ mockDiscoveryPillsVariantName = 'grayIcons';
+ mockDiscoveryTabsVariantName = 'treatment';
+
+ renderWithProvider(
+ ,
+ { state: mockInitialState },
+ );
+
+ expect(mockHomepageDiscoveryPills).not.toHaveBeenCalled();
+ });
+});
+
describe('useHomeDeepLinkEffects', () => {
beforeEach(() => {
jest.clearAllMocks();
diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx
index 0a0a10b97ea..ad2b0086196 100644
--- a/app/components/Views/Wallet/index.tsx
+++ b/app/components/Views/Wallet/index.tsx
@@ -133,11 +133,16 @@ import Homepage from '../Homepage';
// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog
import HomepageDiscoveryTabs from '../Homepage/components/HomepageDiscoveryTabs';
import {
+ HOMEPAGE_DISCOVERY_PILLS_AB_KEY,
+ HOMEPAGE_DISCOVERY_PILLS_AB_TEST_EXPOSURE_OPTIONS,
+ HOMEPAGE_DISCOVERY_PILLS_VARIANTS,
HUB_PAGE_DISCOVERY_TABS_AB_KEY,
HUB_PAGE_DISCOVERY_TABS_VARIANTS,
HubPageDiscoveryTabsVariant,
// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog
} from '../Homepage/abTestConfig';
+// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog
+import { HomepageDiscoveryPills } from '../Homepage/components/HomepageDiscoveryPills';
import { useABTest } from '../../../hooks';
// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog
import { HomepageScrollContext } from '../Homepage/context/HomepageScrollContext';
@@ -783,9 +788,22 @@ const Wallet = ({
HUB_PAGE_DISCOVERY_TABS_VARIANTS,
);
+ const { variant: discoveryPillsVariant } = useABTest(
+ HOMEPAGE_DISCOVERY_PILLS_AB_KEY,
+ HOMEPAGE_DISCOVERY_PILLS_VARIANTS,
+ HOMEPAGE_DISCOVERY_PILLS_AB_TEST_EXPOSURE_OPTIONS,
+ );
+
const isDiscoveryTabsTreatment =
discoveryTabsVariantName === HubPageDiscoveryTabsVariant.Treatment;
+ const discoveryPillsIconStyle = discoveryPillsVariant.iconStyle;
+ const showDiscoveryPills =
+ discoveryPillsVariant.showPills &&
+ !isDiscoveryTabsTreatment &&
+ showWalletHomeMainActions &&
+ discoveryPillsIconStyle !== null;
+
const isPerpsEnabled = isPerpsFlagEnabled;
const homepageDiscoveryTabsRef = useRef(null);
@@ -1039,6 +1057,10 @@ const Wallet = ({
/>
) : null;
+ const homepageDiscoveryPills = showDiscoveryPills ? (
+
+ ) : null;
+
const portfolioHeaderBase = (
{bannerContent}
@@ -1047,6 +1069,7 @@ const Wallet = ({
{/* Hide growth banners when money account is enabled but user is geo-blocked */}
{(!isMoneyAccountEnabled || isMoneyAccountGeoEligible) &&
homeGrowthBannerContent}
+ {homepageDiscoveryPills}
{isMoneyAccountVisible && }
);
@@ -1061,6 +1084,7 @@ const Wallet = ({
{/* Hide growth banners when money account is enabled but user is geo-blocked */}
{(!isMoneyAccountEnabled || isMoneyAccountGeoEligible) &&
homeGrowthBannerContent}
+ {homepageDiscoveryPills}
{isMoneyAccountVisible && }
);
@@ -1131,10 +1155,12 @@ const Wallet = ({
}
hitSlop={touchAreaSlop}
/>
-
+ {!isMoneyAccountVisible && (
+
+ )}
{isNotificationsFeatureEnabled() ? (
({
+ onConfirm: mockOnLedgerConfirm,
+}));
jest.mock('./useLedgerConfirm', () => ({
- useLedgerConfirm: () => ({ onConfirm: mockOnLedgerConfirm }),
+ useLedgerConfirm: (options: unknown) => mockUseLedgerConfirm(options),
}));
const mockOnQrConfirm = jest.fn().mockResolvedValue(undefined);
+const mockUseQrConfirm = jest.fn((_options?: unknown) => ({
+ onConfirm: mockOnQrConfirm,
+}));
jest.mock('../../../../core/HardwareWallet/hooks/useQrConfirm', () => ({
- useQrConfirm: () => ({ onConfirm: mockOnQrConfirm }),
+ useQrConfirm: (options: unknown) => mockUseQrConfirm(options),
}));
const mockIsConfirmationFromQrAccount = jest.requireMock(
@@ -95,6 +101,40 @@ describe('useConfirmAction', () => {
});
});
+ it('passes the confirmation from address to hardware wallet confirmation hooks', () => {
+ renderHookWithProvider(() => useConfirmActions(), {
+ state: personalSignatureConfirmationState,
+ });
+
+ expect(mockUseLedgerConfirm).toHaveBeenCalledWith(
+ expect.objectContaining({
+ fromAddress: '0x935e73edb9ff52e23bac7f7e043a1ecd06d05477',
+ }),
+ );
+ expect(mockUseQrConfirm).toHaveBeenCalledWith(
+ expect.objectContaining({
+ fromAddress: '0x935e73edb9ff52e23bac7f7e043a1ecd06d05477',
+ }),
+ );
+ });
+
+ it('passes the transaction metadata from address when approval request has no from address', () => {
+ renderHookWithProvider(() => useConfirmActions(), {
+ state: stakingDepositConfirmationState,
+ });
+
+ expect(mockUseLedgerConfirm).toHaveBeenCalledWith(
+ expect.objectContaining({
+ fromAddress: '0xdc47789de4ceff0e8fe9d15d728af7f17550c164',
+ }),
+ );
+ expect(mockUseQrConfirm).toHaveBeenCalledWith(
+ expect.objectContaining({
+ fromAddress: '0xdc47789de4ceff0e8fe9d15d728af7f17550c164',
+ }),
+ );
+ });
+
it('sets signing confirmed and shows scanner when QR signing is in progress', async () => {
const clearSecurityAlertResponseSpy = jest.spyOn(
PPOMUtil,
diff --git a/app/components/Views/confirmations/hooks/useLedgerConfirm.test.ts b/app/components/Views/confirmations/hooks/useLedgerConfirm.test.ts
index bb33f91f150..94d60c149e7 100644
--- a/app/components/Views/confirmations/hooks/useLedgerConfirm.test.ts
+++ b/app/components/Views/confirmations/hooks/useLedgerConfirm.test.ts
@@ -2,6 +2,7 @@ import { renderHook, act } from '@testing-library/react-native';
import { useLedgerConfirm } from './useLedgerConfirm';
const mockEnsureDeviceReady = jest.fn();
+const mockSetPendingOperationAddress = jest.fn();
const mockShowAwaitingConfirmation = jest.fn();
const mockHideAwaitingConfirmation = jest.fn();
const mockShowHardwareWalletError = jest.fn();
@@ -10,6 +11,7 @@ const mockIsUserCancellation = jest.fn().mockReturnValue(false);
jest.mock('../../../../core/HardwareWallet', () => ({
useHardwareWallet: () => ({
ensureDeviceReady: mockEnsureDeviceReady,
+ setPendingOperationAddress: mockSetPendingOperationAddress,
showAwaitingConfirmation: mockShowAwaitingConfirmation,
hideAwaitingConfirmation: mockHideAwaitingConfirmation,
showHardwareWalletError: mockShowHardwareWalletError,
@@ -49,6 +51,14 @@ describe('useLedgerConfirm', () => {
});
expect(mockEnsureDeviceReady).toHaveBeenCalledWith('device-123');
+ expect(mockSetPendingOperationAddress).toHaveBeenNthCalledWith(
+ 1,
+ defaultOptions.fromAddress,
+ );
+ expect(
+ mockSetPendingOperationAddress.mock.invocationCallOrder[0],
+ ).toBeLessThan(mockEnsureDeviceReady.mock.invocationCallOrder[0]);
+ expect(mockSetPendingOperationAddress).toHaveBeenLastCalledWith(null);
expect(mockGetDeviceIdForAddress).toHaveBeenCalledWith(
defaultOptions.fromAddress,
);
diff --git a/app/components/Views/confirmations/hooks/useLedgerConfirm.ts b/app/components/Views/confirmations/hooks/useLedgerConfirm.ts
index 601e3668396..07b3b844aca 100644
--- a/app/components/Views/confirmations/hooks/useLedgerConfirm.ts
+++ b/app/components/Views/confirmations/hooks/useLedgerConfirm.ts
@@ -7,7 +7,7 @@ import {
import { getDeviceIdForAddress } from '../../../../core/HardwareWallet/helpers';
interface UseLedgerConfirmOptions {
- fromAddress?: string;
+ fromAddress: string;
onReject: () => void;
onTransactionConfirm: (opts?: {
onError?: (err: unknown) => void;
@@ -30,6 +30,7 @@ export function useLedgerConfirm({
}: UseLedgerConfirmOptions) {
const {
ensureDeviceReady,
+ setPendingOperationAddress,
showHardwareWalletError,
showAwaitingConfirmation,
hideAwaitingConfirmation,
@@ -46,8 +47,15 @@ export function useLedgerConfirm({
onReject();
};
+ if (!fromAddress) {
+ rejectOnce();
+ return;
+ }
+
+ setPendingOperationAddress(fromAddress);
+
try {
- const deviceId = await getDeviceIdForAddress(fromAddress ?? '');
+ const deviceId = await getDeviceIdForAddress(fromAddress);
const isReady = await ensureDeviceReady(deviceId);
if (!isReady) {
@@ -79,6 +87,8 @@ export function useLedgerConfirm({
}
rejectOnce();
+ } finally {
+ setPendingOperationAddress(null);
}
}, [
onReject,
@@ -89,6 +99,7 @@ export function useLedgerConfirm({
showAwaitingConfirmation,
hideAwaitingConfirmation,
showHardwareWalletError,
+ setPendingOperationAddress,
fromAddress,
]);
diff --git a/app/components/hooks/useNetworkEnablement/useNetworkEnablement.test.ts b/app/components/hooks/useNetworkEnablement/useNetworkEnablement.test.ts
index 371f1a1702d..ee204870ec6 100644
--- a/app/components/hooks/useNetworkEnablement/useNetworkEnablement.test.ts
+++ b/app/components/hooks/useNetworkEnablement/useNetworkEnablement.test.ts
@@ -461,6 +461,40 @@ describe('useNetworkEnablement', () => {
});
});
+ describe('referential stability', () => {
+ it('returns a stable object and popularNetworks array across re-renders', () => {
+ // Stable selector results so only the hook's own memoization is exercised.
+ const enabledNetworks = {
+ eip155: { '0x1': true, '0x89': false },
+ };
+ const networkConfigs = { '0x1': {} };
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === selectEnabledNetworksByNamespace) {
+ return enabledNetworks;
+ }
+ if (selector === selectChainId) {
+ return '0x1';
+ }
+ if (selector === selectIsEvmNetworkSelected) {
+ return true;
+ }
+ if (selector === selectNetworkConfigurations) {
+ return networkConfigs;
+ }
+ return undefined;
+ });
+
+ const { result, rerender } = renderHook(() => useNetworkEnablement());
+ const first = result.current;
+ const firstPopularNetworks = result.current.popularNetworks;
+
+ rerender({});
+
+ expect(result.current).toBe(first);
+ expect(result.current.popularNetworks).toBe(firstPopularNetworks);
+ });
+ });
+
describe('hook return values', () => {
it('returns all expected properties', () => {
const { result } = renderHook(() => useNetworkEnablement());
diff --git a/app/components/hooks/useNetworkEnablement/useNetworkEnablement.ts b/app/components/hooks/useNetworkEnablement/useNetworkEnablement.ts
index 5f7724dcafd..09942cedf23 100644
--- a/app/components/hooks/useNetworkEnablement/useNetworkEnablement.ts
+++ b/app/components/hooks/useNetworkEnablement/useNetworkEnablement.ts
@@ -13,7 +13,16 @@ import { toHex } from '@metamask/controller-utils';
import Engine from '../../../core/Engine';
import { selectEnabledNetworksByNamespace } from '../../../selectors/networkEnablementController';
import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController';
-import { selectChainId } from '../../../selectors/networkController';
+import {
+ selectChainId,
+ selectNetworkConfigurations,
+} from '../../../selectors/networkController';
+
+// Stable empty-array fallback shared by all three popular-network lists, so an
+// empty result keeps a constant identity across renders. Typed `never[]`
+// (the bottom array type) so it's assignable to each list's element type
+// without widening the consumer-visible array types.
+const EMPTY_ARRAY: never[] = [];
/**
* Manages network enablement state across namespaces (EVM, Bitcoin, etc).
@@ -61,12 +70,31 @@ export const useNetworkEnablement = () => {
[],
);
- const popularEvmNetworksList =
- networkEnablementController?.listPopularEvmNetworks?.() ?? [];
- const popularMultichainNetworksList =
- networkEnablementController?.listPopularMultichainNetworks?.() ?? [];
- const popularNetworksList =
- networkEnablementController?.listPopularNetworks?.() ?? [];
+ // The popular-network lists are derived from the configured networks (the
+ // controller filters POPULAR_NETWORKS by NetworkController /
+ // MultichainNetworkController state), so key them on the network
+ // configurations as well as the controller instance — this avoids both a
+ // fresh array on every render and serving a stale list when configs change.
+ // `selectNetworkConfigurations` is a deep-equal selector covering both EVM
+ // and non-EVM configs, so it only changes when the configs actually change.
+ const networkConfigurations = useSelector(selectNetworkConfigurations);
+
+ const [
+ popularEvmNetworksList,
+ popularMultichainNetworksList,
+ popularNetworksList,
+ ] = useMemo(
+ () => [
+ networkEnablementController?.listPopularEvmNetworks?.() ?? EMPTY_ARRAY,
+ networkEnablementController?.listPopularMultichainNetworks?.() ??
+ EMPTY_ARRAY,
+ networkEnablementController?.listPopularNetworks?.() ?? EMPTY_ARRAY,
+ ],
+ // `networkConfigurations` is not referenced directly, but the controller
+ // methods above read that state internally, so it must invalidate the memo.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [networkEnablementController, networkConfigurations],
+ );
const enabledNetworksForCurrentNamespace = useMemo(
() => enabledNetworksByNamespace?.[namespace] || {},
@@ -141,20 +169,40 @@ export const useNetworkEnablement = () => {
[isNetworkEnabled, enableNetwork],
);
- return {
- namespace,
- enabledNetworksByNamespace,
- enabledNetworksForCurrentNamespace,
- enabledNetworksForAllNamespaces,
- networkEnablementController,
- enableNetwork,
- disableNetwork,
- enableAllPopularNetworks,
- popularEvmNetworks: popularEvmNetworksList,
- popularMultichainNetworks: popularMultichainNetworksList,
- popularNetworks: popularNetworksList,
- isNetworkEnabled,
- hasOneEnabledNetwork,
- tryEnableEvmNetwork,
- };
+ // Memoize the returned object so consumers (this hook is used in ~22 files,
+ // many feeding it into useMemo/useEffect deps) get a stable identity.
+ return useMemo(
+ () => ({
+ namespace,
+ enabledNetworksByNamespace,
+ enabledNetworksForCurrentNamespace,
+ enabledNetworksForAllNamespaces,
+ networkEnablementController,
+ enableNetwork,
+ disableNetwork,
+ enableAllPopularNetworks,
+ popularEvmNetworks: popularEvmNetworksList,
+ popularMultichainNetworks: popularMultichainNetworksList,
+ popularNetworks: popularNetworksList,
+ isNetworkEnabled,
+ hasOneEnabledNetwork,
+ tryEnableEvmNetwork,
+ }),
+ [
+ namespace,
+ enabledNetworksByNamespace,
+ enabledNetworksForCurrentNamespace,
+ enabledNetworksForAllNamespaces,
+ networkEnablementController,
+ enableNetwork,
+ disableNetwork,
+ enableAllPopularNetworks,
+ popularEvmNetworksList,
+ popularMultichainNetworksList,
+ popularNetworksList,
+ isNetworkEnabled,
+ hasOneEnabledNetwork,
+ tryEnableEvmNetwork,
+ ],
+ );
};
diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts
index 320d36dfcc3..43e635c628c 100644
--- a/app/constants/navigation/Routes.ts
+++ b/app/constants/navigation/Routes.ts
@@ -461,6 +461,7 @@ const Routes = {
HOW_IT_WORKS: 'MoneyHowItWorks',
POTENTIAL_EARNINGS: 'MoneyPotentialEarnings',
ONBOARDING: 'MoneyOnboarding',
+ FIRST_TIME_DEPOSIT: 'MoneyFirstTimeDeposit',
TRANSACTION_DETAILS: 'MoneyTransactionDetails',
MODALS: {
ROOT: 'MoneyModals',
diff --git a/app/constants/navigation/exploreTabIndices.ts b/app/constants/navigation/exploreTabIndices.ts
new file mode 100644
index 00000000000..6b35a09625d
--- /dev/null
+++ b/app/constants/navigation/exploreTabIndices.ts
@@ -0,0 +1,11 @@
+export const EXPLORE_TAB_INDEX = {
+ NOW: 0,
+ MACRO: 1,
+ RWAS: 2,
+ CRYPTO: 3,
+ SPORTS: 4,
+ SITES: 5,
+} as const;
+
+export type ExploreTabIndex =
+ (typeof EXPLORE_TAB_INDEX)[keyof typeof EXPLORE_TAB_INDEX];
diff --git a/app/constants/urls.test.ts b/app/constants/urls.test.ts
index 9c0d124ccd9..e1e45c0c891 100644
--- a/app/constants/urls.test.ts
+++ b/app/constants/urls.test.ts
@@ -1,11 +1,11 @@
import { buildVipPrioritySupportUrl, METAMASK_SUPPORT_URL } from './urls';
describe('buildVipPrioritySupportUrl', () => {
- it('appends VIP priority and account query params to the default support URL', () => {
+ it('appends VIP priority and address query params to the default support URL', () => {
const account = '0xAbC0000000000000000000000000000000000123';
expect(buildVipPrioritySupportUrl(account)).toBe(
- `${METAMASK_SUPPORT_URL}&priority=vip&account=${encodeURIComponent(account)}`,
+ `${METAMASK_SUPPORT_URL}&priority=vip&address=${encodeURIComponent(account)}`,
);
});
@@ -13,7 +13,7 @@ describe('buildVipPrioritySupportUrl', () => {
const baseUrl = 'https://support.metamask.io/';
expect(buildVipPrioritySupportUrl('0xabc', baseUrl)).toBe(
- `${baseUrl}?priority=vip&account=0xabc`,
+ `${baseUrl}?priority=vip&address=0xabc`,
);
});
@@ -21,17 +21,17 @@ describe('buildVipPrioritySupportUrl', () => {
const baseUrl = 'https://support.metamask.io/?utm_source=mobile_app';
expect(buildVipPrioritySupportUrl('0xabc', baseUrl)).toBe(
- `${baseUrl}&priority=vip&account=0xabc`,
+ `${baseUrl}&priority=vip&address=0xabc`,
);
});
- it('URL-encodes the account address', () => {
+ it('URL-encodes the wallet address', () => {
const account = '0xabc&foo=bar?baz';
expect(
buildVipPrioritySupportUrl(account, 'https://support.example.com'),
).toBe(
- `https://support.example.com?priority=vip&account=${encodeURIComponent(account)}`,
+ `https://support.example.com?priority=vip&address=${encodeURIComponent(account)}`,
);
});
});
diff --git a/app/constants/urls.ts b/app/constants/urls.ts
index 24c97eab113..ff2c4a39c90 100644
--- a/app/constants/urls.ts
+++ b/app/constants/urls.ts
@@ -59,17 +59,16 @@ export const REWARDS_LEARN_MORE_URL = `https://support.metamask.io/manage-crypto
// Builds the priority-support help-center URL for VIP users. Includes a fixed
// `priority=vip` tag so the support-side automation can fast-path the request to a
-// human, plus the user's account address so that automation can verify enrollment via
-// the existing support API (GET /support/subscriptions/check?account=, which keys off
-// the account address and returns tier/subscription status).
+// human, plus the user's wallet address so that automation can verify enrollment via
+// the existing support API (GET /support/subscriptions/check?account=).
// NOTE: `priority=vip` is a placeholder value pending support/Intercom confirmation;
-// `account` matches the support endpoint's query-param name.
+// `address` matches the help-center URL param name expected by support automation.
export const buildVipPrioritySupportUrl = (
account: string,
baseUrl: string = METAMASK_SUPPORT_URL,
) => {
const separator = baseUrl.includes('?') ? '&' : '?';
- return `${baseUrl}${separator}priority=vip&account=${encodeURIComponent(account)}`;
+ return `${baseUrl}${separator}priority=vip&address=${encodeURIComponent(account)}`;
};
// Perps
diff --git a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx
index 196d49eecdf..c190257bf3e 100644
--- a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx
+++ b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx
@@ -1,6 +1,6 @@
import React, { useMemo, useRef, useCallback, useEffect } from 'react';
import { StyleSheet } from 'react-native';
-
+import { GestureHandlerRootView } from 'react-native-gesture-handler';
import BottomSheet, {
BottomSheetRef,
} from '../../../../component-library/components/BottomSheets/BottomSheet';
@@ -270,15 +270,17 @@ export const HardwareWalletBottomSheet: React.FC<
// eslint-disable-next-line @typescript-eslint/no-deprecated
return (
-
- {renderContent()}
-
+
+
+ {renderContent()}
+
+
);
};
diff --git a/app/core/HardwareWallet/helpers.ts b/app/core/HardwareWallet/helpers.ts
index e48a8fb8e93..2b189a35d57 100644
--- a/app/core/HardwareWallet/helpers.ts
+++ b/app/core/HardwareWallet/helpers.ts
@@ -12,6 +12,8 @@ import { getDeviceId } from '../Ledger/Ledger';
const LEDGER_DEVICE_ID_LOOKUP_TIMEOUT_MS = 5000;
+type NoEmptyAddress = T extends '' ? never : T;
+
const createLedgerDeviceIdLookupTimeoutError = () =>
new HardwareWalletError('Ledger device id lookup timed out', {
code: ErrorCode.DeviceUnresponsive,
@@ -88,9 +90,10 @@ export function getHardwareWalletTypeForAddress(
*
* Some hardware wallets, like Ledger over BLE, require a persisted device id
* to reconnect. Others, like QR signers, do not.
+ * @param address - The wallet address to get the device id for. It cannot be an empty string.
*/
export async function getDeviceIdForAddress(
- address: string,
+ address: NoEmptyAddress, // !This value cannot be an empty string.
): Promise {
const walletType = getHardwareWalletTypeForAddress(address);
diff --git a/app/core/HardwareWallet/hooks/useQrConfirm.test.ts b/app/core/HardwareWallet/hooks/useQrConfirm.test.ts
index 8be8ac1efd7..4750249d2be 100644
--- a/app/core/HardwareWallet/hooks/useQrConfirm.test.ts
+++ b/app/core/HardwareWallet/hooks/useQrConfirm.test.ts
@@ -2,6 +2,7 @@ import { renderHook, act } from '@testing-library/react-native';
const mockEnsureDeviceReady = jest.fn();
const mockSetTargetWalletType = jest.fn();
+const mockSetPendingOperationAddress = jest.fn();
const mockShowAwaitingConfirmation = jest.fn();
const mockHideAwaitingConfirmation = jest.fn();
const mockShowHardwareWalletError = jest.fn();
@@ -14,6 +15,7 @@ jest.mock('..', () => ({
useHardwareWallet: () => ({
ensureDeviceReady: mockEnsureDeviceReady,
setTargetWalletType: mockSetTargetWalletType,
+ setPendingOperationAddress: mockSetPendingOperationAddress,
showAwaitingConfirmation: mockShowAwaitingConfirmation,
hideAwaitingConfirmation: mockHideAwaitingConfirmation,
showHardwareWalletError: mockShowHardwareWalletError,
@@ -23,24 +25,6 @@ jest.mock('..', () => ({
mockExecuteHardwareWalletOperation(...args),
}));
-const mockApprovalRequest = { requestData: { from: '0xTestAddress' } };
-const mockTransactionMetadata = { txParams: { from: '0xTestAddress' } };
-
-jest.mock(
- '../../../components/Views/confirmations/hooks/useApprovalRequest',
- () => ({
- __esModule: true,
- default: () => ({ approvalRequest: mockApprovalRequest }),
- }),
-);
-
-jest.mock(
- '../../../components/Views/confirmations/hooks/transactions/useTransactionMetadataRequest',
- () => ({
- useTransactionMetadataRequest: () => mockTransactionMetadata,
- }),
-);
-
const mockIsSigningQRObject = { current: false };
jest.mock(
@@ -62,6 +46,7 @@ describe('useQrConfirm', () => {
const executeApproval = jest.fn().mockResolvedValue(undefined);
const defaultOptions = {
+ fromAddress: '0xTestAddress',
onReject,
onTransactionConfirm,
executeApproval,
@@ -93,14 +78,9 @@ describe('useQrConfirm', () => {
});
it('calls rejectOnce when no fromAddress is available', async () => {
- const originalRequestData = mockApprovalRequest.requestData;
- const originalTxParams = mockTransactionMetadata.txParams;
- mockApprovalRequest.requestData =
- {} as typeof mockApprovalRequest.requestData;
- mockTransactionMetadata.txParams =
- {} as typeof mockTransactionMetadata.txParams;
-
- const { result } = renderHook(() => useQrConfirm(defaultOptions));
+ const { result } = renderHook(() =>
+ useQrConfirm({ ...defaultOptions, fromAddress: '' }),
+ );
await act(async () => {
await result.current.onConfirm();
@@ -108,9 +88,6 @@ describe('useQrConfirm', () => {
expect(onReject).toHaveBeenCalledTimes(1);
expect(mockExecuteHardwareWalletOperation).not.toHaveBeenCalled();
-
- mockApprovalRequest.requestData = originalRequestData;
- mockTransactionMetadata.txParams = originalTxParams;
});
it('calls executeHardwareWalletOperation for message signing', async () => {
@@ -124,6 +101,7 @@ describe('useQrConfirm', () => {
expect.objectContaining({
address: '0xTestAddress',
operationType: 'message',
+ setPendingOperationAddress: mockSetPendingOperationAddress,
}),
);
});
@@ -141,6 +119,7 @@ describe('useQrConfirm', () => {
expect.objectContaining({
address: '0xTestAddress',
operationType: 'transaction',
+ setPendingOperationAddress: mockSetPendingOperationAddress,
}),
);
});
@@ -230,13 +209,10 @@ describe('useQrConfirm', () => {
expect(onReject).toHaveBeenCalledTimes(1);
});
- it('uses from address from transaction metadata when approval request has no from', async () => {
- const originalRequestData = mockApprovalRequest.requestData;
- mockApprovalRequest.requestData =
- {} as typeof mockApprovalRequest.requestData;
- mockTransactionMetadata.txParams = { from: '0xTxMetaAddress' };
-
- const { result } = renderHook(() => useQrConfirm(defaultOptions));
+ it('uses the explicit from address option', async () => {
+ const { result } = renderHook(() =>
+ useQrConfirm({ ...defaultOptions, fromAddress: '0xExplicitAddress' }),
+ );
await act(async () => {
await result.current.onConfirm();
@@ -244,11 +220,9 @@ describe('useQrConfirm', () => {
expect(mockExecuteHardwareWalletOperation).toHaveBeenCalledWith(
expect.objectContaining({
- address: '0xTxMetaAddress',
+ address: '0xExplicitAddress',
}),
);
-
- mockApprovalRequest.requestData = originalRequestData;
});
it('rethrows error from onTransactionConfirm onError callback', async () => {
diff --git a/app/core/HardwareWallet/hooks/useQrConfirm.ts b/app/core/HardwareWallet/hooks/useQrConfirm.ts
index f41d9120afa..e96009ab13e 100644
--- a/app/core/HardwareWallet/hooks/useQrConfirm.ts
+++ b/app/core/HardwareWallet/hooks/useQrConfirm.ts
@@ -6,10 +6,9 @@ import {
isUserCancellation,
} from '..';
import { useQRHardwareContext } from '../../../components/Views/confirmations/context/qr-hardware-context';
-import useApprovalRequest from '../../../components/Views/confirmations/hooks/useApprovalRequest';
-import { useTransactionMetadataRequest } from '../../../components/Views/confirmations/hooks/transactions/useTransactionMetadataRequest';
interface UseQrConfirmOptions {
+ fromAddress: string;
onReject: () => void;
onTransactionConfirm: (opts?: {
onError?: (err: unknown) => void;
@@ -28,6 +27,7 @@ interface UseQrConfirmOptions {
* @returns An `onConfirm` callback for the confirmation submit action.
*/
export function useQrConfirm({
+ fromAddress,
onReject,
onTransactionConfirm,
executeApproval,
@@ -35,6 +35,7 @@ export function useQrConfirm({
}: UseQrConfirmOptions) {
const {
ensureDeviceReady,
+ setPendingOperationAddress,
showAwaitingConfirmation,
hideAwaitingConfirmation,
showHardwareWalletError,
@@ -42,9 +43,6 @@ export function useQrConfirm({
const { isSigningQRObject } = useQRHardwareContext();
- const { approvalRequest } = useApprovalRequest();
- const transactionMetadata = useTransactionMetadataRequest();
-
const hasRejectedRef = useRef(false);
const executeQrConfirmation = useCallback(async () => {
@@ -69,10 +67,6 @@ export function useQrConfirm({
onReject();
};
- const fromAddress =
- (approvalRequest?.requestData?.from as string) ||
- (transactionMetadata?.txParams?.from as string);
-
if (!fromAddress) {
rejectOnce();
return;
@@ -96,6 +90,7 @@ export function useQrConfirm({
address: fromAddress,
operationType: isTransactionReq ? 'transaction' : 'message',
ensureDeviceReady,
+ setPendingOperationAddress,
showAwaitingConfirmation,
hideAwaitingConfirmation,
showHardwareWalletError,
@@ -109,13 +104,13 @@ export function useQrConfirm({
rejectOnce();
}
}, [
- approvalRequest?.requestData?.from,
- transactionMetadata?.txParams?.from,
+ fromAddress,
isSigningQRObject,
executeQrConfirmation,
isTransactionReq,
onReject,
ensureDeviceReady,
+ setPendingOperationAddress,
showAwaitingConfirmation,
hideAwaitingConfirmation,
showHardwareWalletError,
diff --git a/app/core/SnapKeyring/MultichainWalletSnapClient.test.ts b/app/core/SnapKeyring/MultichainWalletSnapClient.test.ts
deleted file mode 100644
index 004060b3fdd..00000000000
--- a/app/core/SnapKeyring/MultichainWalletSnapClient.test.ts
+++ /dev/null
@@ -1,382 +0,0 @@
-import {
- BitcoinWalletSnapClient,
- MultichainWalletSnapClient,
- MultichainWalletSnapFactory,
- SolanaWalletSnapClient,
- TronWalletSnapClient,
- WalletClientType,
-} from './MultichainWalletSnapClient';
-import { CaipChainId, SnapId } from '@metamask/snaps-sdk';
-import Engine from '../Engine';
-import { Sender } from '@metamask/keyring-snap-client';
-import { SnapKeyring } from '@metamask/eth-snap-keyring';
-import { SolScope } from '@metamask/keyring-api';
-import { BitcoinWalletSnapSender } from './BitcoinWalletSnap';
-import { SolanaWalletSnapSender } from './SolanaWalletSnap';
-import { TronWalletSnapSender } from './TronWalletSnap';
-
-const mockSnapKeyring = {
- createAccount: jest.fn(),
- discoverAccounts: jest.fn(),
-};
-
-jest.mock('../Engine', () => ({
- controllerMessenger: {
- call: jest.fn(),
- },
- getSnapKeyring: () => mockSnapKeyring,
- context: {
- AccountsController: {
- getNextAvailableAccountName: jest.fn().mockReturnValue('Snap Account 1'),
- },
- },
-}));
-
-const createMockKeyringClient = () => ({
- listAccounts: jest.fn(),
- getAccount: jest.fn(),
- getAccountBalances: jest.fn(),
- createAccount: jest.fn(),
- discoverAccounts: jest.fn(),
-});
-
-let mockKeyringClient = createMockKeyringClient();
-
-jest.mock('@metamask/keyring-snap-client', () => ({
- KeyringClient: jest.fn().mockImplementation(() => mockKeyringClient),
- Sender: jest.fn(),
-}));
-
-describe('MultichainWalletSnapClient', () => {
- const mockSnapId = 'mock-snap-id' as SnapId;
- const mockSnapName = 'mock-snap-name';
- const mockSnapKeyringOptions = {
- displayConfirmation: false,
- displayAccountNameSuggestion: false,
- setSelectedAccount: false,
- };
-
- class TestMultichainWalletSnapClient extends MultichainWalletSnapClient {
- constructor() {
- super(mockSnapId, mockSnapName, mockSnapKeyringOptions);
- }
-
- getScope(): CaipChainId {
- return SolScope.Mainnet;
- }
-
- protected getSnapSender(): Sender {
- return {} as Sender;
- }
-
- getClientType(): WalletClientType {
- return WalletClientType.Solana;
- }
-
- public testWithSnapKeyring(
- callback: (keyring: SnapKeyring) => Promise,
- ) {
- return this.withSnapKeyring(callback);
- }
- }
-
- let client: TestMultichainWalletSnapClient;
-
- beforeEach(() => {
- jest.clearAllMocks();
- mockKeyringClient = createMockKeyringClient();
- client = new TestMultichainWalletSnapClient();
- });
-
- describe('getSnapId', () => {
- it('should return the snap ID', () => {
- expect(client.getSnapId()).toBe(mockSnapId);
- });
- });
-
- describe('getSnapName', () => {
- it('should return the snap name', () => {
- expect(client.getSnapName()).toBe(mockSnapName);
- });
- });
-
- describe('withSnapKeyring', () => {
- it('calls the callback with the keyring', async () => {
- const mockOptions = {};
- const mockCallback = async (keyring: SnapKeyring) => {
- await keyring.createAccount(
- mockSnapId,
- mockOptions,
- mockSnapKeyringOptions,
- );
- };
-
- await client.testWithSnapKeyring(mockCallback);
-
- expect(mockSnapKeyring.createAccount).toHaveBeenCalledWith(
- mockSnapId,
- mockOptions,
- mockSnapKeyringOptions,
- );
- });
-
- it('handles errors from the controller messenger', async () => {
- const mockError = new Error('Test error');
- const mockCallback = jest.fn().mockRejectedValueOnce(mockError);
-
- await expect(client.testWithSnapKeyring(mockCallback)).rejects.toThrow(
- 'Test error',
- );
- });
- });
-
- describe('createAccount', () => {
- it('creates an account with the provided options', async () => {
- const mockOptions = {
- scope: SolScope.Mainnet,
- accountNameSuggestion: 'Solana Account 1',
- entropySource: 'test-entropy',
- };
-
- await client.createAccount(mockOptions);
-
- expect(mockSnapKeyring.createAccount).toHaveBeenCalledWith(
- mockSnapId,
- mockOptions,
- mockSnapKeyringOptions,
- );
- });
- });
-
- describe('discoverAccounts', () => {
- it('discovers accounts with the provided parameters', async () => {
- const mockScopes = [SolScope.Mainnet];
- const mockEntropySource = 'test-entropy';
- const mockGroupIndex = 0;
- const mockDiscoveredAccounts = [
- {
- type: 'bip44',
- scope: [SolScope.Mainnet],
- derivationPath: "m/44'/60'/0'/0",
- },
- ];
-
- mockKeyringClient.discoverAccounts.mockResolvedValueOnce(
- mockDiscoveredAccounts,
- );
-
- const result = await client.discoverAccounts(
- mockScopes,
- mockEntropySource,
- mockGroupIndex,
- );
-
- expect(result).toEqual(mockDiscoveredAccounts);
- expect(mockKeyringClient.discoverAccounts).toHaveBeenCalledWith(
- mockScopes,
- mockEntropySource,
- mockGroupIndex,
- );
- });
- });
-
- describe('addDiscoveredAccounts', () => {
- it('adds discovered accounts to the keyring', async () => {
- const expectAccountName = 'Solana Account ';
- const mockEntropySource = 'test-entropy';
- const mockDiscoveredAccounts = [
- {
- type: 'bip44',
- scope: [SolScope.Mainnet],
- derivationPath: "m/44'/60'/0'/0",
- },
- ];
-
- const mockKeyring = {
- createAccount: jest.fn(),
- };
-
- mockKeyringClient.discoverAccounts
- .mockResolvedValueOnce(mockDiscoveredAccounts)
- .mockResolvedValueOnce([]);
-
- (Engine.controllerMessenger.call as jest.Mock).mockImplementation(
- async (_, __, callback) => {
- await callback({ keyring: mockKeyring });
- },
- );
-
- await client.addDiscoveredAccounts(mockEntropySource, SolScope.Mainnet);
-
- expect(mockSnapKeyring.createAccount).toHaveBeenCalledTimes(1);
- expect(mockSnapKeyring.createAccount).toHaveBeenCalledWith(
- mockSnapId,
- {
- accountNameSuggestion: expectAccountName,
- derivationPath: mockDiscoveredAccounts[0].derivationPath,
- entropySource: mockEntropySource,
- scope: SolScope.Mainnet,
- },
- expect.objectContaining({
- displayConfirmation: false,
- displayAccountNameSuggestion: false,
- setSelectedAccount: false,
- }),
- );
- });
- });
-});
-
-describe('MultichainWalletSnapFactory', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- it('creates a BitcoinWalletSnapClient', () => {
- const bitcoinClient = MultichainWalletSnapFactory.createClient(
- WalletClientType.Bitcoin,
- );
-
- expect(bitcoinClient).toBeInstanceOf(BitcoinWalletSnapClient);
- });
-
- it('creates a SolanaWalletSnapClient', () => {
- const solanaClient = MultichainWalletSnapFactory.createClient(
- WalletClientType.Solana,
- );
- expect(solanaClient).toBeInstanceOf(SolanaWalletSnapClient);
- });
-
- it('creates a TronWalletSnapClient', () => {
- const tronClient = MultichainWalletSnapFactory.createClient(
- WalletClientType.Tron,
- );
- expect(tronClient).toBeInstanceOf(TronWalletSnapClient);
- });
-
- it('throws if an invalid wallet type is provided', () => {
- expect(() =>
- MultichainWalletSnapFactory.createClient('invalid' as WalletClientType),
- ).toThrow('Unsupported client type: invalid');
- });
-
- it('passes options to the client', () => {
- const mockOptions = {
- displayConfirmation: true,
- displayAccountNameSuggestion: true,
- setSelectedAccount: false,
- };
-
- const snapClient = MultichainWalletSnapFactory.createClient(
- WalletClientType.Bitcoin,
- mockOptions,
- );
-
- expect(snapClient.snapKeyringOptions).toStrictEqual(mockOptions);
- });
-});
-
-describe('Wallet Client Implementations', () => {
- const mockSnapKeyringOptions = {
- displayConfirmation: false,
- displayAccountNameSuggestion: false,
- setSelectedAccount: false,
- };
-
- class TestBitcoinWalletSnapClient extends BitcoinWalletSnapClient {
- constructor() {
- super(mockSnapKeyringOptions);
- }
-
- public testGetSnapSender(): Sender {
- return this.getSnapSender();
- }
- }
-
- class TestSolanaWalletSnapClient extends SolanaWalletSnapClient {
- constructor() {
- super(mockSnapKeyringOptions);
- }
-
- public testGetSnapSender(): Sender {
- return this.getSnapSender();
- }
- }
-
- class TestTronWalletSnapClient extends TronWalletSnapClient {
- constructor() {
- super(mockSnapKeyringOptions);
- }
-
- public testGetSnapSender(): Sender {
- return this.getSnapSender();
- }
- }
-
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- describe('BitcoinWalletSnapClient', () => {
- it('should create a BitcoinWalletSnapClient', () => {
- const bitcoinClient = new BitcoinWalletSnapClient(mockSnapKeyringOptions);
- expect(bitcoinClient).toBeDefined();
- });
-
- it('should return Bitcoin client type', () => {
- const bitcoinClient = new BitcoinWalletSnapClient(mockSnapKeyringOptions);
- expect(bitcoinClient.getClientType()).toBe(WalletClientType.Bitcoin);
- });
-
- it('should return a BitcoinWalletSnapSender instance', () => {
- const bitcoinClient = new TestBitcoinWalletSnapClient();
- const sender = bitcoinClient.testGetSnapSender();
- expect(sender).toBeDefined();
- expect(sender).toBeInstanceOf(BitcoinWalletSnapSender);
- });
- });
-
- describe('SolanaWalletSnapClient', () => {
- it('should create a SolanaWalletSnapClient', () => {
- const solanaClient = new SolanaWalletSnapClient(mockSnapKeyringOptions);
- expect(solanaClient).toBeDefined();
- });
-
- it('should return Solana client type', () => {
- const solanaClient = new SolanaWalletSnapClient(mockSnapKeyringOptions);
- expect(solanaClient.getClientType()).toBe(WalletClientType.Solana);
- });
-
- it('should return a SolanaWalletSnapSender instance', () => {
- const solanaClient = new TestSolanaWalletSnapClient();
- const sender = solanaClient.testGetSnapSender();
- expect(sender).toBeDefined();
- expect(sender).toBeInstanceOf(SolanaWalletSnapSender);
- });
- });
-
- describe('TronWalletSnapClient', () => {
- it('should create a TronWalletSnapClient', () => {
- const tronClient = new TronWalletSnapClient(mockSnapKeyringOptions);
- expect(tronClient).toBeDefined();
- });
-
- it('should return Tron client type', () => {
- const tronClient = new TronWalletSnapClient(mockSnapKeyringOptions);
- expect(tronClient.getClientType()).toBe(WalletClientType.Tron);
- });
-
- it('should return a TronWalletSnapSender instance', () => {
- const tronClient = new TestTronWalletSnapClient();
- const sender = tronClient.testGetSnapSender();
- expect(sender).toBeDefined();
- expect(sender).toBeInstanceOf(TronWalletSnapSender);
- });
-
- it('should return the correct snap ID and name', () => {
- const tronClient = new TronWalletSnapClient(mockSnapKeyringOptions);
- expect(tronClient.getSnapId()).toBeDefined();
- expect(tronClient.getSnapName()).toBeDefined();
- });
- });
-});
diff --git a/app/core/SnapKeyring/MultichainWalletSnapClient.ts b/app/core/SnapKeyring/MultichainWalletSnapClient.ts
deleted file mode 100644
index 58ae4afe716..00000000000
--- a/app/core/SnapKeyring/MultichainWalletSnapClient.ts
+++ /dev/null
@@ -1,423 +0,0 @@
-import { CaipChainId, Json, SnapId } from '@metamask/snaps-sdk';
-import { KeyringClient, Sender } from '@metamask/keyring-snap-client';
-import {
- ///: BEGIN:ONLY_INCLUDE_IF(bitcoin)
- BtcScope,
- ///: END:ONLY_INCLUDE_IF
- EntropySourceId,
- ///: BEGIN:ONLY_INCLUDE_IF(solana)
- SolScope,
- ///: END:ONLY_INCLUDE_IF
- ///: BEGIN:ONLY_INCLUDE_IF(tron)
- TrxScope,
- ///: END:ONLY_INCLUDE_IF
-} from '@metamask/keyring-api';
-import { captureException } from '@sentry/react-native';
-import {
- BITCOIN_WALLET_SNAP_ID,
- BITCOIN_WALLET_NAME,
- BitcoinWalletSnapSender,
-} from './BitcoinWalletSnap';
-import {
- SOLANA_WALLET_SNAP_ID,
- SOLANA_WALLET_NAME,
- SolanaWalletSnapSender,
-} from './SolanaWalletSnap';
-///: BEGIN:ONLY_INCLUDE_IF(tron)
-import {
- TRON_WALLET_SNAP_ID,
- TRON_WALLET_NAME,
- TronWalletSnapSender,
-} from './TronWalletSnap';
-///: END:ONLY_INCLUDE_IF
-import Engine from '../Engine';
-import { SnapKeyring } from '@metamask/eth-snap-keyring';
-import { store } from '../../store';
-import { startPerformanceTrace } from '../redux/slices/performance';
-import { PerformanceEventNames } from '../redux/slices/performance/constants';
-import { getMultichainAccountName } from './utils/getMultichainAccountName';
-import { endTrace, trace, TraceName, TraceOperation } from '../../util/trace';
-import { getTraceTags } from '../../util/sentry/tags';
-import {
- ///: BEGIN:ONLY_INCLUDE_IF(bitcoin)
- BITCOIN_DISCOVERY_PENDING,
- ///: END:ONLY_INCLUDE_IF
- ///: BEGIN:ONLY_INCLUDE_IF(solana)
- SOLANA_DISCOVERY_PENDING,
- ///: END:ONLY_INCLUDE_IF
- ///: BEGIN:ONLY_INCLUDE_IF(tron)
- TRON_DISCOVERY_PENDING,
- ///: END:ONLY_INCLUDE_IF
-} from '../../constants/storage';
-
-export enum WalletClientType {
- ///: BEGIN:ONLY_INCLUDE_IF(solana)
- Solana = 'solana',
- ///: END:ONLY_INCLUDE_IF
-
- ///: BEGIN:ONLY_INCLUDE_IF(bitcoin)
- Bitcoin = 'bitcoin',
- ///: END:ONLY_INCLUDE_IF
-
- ///: BEGIN:ONLY_INCLUDE_IF(tron)
- Tron = 'tron',
- ///: END:ONLY_INCLUDE_IF
-}
-
-export const WALLET_SNAP_MAP = {
- ///: BEGIN:ONLY_INCLUDE_IF(bitcoin)
- [WalletClientType.Bitcoin]: {
- id: BITCOIN_WALLET_SNAP_ID,
- name: BITCOIN_WALLET_NAME,
- discoveryScope: BtcScope.Mainnet,
- discoveryStorageId: BITCOIN_DISCOVERY_PENDING,
- },
- ///: END:ONLY_INCLUDE_IF
-
- ///: BEGIN:ONLY_INCLUDE_IF(solana)
- [WalletClientType.Solana]: {
- id: SOLANA_WALLET_SNAP_ID,
- name: SOLANA_WALLET_NAME,
- discoveryScope: SolScope.Mainnet,
- discoveryStorageId: SOLANA_DISCOVERY_PENDING,
- },
- ///: END:ONLY_INCLUDE_IF
-
- ///: BEGIN:ONLY_INCLUDE_IF(tron)
- [WalletClientType.Tron]: {
- id: TRON_WALLET_SNAP_ID,
- name: TRON_WALLET_NAME,
- discoveryScope: TrxScope.Mainnet,
- discoveryStorageId: TRON_DISCOVERY_PENDING,
- },
- ///: END:ONLY_INCLUDE_IF
-};
-
-export interface MultichainWalletSnapOptions {
- scope?: CaipChainId;
- synchronize?: boolean;
- entropySource?: string;
- accountNameSuggestion?: string;
- derivationPath?: string;
-}
-
-interface SnapKeyringOptions {
- displayConfirmation: boolean;
- displayAccountNameSuggestion: boolean;
- setSelectedAccount: boolean;
-}
-
-export abstract class MultichainWalletSnapClient {
- readonly snapId: SnapId;
- readonly snapName: string;
- readonly snapKeyringOptions: SnapKeyringOptions;
-
- protected constructor(
- snapId: SnapId,
- snapName: string,
- snapKeyringOptions: SnapKeyringOptions,
- ) {
- this.snapId = snapId;
- this.snapName = snapName;
- this.snapKeyringOptions = snapKeyringOptions;
- }
-
- getSnapId(): SnapId {
- return this.snapId;
- }
-
- getSnapName(): string {
- return this.snapName;
- }
-
- abstract getClientType(): WalletClientType;
- protected abstract getSnapSender(): Sender;
-
- /**
- * Executes a callback function with a SnapKeyring instance.
- * This method ensures proper initialization of the SnapKeyring and provides a safe way to interact with it.
- *
- * @param callback - An async function that receives a SnapKeyring instance and performs operations with it
- * @returns A Promise that resolves when the callback execution is complete
- * @throws Error if the SnapKeyring cannot be initialized or if the callback execution fails
- *
- */
- protected async withSnapKeyring(
- callback: (keyring: SnapKeyring) => Promise,
- ) {
- const snapKeyring = (await Engine.getSnapKeyring()) as SnapKeyring;
-
- return await callback(snapKeyring);
- }
-
- /**
- * Starts sentry tracing.
- *
- * @param name Trace name.
- * @param op Trace operation.
- */
- private startTrace(name: TraceName, op: TraceOperation) {
- trace({
- name,
- op,
- tags: {
- 'snap.id': this.snapId,
- ...getTraceTags(store.getState()),
- },
- });
- }
-
- /**
- * Ends sentry tracing.
- *
- * @param name Trace name.
- */
- private endTrace(name: TraceName) {
- endTrace({
- name,
- });
- }
-
- /**
- * Creates a new account using the SnapKeyring.
- * This method wraps the account creation process with proper SnapKeyring initialization and error handling.
- *
- * @param options - Configuration options for creating the multichain wallet account
- * @param snapKeyringOptions - Configuration options for the SnapKeyring
- * @returns A Promise that resolves when the account creation is complete
- * @throws Error if the account creation fails or if the SnapKeyring cannot be accessed
- *
- */
- async createAccount(
- options: MultichainWalletSnapOptions,
- snapKeyringOptions?: SnapKeyringOptions,
- ) {
- // This flow is async and start here, to end in the `SnapKeyring.addAccountFinalize` method.
- store.dispatch(
- startPerformanceTrace({
- eventName: PerformanceEventNames.AddSnapAccount,
- }),
- );
-
- // Same here.
- this.startTrace(
- TraceName.CreateSnapAccount,
- TraceOperation.CreateSnapAccount,
- );
-
- const accountName =
- options?.accountNameSuggestion ??
- getMultichainAccountName(options.scope, this.getClientType());
-
- return await this.withSnapKeyring(
- async (keyring) =>
- await keyring.createAccount(
- this.snapId,
- {
- ...options,
- accountNameSuggestion: accountName,
- } as unknown as Record,
- snapKeyringOptions ?? this.snapKeyringOptions,
- ),
- );
- }
-
- /**
- * Discovers accounts for the specified scopes using the provided entropy source.
- *
- * @param scopes - Array of CAIP-2 chain IDs to discover accounts for
- * @param entropySource - The source of entropy to use for account discovery
- * @param groupIndex - The index of the account group to discover
- * @returns A Promise that resolves with the discovered accounts
- * @throws Error if account discovery fails
- */
- async discoverAccounts(
- scopes: CaipChainId[],
- entropySource: EntropySourceId,
- groupIndex: number,
- ) {
- const keyringApiClient = new KeyringClient(this.getSnapSender());
- const accounts = await keyringApiClient.discoverAccounts(
- scopes,
- entropySource,
- groupIndex,
- );
-
- return accounts;
- }
- /**
- * Adds discovered accounts to the SnapKeyring.
- * This method discovers accounts for the configured scopes and adds them to the keyring.
- *
- * @param entropySource - The source of entropy to use for account discovery
- * @returns A Promise that resolves when all accounts have been added
- * @throws Error if account discovery or addition fails
- */
- /**
- * Adds discovered accounts to the SnapKeyring.
- * This method discovers accounts for the configured scopes and adds them to the keyring.
- *
- * @param entropySource - The source of entropy to use for account discovery
- * @param scope - The CAIP-2 chain ID to discover accounts for
- * @returns A Promise that resolves when all accounts have been added
- * @throws Error if account discovery or addition fails
- */
- async addDiscoveredAccounts(
- entropySource: EntropySourceId,
- scope: CaipChainId,
- ) {
- this.startTrace(
- TraceName.SnapDiscoverAccounts,
- TraceOperation.DiscoverAccounts,
- );
-
- let totalDiscoveredAccounts = 0;
-
- for (let index = 0; ; index++) {
- const discoveredAccounts = await this.discoverAccounts(
- [scope],
- entropySource,
- index,
- );
-
- // No accounts discovered
- if (discoveredAccounts.length === 0) {
- // For the first index, create a default account
- if (index === 0) {
- try {
- await this.createAccount(
- {
- scope,
- entropySource,
- synchronize: false,
- },
- {
- displayConfirmation: false,
- displayAccountNameSuggestion: false,
- setSelectedAccount: false,
- },
- );
- } catch (error) {
- captureException(new Error(`Failed to create account ${error}`));
- }
- }
- // Stop discovering accounts when none are found
- break;
- }
-
- // Process discovered accounts sequentially
- for (const account of discoveredAccounts) {
- try {
- await this.createAccount(
- {
- scope,
- derivationPath: account.derivationPath,
- entropySource,
- },
- {
- displayConfirmation: false,
- displayAccountNameSuggestion: false,
- setSelectedAccount: false,
- },
- );
- totalDiscoveredAccounts += 1;
- } catch (error) {
- captureException(new Error(`Failed to create account ${error}`));
- }
- }
- }
-
- this.endTrace(TraceName.SnapDiscoverAccounts);
-
- return totalDiscoveredAccounts;
- }
-}
-
-export class BitcoinWalletSnapClient extends MultichainWalletSnapClient {
- constructor(snapKeyringOptions: SnapKeyringOptions) {
- super(BITCOIN_WALLET_SNAP_ID, BITCOIN_WALLET_NAME, snapKeyringOptions);
- }
-
- getClientType(): WalletClientType {
- return WalletClientType.Bitcoin;
- }
-
- protected getSnapSender(): Sender {
- return new BitcoinWalletSnapSender();
- }
-
- async createAccount(
- options: MultichainWalletSnapOptions,
- snapKeyringOptions?: SnapKeyringOptions,
- ) {
- return super.createAccount(options, snapKeyringOptions);
- }
-}
-
-export class SolanaWalletSnapClient extends MultichainWalletSnapClient {
- constructor(snapKeyringOptions: SnapKeyringOptions) {
- super(SOLANA_WALLET_SNAP_ID, SOLANA_WALLET_NAME, snapKeyringOptions);
- }
-
- getClientType(): WalletClientType {
- return WalletClientType.Solana;
- }
-
- protected getSnapSender(): Sender {
- return new SolanaWalletSnapSender();
- }
-}
-
-///: BEGIN:ONLY_INCLUDE_IF(tron)
-export class TronWalletSnapClient extends MultichainWalletSnapClient {
- constructor(snapKeyringOptions: SnapKeyringOptions) {
- super(TRON_WALLET_SNAP_ID, TRON_WALLET_NAME, snapKeyringOptions);
- }
-
- getClientType(): WalletClientType {
- return WalletClientType.Tron;
- }
-
- protected getSnapSender(): Sender {
- return new TronWalletSnapSender();
- }
-
- async createAccount(
- options: MultichainWalletSnapOptions,
- snapKeyringOptions?: SnapKeyringOptions,
- ) {
- return super.createAccount(options, snapKeyringOptions);
- }
-}
-///: END:ONLY_INCLUDE_IF
-
-export class MultichainWalletSnapFactory {
- private static defaultOptions: SnapKeyringOptions = {
- displayConfirmation: false,
- displayAccountNameSuggestion: false,
- setSelectedAccount: true,
- };
-
- static createClient(
- clientType: WalletClientType,
- options: Partial = {},
- ): MultichainWalletSnapClient {
- const snapKeyringOptions = {
- ...this.defaultOptions,
- ...options,
- };
-
- switch (clientType) {
- case WalletClientType.Bitcoin:
- return new BitcoinWalletSnapClient(snapKeyringOptions);
- case WalletClientType.Solana:
- return new SolanaWalletSnapClient(snapKeyringOptions);
- ///: BEGIN:ONLY_INCLUDE_IF(tron)
- case WalletClientType.Tron:
- return new TronWalletSnapClient(snapKeyringOptions);
- ///: END:ONLY_INCLUDE_IF
- default:
- throw new Error(`Unsupported client type: ${clientType}`);
- }
- }
-}
diff --git a/app/core/SnapKeyring/types.ts b/app/core/SnapKeyring/types.ts
index 8bedfbd9507..6c50b1a9f2e 100644
--- a/app/core/SnapKeyring/types.ts
+++ b/app/core/SnapKeyring/types.ts
@@ -50,3 +50,17 @@ export type SnapKeyringBuilderMessenger = Messenger<
SnapKeyringBuilderAllowActions,
never
>;
+
+export enum WalletClientType {
+ ///: BEGIN:ONLY_INCLUDE_IF(solana)
+ Solana = 'solana',
+ ///: END:ONLY_INCLUDE_IF
+
+ ///: BEGIN:ONLY_INCLUDE_IF(bitcoin)
+ Bitcoin = 'bitcoin',
+ ///: END:ONLY_INCLUDE_IF
+
+ ///: BEGIN:ONLY_INCLUDE_IF(tron)
+ Tron = 'tron',
+ ///: END:ONLY_INCLUDE_IF
+}
diff --git a/app/core/SnapKeyring/utils/getMultichainAccountName.test.ts b/app/core/SnapKeyring/utils/getMultichainAccountName.test.ts
index 4d30cc68349..e2f10fc0b19 100644
--- a/app/core/SnapKeyring/utils/getMultichainAccountName.test.ts
+++ b/app/core/SnapKeyring/utils/getMultichainAccountName.test.ts
@@ -1,6 +1,6 @@
import { BtcScope, SolScope, TrxScope } from '@metamask/keyring-api';
import { getMultichainAccountName } from './getMultichainAccountName';
-import { WalletClientType } from '../MultichainWalletSnapClient';
+import { WalletClientType } from '../types';
describe('getMultichainAccountName', () => {
it('returns the next available account name if no scope or client type is provided', () => {
diff --git a/app/core/SnapKeyring/utils/getMultichainAccountName.ts b/app/core/SnapKeyring/utils/getMultichainAccountName.ts
index 77abb35db55..4a924deccfa 100644
--- a/app/core/SnapKeyring/utils/getMultichainAccountName.ts
+++ b/app/core/SnapKeyring/utils/getMultichainAccountName.ts
@@ -1,7 +1,7 @@
import { BtcScope, SolScope, TrxScope } from '@metamask/keyring-api';
import { CaipChainId } from '@metamask/utils';
import { strings } from '../../../../locales/i18n';
-import { WalletClientType } from '../MultichainWalletSnapClient';
+import { WalletClientType } from '../types';
export function getMultichainAccountName(
scope?: CaipChainId,
diff --git a/app/core/Vault.test.ts b/app/core/Vault.test.ts
index c0146cf4375..bb5b9c423c0 100644
--- a/app/core/Vault.test.ts
+++ b/app/core/Vault.test.ts
@@ -204,19 +204,6 @@ jest.mock('../util/trace', () => ({
endTrace: jest.fn(),
}));
-const mockMultichainWalletSnapClient = {
- createAccount: jest.fn(),
-};
-
-jest.mock('./SnapKeyring/MultichainWalletSnapClient', () => ({
- ...jest.requireActual('./SnapKeyring/MultichainWalletSnapClient'),
- MultichainWalletSnapFactory: {
- createClient: jest
- .fn()
- .mockImplementation(() => mockMultichainWalletSnapClient),
- },
-}));
-
describe('Vault', () => {
beforeEach(() => {
jest.clearAllMocks();
diff --git a/app/core/redux/slices/bridge/index.ts b/app/core/redux/slices/bridge/index.ts
index 4869a402c85..db2e0fd764c 100644
--- a/app/core/redux/slices/bridge/index.ts
+++ b/app/core/redux/slices/bridge/index.ts
@@ -50,7 +50,7 @@ import {
} from '../../../../selectors/assets/assets-migration';
import { selectCanSignTransactions } from '../../../../selectors/accountsController';
import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings';
-import { hasMinimumRequiredVersion } from './utils/hasMinimumRequiredVersion';
+import { hasMinimumRequiredVersion } from '../../../../util/remoteFeatureFlag';
import { Bip44TokensForDefaultPairs } from '../../../../components/UI/Bridge/constants/default-swap-dest-tokens';
import { normalizeTokenAddress } from '../../../../components/UI/Bridge/utils/tokenUtils';
import { isStockRwaBridgeToken } from '../../../../components/UI/Bridge/utils/isStockRwaBridgeToken';
diff --git a/app/core/redux/slices/bridge/selectNoFeeAssets.test.ts b/app/core/redux/slices/bridge/selectNoFeeAssets.test.ts
index 770a6afae72..08b0f73a97c 100644
--- a/app/core/redux/slices/bridge/selectNoFeeAssets.test.ts
+++ b/app/core/redux/slices/bridge/selectNoFeeAssets.test.ts
@@ -31,7 +31,7 @@ jest.mock('../../../Engine/controllers/remote-feature-flag-controller', () => ({
}));
// Avoid compareVersions path by forcing minimum version check to pass
-jest.mock('./utils/hasMinimumRequiredVersion', () => ({
+jest.mock('../../../../util/remoteFeatureFlag', () => ({
hasMinimumRequiredVersion: () => true,
}));
diff --git a/app/core/redux/slices/bridge/utils/hasMinimumRequiredVersion.test.ts b/app/core/redux/slices/bridge/utils/hasMinimumRequiredVersion.test.ts
deleted file mode 100644
index f43a1c60532..00000000000
--- a/app/core/redux/slices/bridge/utils/hasMinimumRequiredVersion.test.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { getVersion } from 'react-native-device-info';
-import { hasMinimumRequiredVersion } from './hasMinimumRequiredVersion';
-
-jest.mock('react-native-device-info', () => ({
- getVersion: jest.fn(),
-}));
-
-describe('hasMinimumRequiredVersion', () => {
- const mockGetVersion = getVersion as jest.MockedFunction;
-
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- describe('when the env var is set to true', () => {
- it('returns true when the version is greater than the required version', () => {
- mockGetVersion.mockReturnValue('2.0.0');
- expect(hasMinimumRequiredVersion('1.0.0')).toBe(true);
- });
-
- it('returns false when the version is less than the required version', () => {
- mockGetVersion.mockReturnValue('1.0.0');
- expect(hasMinimumRequiredVersion('2.0.0')).toBe(false);
- });
- });
-});
diff --git a/app/core/redux/slices/bridge/utils/hasMinimumRequiredVersion.ts b/app/core/redux/slices/bridge/utils/hasMinimumRequiredVersion.ts
deleted file mode 100644
index 0340b8aa9d2..00000000000
--- a/app/core/redux/slices/bridge/utils/hasMinimumRequiredVersion.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import compareVersions from 'compare-versions';
-import { getVersion } from 'react-native-device-info';
-
-export const hasMinimumRequiredVersion = (
- minRequiredVersion: string | undefined,
-) => {
- if (!minRequiredVersion) return false;
- const currentVersion = getVersion();
- return compareVersions.compare(currentVersion, minRequiredVersion, '>=');
-};
diff --git a/app/images/discovery_pill_crypto.png b/app/images/discovery_pill_crypto.png
new file mode 100644
index 00000000000..26a5d661895
Binary files /dev/null and b/app/images/discovery_pill_crypto.png differ
diff --git a/app/images/discovery_pill_perps.png b/app/images/discovery_pill_perps.png
new file mode 100644
index 00000000000..fff09a8a5e7
Binary files /dev/null and b/app/images/discovery_pill_perps.png differ
diff --git a/app/images/discovery_pill_predictions.png b/app/images/discovery_pill_predictions.png
new file mode 100644
index 00000000000..ba16dca9683
Binary files /dev/null and b/app/images/discovery_pill_predictions.png differ
diff --git a/app/images/discovery_pill_stocks.png b/app/images/discovery_pill_stocks.png
new file mode 100644
index 00000000000..3de401f30d2
Binary files /dev/null and b/app/images/discovery_pill_stocks.png differ
diff --git a/app/selectors/cardController.test.ts b/app/selectors/cardController.test.ts
index dbaae6a6d22..8696f804ef5 100644
--- a/app/selectors/cardController.test.ts
+++ b/app/selectors/cardController.test.ts
@@ -1498,8 +1498,8 @@ describe('selectCardResidencyRegion', () => {
});
describe('selectIsCardResidencyBlocked', () => {
- const musdBlockedFlags = (blockedRegions: string[]) => ({
- earnMusdConversionGeoBlockedCountries: { blockedRegions },
+ const moneyBlockedFlags = (blockedRegions: string[]) => ({
+ moneyAccountGeoBlockedCountries: { blockedRegions },
});
it('returns false when countryOfResidence is null (fail-open)', () => {
@@ -1526,7 +1526,7 @@ describe('selectIsCardResidencyBlocked', () => {
} as unknown as CardControllerState['cardHomeData'],
},
undefined,
- musdBlockedFlags(['GB']),
+ moneyBlockedFlags(['GB']),
);
expect(selectIsCardResidencyBlocked(state)).toBe(true);
});
@@ -1547,7 +1547,7 @@ describe('selectIsCardResidencyBlocked', () => {
} as unknown as CardControllerState['cardHomeData'],
},
undefined,
- musdBlockedFlags(['GB']),
+ moneyBlockedFlags(['GB']),
);
expect(selectIsCardResidencyBlocked(state)).toBe(false);
});
@@ -1568,7 +1568,7 @@ describe('selectIsCardResidencyBlocked', () => {
} as unknown as CardControllerState['cardHomeData'],
},
undefined,
- musdBlockedFlags(['US-CA']),
+ moneyBlockedFlags(['US-CA']),
);
expect(selectIsCardResidencyBlocked(state)).toBe(true);
});
@@ -1589,7 +1589,7 @@ describe('selectIsCardResidencyBlocked', () => {
} as unknown as CardControllerState['cardHomeData'],
},
undefined,
- musdBlockedFlags(['US-CA']),
+ moneyBlockedFlags(['US-CA']),
);
expect(selectIsCardResidencyBlocked(state)).toBe(false);
});
@@ -1610,7 +1610,7 @@ describe('selectIsCardResidencyBlocked', () => {
} as unknown as CardControllerState['cardHomeData'],
},
undefined,
- musdBlockedFlags(['US']),
+ moneyBlockedFlags(['US']),
);
expect(selectIsCardResidencyBlocked(state)).toBe(true);
});
@@ -1618,8 +1618,8 @@ describe('selectIsCardResidencyBlocked', () => {
describe('selectCardAvailableTokens residency blocking', () => {
const WALLET_A = '0xwalletA000000000000000000000000000000001';
- const musdBlockedFlags = (blockedRegions: string[]) => ({
- earnMusdConversionGeoBlockedCountries: { blockedRegions },
+ const moneyBlockedFlags = (blockedRegions: string[]) => ({
+ moneyAccountGeoBlockedCountries: { blockedRegions },
});
it('suppresses Money Account placeholder entries when residency is blocked', () => {
@@ -1645,7 +1645,7 @@ describe('selectCardAvailableTokens residency blocking', () => {
homeData as unknown as CardControllerState['cardHomeData'],
},
MONAD_VEDA_FEATURE_FLAG,
- musdBlockedFlags(['GB']),
+ moneyBlockedFlags(['GB']),
);
expect(selectCardAvailableTokens(state)).toStrictEqual([]);
});
@@ -1687,7 +1687,7 @@ describe('selectCardAvailableTokens residency blocking', () => {
homeData as unknown as CardControllerState['cardHomeData'],
},
MONAD_VEDA_FEATURE_FLAG,
- musdBlockedFlags(['GB']),
+ moneyBlockedFlags(['GB']),
);
const tokens = selectCardAvailableTokens(state);
expect(tokens).toHaveLength(1);
diff --git a/app/selectors/cardController.ts b/app/selectors/cardController.ts
index a5f3074458c..6ce6bf5e414 100644
--- a/app/selectors/cardController.ts
+++ b/app/selectors/cardController.ts
@@ -30,7 +30,7 @@ import { isEthAccount } from '../core/Multichain/utils';
import { isMoneyAccountDelegatedForCard } from '../core/Engine/controllers/card-controller/utils/moneyAccountCardToken';
import { selectPrimaryMoneyAccount } from './moneyAccountController';
import { selectCardFeatureFlag } from './featureFlagController/card';
-import { selectMusdConversionBlockedCountries } from '../components/UI/Earn/selectors/featureFlags';
+import { selectMoneyAccountGeoBlockedCountries } from '../components/UI/Money/selectors/featureFlags';
import {
buildCardResidencyRegion,
isCardResidencyInBlockedRegions,
@@ -182,7 +182,7 @@ export const selectCardResidencyRegion = createSelector(
);
const selectCardResidencyBlockedRegions = (state: RootState): string[] =>
- selectMusdConversionBlockedCountries(state);
+ selectMoneyAccountGeoBlockedCountries(state);
export const selectIsCardResidencyBlocked = createSelector(
selectCardResidencyRegion,
diff --git a/app/util/analytics/abTestAnalyticsRegistry.ts b/app/util/analytics/abTestAnalyticsRegistry.ts
index 76c48d7c4e4..7db332161c2 100644
--- a/app/util/analytics/abTestAnalyticsRegistry.ts
+++ b/app/util/analytics/abTestAnalyticsRegistry.ts
@@ -4,6 +4,7 @@ import { NUMPAD_QUICK_ACTIONS_AB_TEST_ANALYTICS_MAPPING } from '../../components
import { POST_TRADE_MODAL_AB_TEST_ANALYTICS_MAPPING } from '../../components/UI/Bridge/components/PostTradeBottomSheet/abTestConfig';
import { TOKEN_SELECTOR_BALANCE_LAYOUT_AB_TEST_ANALYTICS_MAPPING } from '../../components/UI/Bridge/components/TokenSelectorItem.abTestConfig';
import {
+ HOMEPAGE_DISCOVERY_PILLS_AB_TEST_ANALYTICS_MAPPING,
HOMEPAGE_PERPS_PILLS_EMPTY_AB_TEST_HOME_VIEWED_MAPPING,
HOMEPAGE_TRENDING_SECTIONS_AB_TEST_ANALYTICS_MAPPING,
HUB_PAGE_DISCOVERY_TABS_AB_TEST_ANALYTICS_MAPPING,
@@ -24,6 +25,7 @@ export const AB_TEST_ANALYTICS_MAPPINGS: readonly ABTestAnalyticsMapping[] = [
TOKEN_SELECTOR_BALANCE_LAYOUT_AB_TEST_ANALYTICS_MAPPING,
// Homepage
+ HOMEPAGE_DISCOVERY_PILLS_AB_TEST_ANALYTICS_MAPPING,
HOMEPAGE_PERPS_PILLS_EMPTY_AB_TEST_HOME_VIEWED_MAPPING,
HOMEPAGE_TRENDING_SECTIONS_AB_TEST_ANALYTICS_MAPPING,
HUB_PAGE_DISCOVERY_TABS_AB_TEST_ANALYTICS_MAPPING,
diff --git a/locales/languages/de.json b/locales/languages/de.json
index 090da3370dd..a38d0fcc82e 100644
--- a/locales/languages/de.json
+++ b/locales/languages/de.json
@@ -8827,7 +8827,7 @@
"points_from_referrals_label": "Punkte aus Empfehlungen",
"referrals_label": "VIP-Empfehlungen",
"tier_benefits_title": "Vorteile der Stufe",
- "splash_title": "WILLKOMMEN\nBEIM GOLD-\nFOX-VIP",
+ "splash_title": "WILLKOMMEN\nBEIM GOLD FOX\nCOLLECTIVE",
"splash_description": "Schalten Sie exklusive Vorteile, frühe Funktionen und besondere Belohnungen frei. Nur auf Einladung.",
"splash_accept_invite": "Einladung annehmen",
"splash_not_now": "Nicht jetzt",
@@ -8842,17 +8842,14 @@
"tiers_title": "Stufen",
"tier_thresholds": "{{points}} Punkte",
"bps_value": "{{bps}} Basispunkte",
- "referee_splash_title": "WILLKOMMEN\nBEIM GOLD-\nFOX-VIP",
- "referee_splash_description": "Schalten Sie exklusive Vorteile, frühe Funktionen und besondere Belohnungen frei. Nur auf Einladung.",
"referee_splash_continue": "Fortfahren",
- "referee_splash_not_now": "Nicht jetzt",
"referee_referred_by": "Empfohlen von {{code}}",
"referee_page_title": "VIP-Pilot",
"referee_stats_title": "Statistiken",
"referee_period_last_30d": "Letzten 30 Tage",
- "referee_points_label": "Punkte",
"referee_swaps_volume_label": "Swap-Volumen",
"referee_perps_volume_label": "Perps-Volumen",
+ "referee_points_to_label": "Punkte für {{code}}",
"referee_error_title": "Wir konnten Ihre Daten zur VIP-Referenzperson nicht laden",
"referee_error_description": "Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
"last_updated": "Zuletzt aktualisiert: {{time}}"
diff --git a/locales/languages/el.json b/locales/languages/el.json
index b98551668a0..91394a1ac2c 100644
--- a/locales/languages/el.json
+++ b/locales/languages/el.json
@@ -8827,7 +8827,7 @@
"points_from_referrals_label": "Πόντοι από παραπομπές",
"referrals_label": "VIP παραπομπές",
"tier_benefits_title": "Οφέλη επιπέδου",
- "splash_title": "ΚΑΛΩΣ ΗΡΘΑΤΕ\nΣΤΟ GOLD\nFOX VIP",
+ "splash_title": "ΚΑΛΩΣ ΗΡΘΑΤΕ\nΣΤΟ GOLD FOX\nCOLLECTIVE",
"splash_description": "Ξεκλειδώστε αποκλειστικά προνόμια, πρώιμη πρόσβαση σε λειτουργίες και επιλεγμένες ανταμοιβές. Μόνο με πρόσκληση.",
"splash_accept_invite": "Αποδοχή πρόσκλησης",
"splash_not_now": "Όχι τώρα",
@@ -8842,17 +8842,14 @@
"tiers_title": "Επίπεδα",
"tier_thresholds": "{{points}} πόντοι",
"bps_value": "{{bps}} bps",
- "referee_splash_title": "ΚΑΛΩΣ ΗΡΘΑΤΕ\nΣΤΟ GOLD\nFOX VIP",
- "referee_splash_description": "Ξεκλειδώστε αποκλειστικά προνόμια, πρώιμη πρόσβαση σε λειτουργίες και επιλεγμένες ανταμοιβές. Μόνο με πρόσκληση.",
"referee_splash_continue": "Συνεχίστε",
- "referee_splash_not_now": "Όχι τώρα",
"referee_referred_by": "Παραπομπή από {{code}}",
"referee_page_title": "VIP δοκιμαστικό πρόγραμμα",
"referee_stats_title": "Στατιστικά",
"referee_period_last_30d": "Τελευταίες 30 ημέρες",
- "referee_points_label": "Πόντοι",
"referee_swaps_volume_label": "Όγκος ανταλλαγών",
"referee_perps_volume_label": "Όγκος perps",
+ "referee_points_to_label": "Πόντοι προς {{code}}",
"referee_error_title": "Δεν ήταν δυνατή η φόρτωση των στοιχείων της VIP παραπομπής σας",
"referee_error_description": "Ελέγξτε τη σύνδεσή σας και προσπαθήστε ξανά.",
"last_updated": "Τελευταία ενημέρωση: {{time}}"
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 724fa475ec7..bfbb93252ce 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -1074,9 +1074,9 @@
"trader_profile": {
"followers_count": "{{count}} follower",
"followers_count_plural": "{{count}} followers",
- "win_rate": "win rate",
- "pnl_7d": "7D Return",
- "hold_time": "hold time",
+ "win_rate": "Win rate",
+ "pnl_7d": "7D P&L",
+ "hold_time": "Avg. hold",
"hold_time_minutes": "{{count}}m",
"hold_time_hours": "{{count}} hrs",
"hold_time_days": "{{count}} days",
@@ -1098,8 +1098,8 @@
"trades": "Trades",
"buy": "Buy",
"trade": "Trade",
- "bought": "{{name}} bought",
- "sold": "{{name}} sold",
+ "bought": "Bought",
+ "sold": "Sold",
"no_trades": "No trades yet",
"closed_position": "Closed position",
"fallback_title": "Position not available",
@@ -1109,8 +1109,8 @@
"sell": "Sell",
"long": "Long",
"short": "Short",
- "opened": "{{name}} opened",
- "closed_action": "{{name}} closed"
+ "opened": "Opened",
+ "closed_action": "Closed"
},
"quick_buy": {
"title": "Buy {{symbol}}",
@@ -7020,6 +7020,10 @@
}
}
},
+ "first_time_deposit": {
+ "title": "First deposit, done",
+ "content": "Your funds are on the way to your account and will start earning soon."
+ },
"rive_onboarding": {
"step1_title": "Money accounts are here",
"step1_body": "Earn up to {{percentage}}% APY on your balance, available across your entire wallet.",
@@ -8704,6 +8708,11 @@
"title": "Set up Linea funding",
"description": "You need at least one approved funding source on Linea before redeeming mUSD back.",
"confirm_button_label": "Set up funding"
+ },
+ "money_account_required": {
+ "title": "Set up Money Account",
+ "description": "You need a linked Money Account before redeeming mUSD back.",
+ "confirm_button_label": "Set up Money Account"
}
},
"change_asset": {
@@ -8927,7 +8936,7 @@
"points_from_referrals_label": "Points from referrals",
"referrals_label": "VIP Referrals",
"tier_benefits_title": "Tier benefits",
- "splash_title": "WELCOME\nTO GOLD\nFOX VIP",
+ "splash_title": "WELCOME\nTO GOLD FOX\nCOLLECTIVE",
"splash_description": "Unlock exclusive perks, early features, and curated rewards. By invitation only.",
"splash_accept_invite": "Accept invite",
"splash_not_now": "Not now",
@@ -8942,17 +8951,14 @@
"tiers_title": "Tiers",
"tier_thresholds": "{{points}} points",
"bps_value": "{{bps}} bps",
- "referee_splash_title": "WELCOME\nTO GOLD\nFOX VIP",
- "referee_splash_description": "Unlock exclusive perks, early features, and curated rewards. By invitation only.",
"referee_splash_continue": "Continue",
- "referee_splash_not_now": "Not now",
"referee_referred_by": "Referred by {{code}}",
"referee_page_title": "VIP Pilot",
"referee_stats_title": "Stats",
"referee_period_last_30d": "Last 30d",
- "referee_points_label": "Points",
"referee_swaps_volume_label": "Swaps volume",
"referee_perps_volume_label": "Perps volume",
+ "referee_points_to_label": "Points to {{code}}",
"referee_error_title": "We couldn’t load your VIP referee details",
"referee_error_description": "Check your connection and try again.",
"referee_contact_support": "Contact support",
diff --git a/locales/languages/es.json b/locales/languages/es.json
index 84630cf2f4b..575b2278b2d 100644
--- a/locales/languages/es.json
+++ b/locales/languages/es.json
@@ -8827,7 +8827,7 @@
"points_from_referrals_label": "Puntos por referidos",
"referrals_label": "Referidos VIP",
"tier_benefits_title": "Beneficios del nivel",
- "splash_title": "BIENVENIDO\nA GOLD\nFOX VIP",
+ "splash_title": "BIENVENIDO\nA GOLD FOX\nCOLLECTIVE",
"splash_description": "Desbloquea ventajas exclusivas, funciones anticipadas y recompensas seleccionadas. Solo por invitación.",
"splash_accept_invite": "Aceptar invitación",
"splash_not_now": "Ahora no",
@@ -8842,17 +8842,14 @@
"tiers_title": "Niveles",
"tier_thresholds": "{{points}} puntos",
"bps_value": "{{bps}} bps",
- "referee_splash_title": "BIENVENIDO\nA GOLD\nFOX VIP",
- "referee_splash_description": "Desbloquea ventajas exclusivas, funciones anticipadas y recompensas seleccionadas. Solo por invitación.",
"referee_splash_continue": "Continuar",
- "referee_splash_not_now": "Ahora no",
"referee_referred_by": "Recomendado por {{code}}",
"referee_page_title": "Piloto VIP",
"referee_stats_title": "Estadísticas",
"referee_period_last_30d": "Últimos 30 días",
- "referee_points_label": "Puntos",
"referee_swaps_volume_label": "Volumen de canjes",
"referee_perps_volume_label": "Volumen de Perps",
+ "referee_points_to_label": "Puntos para {{code}}",
"referee_error_title": "No pudimos cargar los datos de tu referente VIP",
"referee_error_description": "Comprueba tu conexión y vuelve a intentarlo.",
"last_updated": "Última actualización: {{time}}"
diff --git a/locales/languages/fr.json b/locales/languages/fr.json
index 616ce346227..54d404cf2cb 100644
--- a/locales/languages/fr.json
+++ b/locales/languages/fr.json
@@ -8827,7 +8827,7 @@
"points_from_referrals_label": "Points de parrainages",
"referrals_label": "Parrainages VIP",
"tier_benefits_title": "Avantages par niveau",
- "splash_title": "BIENVENUE\nDANS GOLD\nFOX VIP",
+ "splash_title": "BIENVENUE\nDANS GOLD FOX\nCOLLECTIVE",
"splash_description": "Bénéficiez d’avantages exclusifs, de fonctionnalités en avant-première et de récompenses privilégiées. Sur invitation uniquement.",
"splash_accept_invite": "Accepter l’invite",
"splash_not_now": "Pas maintenant",
@@ -8842,17 +8842,14 @@
"tiers_title": "Niveaux",
"tier_thresholds": "{{points}} points",
"bps_value": "{{bps}} points de base",
- "referee_splash_title": "BIENVENUE\nDANS GOLD\nFOX VIP",
- "referee_splash_description": "Bénéficiez d’avantages exclusifs, de fonctionnalités en avant-première et de récompenses privilégiées. Sur invitation uniquement.",
"referee_splash_continue": "Continuer",
- "referee_splash_not_now": "Pas maintenant",
"referee_referred_by": "Parrainé par {{code}}",
"referee_page_title": "VIP Pilot",
"referee_stats_title": "Statistiques",
"referee_period_last_30d": "30 derniers jours",
- "referee_points_label": "Points",
"referee_swaps_volume_label": "Volume des swaps",
"referee_perps_volume_label": "Volume des perps",
+ "referee_points_to_label": "Points pour {{code}}",
"referee_error_title": "Nous n’avons pas pu charger les informations de votre parrain VIP",
"referee_error_description": "Vérifiez votre connexion et réessayez.",
"last_updated": "Dernière mise à jour : {{time}}"
diff --git a/locales/languages/hi.json b/locales/languages/hi.json
index a3a19bb8824..2f5b15c5edb 100644
--- a/locales/languages/hi.json
+++ b/locales/languages/hi.json
@@ -8827,7 +8827,7 @@
"points_from_referrals_label": "रेफरल से पॉइंट्स",
"referrals_label": "वीआईपी रेफरल",
"tier_benefits_title": "टियर लाभ",
- "splash_title": "गोल्ड\nफॉक्स वीआईपी में\nआपका स्वागत है",
+ "splash_title": "गोल्ड फॉक्स\nCOLLECTIVE में\nआपका स्वागत है",
"splash_description": "विशेष लाभ, नए फीचर्स तक जल्दी पहुँच और चुने हुए रिवॉर्ड्स अनलॉक करें। केवल आमंत्रण द्वारा।",
"splash_accept_invite": "आमंत्रण स्वीकार करें",
"splash_not_now": "अभी नहीं",
@@ -8842,17 +8842,14 @@
"tiers_title": "टियर्स",
"tier_thresholds": "{{points}} पॉइंट्स",
"bps_value": "{{bps}} बीपीएस",
- "referee_splash_title": "गोल्ड\nफॉक्स वीआईपी में\nआपका स्वागत है",
- "referee_splash_description": "विशेष लाभ, नए फीचर्स तक जल्दी पहुँच और चुने हुए रिवॉर्ड्स अनलॉक करें। केवल आमंत्रण द्वारा।",
"referee_splash_continue": "जारी रखें",
- "referee_splash_not_now": "अभी नहीं",
"referee_referred_by": "{{code}} द्वारा रेफर किया गया",
"referee_page_title": "VIP पायलट",
"referee_stats_title": "आँकड़े",
"referee_period_last_30d": "पिछले 30 दिन",
- "referee_points_label": "पॉइंट्स",
"referee_swaps_volume_label": "स्वैप्स वॉल्यूम",
"referee_perps_volume_label": "पर्प्स वॉल्यूम",
+ "referee_points_to_label": "{{code}} के लिए पॉइंट्स",
"referee_error_title": "हम आपके VIP रेफरी की जानकारी लोड नहीं कर सके",
"referee_error_description": "अपना कनेक्शन जांचें और फिर से प्रयास करें।",
"last_updated": "पिछला अपडेट इस समय पर किया गया: {{time}}"
diff --git a/locales/languages/id.json b/locales/languages/id.json
index 0d34fa0b793..fdeff4df6f6 100644
--- a/locales/languages/id.json
+++ b/locales/languages/id.json
@@ -8827,7 +8827,7 @@
"points_from_referrals_label": "Poin dari referensi",
"referrals_label": "Referensi VIP",
"tier_benefits_title": "Manfaat tingkatan",
- "splash_title": "SELAMAT DATANG\nDI GOLD\nFOX VIP",
+ "splash_title": "SELAMAT DATANG\nDI GOLD FOX\nCOLLECTIVE",
"splash_description": "Dapatkan keuntungan eksklusif, fitur-fitur awal, dan reward pilihan. Hanya dengan undangan.",
"splash_accept_invite": "Terima undangan",
"splash_not_now": "Tidak sekarang",
@@ -8842,17 +8842,14 @@
"tiers_title": "Tingkatan",
"tier_thresholds": "{{points}} poin",
"bps_value": "{{bps}} bps",
- "referee_splash_title": "SELAMAT DATANG\nDI GOLD\nFOX VIP",
- "referee_splash_description": "Dapatkan keuntungan eksklusif, fitur-fitur awal, dan reward pilihan. Hanya dengan undangan.",
"referee_splash_continue": "Lanjutkan",
- "referee_splash_not_now": "Tidak sekarang",
"referee_referred_by": "Direferensikan oleh {{code}}",
"referee_page_title": "Pilot VIP",
"referee_stats_title": "Statistik",
"referee_period_last_30d": "30 hari terakhir",
- "referee_points_label": "Poin",
"referee_swaps_volume_label": "Volume swap",
"referee_perps_volume_label": "Volume perp",
+ "referee_points_to_label": "Poin untuk {{code}}",
"referee_error_title": "Kami tidak dapat memuat detail perujuk VIP Anda",
"referee_error_description": "Periksa koneksi Anda dan coba lagi.",
"last_updated": "Terakhir diperbarui: {{time}}"
diff --git a/locales/languages/ja.json b/locales/languages/ja.json
index 836747ed120..d05b6da097d 100644
--- a/locales/languages/ja.json
+++ b/locales/languages/ja.json
@@ -8827,7 +8827,7 @@
"points_from_referrals_label": "紹介からのポイント",
"referrals_label": "VIP紹介",
"tier_benefits_title": "レベル特典",
- "splash_title": "GOLD FOX\nVIPへ\nようこそ",
+ "splash_title": "GOLD FOX\nCOLLECTIVEへ\nようこそ",
"splash_description": "限定特典、先行機能、キュレートされたリワードのロックを解除。招待制。",
"splash_accept_invite": "招待を受け入れる",
"splash_not_now": "後で",
@@ -8842,17 +8842,14 @@
"tiers_title": "レベル",
"tier_thresholds": "{{points}}ポイント",
"bps_value": "{{bps}}bps",
- "referee_splash_title": "GOLD FOX\nVIPへ\nようこそ",
- "referee_splash_description": "限定特典、先行機能、キュレートされたリワードのロックを解除。招待制。",
"referee_splash_continue": "続行",
- "referee_splash_not_now": "後で",
"referee_referred_by": "Referred by {{code}}",
"referee_page_title": "VIP Pilot",
"referee_stats_title": "統計",
"referee_period_last_30d": "Last 30d",
- "referee_points_label": "ポイント",
"referee_swaps_volume_label": "Swaps volume",
"referee_perps_volume_label": "Perps volume",
+ "referee_points_to_label": "{{code}}へのポイント",
"referee_error_title": "We couldn’t load your VIP referee details",
"referee_error_description": "接続を確認して、もう一度お試しください。",
"last_updated": "最終更新時刻: {{time}}"
diff --git a/locales/languages/ko.json b/locales/languages/ko.json
index 6eb62c72c20..7edbc40b2f0 100644
--- a/locales/languages/ko.json
+++ b/locales/languages/ko.json
@@ -8827,7 +8827,7 @@
"points_from_referrals_label": "추천으로 얻은 포인트",
"referrals_label": "VIP 추천",
"tier_benefits_title": "티어 혜택",
- "splash_title": "골드 폭스 VIP에\n오신 것을\n환영합니다",
+ "splash_title": "골드 폭스 COLLECTIVE에\n오신 것을\n환영합니다",
"splash_description": "독점적 혜택, 우선 이용 가능한 기능, 엄선된 보상을 만나보세요. 초대받은 사용자만 이용할 수 있습니다.",
"splash_accept_invite": "초대 수락",
"splash_not_now": "나중에",
@@ -8842,17 +8842,14 @@
"tiers_title": "티어",
"tier_thresholds": "{{points}}포인트",
"bps_value": "{{bps}} bps",
- "referee_splash_title": "골드 폭스 VIP에\n오신 것을\n환영합니다",
- "referee_splash_description": "독점적 혜택, 우선 이용 가능한 기능, 엄선된 보상을 만나보세요. 초대받은 사용자만 이용할 수 있습니다.",
"referee_splash_continue": "계속",
- "referee_splash_not_now": "나중에",
"referee_referred_by": "추천 코드: {{code}}",
"referee_page_title": "VIP 파일럿",
"referee_stats_title": "통계",
"referee_period_last_30d": "최근 30일",
- "referee_points_label": "포인트",
"referee_swaps_volume_label": "스왑 거래량",
"referee_perps_volume_label": "무기한 선물 거래량",
+ "referee_points_to_label": "{{code}}에 대한 포인트",
"referee_error_title": "추천받은 VIP 사용자 정보를 불러올 수 없습니다",
"referee_error_description": "연결 상태를 확인하고 다시 시도하세요.",
"last_updated": "마지막 업데이트: {{time}}"
diff --git a/locales/languages/pt.json b/locales/languages/pt.json
index 182a987839f..16fddaf80f9 100644
--- a/locales/languages/pt.json
+++ b/locales/languages/pt.json
@@ -8827,7 +8827,7 @@
"points_from_referrals_label": "Pontos de indicações",
"referrals_label": "Indicações VIP",
"tier_benefits_title": "Benefícios de nível",
- "splash_title": "BOAS-VINDAS\nAO GOLD\nFOX VIP",
+ "splash_title": "BOAS-VINDAS\nAO GOLD FOX\nCOLLECTIVE",
"splash_description": "Desbloqueie vantagens exclusivas, acesso antecipado a recursos e recompensas selecionadas. Somente por convite.",
"splash_accept_invite": "Aceitar convite",
"splash_not_now": "Agora não",
@@ -8842,17 +8842,14 @@
"tiers_title": "Níveis",
"tier_thresholds": "{{points}} pontos",
"bps_value": "{{bps}} bps",
- "referee_splash_title": "BOAS-VINDAS\nAO GOLD\nFOX VIP",
- "referee_splash_description": "Desbloqueie vantagens exclusivas, acesso antecipado a recursos e recompensas selecionadas. Somente por convite.",
"referee_splash_continue": "Continuar",
- "referee_splash_not_now": "Agora não",
"referee_referred_by": "Indicado por {{code}}",
"referee_page_title": "Piloto VIP",
"referee_stats_title": "Estatísticas",
"referee_period_last_30d": "Últimos 30 dias",
- "referee_points_label": "Pontos",
"referee_swaps_volume_label": "Volume de trocas",
"referee_perps_volume_label": "Volume de perps",
+ "referee_points_to_label": "Pontos para {{code}}",
"referee_error_title": "Não foi possível carregar os detalhes do seu árbitro VIP",
"referee_error_description": "Verifique sua conexão e tente novamente.",
"last_updated": "Última atualização: {{time}}"
diff --git a/locales/languages/ru.json b/locales/languages/ru.json
index d12f54d676e..08059baa1bb 100644
--- a/locales/languages/ru.json
+++ b/locales/languages/ru.json
@@ -8827,7 +8827,7 @@
"points_from_referrals_label": "Баллы от рефералов",
"referrals_label": "VIP-рефералы",
"tier_benefits_title": "Преимущества уровня",
- "splash_title": "ДОБРО ПОЖАЛОВАТЬ\nВ GOLD\nFOX VIP",
+ "splash_title": "ДОБРО ПОЖАЛОВАТЬ\nВ GOLD FOX\nCOLLECTIVE",
"splash_description": "Разблокируйте эксклюзивные привилегии, ранние функции и специально подобранные награды. Только по приглашению.",
"splash_accept_invite": "Принять приглашение",
"splash_not_now": "Не сейчас",
@@ -8842,17 +8842,14 @@
"tiers_title": "Уровни",
"tier_thresholds": "{{points}} балла(-ов)",
"bps_value": "{{bps}} базисных пункта(-ов)",
- "referee_splash_title": "ДОБРО ПОЖАЛОВАТЬ\nВ GOLD\nFOX VIP",
- "referee_splash_description": "Разблокируйте эксклюзивные привилегии, ранние функции и специально подобранные награды. Только по приглашению.",
"referee_splash_continue": "Продолжить",
- "referee_splash_not_now": "Не сейчас",
"referee_referred_by": "По рекомендации {{code}}",
"referee_page_title": "VIP Pilot",
"referee_stats_title": "Статистика",
"referee_period_last_30d": "Последние 30 дн.",
- "referee_points_label": "Баллы",
"referee_swaps_volume_label": "Объем свопов",
"referee_perps_volume_label": "Объем перпов",
+ "referee_points_to_label": "Баллы для {{code}}",
"referee_error_title": "Не удалось загрузить данные вашего VIP-приглашенного",
"referee_error_description": "Проверьте соединение и повторите попытку.",
"last_updated": "Последнее обновление: {{time}}"
diff --git a/locales/languages/tl.json b/locales/languages/tl.json
index 1dc79f1e181..2429180c0f7 100644
--- a/locales/languages/tl.json
+++ b/locales/languages/tl.json
@@ -8827,7 +8827,7 @@
"points_from_referrals_label": "Mga punto mula sa mga referral",
"referrals_label": "Mga Referral ng VIP",
"tier_benefits_title": "Mga benepisyo ng tier",
- "splash_title": "MALIGAYANG PAGDATING\nSA GOLD\nFOX VIP",
+ "splash_title": "MALIGAYANG PAGDATING\nSA GOLD FOX\nCOLLECTIVE",
"splash_description": "I-unlock ang mga eksklusibong perk, mga unang feature, at na-curate na reward. Para sa imbitado lang.",
"splash_accept_invite": "Tanggapin ang imbitasyon",
"splash_not_now": "Hindi sa ngayon",
@@ -8842,17 +8842,14 @@
"tiers_title": "Mga Tier",
"tier_thresholds": "{{points}} (na) point",
"bps_value": "{{bps}} bps",
- "referee_splash_title": "MALIGAYANG PAGDATING\nSA GOLD\nFOX VIP",
- "referee_splash_description": "I-unlock ang mga eksklusibong perk, mga unang feature, at na-curate na reward. Para sa imbitado lang.",
"referee_splash_continue": "Magpatuloy",
- "referee_splash_not_now": "Hindi sa ngayon",
"referee_referred_by": "Referred by {{code}}",
"referee_page_title": "VIP Pilot",
"referee_stats_title": "Estadistika",
"referee_period_last_30d": "Last 30d",
- "referee_points_label": "Mga Point",
"referee_swaps_volume_label": "Swaps volume",
"referee_perps_volume_label": "Perps volume",
+ "referee_points_to_label": "Mga puntos para kay {{code}}",
"referee_error_title": "We couldn’t load your VIP referee details",
"referee_error_description": "Suriin ang iyong koneksyon at subukang muli.",
"last_updated": "Huling na-update: {{time}}"
diff --git a/locales/languages/tr.json b/locales/languages/tr.json
index c9e3b0d9833..c723a738b22 100644
--- a/locales/languages/tr.json
+++ b/locales/languages/tr.json
@@ -8827,7 +8827,7 @@
"points_from_referrals_label": "Referanslardan gelen puanlar",
"referrals_label": "VIP Referanslar",
"tier_benefits_title": "Aşama faydaları",
- "splash_title": "GOLD\nFOX VIP'YE\nHOŞ GELDİNİZ",
+ "splash_title": "GOLD FOX\nCOLLECTIVE'YE\nHOŞ GELDİNİZ",
"splash_description": "Özel avantajların, erken erişim özelliklerinin ve seçkin ödüllerin kilidini açın. Yalnızca davet yoluyla.",
"splash_accept_invite": "Daveti kabul et",
"splash_not_now": "Şimdi değil",
@@ -8842,17 +8842,14 @@
"tiers_title": "Aşamalar",
"tier_thresholds": "{{points}} puan",
"bps_value": "{{bps}} bps",
- "referee_splash_title": "GOLD\nFOX VIP'YE\nHOŞ GELDİNİZ",
- "referee_splash_description": "Özel avantajların, erken erişim özelliklerinin ve seçkin ödüllerin kilidini açın. Yalnızca davet yoluyla.",
"referee_splash_continue": "Devam et",
- "referee_splash_not_now": "Şimdi değil",
"referee_referred_by": "Öneren {{code}}",
"referee_page_title": "VIP Pilot",
"referee_stats_title": "İstatistikler",
"referee_period_last_30d": "Son 30g",
- "referee_points_label": "Puan",
"referee_swaps_volume_label": "Takas hacmi",
"referee_perps_volume_label": "Perps hacmi",
+ "referee_points_to_label": "{{code}} için puan",
"referee_error_title": "VIP referans bilgilerinizi yükleyemedik",
"referee_error_description": "Bağlantınızı kontrol edin ve tekrar deneyin.",
"last_updated": "Son güncelleme: {{time}}"
diff --git a/locales/languages/vi.json b/locales/languages/vi.json
index da3fde37ef2..d2e3aa22437 100644
--- a/locales/languages/vi.json
+++ b/locales/languages/vi.json
@@ -8827,7 +8827,7 @@
"points_from_referrals_label": "Điểm từ giới thiệu",
"referrals_label": "Giới thiệu VIP",
"tier_benefits_title": "Quyền lợi bậc",
- "splash_title": "CHÀO MỪNG\nĐẾN VỚI\nVIP CÁO VÀNG",
+ "splash_title": "CHÀO MỪNG\nĐẾN VỚI\nGOLD FOX COLLECTIVE",
"splash_description": "Mở khóa các đặc quyền độc quyền, tính năng sớm và phần thưởng được tuyển chọn. Chỉ dành cho người được mời.",
"splash_accept_invite": "Chấp nhận lời mời",
"splash_not_now": "Không phải bây giờ",
@@ -8842,17 +8842,14 @@
"tiers_title": "Bậc",
"tier_thresholds": "{{points}} điểm",
"bps_value": "{{bps}} bps",
- "referee_splash_title": "CHÀO MỪNG\nĐẾN VỚI\nVIP CÁO VÀNG",
- "referee_splash_description": "Mở khóa các đặc quyền độc quyền, tính năng sớm và phần thưởng được tuyển chọn. Chỉ dành cho người được mời.",
"referee_splash_continue": "Tiếp tục",
- "referee_splash_not_now": "Không phải bây giờ",
"referee_referred_by": "Được giới thiệu bởi {{code}}",
"referee_page_title": "Chương trình thử nghiệm VIP",
"referee_stats_title": "Thống kê",
"referee_period_last_30d": "30 ngày gần nhất",
- "referee_points_label": "Điểm",
"referee_swaps_volume_label": "Khối lượng hoán đổi",
"referee_perps_volume_label": "Khối lượng giao dịch hợp đồng vĩnh cửu",
+ "referee_points_to_label": "Điểm cho {{code}}",
"referee_error_title": "Chúng tôi không thể tải thông tin người giới thiệu VIP của bạn",
"referee_error_description": "Kiểm tra kết nối của bạn và thử lại.",
"last_updated": "Cập nhật lần cuối: {{time}}"
diff --git a/locales/languages/zh.json b/locales/languages/zh.json
index e5197c219c3..32fe83b00ed 100644
--- a/locales/languages/zh.json
+++ b/locales/languages/zh.json
@@ -8827,7 +8827,7 @@
"points_from_referrals_label": "通过推荐所获积分",
"referrals_label": "VIP 推荐",
"tier_benefits_title": "等级权益",
- "splash_title": "欢迎\n成为黄金\nFOX VIP",
+ "splash_title": "欢迎\n加入 GOLD FOX\nCOLLECTIVE",
"splash_description": "解锁专属权益、抢先体验功能及精选奖励。仅限受邀用户。",
"splash_accept_invite": "接受邀请",
"splash_not_now": "暂时不",
@@ -8842,17 +8842,14 @@
"tiers_title": "等级",
"tier_thresholds": "{{points}} 积分",
"bps_value": "{{bps}} 个基点",
- "referee_splash_title": "欢迎\n成为黄金\nFOX VIP",
- "referee_splash_description": "解锁专属权益、抢先体验功能及精选奖励。仅限受邀用户。",
"referee_splash_continue": "继续",
- "referee_splash_not_now": "暂时不",
"referee_referred_by": "由 {{code}} 推荐",
"referee_page_title": "VIP 抢先体验计划",
"referee_stats_title": "统计数据",
"referee_period_last_30d": "过去 30 天",
- "referee_points_label": "积分",
"referee_swaps_volume_label": "兑换交易量",
"referee_perps_volume_label": "永续合约交易量",
+ "referee_points_to_label": "给 {{code}} 的积分",
"referee_error_title": "无法加载您的 VIP 被推荐人详情",
"referee_error_description": "请检查连接后重试。",
"last_updated": "最后更新于:{{time}}"
diff --git a/package.json b/package.json
index 11d1c630797..8ba3595bf17 100644
--- a/package.json
+++ b/package.json
@@ -296,7 +296,7 @@
"@metamask/metamask-eth-abis": "3.1.1",
"@metamask/mobile-wallet-protocol-core": "^0.4.0",
"@metamask/mobile-wallet-protocol-wallet-client": "^0.3.0",
- "@metamask/money-account-balance-service": "^2.0.0",
+ "@metamask/money-account-balance-service": "^2.1.0",
"@metamask/money-account-controller": "^0.2.0",
"@metamask/money-account-upgrade-controller": "^2.0.5",
"@metamask/multichain-account-service": "^10.0.2",
diff --git a/tests/component-view/fixtures/predict.ts b/tests/component-view/fixtures/predict.ts
index 972600f7f7e..70a6254a92f 100644
--- a/tests/component-view/fixtures/predict.ts
+++ b/tests/component-view/fixtures/predict.ts
@@ -34,3 +34,62 @@ export const MOCK_PREDICT_MARKET: PredictMarket = {
liquidity: 500_000,
volume: 1_000_000,
};
+
+/** Scoreboard-capable live sports market for Live Now / sport-card view tests. */
+export const MOCK_PREDICT_LIVE_SPORT_MARKET: PredictMarket = {
+ id: 'market-live-sport-1',
+ providerId: 'polymarket',
+ slug: 'spain-vs-england-live',
+ title: 'Spain vs England',
+ description: 'Live World Cup matchup between Spain and England',
+ image: '',
+ status: 'open',
+ recurrence: Recurrence.NONE,
+ category: 'sports',
+ tags: ['World Cup'],
+ outcomes: [
+ {
+ id: 'outcome-game-winner',
+ providerId: 'polymarket',
+ marketId: 'market-live-sport-1',
+ title: 'Game Winner',
+ description: 'Who will win the game',
+ image: '',
+ status: 'open',
+ tokens: [
+ { id: 'token-home', title: 'Spain', price: 0.6 },
+ { id: 'token-draw', title: 'Draw', price: 0.15 },
+ { id: 'token-away', title: 'England', price: 0.62 },
+ ],
+ volume: 1_000_000,
+ groupItemTitle: '',
+ },
+ ],
+ liquidity: 500_000,
+ volume: 1_000_000,
+ game: {
+ id: 'game-live-1',
+ startTime: '2026-06-08T21:30:00Z',
+ status: 'ongoing',
+ league: 'fifwc',
+ elapsed: "45'",
+ period: '1H',
+ score: { home: 1, away: 0, raw: '0-1' },
+ awayTeam: {
+ id: 'england',
+ name: 'England',
+ logo: 'https://example.com/england.png',
+ abbreviation: 'ENG',
+ color: '#FF0000',
+ alias: 'England',
+ },
+ homeTeam: {
+ id: 'spain',
+ name: 'Spain',
+ logo: 'https://example.com/spain.png',
+ abbreviation: 'SPA',
+ color: '#FF8800',
+ alias: 'Spain',
+ },
+ },
+};
diff --git a/tests/component-view/mocks.ts b/tests/component-view/mocks.ts
index 224cda6c91c..6b9c5d9ecf6 100644
--- a/tests/component-view/mocks.ts
+++ b/tests/component-view/mocks.ts
@@ -356,7 +356,11 @@ jest.mock('../../app/core/Engine', () => {
getCryptoTargetPrice: jest.fn().mockResolvedValue(69000),
subscribeToMarketPrices: jest.fn(() => () => undefined),
subscribeToCryptoPrices: jest.fn(() => () => undefined),
- getConnectionStatus: jest.fn(() => ({ marketConnected: false })),
+ subscribeToGameUpdates: jest.fn(() => () => undefined),
+ getConnectionStatus: jest.fn(() => ({
+ marketConnected: false,
+ sportsConnected: false,
+ })),
trackFeedViewed: jest.fn(),
trackTabChanged: jest.fn(),
trackBannerAction: jest.fn(),
diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts
index 0ef103cf04b..355ae3b602d 100644
--- a/tests/feature-flags/feature-flag-registry.ts
+++ b/tests/feature-flags/feature-flag-registry.ts
@@ -3230,6 +3230,14 @@ export const FEATURE_FLAG_REGISTRY: Record = {
status: FeatureFlagStatus.Active,
},
+ earnMoneyFirstTimeDepositAnimationEnabled: {
+ name: 'earnMoneyFirstTimeDepositAnimationEnabled',
+ type: FeatureFlagType.Remote,
+ inProd: true,
+ productionDefault: false,
+ status: FeatureFlagStatus.Active,
+ },
+
earnMoneyHubEnabled: {
name: 'earnMoneyHubEnabled',
type: FeatureFlagType.Remote,
@@ -4877,6 +4885,16 @@ export const FEATURE_FLAG_REGISTRY: Record = {
status: FeatureFlagStatus.Active,
},
+ homeTMCU926AbtestDiscoveryPills: {
+ name: 'homeTMCU926AbtestDiscoveryPills',
+ type: FeatureFlagType.Remote,
+ inProd: true,
+ productionDefault: {
+ enabled: false,
+ },
+ status: FeatureFlagStatus.Active,
+ },
+
homepageRedesignV1: {
name: 'homepageRedesignV1',
type: FeatureFlagType.Remote,
@@ -5388,6 +5406,17 @@ export const FEATURE_FLAG_REGISTRY: Record = {
status: FeatureFlagStatus.Active,
},
+ predictSportCardLivePrices: {
+ name: 'predictSportCardLivePrices',
+ type: FeatureFlagType.Remote,
+ inProd: true,
+ productionDefault: {
+ minimumVersion: '7.81.0',
+ enabled: true,
+ },
+ status: FeatureFlagStatus.Active,
+ },
+
smartTransactionsAllowedRpcHosts: {
name: 'smartTransactionsAllowedRpcHosts',
type: FeatureFlagType.Remote,
diff --git a/yarn.lock b/yarn.lock
index 159dd2ff0c5..58a91f2c502 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9189,9 +9189,9 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/money-account-balance-service@npm:^2.0.0":
- version: 2.0.0
- resolution: "@metamask/money-account-balance-service@npm:2.0.0"
+"@metamask/money-account-balance-service@npm:^2.1.0":
+ version: 2.1.0
+ resolution: "@metamask/money-account-balance-service@npm:2.1.0"
dependencies:
"@ethersproject/contracts": "npm:^5.7.0"
"@ethersproject/providers": "npm:^5.7.0"
@@ -9203,7 +9203,7 @@ __metadata:
"@metamask/remote-feature-flag-controller": "npm:^4.2.2"
"@metamask/superstruct": "npm:^3.1.0"
"@metamask/utils": "npm:^11.11.0"
- checksum: 10/47c66554d975250d8de73287d7eb35fb3c3d57de53514902ec5801482f20c12e69c8ebc2910996a3c97c73d5acdc403660e53b755aff88a7877a3979f6cc9544
+ checksum: 10/3bc2947f2b93c0d3ceaa5637c728998153d3dc1a2bf4b6e6d503b9cf4e8cbf8288f76189b378e190c67d324652985cd6fc48c55506511f972f94e6e837b3df89
languageName: node
linkType: hard
@@ -35466,7 +35466,7 @@ __metadata:
"@metamask/mobile-provider": "npm:^3.0.0"
"@metamask/mobile-wallet-protocol-core": "npm:^0.4.0"
"@metamask/mobile-wallet-protocol-wallet-client": "npm:^0.3.0"
- "@metamask/money-account-balance-service": "npm:^2.0.0"
+ "@metamask/money-account-balance-service": "npm:^2.1.0"
"@metamask/money-account-controller": "npm:^0.2.0"
"@metamask/money-account-upgrade-controller": "npm:^2.0.5"
"@metamask/multichain-account-service": "npm:^10.0.2"