diff --git a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx
index f669718c1b1..e0018003b4b 100644
--- a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx
+++ b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx
@@ -5,7 +5,7 @@ import MoneyAddMoneySheet from './MoneyAddMoneySheet';
import { MoneyAddMoneySheetTestIds } from './MoneyAddMoneySheet.testIds';
import { useMusdConversionFlowData } from '../../../Earn/hooks/useMusdConversionFlowData';
import { useRampNavigation } from '../../../Ramp/hooks/useRampNavigation';
-import { useMusdBalance } from '../../../Earn/hooks/useMusdBalance';
+import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance';
import { useMoneyAccountDeposit } from '../../hooks/useMoneyAccount';
import {
MUSD_CONVERSION_DEFAULT_CHAIN_ID,
@@ -38,8 +38,9 @@ jest.mock('../../../Ramp/hooks/useRampNavigation', () => ({
useRampNavigation: jest.fn(),
}));
-jest.mock('../../../Earn/hooks/useMusdBalance', () => ({
- useMusdBalance: jest.fn(),
+jest.mock('../../hooks/useMoneyAccountBalance', () => ({
+ __esModule: true,
+ default: jest.fn(),
}));
jest.mock('../../hooks/useMoneyAccount', () => ({
@@ -86,8 +87,8 @@ describe('MoneyAddMoneySheet', () => {
(useRampNavigation as jest.Mock).mockReturnValue({
goToBuy: mockGoToBuy,
});
- (useMusdBalance as jest.Mock).mockReturnValue({
- fiatBalanceAggregatedFormatted: '$1,203.89',
+ (useMoneyAccountBalance as jest.Mock).mockReturnValue({
+ totalFiatFormatted: '$1,203.89',
});
(useMoneyAccountDeposit as jest.Mock).mockReturnValue({
initiateDeposit: mockInitiateDeposit,
@@ -110,8 +111,8 @@ describe('MoneyAddMoneySheet', () => {
});
it('preserves the locale fiat prefix in the Move mUSD row', () => {
- (useMusdBalance as jest.Mock).mockReturnValue({
- fiatBalanceAggregatedFormatted: 'CA$1,500.00',
+ (useMoneyAccountBalance as jest.Mock).mockReturnValue({
+ totalFiatFormatted: 'CA$1,500.00',
});
const { getByText } = renderWithProvider();
@@ -119,8 +120,8 @@ describe('MoneyAddMoneySheet', () => {
});
it('falls back to the no-amount copy when the mUSD balance is unavailable', () => {
- (useMusdBalance as jest.Mock).mockReturnValue({
- fiatBalanceAggregatedFormatted: undefined,
+ (useMoneyAccountBalance as jest.Mock).mockReturnValue({
+ totalFiatFormatted: undefined,
});
const { getByText } = renderWithProvider();
diff --git a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx
index 6b559e9982d..c62a4bbda98 100644
--- a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx
+++ b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx
@@ -17,8 +17,8 @@ import {
import Tag from '../../../../../component-library/components/Tags/Tag';
import { strings } from '../../../../../../locales/i18n';
import { useStyles } from '../../../../../component-library/hooks';
-import { useMusdBalance } from '../../../Earn/hooks/useMusdBalance';
import { useMusdConversionFlowData } from '../../../Earn/hooks/useMusdConversionFlowData';
+import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance';
import {
MUSD_CONVERSION_DEFAULT_CHAIN_ID,
MUSD_TOKEN_ASSET_ID_BY_CHAIN,
@@ -40,7 +40,7 @@ const MoneyAddMoneySheet: React.FC = () => {
const navigation = useNavigation();
const { styles } = useStyles(styleSheet, {});
- const { fiatBalanceAggregatedFormatted } = useMusdBalance();
+ const { totalFiatFormatted } = useMoneyAccountBalance();
const { getChainIdForBuyFlow } = useMusdConversionFlowData();
const { goToBuy } = useRampNavigation();
const { initiateDeposit } = useMoneyAccountDeposit();
@@ -94,9 +94,9 @@ const MoneyAddMoneySheet: React.FC = () => {
testID: MoneyAddMoneySheetTestIds.DEPOSIT_FUNDS_OPTION,
},
{
- label: fiatBalanceAggregatedFormatted
+ label: totalFiatFormatted
? strings('money.add_money_sheet.move_musd', {
- amount: fiatBalanceAggregatedFormatted,
+ amount: totalFiatFormatted,
})
: strings('money.add_money_sheet.move_musd_no_amount'),
icon: IconName.Add,
diff --git a/app/components/UI/Rewards/RewardsNavigator.test.tsx b/app/components/UI/Rewards/RewardsNavigator.test.tsx
index e903bafb8ec..b824e326b1c 100644
--- a/app/components/UI/Rewards/RewardsNavigator.test.tsx
+++ b/app/components/UI/Rewards/RewardsNavigator.test.tsx
@@ -282,6 +282,8 @@ jest.mock('./hooks/useRewardsToast', () => ({
loading: jest.fn(),
entriesClosed: jest.fn(),
enableNotificationsNudge: jest.fn(),
+ outcomeWinner: jest.fn(),
+ outcomeNonWinner: jest.fn(),
},
})),
}));
diff --git a/app/components/UI/Rewards/RewardsNavigator.tsx b/app/components/UI/Rewards/RewardsNavigator.tsx
index f9aabffa47a..77ab123834e 100644
--- a/app/components/UI/Rewards/RewardsNavigator.tsx
+++ b/app/components/UI/Rewards/RewardsNavigator.tsx
@@ -37,9 +37,9 @@ import { useReferralDetails } from './hooks/useReferralDetails';
import { useRewardsNotificationsNudge } from './hooks/useRewardsNotificationsNudge';
import useRewardsToast from './hooks/useRewardsToast';
import { strings } from '../../../../locales/i18n';
+import PerpsTradingCampaignWinningView from './Views/PerpsTradingCampaignWinningView';
let sessionNotificationsNudgeShown = false;
-
const Stack = createStackNavigator();
const RewardsNavigator: React.FC = () => {
@@ -296,6 +296,11 @@ const RewardsNavigator: React.FC = () => {
component={PerpsTradingCampaignStatsView}
options={{ headerShown: false }}
/>
+
>
) : null}
diff --git a/app/components/UI/Rewards/Views/CampaignWinningView.test.tsx b/app/components/UI/Rewards/Views/CampaignWinningView.test.tsx
new file mode 100644
index 00000000000..afbc489f176
--- /dev/null
+++ b/app/components/UI/Rewards/Views/CampaignWinningView.test.tsx
@@ -0,0 +1,297 @@
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react-native';
+import { Linking } from 'react-native';
+import Clipboard from '@react-native-clipboard/clipboard';
+import CampaignWinningView, {
+ CampaignWinningViewProps,
+} from './CampaignWinningView';
+import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView';
+
+jest.mock('../../../../images/rewards/campaign_winning.png', () => ({
+ __esModule: true,
+ default: 1,
+}));
+
+const mockGoBack = jest.fn();
+const mockNavigate = jest.fn();
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate }),
+}));
+
+jest.mock('@metamask/design-system-twrnc-preset', () => {
+ const tw = (...args: unknown[]) => args;
+ tw.style = (...args: unknown[]) => args;
+ return { useTailwind: () => tw };
+});
+
+jest.mock('react-native/Libraries/Utilities/useWindowDimensions', () => ({
+ default: () => ({ width: 390, height: 844 }),
+}));
+
+jest.mock('react-native-safe-area-context', () => {
+ const actual = jest.requireActual('react-native-safe-area-context');
+ return {
+ ...actual,
+ useSafeAreaInsets: () => ({ top: 44, bottom: 34, left: 0, right: 0 }),
+ };
+});
+
+jest.mock('../../../Views/ErrorBoundary', () => {
+ const ReactActual = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: ({ children }: { children: React.ReactNode }) =>
+ ReactActual.createElement(View, null, children),
+ };
+});
+
+jest.mock('../hooks/useTrackRewardsPageView', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+
+jest.mock('../../../../core/Analytics', () => ({
+ MetaMetricsEvents: {
+ REWARDS_PAGE_BUTTON_CLICKED: 'REWARDS_PAGE_BUTTON_CLICKED',
+ },
+}));
+
+jest.mock('../utils', () => ({
+ RewardsMetricsButtons: {
+ COPY_WINNER_VERIFICATION_CODE: 'copy_winner_verification_code',
+ },
+}));
+
+const mockTrackEvent = jest.fn();
+const mockBuild = jest.fn(() => ({}));
+jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: () => ({
+ addProperties: () => ({ build: mockBuild }),
+ }),
+ }),
+}));
+
+jest.mock('../components/ReferralDetails/CopyableField', () => {
+ const ReactActual = jest.requireActual('react');
+ const { View, Text, Pressable } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: ({
+ label,
+ value,
+ onCopy,
+ }: {
+ label: string;
+ value?: string | null;
+ onCopy?: () => void;
+ }) =>
+ ReactActual.createElement(
+ View,
+ { testID: 'copyable-field' },
+ ReactActual.createElement(Text, null, label),
+ ReactActual.createElement(
+ Text,
+ { testID: 'copyable-value' },
+ value ?? '',
+ ),
+ ReactActual.createElement(Pressable, {
+ testID: 'copyable-trigger',
+ onPress: onCopy,
+ }),
+ ),
+ };
+});
+
+jest.mock('../../../../../locales/i18n', () => ({
+ strings: jest.fn(
+ (
+ key: string,
+ params?: {
+ code?: string;
+ campaignName?: string;
+ email?: string;
+ },
+ ) => {
+ if (
+ key === 'rewards.campaign_winning.mail_subject' &&
+ params?.campaignName
+ )
+ return `${params.campaignName} prize claim`;
+ if (key === 'rewards.campaign_winning.mail_body' && params?.code)
+ return `My winning code: ${params.code}`;
+ if (
+ key === 'rewards.campaign_winning.email_instructions' &&
+ params?.email
+ )
+ return `Email ${params.email} with your code`;
+ const map: Record = {
+ 'rewards.campaign_winning.you_won': 'You won',
+ 'rewards.campaign_winning.open_mail': 'Open mail',
+ 'rewards.campaign_winning.skip_for_now': 'Skip for now',
+ 'rewards.campaign_winning.winning_code': 'Winning code',
+ 'rewards.campaign_winning.close_a11y': 'Close',
+ };
+ return map[key] ?? key;
+ },
+ ),
+}));
+
+const PRIZE_EMAIL = 'test@consensys.net';
+const CAMPAIGN_NAME = 'Test Campaign';
+const CAMPAIGN_ID = 'campaign-test-1';
+const WINNING_CODE = 'WIN-123';
+const mockUseTrackRewardsPageView =
+ useTrackRewardsPageView as jest.MockedFunction<
+ typeof useTrackRewardsPageView
+ >;
+
+const defaultProps: CampaignWinningViewProps = {
+ testID: 'test-winning-view',
+ viewName: 'TestWinningView',
+ prizeEmail: PRIZE_EMAIL,
+ campaignName: CAMPAIGN_NAME,
+ campaignId: CAMPAIGN_ID,
+ analyticsPageType: 'test_campaign_winning',
+ winningCode: WINNING_CODE,
+ hasOutcomeLoaded: true,
+ isLoading: false,
+ rankDisplay: null,
+};
+
+describe('CampaignWinningView', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the main container with the provided testID', () => {
+ const { getByTestId } = render();
+ expect(getByTestId('test-winning-view')).toBeTruthy();
+ });
+
+ it('renders "You won" text', () => {
+ const { getByText } = render();
+ expect(getByText('You won')).toBeTruthy();
+ });
+
+ it('renders email instructions with the prizeEmail', () => {
+ const { getByText } = render();
+ expect(getByText(`Email ${PRIZE_EMAIL} with your code`)).toBeTruthy();
+ });
+
+ it('tracks page view with the campaign id', () => {
+ render();
+ expect(mockUseTrackRewardsPageView).toHaveBeenCalledWith({
+ page_type: 'test_campaign_winning',
+ campaign_id: CAMPAIGN_ID,
+ });
+ });
+
+ it('renders rank and result display when provided', () => {
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText('3rd')).toBeTruthy();
+ expect(getByText('+12.34%')).toBeTruthy();
+ });
+
+ it('calls goBack when Skip for now is pressed', () => {
+ const { getByText } = render();
+ fireEvent.press(getByText('Skip for now'));
+ expect(mockGoBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls goBack when Close button is pressed', () => {
+ const { getByLabelText } = render(
+ ,
+ );
+ fireEvent.press(getByLabelText('Close'));
+ expect(mockGoBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('copies winning code and fires analytics when copy is triggered', () => {
+ const setStringSpy = jest.spyOn(Clipboard, 'setString');
+ const { getByTestId } = render();
+ fireEvent.press(getByTestId('copyable-trigger'));
+ expect(setStringSpy).toHaveBeenCalledWith(WINNING_CODE);
+ expect(mockTrackEvent).toHaveBeenCalled();
+ setStringSpy.mockRestore();
+ });
+
+ it('opens mailto with the correct email and code when Open mail is pressed', async () => {
+ const openSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined);
+ const { getByText } = render();
+ fireEvent.press(getByText('Open mail'));
+ expect(openSpy).toHaveBeenCalled();
+ const url = openSpy.mock.calls[0][0] as string;
+ expect(url).toContain(`mailto:${PRIZE_EMAIL}`);
+ expect(url).toContain(encodeURIComponent(WINNING_CODE));
+ openSpy.mockRestore();
+ });
+
+ it('navigates to fallback route when outcome loads without a winning code', () => {
+ const fallbackRoute = {
+ route: 'CampaignDetails',
+ params: { campaignId: CAMPAIGN_ID },
+ };
+
+ render(
+ ,
+ );
+
+ expect(mockNavigate).toHaveBeenCalledWith(
+ fallbackRoute.route,
+ fallbackRoute.params,
+ );
+ expect(mockGoBack).not.toHaveBeenCalled();
+ });
+
+ it('falls back to goBack when outcome loads without a winning code and no fallback route is provided', () => {
+ render(
+ ,
+ );
+ expect(mockGoBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call goBack before outcome has loaded', () => {
+ render(
+ ,
+ );
+ expect(mockGoBack).not.toHaveBeenCalled();
+ });
+
+ it('does not call goBack while still loading', () => {
+ render(
+ ,
+ );
+ expect(mockGoBack).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/components/UI/Rewards/Views/CampaignWinningView.tsx b/app/components/UI/Rewards/Views/CampaignWinningView.tsx
new file mode 100644
index 00000000000..8eb65c54a01
--- /dev/null
+++ b/app/components/UI/Rewards/Views/CampaignWinningView.tsx
@@ -0,0 +1,267 @@
+import React, { useCallback, useEffect } from 'react';
+import { Image, Linking, ScrollView, useWindowDimensions } from 'react-native';
+import Clipboard from '@react-native-clipboard/clipboard';
+import {
+ useNavigation,
+ type NavigationProp,
+ type ParamListBase,
+} from '@react-navigation/native';
+import {
+ SafeAreaView,
+ useSafeAreaInsets,
+} from 'react-native-safe-area-context';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import {
+ Box,
+ BoxFlexDirection,
+ Button,
+ ButtonSize,
+ ButtonVariant,
+ ButtonIcon,
+ ButtonIconSize,
+ IconName,
+ Skeleton,
+ Text,
+ TextColor,
+ TextVariant,
+ FontWeight,
+} from '@metamask/design-system-react-native';
+import ErrorBoundary from '../../../Views/ErrorBoundary';
+import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView';
+import { strings } from '../../../../../locales/i18n';
+import CopyableField from '../components/ReferralDetails/CopyableField';
+import { RewardsMetricsButtons } from '../utils';
+import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics';
+import { MetaMetricsEvents } from '../../../../core/Analytics';
+import campaignWinningHero from '../../../../images/rewards/campaign_winning.png';
+
+const HERO_HEIGHT_RATIO = 0.5;
+
+export interface CampaignWinningViewProps {
+ testID: string;
+ viewName: string;
+ prizeEmail: string;
+ campaignName: string;
+ campaignId: string;
+ analyticsPageType: string;
+ winningCode: string | null;
+ hasOutcomeLoaded: boolean;
+ isLoading: boolean;
+ rankDisplay: string | null;
+ resultDisplay?: string | null;
+ isRankLoading?: boolean;
+ isResultLoading?: boolean;
+ fallbackRoute?: {
+ route: string;
+ params?: object;
+ };
+}
+
+const CampaignWinningView: React.FC = ({
+ testID,
+ viewName,
+ prizeEmail,
+ campaignName,
+ campaignId,
+ analyticsPageType,
+ winningCode,
+ hasOutcomeLoaded,
+ isLoading,
+ rankDisplay,
+ resultDisplay = null,
+ isRankLoading = false,
+ isResultLoading = false,
+ fallbackRoute,
+}) => {
+ const tw = useTailwind();
+ const { height: windowHeight } = useWindowDimensions();
+ const heroHeight = windowHeight * HERO_HEIGHT_RATIO;
+ const insets = useSafeAreaInsets();
+ const navigation = useNavigation>();
+ const { trackEvent, createEventBuilder } = useAnalytics();
+
+ useTrackRewardsPageView({
+ page_type: analyticsPageType,
+ campaign_id: campaignId,
+ });
+
+ useEffect(() => {
+ if (!isLoading && hasOutcomeLoaded && winningCode === null) {
+ if (fallbackRoute) {
+ navigation.navigate(fallbackRoute.route, fallbackRoute.params);
+ return;
+ }
+ navigation.goBack();
+ }
+ }, [isLoading, hasOutcomeLoaded, winningCode, fallbackRoute, navigation]);
+
+ const onDismiss = useCallback(() => {
+ navigation.goBack();
+ }, [navigation]);
+
+ const handleCopyWinningCode = useCallback(() => {
+ if (winningCode) {
+ Clipboard.setString(winningCode);
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.REWARDS_PAGE_BUTTON_CLICKED)
+ .addProperties({
+ button_type: RewardsMetricsButtons.COPY_WINNER_VERIFICATION_CODE,
+ })
+ .build(),
+ );
+ }
+ }, [winningCode, trackEvent, createEventBuilder]);
+
+ const handleOpenMail = useCallback(async () => {
+ const baseSubject = strings('rewards.campaign_winning.mail_subject', {
+ campaignName,
+ });
+ const subject = winningCode
+ ? `${baseSubject} - ${winningCode}`
+ : baseSubject;
+ const body = strings('rewards.campaign_winning.mail_body', {
+ code: winningCode || '—',
+ });
+ const url = `mailto:${prizeEmail}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
+ try {
+ await Linking.openURL(url);
+ } catch {
+ // no-op: device may not have a mail handler
+ }
+ }, [winningCode, prizeEmail, campaignName]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {strings('rewards.campaign_winning.you_won')}
+
+
+
+ {rankDisplay !== null ? (
+
+ {rankDisplay}
+
+ ) : isRankLoading ? (
+
+ ) : null}
+
+ {resultDisplay !== null ? (
+
+ {resultDisplay}
+
+ ) : isResultLoading ? (
+
+ ) : null}
+
+
+
+ {strings('rewards.campaign_winning.email_instructions', {
+ email: prizeEmail,
+ })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default CampaignWinningView;
diff --git a/app/components/UI/Rewards/Views/CampaignsView.test.tsx b/app/components/UI/Rewards/Views/CampaignsView.test.tsx
index 0bca5c0bfef..a811a188841 100644
--- a/app/components/UI/Rewards/Views/CampaignsView.test.tsx
+++ b/app/components/UI/Rewards/Views/CampaignsView.test.tsx
@@ -6,6 +6,8 @@ import {
CampaignType,
} from '../../../../core/Engine/controllers/rewards-controller/types';
import { useRewardCampaigns } from '../hooks/useRewardCampaigns';
+import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast';
+import { usePerpsTradingCampaignEndedOutcomeToast } from '../hooks/usePerpsTradingCampaignEndedOutcomeToast';
import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants';
const mockGoBack = jest.fn();
@@ -30,11 +32,18 @@ const mockUseRewardCampaigns = useRewardCampaigns as jest.MockedFunction<
jest.mock('../hooks/useOndoOutcomeToast', () => ({
useOndoOutcomeToast: jest.fn(),
}));
-import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast';
const mockUseOndoOutcomeToast = useOndoOutcomeToast as jest.MockedFunction<
typeof useOndoOutcomeToast
>;
+jest.mock('../hooks/usePerpsTradingCampaignEndedOutcomeToast', () => ({
+ usePerpsTradingCampaignEndedOutcomeToast: jest.fn(),
+}));
+const mockUsePerpsTradingCampaignEndedOutcomeToast =
+ usePerpsTradingCampaignEndedOutcomeToast as jest.MockedFunction<
+ typeof usePerpsTradingCampaignEndedOutcomeToast
+ >;
+
jest.mock('../components/Campaigns/CampaignsGroup', () => {
const ReactActual = jest.requireActual('react');
const { View, Text } = jest.requireActual('react-native');
@@ -173,7 +182,6 @@ describe('CampaignsView', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseRewardCampaigns.mockReturnValue(hookDefaults);
- mockUseOndoOutcomeToast.mockReturnValue(undefined);
});
it('renders the header with the correct title', () => {
@@ -185,6 +193,15 @@ describe('CampaignsView', () => {
expect(getByText('Campaigns')).toBeOnTheScreen();
});
+ it('mounts campaign outcome toast hooks on render', () => {
+ render();
+
+ expect(mockUseOndoOutcomeToast).toHaveBeenCalledTimes(1);
+ expect(mockUsePerpsTradingCampaignEndedOutcomeToast).toHaveBeenCalledTimes(
+ 1,
+ );
+ });
+
it('navigates back when the back button is pressed', () => {
const { getByTestId } = render();
@@ -374,11 +391,4 @@ describe('CampaignsView', () => {
expect(queryByText('Refreshing...')).toBeNull();
});
});
-
- describe('hook integration', () => {
- it('calls useOndoOutcomeToast on render', () => {
- render();
- expect(mockUseOndoOutcomeToast).toHaveBeenCalledTimes(1);
- });
- });
});
diff --git a/app/components/UI/Rewards/Views/CampaignsView.tsx b/app/components/UI/Rewards/Views/CampaignsView.tsx
index b4b89d1ec1d..14e5324ba17 100644
--- a/app/components/UI/Rewards/Views/CampaignsView.tsx
+++ b/app/components/UI/Rewards/Views/CampaignsView.tsx
@@ -16,11 +16,12 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import ErrorBoundary from '../../../Views/ErrorBoundary';
import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard';
import { useRewardCampaigns } from '../hooks/useRewardCampaigns';
-import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast';
import RewardsErrorBanner from '../components/RewardsErrorBanner';
import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants';
import CampaignsGroup from '../components/Campaigns/CampaignsGroup';
import { strings } from '../../../../../locales/i18n';
+import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast';
+import { usePerpsTradingCampaignEndedOutcomeToast } from '../hooks/usePerpsTradingCampaignEndedOutcomeToast';
/**
* CampaignsView displays all campaigns organized by status:
@@ -31,9 +32,10 @@ import { strings } from '../../../../../locales/i18n';
const CampaignsView: React.FC = () => {
const tw = useTailwind();
const navigation = useNavigation();
- useOndoOutcomeToast();
const { categorizedCampaigns, isLoading, hasError, fetchCampaigns } =
useRewardCampaigns();
+ useOndoOutcomeToast();
+ usePerpsTradingCampaignEndedOutcomeToast();
useTrackRewardsPageView({ page_type: 'campaigns_overview' });
diff --git a/app/components/UI/Rewards/Views/OndoCampaignStatsView.test.tsx b/app/components/UI/Rewards/Views/OndoCampaignStatsView.test.tsx
index 0a84edb2cbd..d1d1915730b 100644
--- a/app/components/UI/Rewards/Views/OndoCampaignStatsView.test.tsx
+++ b/app/components/UI/Rewards/Views/OndoCampaignStatsView.test.tsx
@@ -445,7 +445,9 @@ describe('OndoCampaignStatsView', () => {
hasError: false,
});
const { getByText } = render();
- const title = getByText('rewards.ondo_outcome_banner.winner_pending.title');
+ const title = getByText(
+ 'rewards.campaign_outcome_banner.winner_pending.title',
+ );
fireEvent.press(title);
expect(mockNavigate).toHaveBeenCalledWith(
Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW,
@@ -475,7 +477,7 @@ describe('OndoCampaignStatsView', () => {
});
const { queryByText } = render();
expect(
- queryByText('rewards.ondo_outcome_banner.winner_pending.title'),
+ queryByText('rewards.campaign_outcome_banner.winner_pending.title'),
).toBeNull();
});
@@ -1024,7 +1026,9 @@ describe('OndoCampaignStatsView', () => {
hasError: false,
});
const { getByText } = render();
- const title = getByText('rewards.ondo_outcome_banner.winner_pending.title');
+ const title = getByText(
+ 'rewards.campaign_outcome_banner.winner_pending.title',
+ );
fireEvent.press(title);
expect(mockNavigate).toHaveBeenCalledWith(
Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW,
diff --git a/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx b/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx
index b17b28efa50..520d10dfafd 100644
--- a/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx
+++ b/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx
@@ -14,7 +14,7 @@ import {
TextColor,
TextVariant,
} from '@metamask/design-system-react-native';
-import { OndoGmCampaignOutcomeBanner } from '../components/Campaigns/OndoCampaignOutcomeBanners';
+import { CampaignOutcomeBanner } from '../components/Campaigns/CampaignOutcomeBanners';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import { SafeAreaView } from 'react-native-safe-area-context';
import ErrorBoundary from '../../../Views/ErrorBoundary';
@@ -284,7 +284,7 @@ const OndoCampaignStatsView: React.FC = () => {
{/* ── Outcome banner (campaign ended) ── */}
{isCampaignComplete && participantOutcome && (
- ({
- __esModule: true,
- default: 1,
-}));
-
-const mockGoBack = jest.fn();
-
-const mockNavigate = jest.fn();
-
-jest.mock('@react-navigation/native', () => ({
- useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate }),
- useRoute: () => ({
- params: { campaignId: 'campaign-ondo-1', campaignName: 'Ondo Campaign' },
- }),
-}));
-
-jest.mock('@metamask/design-system-twrnc-preset', () => {
- const tw = (...args: unknown[]) => args;
- tw.style = (...args: unknown[]) => args;
- return { useTailwind: () => tw };
-});
-
-jest.mock('react-native-safe-area-context', () => {
- const actual = jest.requireActual('react-native-safe-area-context');
- return {
- ...actual,
- useSafeAreaInsets: () => ({ top: 44, bottom: 34, left: 0, right: 0 }),
- };
-});
-
-jest.mock('../hooks/useOndoCampaignParticipantOutcome', () => ({
- useOndoCampaignParticipantOutcome: jest.fn(),
-}));
-
-const mockUseOndoCampaignParticipantOutcome =
- useOndoCampaignParticipantOutcome as jest.MockedFunction<
- typeof useOndoCampaignParticipantOutcome
- >;
-
-jest.mock('../../../Views/ErrorBoundary', () => {
+jest.mock('./CampaignWinningView', () => {
const ReactActual = jest.requireActual('react');
const { View } = jest.requireActual('react-native');
return {
__esModule: true,
- default: ({ children }: { children: React.ReactNode }) =>
- ReactActual.createElement(View, null, children),
+ default: jest.fn(({ testID }: { testID: string }) =>
+ ReactActual.createElement(View, { testID }),
+ ),
};
});
-jest.mock('../hooks/useTrackRewardsPageView', () => ({
- __esModule: true,
- default: jest.fn(),
-}));
-
-jest.mock('../../../../core/Analytics', () => ({
- MetaMetricsEvents: {
- REWARDS_PAGE_BUTTON_CLICKED: 'REWARDS_PAGE_BUTTON_CLICKED',
- },
-}));
-
-jest.mock('../utils', () => ({
- RewardsMetricsButtons: {
- COPY_REFERRAL_CODE: 'copy_referral_code',
- },
-}));
-
-const mockTrackEvent = jest.fn();
-const mockBuild = jest.fn(() => ({}));
-jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({
- useAnalytics: () => ({
- trackEvent: mockTrackEvent,
- createEventBuilder: () => ({
- addProperties: () => ({ build: mockBuild }),
- }),
- }),
+jest.mock('../hooks/useOndoCampaignParticipantOutcome', () => ({
+ useOndoCampaignParticipantOutcome: jest.fn(),
}));
-const mockPosition = {
- projectedTier: 'MID',
- rank: 3,
- totalInTier: 100,
- rateOfReturn: 0.2823,
- currentUsdValue: 2000,
- totalUsdDeposited: 1000,
- netDeposit: 900,
- qualifiedDays: 10,
- qualified: true,
- neighbors: [],
- computedAt: '2024-01-01T00:00:00.000Z',
-};
-
jest.mock('../hooks/useGetOndoLeaderboardPosition', () => ({
useGetOndoLeaderboardPosition: jest.fn(),
}));
-const mockUseGetOndoLeaderboardPosition =
- useGetOndoLeaderboardPosition as jest.MockedFunction<
- typeof useGetOndoLeaderboardPosition
- >;
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({ goBack: jest.fn(), navigate: jest.fn() }),
+ useRoute: () => ({
+ params: { campaignId: 'campaign-ondo-1', campaignName: 'Ondo Campaign' },
+ }),
+}));
-jest.mock('../components/ReferralDetails/CopyableField', () => {
- const ReactActual = jest.requireActual('react');
- const { View, Text, Pressable } = jest.requireActual('react-native');
- return {
- __esModule: true,
- default: ({
- label,
- value,
- onCopy,
- }: {
- label: string;
- value?: string | null;
- onCopy?: () => void;
- }) =>
- ReactActual.createElement(
- View,
- { testID: 'copyable-field' },
- ReactActual.createElement(Text, null, label),
- ReactActual.createElement(
- Text,
- { testID: 'copyable-value' },
- value ?? '',
- ),
- ReactActual.createElement(Pressable, {
- testID: 'copyable-trigger',
- onPress: onCopy,
- }),
- ),
- };
+jest.mock('@metamask/design-system-twrnc-preset', () => {
+ const tw = (...args: unknown[]) => args;
+ tw.style = (...args: unknown[]) => args;
+ return { useTailwind: () => tw };
});
-jest.mock('../../../../../locales/i18n', () => ({
- strings: jest.fn(
- (key: string, params?: { place?: string; code?: string }) => {
- const map: Record = {
- 'rewards.ondo_campaign_winning.you_won': 'You won',
- 'rewards.ondo_campaign_winning.email_instructions':
- 'Email ondocampaign@consensys.net with your code to claim your prize.',
- 'rewards.ondo_campaign_winning.open_mail': 'Open mail',
- 'rewards.ondo_campaign_winning.skip_for_now': 'Skip for now',
- 'rewards.ondo_campaign_winning.mail_subject':
- 'Ondo campaign prize claim',
- 'rewards.ondo_campaign_winning.mail_body': `My winning code: ${params?.code ?? ''}`,
- 'rewards.ondo_campaign_winning.winning_code': 'Winning code',
- 'rewards.ondo_campaign_winning.close_a11y': 'Close',
- 'rewards.ondo_campaign_winning.error_title':
- 'Could not load your winning code',
- 'rewards.ondo_campaign_winning.error_description':
- 'Something went wrong while fetching your code. Please try again later or contact support.',
- 'rewards.ondo_campaign_winning.error_retry': 'Try again',
- };
- if (key === 'rewards.ondo_campaign_winning.rank_label' && params?.place) {
- return `${params.place} place`;
- }
- return map[key] ?? key;
- },
- ),
-}));
+const mockUseOutcome = useOndoCampaignParticipantOutcome as jest.MockedFunction<
+ typeof useOndoCampaignParticipantOutcome
+>;
+const mockUsePosition = useGetOndoLeaderboardPosition as jest.MockedFunction<
+ typeof useGetOndoLeaderboardPosition
+>;
+const mockCampaignWinningView = CampaignWinningView as jest.MockedFunction<
+ typeof CampaignWinningView
+>;
describe('OndoCampaignWinningView', () => {
beforeEach(() => {
jest.clearAllMocks();
- mockUseGetOndoLeaderboardPosition.mockReturnValue({
- position: mockPosition,
- isLoading: false,
- hasError: false,
- hasFetched: true,
- refetch: jest.fn(),
- });
- mockUseOndoCampaignParticipantOutcome.mockReturnValue({
+ mockUseOutcome.mockReturnValue({
outcome: {
subscriptionId: 'sub-1',
outcomeStatus: 'pending',
- winnerVerificationCode: 'LVL346',
+ winnerVerificationCode: 'ONDO-WIN-99',
},
isLoading: false,
hasError: false,
});
+ mockUsePosition.mockReturnValue({
+ position: null,
+ isLoading: false,
+ hasError: false,
+ hasFetched: true,
+ refetch: jest.fn(),
+ });
});
- it('renders the main container', () => {
+ it('renders the container with the Ondo testID', () => {
const { getByTestId } = render();
expect(
getByTestId(ONDO_CAMPAIGN_WINNING_VIEW_TEST_IDS.CONTAINER),
).toBeTruthy();
});
- it('shows you won, rank place, and rate from leaderboard position', () => {
- const { getByText } = render();
- expect(getByText('You won')).toBeTruthy();
- expect(getByText('3rd place')).toBeTruthy();
- expect(getByText('+28.23%')).toBeTruthy();
- });
-
- it('calls goBack when Skip for now is pressed', () => {
- const { getByText } = render();
- fireEvent.press(getByText('Skip for now'));
- expect(mockGoBack).toHaveBeenCalledTimes(1);
- });
-
- it('calls goBack when close is pressed', () => {
- const { getByLabelText } = render();
- fireEvent.press(getByLabelText('Close'));
- expect(mockGoBack).toHaveBeenCalledTimes(1);
- });
-
- it('copies referral code and tracks analytics when copy is triggered', () => {
- const setStringSpy = jest.spyOn(Clipboard, 'setString');
- const { getByTestId } = render();
- fireEvent.press(getByTestId('copyable-trigger'));
- expect(setStringSpy).toHaveBeenCalledWith('LVL346');
- expect(mockTrackEvent).toHaveBeenCalled();
- });
-
- it('opens mailto when Open mail is pressed', async () => {
- const openSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined);
- const { getByText } = render();
- fireEvent.press(getByText('Open mail'));
- expect(openSpy).toHaveBeenCalled();
- const url = openSpy.mock.calls[0][0] as string;
- expect(url).toContain('mailto:ondocampaign@consensys.net');
- expect(url).toContain(encodeURIComponent('LVL346'));
- openSpy.mockRestore();
- });
-
- describe('auto-redirect when user is not a winner', () => {
- it('navigates to details view when outcome loaded but has no winner code', () => {
- mockUseOndoCampaignParticipantOutcome.mockReturnValue({
- outcome: {
- subscriptionId: 'sub-1',
- outcomeStatus: 'pending',
- winnerVerificationCode: null,
- },
- isLoading: false,
- hasError: false,
- });
- render();
- expect(mockNavigate).toHaveBeenCalledWith(
- Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW,
- { campaignId: 'campaign-ondo-1' },
- );
- });
-
- it('does not navigate while outcome is still loading', () => {
- mockUseOndoCampaignParticipantOutcome.mockReturnValue({
- outcome: null,
- isLoading: true,
- hasError: false,
- });
- render();
- expect(mockNavigate).not.toHaveBeenCalled();
- });
-
- it('does not navigate when outcome is null after load', () => {
- mockUseOndoCampaignParticipantOutcome.mockReturnValue({
- outcome: null,
- isLoading: false,
- hasError: false,
- });
- render();
- expect(mockNavigate).not.toHaveBeenCalled();
- });
- });
-
- describe('loading states', () => {
- it('shows CopyableField once winning code has loaded', () => {
- const { getByTestId } = render();
- expect(getByTestId('copyable-field')).toBeTruthy();
- });
-
- it('shows the primary CTA in loading state while outcome is loading', () => {
- mockUseOndoCampaignParticipantOutcome.mockReturnValue({
- outcome: null,
- isLoading: true,
- hasError: false,
- });
- const openSpy = jest
- .spyOn(Linking, 'openURL')
- .mockResolvedValue(undefined);
- const { getByText } = render();
- fireEvent.press(getByText('Open mail'));
- expect(openSpy).not.toHaveBeenCalled();
- openSpy.mockRestore();
- });
-
- it('does not show the primary CTA in loading state once code has loaded', () => {
- const openSpy = jest
- .spyOn(Linking, 'openURL')
- .mockResolvedValue(undefined);
- const { getByText } = render();
- fireEvent.press(getByText('Open mail'));
- expect(openSpy).toHaveBeenCalledTimes(1);
- openSpy.mockRestore();
- });
-
- it('hides rank and rate text while position is loading', () => {
- mockUseGetOndoLeaderboardPosition.mockReturnValue({
- position: null,
- isLoading: true,
- hasError: false,
- hasFetched: false,
- refetch: jest.fn(),
- });
- const { queryByText } = render();
- expect(queryByText('3rd place')).toBeNull();
- expect(queryByText('+28.23%')).toBeNull();
- });
- });
-
- describe('error states', () => {
- it('hides the rank/rate section entirely when position fails to load', () => {
- mockUseGetOndoLeaderboardPosition.mockReturnValue({
- position: null,
+ it('passes correct Ondo-specific props to CampaignWinningView', () => {
+ render();
+ expect(mockCampaignWinningView).toHaveBeenCalledWith(
+ expect.objectContaining({
+ testID: ONDO_CAMPAIGN_WINNING_VIEW_TEST_IDS.CONTAINER,
+ prizeEmail: 'ondocampaign@consensys.net',
+ campaignName: 'Ondo Campaign',
+ campaignId: 'campaign-ondo-1',
+ analyticsPageType: 'ondo_campaign_winning',
+ winningCode: 'ONDO-WIN-99',
+ hasOutcomeLoaded: true,
isLoading: false,
- hasError: true,
- hasFetched: true,
- refetch: jest.fn(),
- });
- const { queryByText } = render();
- expect(queryByText('3rd place')).toBeNull();
- expect(queryByText('+28.23%')).toBeNull();
- });
-
- it('hides the rank/rate section when position is null and not loading', () => {
- mockUseGetOndoLeaderboardPosition.mockReturnValue({
- position: null,
- isLoading: false,
- hasError: false,
- hasFetched: true,
- refetch: jest.fn(),
- });
- const { queryByText } = render();
- expect(queryByText('3rd place')).toBeNull();
- expect(queryByText('+28.23%')).toBeNull();
- });
+ rankDisplay: null,
+ resultDisplay: null,
+ isRankLoading: false,
+ isResultLoading: false,
+ fallbackRoute: {
+ route: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW,
+ params: { campaignId: 'campaign-ondo-1' },
+ },
+ }),
+ {},
+ );
});
- it('does not throw when mailto openURL rejects', async () => {
- const openSpy = jest
- .spyOn(Linking, 'openURL')
- .mockRejectedValue(new Error('no mail app'));
- const { getByText } = render();
- await act(async () => {
- fireEvent.press(getByText('Open mail'));
+ it('passes winningCode as null when outcome has no code', () => {
+ mockUseOutcome.mockReturnValue({
+ outcome: {
+ subscriptionId: 'sub-1',
+ outcomeStatus: 'finalized',
+ winnerVerificationCode: null,
+ },
+ isLoading: false,
+ hasError: false,
});
- expect(openSpy).toHaveBeenCalled();
- openSpy.mockRestore();
+ render();
+ expect(mockCampaignWinningView).toHaveBeenCalledWith(
+ expect.objectContaining({
+ winningCode: null,
+ hasOutcomeLoaded: true,
+ }),
+ {},
+ );
});
- describe('mailto URL construction', () => {
- it('appends the winning code to the mail subject', async () => {
- const openSpy = jest
- .spyOn(Linking, 'openURL')
- .mockResolvedValue(undefined);
- const { getByText } = render();
- fireEvent.press(getByText('Open mail'));
- const url = openSpy.mock.calls[0][0] as string;
- expect(url).toContain(
- encodeURIComponent('Ondo campaign prize claim - LVL346'),
- );
- openSpy.mockRestore();
- });
-
- it('uses base subject without code when winningCode is null', async () => {
- mockUseOndoCampaignParticipantOutcome.mockReturnValue({
- outcome: {
- subscriptionId: 'sub-1',
- outcomeStatus: 'pending',
- winnerVerificationCode: null,
- },
- isLoading: false,
- hasError: false,
- });
- const openSpy = jest
- .spyOn(Linking, 'openURL')
- .mockResolvedValue(undefined);
- const { getByText } = render();
- fireEvent.press(getByText('Open mail'));
- const url = openSpy.mock.calls[0][0] as string;
- expect(url).toContain(encodeURIComponent('Ondo campaign prize claim'));
- expect(url).not.toContain(' - ');
- openSpy.mockRestore();
+ it('does not mark outcome as loaded until the outcome exists', () => {
+ mockUseOutcome.mockReturnValue({
+ outcome: null,
+ isLoading: false,
+ hasError: false,
});
+ render();
+ expect(mockCampaignWinningView).toHaveBeenCalledWith(
+ expect.objectContaining({
+ winningCode: null,
+ hasOutcomeLoaded: false,
+ }),
+ {},
+ );
});
- it('does not copy to clipboard when winning code is null', () => {
- mockUseOndoCampaignParticipantOutcome.mockReturnValue({
+ it('passes rank and result display when position is available', () => {
+ mockUseOutcome.mockReturnValue({
outcome: {
subscriptionId: 'sub-1',
outcomeStatus: 'pending',
- winnerVerificationCode: null,
+ winnerVerificationCode: 'ONDO-WIN-99',
+ tierRank: 3,
},
isLoading: false,
hasError: false,
});
- const setStringSpy = jest.spyOn(Clipboard, 'setString');
- const { getByTestId } = render();
- fireEvent.press(getByTestId('copyable-trigger'));
- expect(setStringSpy).not.toHaveBeenCalled();
+ mockUsePosition.mockReturnValue({
+ position: { rank: 9, rateOfReturn: 0.1234 } as never,
+ isLoading: false,
+ hasError: false,
+ hasFetched: true,
+ refetch: jest.fn(),
+ });
+
+ render();
+ expect(mockCampaignWinningView).toHaveBeenCalledWith(
+ expect.objectContaining({
+ rankDisplay: '3rd',
+ resultDisplay: '+12.34%',
+ isRankLoading: false,
+ isResultLoading: false,
+ }),
+ {},
+ );
});
});
diff --git a/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx b/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx
index 59b3434babe..7e40d530493 100644
--- a/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx
+++ b/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx
@@ -1,45 +1,13 @@
-import React, { useCallback, useEffect, useMemo } from 'react';
-import { Image, Linking, ScrollView, StyleSheet } from 'react-native';
-import Clipboard from '@react-native-clipboard/clipboard';
-import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
-import {
- SafeAreaView,
- useSafeAreaInsets,
-} from 'react-native-safe-area-context';
-import { useTailwind } from '@metamask/design-system-twrnc-preset';
-import {
- Box,
- BoxFlexDirection,
- Button,
- ButtonSize,
- ButtonVariant,
- ButtonIcon,
- ButtonIconSize,
- IconName,
- Skeleton,
- Text,
- TextColor,
- TextVariant,
-} from '@metamask/design-system-react-native';
-import ErrorBoundary from '../../../Views/ErrorBoundary';
-import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView';
+import React, { useMemo } from 'react';
+import { useRoute, RouteProp } from '@react-navigation/native';
import { useOndoCampaignParticipantOutcome } from '../hooks/useOndoCampaignParticipantOutcome';
-import Routes from '../../../../constants/navigation/Routes';
-import { strings } from '../../../../../locales/i18n';
-import CopyableField from '../components/ReferralDetails/CopyableField';
import { formatOrdinalRank, formatPercentChange } from '../utils/formatUtils';
-import { RewardsMetricsButtons } from '../utils';
-import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics';
-import { MetaMetricsEvents } from '../../../../core/Analytics';
import { useGetOndoLeaderboardPosition } from '../hooks/useGetOndoLeaderboardPosition';
-import campaignWinningHero from '../../../../images/rewards/campaign_winning.png';
+import CampaignWinningView from './CampaignWinningView';
+import Routes from '../../../../constants/navigation/Routes';
const PRIZE_EMAIL = 'ondocampaign@consensys.net';
-const styles = StyleSheet.create({
- heroBox: { aspectRatio: 1 },
-});
-
// ParamListBase requires an index signature, which interfaces don't support
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type OndoCampaignWinningRouteParams = {
@@ -51,15 +19,11 @@ export const ONDO_CAMPAIGN_WINNING_VIEW_TEST_IDS = {
} as const;
const OndoCampaignWinningView: React.FC = () => {
- const tw = useTailwind();
- const insets = useSafeAreaInsets();
- const navigation = useNavigation();
- const { trackEvent, createEventBuilder } = useAnalytics();
const route =
useRoute<
RouteProp
>();
- const { campaignId } = route.params;
+ const { campaignId, campaignName = '' } = route.params;
const { position, isLoading: positionLoading } =
useGetOndoLeaderboardPosition(campaignId);
@@ -68,186 +32,41 @@ const OndoCampaignWinningView: React.FC = () => {
useOndoCampaignParticipantOutcome(campaignId);
const winningCode = outcome?.winnerVerificationCode ?? null;
- useEffect(() => {
- if (!isOutcomeLoading && outcome && !winningCode) {
- navigation.navigate(Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, {
- campaignId,
- });
- }
- }, [isOutcomeLoading, outcome, winningCode, campaignId, navigation]);
-
- useTrackRewardsPageView({
- page_type: 'ondo_campaign_winning',
- campaign_id: campaignId,
- });
-
- const onDismiss = () => navigation.goBack();
-
- const handleCopyWinningCode = useCallback(() => {
- if (winningCode) {
- Clipboard.setString(winningCode);
- trackEvent(
- createEventBuilder(MetaMetricsEvents.REWARDS_PAGE_BUTTON_CLICKED)
- .addProperties({
- button_type: RewardsMetricsButtons.COPY_REFERRAL_CODE,
- })
- .build(),
- );
- }
- }, [winningCode, trackEvent, createEventBuilder]);
-
- const handleOpenMail = useCallback(async () => {
- const baseSubject = strings('rewards.ondo_campaign_winning.mail_subject');
- const subject = winningCode
- ? `${baseSubject} - ${winningCode}`
- : baseSubject;
- const body = strings('rewards.ondo_campaign_winning.mail_body', {
- code: winningCode || '—',
- });
- const url = `mailto:${PRIZE_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
- try {
- await Linking.openURL(url);
- } catch {
- // no-op: device may not have a mail handler
- }
- }, [winningCode]);
-
const rankDisplay = useMemo(() => {
- if (!position) return null;
- return strings('rewards.ondo_campaign_winning.rank_label', {
- place: formatOrdinalRank(position.rank),
- });
- }, [position]);
+ if (!outcome?.tierRank) return null;
+ return formatOrdinalRank(outcome.tierRank);
+ }, [outcome]);
- const rateDisplay = useMemo(() => {
+ const resultDisplay = useMemo(() => {
if (!position) return null;
return formatPercentChange(position.rateOfReturn);
}, [position]);
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- {strings('rewards.ondo_campaign_winning.you_won')}
-
-
- {(positionLoading || position) && (
-
- {rankDisplay !== null ? (
-
- {rankDisplay}
-
- ) : (
-
- )}
-
- {rateDisplay !== null ? (
-
- {rateDisplay}
-
- ) : (
-
- )}
-
- )}
-
-
- {strings('rewards.ondo_campaign_winning.email_instructions')}
-
-
-
-
-
-
-
-
-
+ const fallbackRoute = useMemo(
+ () => ({
+ route: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW,
+ params: { campaignId },
+ }),
+ [campaignId],
+ );
-
-
-
-
-
+ return (
+
);
};
diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx
index 54fe6b66bee..5ab6a0e70aa 100644
--- a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx
+++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx
@@ -2,18 +2,21 @@ import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import PerpsTradingCampaignDetailsView, {
PERPS_CAMPAIGN_DETAILS_TEST_IDS,
+ resetPerpsTradingCampaignDetailsSessionAutoNavigationForTests,
} from './PerpsTradingCampaignDetailsView';
import {
type CampaignDto,
CampaignType,
type PerpsTradingCampaignLeaderboardEntry,
type PerpsTradingCampaignLeaderboardPositionDto,
+ type PerpsTradingCampaignParticipantOutcomeDto,
} from '../../../../core/Engine/controllers/rewards-controller/types';
import { useRewardCampaigns } from '../hooks/useRewardCampaigns';
import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus';
import { useGetPerpsTradingCampaignLeaderboard } from '../hooks/useGetPerpsTradingCampaignLeaderboard';
import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition';
import { useGetPerpsTradingCampaignVolume } from '../hooks/useGetPerpsTradingCampaignVolume';
+import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome';
import Routes from '../../../../constants/navigation/Routes';
const mockGoBack = jest.fn();
@@ -24,6 +27,7 @@ const mockRouteState: { params: { campaignId?: string } } = {
};
jest.mock('@react-navigation/native', () => ({
+ useFocusEffect: (callback: () => void) => callback(),
useNavigation: () => ({
goBack: mockGoBack,
navigate: mockNavigate,
@@ -135,15 +139,61 @@ jest.mock('../components/Campaigns/CampaignHowItWorks', () => {
};
});
+jest.mock('../components/Campaigns/CampaignOutcomeBanners', () => {
+ const ReactActual = jest.requireActual('react');
+ const { Pressable, Text } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ CampaignOutcomeBanner: ({
+ outcomeStatus,
+ winnerVerificationCode,
+ onWinnerPress,
+ }: {
+ outcomeStatus: string;
+ winnerVerificationCode: string | null | undefined;
+ onWinnerPress: () => void;
+ }) =>
+ ReactActual.createElement(
+ Pressable,
+ {
+ testID: `campaign-outcome-banner-${outcomeStatus}-${winnerVerificationCode ?? 'null'}`,
+ onPress: onWinnerPress,
+ },
+ ReactActual.createElement(Text, null, 'Campaign outcome'),
+ ),
+ };
+});
+
jest.mock('../components/Campaigns/PerpsCampaignStatsSummary', () => {
const ReactActual = jest.requireActual('react');
- const { View } = jest.requireActual('react-native');
+ const { Pressable, Text, View } = jest.requireActual('react-native');
return {
__esModule: true,
- default: () =>
- ReactActual.createElement(View, {
- testID: 'perps-campaign-stats-summary-container',
- }),
+ default: ({
+ outcomeStatus,
+ winnerVerificationCode,
+ onWinnerPress,
+ }: {
+ outcomeStatus?: string;
+ winnerVerificationCode?: string | null;
+ onWinnerPress?: () => void;
+ }) =>
+ ReactActual.createElement(
+ View,
+ {
+ testID: 'perps-campaign-stats-summary-container',
+ },
+ outcomeStatus &&
+ onWinnerPress &&
+ ReactActual.createElement(
+ Pressable,
+ {
+ testID: `campaign-outcome-banner-${outcomeStatus}-${winnerVerificationCode ?? 'null'}`,
+ onPress: onWinnerPress,
+ },
+ ReactActual.createElement(Text, null, 'Campaign outcome'),
+ ),
+ ),
};
});
@@ -157,6 +207,18 @@ jest.mock('../components/Campaigns/PerpsTradingCampaignPrizePool', () => {
};
});
+jest.mock('../components/Campaigns/PerpsTradingCampaignEndedStats', () => {
+ const ReactActual = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: () =>
+ ReactActual.createElement(View, {
+ testID: 'perps-campaign-ended-stats',
+ }),
+ };
+});
+
jest.mock('../components/Campaigns/PerpsTradingCampaignLeaderboard', () => {
const ReactActual = jest.requireActual('react');
const { View } = jest.requireActual('react-native');
@@ -247,6 +309,12 @@ const mockUseGetPerpsTradingCampaignVolume =
typeof useGetPerpsTradingCampaignVolume
>;
+jest.mock('../hooks/usePerpsTradingCampaignParticipantOutcome');
+const mockUsePerpsTradingCampaignParticipantOutcome =
+ usePerpsTradingCampaignParticipantOutcome as jest.MockedFunction<
+ typeof usePerpsTradingCampaignParticipantOutcome
+ >;
+
import { useSelector } from 'react-redux';
import { selectReferralCode } from '../../../../reducers/rewards/selectors';
@@ -316,7 +384,9 @@ function setupHooks(
hasCampaignsError?: boolean;
participant?: { optedIn: boolean };
position?: { rank: number; neighbors: unknown[] } | null;
+ isPositionLoading?: boolean;
totalParticipants?: number;
+ outcome?: PerpsTradingCampaignParticipantOutcomeDto | null;
} = {},
) {
const {
@@ -325,7 +395,9 @@ function setupHooks(
hasCampaignsError = false,
participant = { optedIn: false },
position = null,
+ isPositionLoading = false,
totalParticipants: totalParticipantsOverride,
+ outcome = null,
} = overrides;
mockUseRewardCampaigns.mockReturnValue({
@@ -361,7 +433,7 @@ function setupHooks(
mockUseGetPerpsTradingCampaignLeaderboardPosition.mockReturnValue({
position: toMockLeaderboardPosition(position),
- isLoading: false,
+ isLoading: isPositionLoading,
hasError: false,
hasFetched: true,
refetch: jest.fn(),
@@ -370,6 +442,12 @@ function setupHooks(
mockUseGetPerpsTradingCampaignVolume.mockReturnValue({
...defaultVolumeHook,
} as ReturnType);
+
+ mockUsePerpsTradingCampaignParticipantOutcome.mockReturnValue({
+ outcome,
+ isLoading: false,
+ hasError: false,
+ } as ReturnType);
}
jest.mock('../../../../../locales/i18n', () => ({
@@ -398,6 +476,7 @@ describe('PerpsTradingCampaignDetailsView', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2025-08-15T12:00:00.000Z'));
jest.clearAllMocks();
+ resetPerpsTradingCampaignDetailsSessionAutoNavigationForTests();
mockRouteState.params = { campaignId: 'perps-campaign-1' };
mockUseSelector.mockImplementation((selector) => {
if (selector === selectReferralCode) {
@@ -434,10 +513,10 @@ describe('PerpsTradingCampaignDetailsView', () => {
expect(mockFetchCampaigns).toHaveBeenCalledTimes(1);
});
- it('renders header, campaign status, prize pool, leaderboard, and CTA for active campaign', () => {
- const { getByTestId, getByText } = render(
- ,
- );
+ it('renders header, campaign status, prize pool, leaderboard, and CTA for active opted-in campaign', () => {
+ setupHooks({ participant: { optedIn: true } });
+
+ const { getByTestId } = render();
expect(
getByTestId(PERPS_CAMPAIGN_DETAILS_TEST_IDS.CONTAINER),
@@ -449,7 +528,12 @@ describe('PerpsTradingCampaignDetailsView', () => {
expect(getByTestId('perps-trading-cta')).toBeDefined();
});
- it('hides How it works when the user has a leaderboard position', () => {
+ it('shows the prize pool section for active non-opted-in users', () => {
+ const { getByTestId } = render();
+ expect(getByTestId('perps-prize-pool')).toBeDefined();
+ });
+
+ it('hides How it works when the user is opted in and has a leaderboard position', () => {
setupHooks({
campaigns: [
buildPerpsCampaign({
@@ -470,7 +554,50 @@ describe('PerpsTradingCampaignDetailsView', () => {
expect(queryByTestId('campaign-how-it-works')).toBeNull();
});
- it('shows How it works when active, user has no leaderboard position, and details include howItWorks', () => {
+ it('shows How it works when opted in, no position, and position not loading', () => {
+ setupHooks({
+ campaigns: [
+ buildPerpsCampaign({
+ details: {
+ howItWorks: {
+ title: 'How it works',
+ description: 'Test description',
+ steps: [],
+ },
+ },
+ }),
+ ],
+ participant: { optedIn: true },
+ position: null,
+ });
+
+ const { getByTestId } = render();
+ expect(getByTestId('campaign-how-it-works')).toBeDefined();
+ });
+
+ it('hides How it works while the leaderboard position is still loading for an opted-in user', () => {
+ setupHooks({
+ campaigns: [
+ buildPerpsCampaign({
+ details: {
+ howItWorks: {
+ title: 'How it works',
+ description: 'Test description',
+ steps: [],
+ },
+ },
+ }),
+ ],
+ participant: { optedIn: true },
+ position: null,
+ isPositionLoading: true,
+ });
+
+ const { queryByTestId } = render();
+ expect(queryByTestId('campaign-how-it-works')).toBeNull();
+ });
+
+ it('shows How it works when active, user is not opted in, and details include howItWorks', () => {
setupHooks({
campaigns: [
buildPerpsCampaign({
@@ -532,7 +659,7 @@ describe('PerpsTradingCampaignDetailsView', () => {
);
});
- it('complete campaign shows leaderboard without stats row and hides CTA', () => {
+ it('complete campaign for non-opted-in user shows leaderboard, prize pool, and ended stats and hides CTA', () => {
setupHooks({
campaigns: [
buildPerpsCampaign({
@@ -548,10 +675,149 @@ describe('PerpsTradingCampaignDetailsView', () => {
expect(getByTestId('perps-leaderboard')).toBeDefined();
expect(queryByTestId('perps-campaign-stats-summary-container')).toBeNull();
- expect(queryByTestId('perps-prize-pool')).toBeNull();
+ expect(getByTestId('perps-prize-pool')).toBeDefined();
+ expect(getByTestId('perps-campaign-ended-stats')).toBeDefined();
expect(queryByTestId('perps-trading-cta')).toBeNull();
});
+ it('complete campaign for opted-in user (no leaderboard position) shows ended stats and prize pool', () => {
+ setupHooks({
+ campaigns: [
+ buildPerpsCampaign({
+ startDate: '2024-01-01T00:00:00.000Z',
+ endDate: '2025-01-01T00:00:00.000Z',
+ }),
+ ],
+ participant: { optedIn: true },
+ position: null,
+ });
+
+ const { getByTestId, queryByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('perps-campaign-ended-stats')).toBeDefined();
+ expect(getByTestId('perps-prize-pool')).toBeDefined();
+ expect(queryByTestId('perps-campaign-stats-summary-container')).toBeNull();
+ });
+
+ it('shows outcome banner for completed opted-in participants and navigates winners to winning view', () => {
+ setupHooks({
+ campaigns: [
+ buildPerpsCampaign({
+ startDate: '2024-01-01T00:00:00.000Z',
+ endDate: '2025-01-01T00:00:00.000Z',
+ }),
+ ],
+ participant: { optedIn: true },
+ position: { rank: 3, neighbors: [] },
+ outcome: {
+ subscriptionId: 'subscription-id',
+ outcomeStatus: 'pending',
+ winnerVerificationCode: 'PERPS-WINNER-123',
+ rank: 3,
+ },
+ });
+
+ const { getByTestId } = render();
+
+ fireEvent.press(
+ getByTestId('campaign-outcome-banner-pending-PERPS-WINNER-123'),
+ );
+ expect(mockNavigate).toHaveBeenCalledWith(
+ Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW,
+ {
+ campaignId: 'perps-campaign-1',
+ campaignName: 'Perps Trading',
+ },
+ );
+ });
+
+ it('auto-navigates once to winning view for a completed pending winner outcome', () => {
+ setupHooks({
+ campaigns: [
+ buildPerpsCampaign({
+ startDate: '2024-01-01T00:00:00.000Z',
+ endDate: '2025-01-01T00:00:00.000Z',
+ }),
+ ],
+ participant: { optedIn: true },
+ position: { rank: 3, neighbors: [] },
+ outcome: {
+ subscriptionId: 'subscription-id',
+ outcomeStatus: 'pending',
+ winnerVerificationCode: 'PERPS-WINNER-123',
+ rank: 3,
+ },
+ });
+
+ const { rerender } = render();
+
+ expect(mockNavigate).toHaveBeenCalledWith(
+ Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW,
+ {
+ campaignId: 'perps-campaign-1',
+ campaignName: 'Perps Trading',
+ },
+ );
+
+ mockNavigate.mockClear();
+ rerender();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('does not auto-navigate for finalized outcomes', () => {
+ setupHooks({
+ campaigns: [
+ buildPerpsCampaign({
+ startDate: '2024-01-01T00:00:00.000Z',
+ endDate: '2025-01-01T00:00:00.000Z',
+ }),
+ ],
+ participant: { optedIn: true },
+ position: { rank: 3, neighbors: [] },
+ outcome: {
+ subscriptionId: 'subscription-id',
+ outcomeStatus: 'finalized',
+ winnerVerificationCode: 'PERPS-WINNER-123',
+ rank: 3,
+ },
+ });
+
+ render();
+
+ expect(mockNavigate).not.toHaveBeenCalledWith(
+ Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW,
+ expect.any(Object),
+ );
+ });
+
+ it('shows outcome banner inside the ended stats section for opted-in users with no leaderboard position', () => {
+ setupHooks({
+ campaigns: [
+ buildPerpsCampaign({
+ startDate: '2024-01-01T00:00:00.000Z',
+ endDate: '2025-01-01T00:00:00.000Z',
+ }),
+ ],
+ participant: { optedIn: true },
+ position: null,
+ outcome: {
+ subscriptionId: 'subscription-id',
+ outcomeStatus: 'finalized',
+ winnerVerificationCode: null,
+ },
+ });
+
+ const { getByTestId, queryByTestId } = render(
+ ,
+ );
+
+ expect(queryByTestId('perps-campaign-stats-summary-container')).toBeNull();
+ expect(getByTestId('perps-campaign-ended-stats')).toBeDefined();
+ expect(getByTestId('campaign-outcome-banner-finalized-null')).toBeDefined();
+ });
+
it('displays total participant count when the leaderboard reports participants', () => {
setupHooks({ totalParticipants: 1500 });
diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx
index 0310b69d8ea..14032c9d1db 100644
--- a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx
+++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx
@@ -1,6 +1,11 @@
import React, { useCallback, useMemo } from 'react';
import { Pressable, ScrollView } from 'react-native';
-import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
+import {
+ useFocusEffect,
+ useNavigation,
+ useRoute,
+ RouteProp,
+} from '@react-navigation/native';
import { useSelector } from 'react-redux';
import {
Box,
@@ -28,11 +33,14 @@ import PerpsTradingCampaignLeaderboard, {
import PerpsTradingCampaignPrizePool from '../components/Campaigns/PerpsTradingCampaignPrizePool';
import PerpsTradingCampaignCTA from '../components/Campaigns/PerpsTradingCampaignCTA';
import PerpsCampaignStatsSummary from '../components/Campaigns/PerpsCampaignStatsSummary';
+import PerpsTradingCampaignEndedStats from '../components/Campaigns/PerpsTradingCampaignEndedStats';
+import { CampaignOutcomeBanner } from '../components/Campaigns/CampaignOutcomeBanners';
import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils';
import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus';
import { useGetPerpsTradingCampaignLeaderboard } from '../hooks/useGetPerpsTradingCampaignLeaderboard';
import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition';
import { useGetPerpsTradingCampaignVolume } from '../hooks/useGetPerpsTradingCampaignVolume';
+import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome';
import { useRewardCampaigns } from '../hooks/useRewardCampaigns';
import { strings } from '../../../../../locales/i18n';
import Routes from '../../../../constants/navigation/Routes';
@@ -53,6 +61,11 @@ export const PERPS_CAMPAIGN_DETAILS_TEST_IDS = {
CONTAINER: 'perps-campaign-details-container',
} as const;
+const sessionWinningViewAutoNavCampaignIds = new Set();
+export function resetPerpsTradingCampaignDetailsSessionAutoNavigationForTests(): void {
+ sessionWinningViewAutoNavCampaignIds.clear();
+}
+
const PerpsTradingCampaignDetailsView: React.FC = () => {
const tw = useTailwind();
const navigation = useNavigation();
@@ -103,9 +116,14 @@ const PerpsTradingCampaignDetailsView: React.FC = () => {
refetch: refetchLeaderboard,
} = useGetPerpsTradingCampaignLeaderboard(effectiveCampaignId || undefined);
- const { position } = useGetPerpsTradingCampaignLeaderboardPosition(
- isOptedIn ? effectiveCampaignId || undefined : undefined,
- );
+ const { position, isLoading: isPositionLoading } =
+ useGetPerpsTradingCampaignLeaderboardPosition(
+ isOptedIn ? effectiveCampaignId || undefined : undefined,
+ );
+ const { outcome: participantOutcome } =
+ usePerpsTradingCampaignParticipantOutcome(
+ isComplete && isOptedIn ? effectiveCampaignId || undefined : undefined,
+ );
const {
volume,
@@ -130,6 +148,7 @@ const PerpsTradingCampaignDetailsView: React.FC = () => {
showStatsSummarySection,
showPrizePoolSection,
showLeaderboardSection,
+ showCampaignEndedStats,
} = useMemo(() => {
if (!campaign) {
return {
@@ -137,17 +156,32 @@ const PerpsTradingCampaignDetailsView: React.FC = () => {
showStatsSummarySection: false,
showPrizePoolSection: false,
showLeaderboardSection: false,
+ showCampaignEndedStats: false,
};
}
+ const showEndedStats =
+ isComplete && !isParticipantStatusLoading && (!isOptedIn || !hasPosition);
+
return {
showHowItWorksSection:
- Boolean(campaign.details?.howItWorks) && isActive && !hasPosition,
+ Boolean(campaign.details?.howItWorks) &&
+ isActive &&
+ (!isOptedIn || (!hasPosition && !isPositionLoading)),
showStatsSummarySection: hasPosition,
- showPrizePoolSection: isActive,
+ showPrizePoolSection: isActive || isComplete,
showLeaderboardSection: true,
+ showCampaignEndedStats: showEndedStats,
};
- }, [campaign, isActive, hasPosition]);
+ }, [
+ campaign,
+ isActive,
+ isComplete,
+ isOptedIn,
+ isParticipantStatusLoading,
+ hasPosition,
+ isPositionLoading,
+ ]);
const navigateToLeaderboard = useCallback(() => {
if (!effectiveCampaignId) return;
@@ -163,6 +197,36 @@ const PerpsTradingCampaignDetailsView: React.FC = () => {
});
}, [navigation, effectiveCampaignId]);
+ const navigateToWinningView = useCallback(() => {
+ if (!effectiveCampaignId) return;
+ navigation.navigate(Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, {
+ campaignId: effectiveCampaignId,
+ campaignName: campaign?.name ?? '',
+ });
+ }, [navigation, effectiveCampaignId, campaign]);
+
+ useFocusEffect(
+ useCallback(() => {
+ if (
+ !sessionWinningViewAutoNavCampaignIds.has(effectiveCampaignId) &&
+ campaign &&
+ isComplete &&
+ participantOutcome?.winnerVerificationCode &&
+ participantOutcome?.outcomeStatus === 'pending' &&
+ effectiveCampaignId
+ ) {
+ sessionWinningViewAutoNavCampaignIds.add(effectiveCampaignId);
+ navigateToWinningView();
+ }
+ }, [
+ campaign,
+ effectiveCampaignId,
+ isComplete,
+ navigateToWinningView,
+ participantOutcome,
+ ]),
+ );
+
const navigateToMechanics = useCallback(() => {
if (!effectiveCampaignId) return;
navigation.navigate(Routes.REWARDS_CAMPAIGN_MECHANICS, {
@@ -233,6 +297,30 @@ const PerpsTradingCampaignDetailsView: React.FC = () => {
)}
+ {showCampaignEndedStats && (
+
+
+ {isOptedIn && participantOutcome?.outcomeStatus != null && (
+
+ )}
+
+ )}
+
{showStatsSummarySection && (
@@ -258,6 +346,11 @@ const PerpsTradingCampaignDetailsView: React.FC = () => {
leaderboardPosition={position}
leaderboard={leaderboard}
isCampaignComplete={isComplete}
+ outcomeStatus={participantOutcome?.outcomeStatus}
+ winnerVerificationCode={
+ participantOutcome?.winnerVerificationCode ?? null
+ }
+ onWinnerPress={navigateToWinningView}
/>
)}
diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx
index a62780a64e2..b601329431d 100644
--- a/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx
+++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx
@@ -113,6 +113,7 @@ const PerpsTradingCampaignLeaderboardView: React.FC = () => {
diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx
index 179886d186f..64fbed6e9ad 100644
--- a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx
+++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx
@@ -6,6 +6,7 @@ import PerpsTradingCampaignStatsView, {
} from './PerpsTradingCampaignStatsView';
import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition';
import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus';
+import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome';
import {
CampaignType,
type PerpsTradingCampaignLeaderboardPositionDto,
@@ -142,6 +143,13 @@ jest.mock('../components/RewardsErrorBanner', () => {
jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboardPosition');
jest.mock('../hooks/useGetCampaignParticipantStatus');
+jest.mock('../hooks/usePerpsTradingCampaignParticipantOutcome', () => ({
+ usePerpsTradingCampaignParticipantOutcome: jest.fn(() => ({
+ outcome: null,
+ isLoading: false,
+ hasError: false,
+ })),
+}));
jest.mock('../../../../../locales/i18n', () => ({
strings: (key: string) => key,
@@ -156,6 +164,10 @@ const mockUseGetParticipant =
useGetCampaignParticipantStatus as jest.MockedFunction<
typeof useGetCampaignParticipantStatus
>;
+const mockUsePerpsTradingCampaignParticipantOutcome =
+ usePerpsTradingCampaignParticipantOutcome as jest.MockedFunction<
+ typeof usePerpsTradingCampaignParticipantOutcome
+ >;
const basePosition: PerpsTradingCampaignLeaderboardPositionDto = {
rank: 4,
@@ -197,6 +209,11 @@ describe('PerpsTradingCampaignStatsView', () => {
hasError: false,
refetch: jest.fn(),
});
+ mockUsePerpsTradingCampaignParticipantOutcome.mockReturnValue({
+ outcome: null,
+ isLoading: false,
+ hasError: false,
+ });
mockUseGetPosition.mockReturnValue({
position: basePosition,
isLoading: false,
@@ -310,6 +327,30 @@ describe('PerpsTradingCampaignStatsView', () => {
).toBeNull();
});
+ it('hides volume and margin StatCells when campaign is complete (only PnL remains)', () => {
+ const completeCampaign = {
+ ...mockCampaign,
+ endDate: '2020-01-01T00:00:00Z',
+ };
+ mockUseSelector.mockImplementation((selector: (s: unknown) => unknown) =>
+ selector({
+ rewards: { campaigns: [completeCampaign] },
+ }),
+ );
+ const { getByTestId, queryByTestId } = render(
+ ,
+ );
+ expect(
+ getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_PNL),
+ ).toBeDefined();
+ expect(
+ queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_VOLUME),
+ ).toBeNull();
+ expect(
+ queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARGIN),
+ ).toBeNull();
+ });
+
it('hides qualification cards when campaign is complete and shows last-computed after performance when position exists', () => {
const completeCampaign = {
...mockCampaign,
diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx
index 8e6213ed14d..73df3b431f5 100644
--- a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx
+++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo } from 'react';
+import React, { useCallback, useMemo } from 'react';
import { ScrollView } from 'react-native';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
@@ -35,6 +35,8 @@ import {
formatUsd,
} from '../utils/formatUtils';
import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils';
+import { CampaignOutcomeBanner } from '../components/Campaigns/CampaignOutcomeBanners';
+import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome';
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type PerpsTradingCampaignStatsRouteParams = {
@@ -113,6 +115,18 @@ const PerpsTradingCampaignStatsView: React.FC = () => {
const positionError = hasError && !position;
+ const { outcome: participantOutcome } =
+ usePerpsTradingCampaignParticipantOutcome(
+ isCampaignComplete && isOptedIn ? campaignId : undefined,
+ );
+
+ const navigateToWinningView = useCallback(() => {
+ navigation.navigate(Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, {
+ campaignId,
+ campaignName: campaign?.name ?? '',
+ });
+ }, [navigation, campaignId, campaign]);
+
return (
{
isLoading={isLoading}
showComputedAt={false}
showPnl={false}
+ isCampaignComplete={isCampaignComplete}
/>
@@ -164,22 +179,24 @@ const PerpsTradingCampaignStatsView: React.FC = () => {
-
- : undefined}
- testID={PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_VOLUME}
- />
- : undefined}
- testID={PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARGIN}
- />
-
+ {!isCampaignComplete && (
+
+ : undefined}
+ testID={PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_VOLUME}
+ />
+ : undefined}
+ testID={PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARGIN}
+ />
+
+ )}
{showQualifiedCard && (
{
)}
+ {/* ── Outcome banner (campaign ended) ── */}
+ {isCampaignComplete && participantOutcome && (
+
+ )}
+
{/* ── Error banner ── */}
{positionError && (
{
+ const ReactActual = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: jest.fn(({ testID }: { testID: string }) =>
+ ReactActual.createElement(View, { testID }),
+ ),
+ };
+});
+
+jest.mock('../hooks/usePerpsTradingCampaignParticipantOutcome', () => ({
+ usePerpsTradingCampaignParticipantOutcome: jest.fn(),
+}));
+
+jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboardPosition', () => ({
+ useGetPerpsTradingCampaignLeaderboardPosition: jest.fn(),
+}));
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({ goBack: jest.fn(), navigate: jest.fn() }),
+ useRoute: () => ({
+ params: { campaignId: 'campaign-perps-1', campaignName: 'Perps Campaign' },
+ }),
+}));
+
+jest.mock('@metamask/design-system-twrnc-preset', () => {
+ const tw = (...args: unknown[]) => args;
+ tw.style = (...args: unknown[]) => args;
+ return { useTailwind: () => tw };
+});
+
+const mockUseOutcome =
+ usePerpsTradingCampaignParticipantOutcome as jest.MockedFunction<
+ typeof usePerpsTradingCampaignParticipantOutcome
+ >;
+const mockUsePosition =
+ useGetPerpsTradingCampaignLeaderboardPosition as jest.MockedFunction<
+ typeof useGetPerpsTradingCampaignLeaderboardPosition
+ >;
+const mockCampaignWinningView = CampaignWinningView as jest.MockedFunction<
+ typeof CampaignWinningView
+>;
+
+describe('PerpsTradingCampaignWinningView', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseOutcome.mockReturnValue({
+ outcome: {
+ subscriptionId: 'sub-1',
+ outcomeStatus: 'pending',
+ winnerVerificationCode: 'PERPS-WIN-99',
+ rank: 3,
+ },
+ isLoading: false,
+ hasError: false,
+ });
+ mockUsePosition.mockReturnValue({
+ position: {
+ rank: 3,
+ pnl: 1500.25,
+ notionalVolume: 30000,
+ marginDeployed: 1200,
+ qualified: true,
+ neighbors: [],
+ computedAt: '2025-08-15T12:00:00.000Z',
+ },
+ isLoading: false,
+ hasError: false,
+ hasFetched: true,
+ refetch: jest.fn(),
+ });
+ });
+
+ it('renders the container with the Perps testID', () => {
+ const { getByTestId } = render();
+ expect(
+ getByTestId(PERPS_TRADING_CAMPAIGN_WINNING_VIEW_TEST_IDS.CONTAINER),
+ ).toBeTruthy();
+ });
+
+ it('passes correct Perps-specific props to CampaignWinningView', () => {
+ render();
+ expect(mockCampaignWinningView).toHaveBeenCalledWith(
+ expect.objectContaining({
+ testID: PERPS_TRADING_CAMPAIGN_WINNING_VIEW_TEST_IDS.CONTAINER,
+ prizeEmail: 'perpscampaign@consensys.net',
+ campaignName: 'Perps Campaign',
+ campaignId: 'campaign-perps-1',
+ analyticsPageType: 'perps_trading_campaign_winning',
+ winningCode: 'PERPS-WIN-99',
+ hasOutcomeLoaded: true,
+ isLoading: false,
+ rankDisplay: '3rd',
+ resultDisplay: '+$1,500.25',
+ isRankLoading: false,
+ isResultLoading: false,
+ fallbackRoute: {
+ route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW,
+ params: { campaignId: 'campaign-perps-1' },
+ },
+ }),
+ {},
+ );
+ });
+
+ it('passes winningCode as null when outcome has no code', () => {
+ mockUseOutcome.mockReturnValue({
+ outcome: {
+ subscriptionId: 'sub-1',
+ outcomeStatus: 'finalized',
+ winnerVerificationCode: null,
+ rank: 21,
+ },
+ isLoading: false,
+ hasError: false,
+ });
+ render();
+ expect(mockCampaignWinningView).toHaveBeenCalledWith(
+ expect.objectContaining({
+ winningCode: null,
+ hasOutcomeLoaded: true,
+ }),
+ {},
+ );
+ });
+
+ it('does not mark outcome as loaded until the outcome exists', () => {
+ mockUseOutcome.mockReturnValue({
+ outcome: null,
+ isLoading: false,
+ hasError: false,
+ });
+ render();
+ expect(mockCampaignWinningView).toHaveBeenCalledWith(
+ expect.objectContaining({
+ winningCode: null,
+ hasOutcomeLoaded: false,
+ }),
+ {},
+ );
+ });
+
+ it('passes rankDisplay when rank is available', () => {
+ render();
+ expect(mockCampaignWinningView).toHaveBeenCalledWith(
+ expect.objectContaining({
+ rankDisplay: '3rd',
+ isRankLoading: false,
+ isResultLoading: false,
+ }),
+ {},
+ );
+ });
+
+ it('passes rank from outcome and no result when position is unavailable', () => {
+ mockUsePosition.mockReturnValue({
+ position: null,
+ isLoading: false,
+ hasError: false,
+ hasFetched: true,
+ refetch: jest.fn(),
+ });
+ render();
+ expect(mockCampaignWinningView).toHaveBeenCalledWith(
+ expect.objectContaining({
+ rankDisplay: '3rd',
+ resultDisplay: null,
+ isRankLoading: false,
+ isResultLoading: false,
+ }),
+ {},
+ );
+ });
+
+ it('does not pass rankDisplay when outcome has no rank', () => {
+ mockUseOutcome.mockReturnValue({
+ outcome: {
+ subscriptionId: 'sub-1',
+ outcomeStatus: 'pending',
+ winnerVerificationCode: 'CODE',
+ rank: null,
+ },
+ isLoading: false,
+ hasError: false,
+ });
+ render();
+ expect(mockCampaignWinningView).toHaveBeenCalledWith(
+ expect.objectContaining({
+ rankDisplay: null,
+ isRankLoading: false,
+ }),
+ {},
+ );
+ });
+});
diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx
new file mode 100644
index 00000000000..f2a95fbd700
--- /dev/null
+++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx
@@ -0,0 +1,80 @@
+import React, { useMemo } from 'react';
+import { useRoute, RouteProp } from '@react-navigation/native';
+import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome';
+import { formatOrdinalRank, formatSignedUsd } from '../utils/formatUtils';
+import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition';
+import CampaignWinningView from './CampaignWinningView';
+import Routes from '../../../../constants/navigation/Routes';
+
+const PRIZE_EMAIL = 'perpscampaign@consensys.net';
+
+// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
+type PerpsTradingCampaignWinningRouteParams = {
+ RewardsPerpsTradingCampaignWinning: {
+ campaignId: string;
+ campaignName: string;
+ };
+};
+
+export const PERPS_TRADING_CAMPAIGN_WINNING_VIEW_TEST_IDS = {
+ CONTAINER: 'perps-trading-campaign-winning-view-container',
+} as const;
+
+const PerpsTradingCampaignWinningView: React.FC = () => {
+ const route =
+ useRoute<
+ RouteProp<
+ PerpsTradingCampaignWinningRouteParams,
+ 'RewardsPerpsTradingCampaignWinning'
+ >
+ >();
+ const { campaignId, campaignName } = route.params;
+
+ const { outcome, isLoading: isOutcomeLoading } =
+ usePerpsTradingCampaignParticipantOutcome(campaignId);
+ const winningCode = outcome?.winnerVerificationCode ?? null;
+
+ const { position, isLoading: positionLoading } =
+ useGetPerpsTradingCampaignLeaderboardPosition(campaignId);
+
+ const rankDisplay = useMemo(() => {
+ if (!outcome?.rank) {
+ return null;
+ }
+ return formatOrdinalRank(outcome.rank);
+ }, [outcome]);
+
+ const resultDisplay = useMemo(() => {
+ if (!position) return null;
+ return formatSignedUsd(position.pnl);
+ }, [position]);
+
+ const fallbackRoute = useMemo(
+ () => ({
+ route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW,
+ params: { campaignId },
+ }),
+ [campaignId],
+ );
+
+ return (
+
+ );
+};
+
+export default PerpsTradingCampaignWinningView;
diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx
index 98fb1ab3dfa..27ccd54e53f 100644
--- a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx
+++ b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx
@@ -4,6 +4,8 @@ import { useSelector } from 'react-redux';
import RewardsDashboard from './RewardsDashboard';
import Routes from '../../../../constants/navigation/Routes';
import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants';
+import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast';
+import { usePerpsTradingCampaignEndedOutcomeToast } from '../hooks/usePerpsTradingCampaignEndedOutcomeToast';
// Mock dependencies
jest.mock('react-redux', () => ({
@@ -170,6 +172,10 @@ jest.mock('../hooks/useOndoOutcomeToast', () => ({
useOndoOutcomeToast: jest.fn(),
}));
+jest.mock('../hooks/usePerpsTradingCampaignEndedOutcomeToast', () => ({
+ usePerpsTradingCampaignEndedOutcomeToast: jest.fn(),
+}));
+
// Import mocked hooks
import { useRewardOptinSummary } from '../hooks/useRewardOptinSummary';
import { useRewardDashboardModals } from '../hooks/useRewardDashboardModals';
@@ -186,6 +192,13 @@ const mockUseRewardDashboardModals =
const mockUseBulkLinkState = useBulkLinkState as jest.MockedFunction<
typeof useBulkLinkState
>;
+const mockUseOndoOutcomeToast = useOndoOutcomeToast as jest.MockedFunction<
+ typeof useOndoOutcomeToast
+>;
+const mockUsePerpsTradingCampaignEndedOutcomeToast =
+ usePerpsTradingCampaignEndedOutcomeToast as jest.MockedFunction<
+ typeof usePerpsTradingCampaignEndedOutcomeToast
+ >;
describe('RewardsDashboard', () => {
const mockShowUnlinkedAccountsModal = jest.fn();
@@ -320,6 +333,15 @@ describe('RewardsDashboard', () => {
expect(getByText('Rewards')).toBeTruthy();
});
+ it('mounts campaign outcome toast hooks on render', () => {
+ render();
+
+ expect(mockUseOndoOutcomeToast).toHaveBeenCalledTimes(1);
+ expect(
+ mockUsePerpsTradingCampaignEndedOutcomeToast,
+ ).toHaveBeenCalledTimes(1);
+ });
+
it('renders all child components', () => {
// Act
const { getByTestId } = render();
diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.tsx
index ded32bc79da..3613bd3a6d5 100644
--- a/app/components/UI/Rewards/Views/RewardsDashboard.tsx
+++ b/app/components/UI/Rewards/Views/RewardsDashboard.tsx
@@ -21,7 +21,6 @@ import {
RewardsDashboardModalType,
} from '../hooks/useRewardDashboardModals';
import { useBulkLinkState } from '../hooks/useBulkLinkState';
-import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast';
import { MetaMetricsEvents } from '../../../../core/Analytics';
import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics';
import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView';
@@ -30,6 +29,8 @@ import CampaignsPreview from '../components/Campaigns/CampaignsPreview';
import EarnRewardsPreview from '../components/EarnRewards/EarnRewardsPreview';
import BenefitsPreview from '../components/Benefits/BenefitsPreview.tsx';
import { ScrollView } from 'react-native';
+import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast';
+import { usePerpsTradingCampaignEndedOutcomeToast } from '../hooks/usePerpsTradingCampaignEndedOutcomeToast';
const RewardsDashboard: React.FC = () => {
const tw = useTailwind();
@@ -41,6 +42,8 @@ const RewardsDashboard: React.FC = () => {
useTrackRewardsPageView({ page_type: 'home' });
useOndoOutcomeToast();
+ usePerpsTradingCampaignEndedOutcomeToast();
+
const hideUnlinkedAccountsBanner = useSelector(
selectHideUnlinkedAccountsBanner,
);
diff --git a/app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.test.tsx
similarity index 67%
rename from app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.test.tsx
rename to app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.test.tsx
index 0b623fd0e53..56d7c49a5ba 100644
--- a/app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.test.tsx
+++ b/app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.test.tsx
@@ -5,8 +5,8 @@ import {
WinnerFinalizedBanner,
ParticipantFinalizedBanner,
ParticipantPendingBanner,
- OndoGmCampaignOutcomeBanner,
-} from './OndoCampaignOutcomeBanners';
+ CampaignOutcomeBanner,
+} from './CampaignOutcomeBanners';
jest.mock('../../../../../../locales/i18n', () => ({
strings: (key: string) => key,
@@ -28,13 +28,13 @@ jest.mock('../RewardsInfoBanner', () => {
});
describe('WinnerPendingBanner', () => {
- it('renders title and description', () => {
+ it('renders title and description using consolidated locale keys', () => {
const { getByText } = render();
expect(
- getByText('rewards.ondo_outcome_banner.winner_pending.title'),
+ getByText('rewards.campaign_outcome_banner.winner_pending.title'),
).toBeDefined();
expect(
- getByText('rewards.ondo_outcome_banner.winner_pending.description'),
+ getByText('rewards.campaign_outcome_banner.winner_pending.description'),
).toBeDefined();
});
@@ -43,7 +43,7 @@ describe('WinnerPendingBanner', () => {
,
);
expect(
- getByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'),
+ getByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'),
).toBeDefined();
});
@@ -53,51 +53,53 @@ describe('WinnerPendingBanner', () => {
,
);
fireEvent.press(
- getByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'),
+ getByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'),
);
expect(onPress).toHaveBeenCalledTimes(1);
});
});
describe('WinnerFinalizedBanner', () => {
- it('renders with winner_finalized strings', () => {
+ it('renders with consolidated winner_finalized strings', () => {
const { getByText } = render();
expect(
- getByText('rewards.ondo_outcome_banner.winner_finalized.title'),
+ getByText('rewards.campaign_outcome_banner.winner_finalized.title'),
).toBeDefined();
expect(
- getByText('rewards.ondo_outcome_banner.winner_finalized.description'),
+ getByText('rewards.campaign_outcome_banner.winner_finalized.description'),
).toBeDefined();
});
});
describe('ParticipantFinalizedBanner', () => {
- it('renders with participant_finalized strings', () => {
+ it('renders with consolidated participant_finalized strings', () => {
const { getByText } = render();
expect(
- getByText('rewards.ondo_outcome_banner.participant_finalized.title'),
+ getByText('rewards.campaign_outcome_banner.participant_finalized.title'),
).toBeDefined();
expect(
getByText(
- 'rewards.ondo_outcome_banner.participant_finalized.description',
+ 'rewards.campaign_outcome_banner.participant_finalized.description',
),
).toBeDefined();
});
});
describe('ParticipantPendingBanner', () => {
- it('renders with participant_pending strings', () => {
+ it('renders with consolidated participant_pending strings', () => {
const { getByText } = render();
expect(
- getByText('rewards.ondo_outcome_banner.participant_pending.title'),
+ getByText('rewards.campaign_outcome_banner.participant_pending.title'),
).toBeDefined();
expect(
- getByText('rewards.ondo_outcome_banner.participant_pending.description'),
+ getByText(
+ 'rewards.campaign_outcome_banner.participant_pending.description',
+ ),
).toBeDefined();
});
});
-describe('OndoGmCampaignOutcomeBanner', () => {
+describe('CampaignOutcomeBanner', () => {
const onWinnerPress = jest.fn();
beforeEach(() => {
@@ -106,83 +108,83 @@ describe('OndoGmCampaignOutcomeBanner', () => {
it('renders WinnerPendingBanner when winner code is present and status is pending', () => {
const { getByLabelText } = render(
- ,
);
expect(
- getByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'),
+ getByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'),
).toBeDefined();
});
it('renders WinnerFinalizedBanner when winner code is present and status is finalized', () => {
const { getByText, queryByLabelText } = render(
- ,
);
expect(
- getByText('rewards.ondo_outcome_banner.winner_finalized.title'),
+ getByText('rewards.campaign_outcome_banner.winner_finalized.title'),
).toBeDefined();
expect(
- queryByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'),
+ queryByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'),
).toBeNull();
});
it('renders ParticipantFinalizedBanner when no code and status is finalized', () => {
const { getByText } = render(
- ,
);
expect(
- getByText('rewards.ondo_outcome_banner.participant_finalized.title'),
+ getByText('rewards.campaign_outcome_banner.participant_finalized.title'),
).toBeDefined();
});
it('renders ParticipantPendingBanner when no code and status is pending', () => {
const { getByText } = render(
- ,
);
expect(
- getByText('rewards.ondo_outcome_banner.participant_pending.title'),
+ getByText('rewards.campaign_outcome_banner.participant_pending.title'),
).toBeDefined();
});
it('renders ParticipantPendingBanner when winnerVerificationCode is undefined', () => {
const { getByText } = render(
- ,
);
expect(
- getByText('rewards.ondo_outcome_banner.participant_pending.title'),
+ getByText('rewards.campaign_outcome_banner.participant_pending.title'),
).toBeDefined();
});
it('calls onWinnerPress when WinnerPendingBanner is pressed', () => {
const mockOnWinnerPress = jest.fn();
const { getByLabelText } = render(
- ,
);
fireEvent.press(
- getByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'),
+ getByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'),
);
expect(mockOnWinnerPress).toHaveBeenCalledTimes(1);
});
diff --git a/app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.tsx
similarity index 53%
rename from app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.tsx
rename to app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.tsx
index 5127253bbb7..3402bca2d41 100644
--- a/app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.tsx
+++ b/app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.tsx
@@ -15,7 +15,7 @@ import {
} from '@metamask/design-system-react-native';
import { strings } from '../../../../../../locales/i18n';
import RewardsInfoBanner from '../RewardsInfoBanner';
-import type { OndoGmCampaignParticipantOutcomeStatus } from '../../../../../core/Engine/controllers/rewards-controller/types';
+import type { CampaignParticipantOutcomeStatus } from '../../../../../core/Engine/controllers/rewards-controller/types';
export interface WinnerPendingBannerProps {
onPress: () => void;
@@ -25,7 +25,7 @@ export const WinnerPendingBanner = React.memo(
({ onPress }) => (
@@ -36,10 +36,12 @@ export const WinnerPendingBanner = React.memo(
>
- {strings('rewards.ondo_outcome_banner.winner_pending.title')}
+ {strings('rewards.campaign_outcome_banner.winner_pending.title')}
- {strings('rewards.ondo_outcome_banner.winner_pending.description')}
+ {strings(
+ 'rewards.campaign_outcome_banner.winner_pending.description',
+ )}
(
export const WinnerFinalizedBanner = React.memo(() => (
));
export const ParticipantFinalizedBanner = React.memo(() => (
));
export const ParticipantPendingBanner = React.memo(() => (
));
-export interface OndoGmCampaignOutcomeBannerProps {
- outcomeStatus: OndoGmCampaignParticipantOutcomeStatus;
+export interface CampaignOutcomeBannerProps {
+ outcomeStatus: CampaignParticipantOutcomeStatus;
winnerVerificationCode: string | null | undefined;
onWinnerPress: () => void;
}
-export const OndoGmCampaignOutcomeBanner =
- React.memo(
- ({ outcomeStatus, winnerVerificationCode, onWinnerPress }) => {
- const hasCode = Boolean(winnerVerificationCode);
- const isFinalized = outcomeStatus === 'finalized';
- if (hasCode && !isFinalized)
- return ;
- if (hasCode && isFinalized) return ;
- if (isFinalized) return ;
- return ;
- },
- );
+export const CampaignOutcomeBanner = React.memo(
+ ({ outcomeStatus, winnerVerificationCode, onWinnerPress }) => {
+ const hasCode = Boolean(winnerVerificationCode);
+ const isFinalized = outcomeStatus === 'finalized';
+ if (hasCode && !isFinalized)
+ return ;
+ if (hasCode && isFinalized) return ;
+ if (isFinalized) return ;
+ return ;
+ },
+);
diff --git a/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx b/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx
index 2ba425c2bf6..9926b195451 100644
--- a/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx
+++ b/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx
@@ -28,12 +28,12 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({
useTailwind: () => ({ style: (...args: unknown[]) => args }),
}));
-jest.mock('./OndoCampaignOutcomeBanners', () => {
+jest.mock('./CampaignOutcomeBanners', () => {
const ReactActual = jest.requireActual('react');
const { View } = jest.requireActual('react-native');
return {
__esModule: true,
- OndoGmCampaignOutcomeBanner: ({
+ CampaignOutcomeBanner: ({
outcomeStatus,
winnerVerificationCode,
}: {
diff --git a/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.tsx b/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.tsx
index 0b9fa4b54cf..fd21c22ee46 100644
--- a/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.tsx
+++ b/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.tsx
@@ -24,7 +24,7 @@ import { formatPercentChange, formatUsd } from '../../utils/formatUtils';
import { ONDO_GM_REQUIRED_QUALIFIED_DAYS } from '../../utils/ondoCampaignConstants';
import { formatTierDisplayName } from './OndoLeaderboard.utils';
import RewardsErrorBanner from '../RewardsErrorBanner';
-import { OndoGmCampaignOutcomeBanner } from './OndoCampaignOutcomeBanners';
+import { CampaignOutcomeBanner } from './CampaignOutcomeBanners';
const CELL_STYLE = { flex: 1 } as const;
@@ -244,7 +244,7 @@ const OndoCampaignStatsSummary: React.FC = ({
{/* Outcome banner (campaign ended) */}
{isCampaignComplete && outcomeStatus != null && onWinnerPress != null && (
- ({
strings: (key: string) => key,
}));
+jest.mock('./CampaignOutcomeBanners', () => {
+ const ReactActual = jest.requireActual('react');
+ const { Pressable, Text } = jest.requireActual('react-native');
+ return {
+ CampaignOutcomeBanner: ({
+ outcomeStatus,
+ winnerVerificationCode,
+ onWinnerPress,
+ }: {
+ outcomeStatus: string;
+ winnerVerificationCode?: string | null;
+ onWinnerPress: () => void;
+ }) =>
+ ReactActual.createElement(
+ Pressable,
+ {
+ testID: `campaign-outcome-banner-${outcomeStatus}-${winnerVerificationCode ?? 'null'}`,
+ onPress: onWinnerPress,
+ },
+ ReactActual.createElement(Text, null, 'Campaign outcome'),
+ ),
+ };
+});
+
const TEST_IDS = PERPS_CAMPAIGN_STATS_SUMMARY_TEST_IDS;
const mockLeaderboard = {
@@ -160,6 +184,20 @@ describe('PerpsCampaignStatsSummary', () => {
expect(queryByTestId(TEST_IDS.QUALIFY_FOR_RANK_CARD)).toBeNull();
});
+ it('hides volume and margin StatCells when campaign is complete (only rank and PnL remain)', () => {
+ const { queryByTestId, getByTestId } = render(
+ ,
+ );
+ expect(getByTestId(TEST_IDS.RANK)).toBeDefined();
+ expect(getByTestId(TEST_IDS.PNL)).toBeDefined();
+ expect(queryByTestId(TEST_IDS.NOTIONAL_VOLUME)).toBeNull();
+ expect(queryByTestId(TEST_IDS.MARGIN_DEPLOYED)).toBeNull();
+ });
+
it("hides You're qualified card when campaign is complete", () => {
const { queryByTestId } = render(
{
);
expect(queryByTestId(TEST_IDS.QUALIFY_FOR_RANK_CARD)).toBeNull();
});
+
+ it('shows outcome banner for complete campaigns and handles winner press', () => {
+ const onWinnerPress = jest.fn();
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.press(
+ getByTestId('campaign-outcome-banner-pending-PERPS-WINNER-123'),
+ );
+ expect(onWinnerPress).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not show outcome banner before campaign completion', () => {
+ const { queryByTestId } = render(
+ ,
+ );
+
+ expect(
+ queryByTestId('campaign-outcome-banner-pending-PERPS-WINNER-123'),
+ ).toBeNull();
+ });
});
diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx
index 16a2608dfa0..7c3c7a2fe49 100644
--- a/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx
+++ b/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx
@@ -13,6 +13,7 @@ import {
TextVariant,
} from '@metamask/design-system-react-native';
import type {
+ CampaignParticipantOutcomeStatus,
PerpsTradingCampaignLeaderboardDto,
PerpsTradingCampaignLeaderboardPositionDto,
} from '../../../../../core/Engine/controllers/rewards-controller/types';
@@ -20,6 +21,7 @@ import { strings } from '../../../../../../locales/i18n';
import { formatSignedUsd, formatUsd } from '../../utils/formatUtils';
import { PERPS_QUALIFICATION_NOTIONAL_USD } from '../../utils/perpsCampaignConstants';
import { PendingTag, StatCell } from './OndoCampaignStatsSummary';
+import { CampaignOutcomeBanner } from './CampaignOutcomeBanners';
const PERPS_NOTIONAL_THRESHOLD_LABEL = formatUsd(
PERPS_QUALIFICATION_NOTIONAL_USD,
@@ -43,12 +45,18 @@ export interface PerpsCampaignStatsSummaryProps {
leaderboard: PerpsTradingCampaignLeaderboardDto | null;
/** When false, pending (not yet qualified) users see a {@link PendingTag} next to rank. */
isCampaignComplete?: boolean;
+ outcomeStatus?: CampaignParticipantOutcomeStatus;
+ winnerVerificationCode?: string | null;
+ onWinnerPress?: () => void;
}
const PerpsCampaignStatsSummary: React.FC = ({
leaderboardPosition,
leaderboard: _leaderboard,
isCampaignComplete = false,
+ outcomeStatus,
+ winnerVerificationCode,
+ onWinnerPress,
}) => {
const isPending =
leaderboardPosition != null && !leaderboardPosition.qualified;
@@ -125,18 +133,20 @@ const PerpsCampaignStatsSummary: React.FC = ({
testID={PERPS_CAMPAIGN_STATS_SUMMARY_TEST_IDS.PNL}
/>
-
-
-
-
+ {!isCampaignComplete && (
+
+
+
+
+ )}
{showQualifiedCard && (
= ({
)}
+
+ {isCampaignComplete && outcomeStatus != null && onWinnerPress != null && (
+
+ )}
);
};
diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.test.tsx
new file mode 100644
index 00000000000..f3cb469d9ea
--- /dev/null
+++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.test.tsx
@@ -0,0 +1,276 @@
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react-native';
+import PerpsTradingCampaignEndedStats, {
+ PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS,
+} from './PerpsTradingCampaignEndedStats';
+import type {
+ PerpsTradingCampaignLeaderboardDto,
+ PerpsTradingCampaignLeaderboardEntry,
+} from '../../../../../core/Engine/controllers/rewards-controller/types';
+
+jest.mock('../RewardsErrorBanner', () => {
+ const ReactActual = jest.requireActual('react');
+ const RN = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: (props: { title: string; onConfirm: () => void }) =>
+ ReactActual.createElement(
+ RN.View,
+ { testID: 'rewards-error-banner' },
+ ReactActual.createElement(RN.Text, null, props.title),
+ ReactActual.createElement(RN.TouchableOpacity, {
+ testID: 'rewards-error-banner-retry',
+ onPress: props.onConfirm,
+ }),
+ ),
+ };
+});
+
+jest.mock('@metamask/design-system-react-native', () => {
+ const actual = jest.requireActual('@metamask/design-system-react-native');
+ const ReactActual = jest.requireActual('react');
+ const RN = jest.requireActual('react-native');
+ return {
+ ...actual,
+ Text: (props: Record) =>
+ ReactActual.createElement(RN.Text, props, props.children),
+ Skeleton: (props: Record) =>
+ ReactActual.createElement(RN.View, { testID: 'skeleton', ...props }),
+ };
+});
+
+jest.mock('@metamask/design-system-twrnc-preset', () => ({
+ useTailwind: () => ({ style: (...args: unknown[]) => args }),
+}));
+
+jest.mock('../../../../../../locales/i18n', () => ({
+ strings: (key: string) => key,
+}));
+
+jest.mock('../../utils/formatUtils', () => ({
+ formatCompactUsd: (value: number) => `$${(value / 1_000_000).toFixed(1)}M`,
+ formatSignedUsd: (value: number) => {
+ const sign = value >= 0 ? '+' : '-';
+ const abs = Math.abs(value).toLocaleString();
+ return `${sign}$${abs}`;
+ },
+}));
+
+const makeEntry = (
+ rank: number,
+ pnl: number,
+ qualified = true,
+): PerpsTradingCampaignLeaderboardEntry => ({
+ rank,
+ referralCode: `T-${rank}`,
+ pnl,
+ qualified,
+});
+
+const makeLeaderboard = (
+ entriesCount: number,
+ totalParticipants?: number,
+ topPnl = 50_000,
+): PerpsTradingCampaignLeaderboardDto => {
+ const entries = Array.from({ length: entriesCount }, (_, i) =>
+ makeEntry(i + 1, topPnl - i * 1000),
+ );
+ return {
+ campaignId: 'perps-1',
+ computedAt: '2026-01-01T00:00:00Z',
+ totalParticipants: totalParticipants ?? entriesCount,
+ entries,
+ };
+};
+
+describe('PerpsTradingCampaignEndedStats', () => {
+ it('renders all four stat cells with correct values when leaderboard has 20+ entries', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.CONTAINER),
+ ).toBeTruthy();
+ expect(
+ getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_PARTICIPANTS).props
+ .children,
+ ).toBe((200).toLocaleString());
+ expect(
+ getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_VOLUME).props
+ .children,
+ ).toBe('$27.5M');
+ expect(
+ getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children,
+ ).toBe('+$80,000');
+ // Leaderboard has 25 entries (>= 20) → fixed 20 winners
+ expect(
+ getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children,
+ ).toBe('20');
+ });
+
+ it('shows dash for winners when leaderboard has fewer than 20 entries', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children,
+ ).toBe('-');
+ });
+
+ it('shows dashes when leaderboard and volume are null', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_PARTICIPANTS).props
+ .children,
+ ).toBe('-');
+ expect(
+ getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_VOLUME).props
+ .children,
+ ).toBe('-');
+ expect(
+ getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children,
+ ).toBe('-');
+ expect(
+ getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children,
+ ).toBe('-');
+ });
+
+ it('renders skeletons while data is loading', () => {
+ const { getAllByTestId } = render(
+ ,
+ );
+
+ const skeletons = getAllByTestId('skeleton');
+ expect(skeletons.length).toBeGreaterThanOrEqual(3);
+ });
+
+ it('handles a leaderboard with no entries (no top PnL)', () => {
+ const empty: PerpsTradingCampaignLeaderboardDto = {
+ campaignId: 'perps-1',
+ computedAt: '2026-01-01T00:00:00Z',
+ totalParticipants: 0,
+ entries: [],
+ };
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_PARTICIPANTS).props
+ .children,
+ ).toBe('0');
+ expect(
+ getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children,
+ ).toBe('-');
+ expect(
+ getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children,
+ ).toBe('-');
+ });
+
+ it('shows error banner when both sources fail and triggers both retries', () => {
+ const onRetryLeaderboard = jest.fn();
+ const onRetryVolume = jest.fn();
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('rewards-error-banner')).toBeTruthy();
+ fireEvent.press(getByTestId('rewards-error-banner-retry'));
+ expect(onRetryLeaderboard).toHaveBeenCalledTimes(1);
+ expect(onRetryVolume).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows error banner when only leaderboard fails; volume still renders', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId('rewards-error-banner')).toBeTruthy();
+ expect(
+ getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_VOLUME).props
+ .children,
+ ).toBe('$27.5M');
+ });
+
+ it('does not render error banner when there are no errors', () => {
+ const { queryByTestId } = render(
+ ,
+ );
+
+ expect(queryByTestId('rewards-error-banner')).toBeNull();
+ });
+
+ it('renders negative top PnL with error color and a minus sign', () => {
+ const negativeTop: PerpsTradingCampaignLeaderboardDto = {
+ campaignId: 'perps-1',
+ computedAt: '2026-01-01T00:00:00Z',
+ totalParticipants: 1,
+ entries: [makeEntry(1, -5_000)],
+ };
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(
+ getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children,
+ ).toBe('-$5,000');
+ });
+});
diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx
new file mode 100644
index 00000000000..af3a1616288
--- /dev/null
+++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx
@@ -0,0 +1,153 @@
+import React, { useMemo } from 'react';
+import {
+ Box,
+ BoxFlexDirection,
+ FontWeight,
+ Text,
+ TextColor,
+ TextVariant,
+} from '@metamask/design-system-react-native';
+import type { PerpsTradingCampaignLeaderboardDto } from '../../../../../core/Engine/controllers/rewards-controller/types';
+import { StatCell } from './OndoCampaignStatsSummary';
+import RewardsErrorBanner from '../RewardsErrorBanner';
+import { strings } from '../../../../../../locales/i18n';
+import { formatCompactUsd, formatSignedUsd } from '../../utils/formatUtils';
+
+const PERPS_WINNERS_CAP = 20;
+
+export const PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS = {
+ CONTAINER: 'perps-campaign-ended-stats-container',
+ TOTAL_PARTICIPANTS: 'perps-campaign-ended-stats-total-participants',
+ TOTAL_VOLUME: 'perps-campaign-ended-stats-total-volume',
+ TOP_PNL: 'perps-campaign-ended-stats-top-pnl',
+ WINNERS: 'perps-campaign-ended-stats-winners',
+} as const;
+
+interface PerpsTradingCampaignEndedStatsProps {
+ leaderboard: PerpsTradingCampaignLeaderboardDto | null;
+ totalNotionalVolume: string | null;
+ isLeaderboardLoading: boolean;
+ isVolumeLoading: boolean;
+ hasLeaderboardError?: boolean;
+ hasVolumeError?: boolean;
+ onRetryLeaderboard?: () => void;
+ onRetryVolume?: () => void;
+}
+
+const PerpsTradingCampaignEndedStats: React.FC<
+ PerpsTradingCampaignEndedStatsProps
+> = ({
+ leaderboard,
+ totalNotionalVolume,
+ isLeaderboardLoading,
+ isVolumeLoading,
+ hasLeaderboardError,
+ hasVolumeError,
+ onRetryLeaderboard,
+ onRetryVolume,
+}) => {
+ const stats = useMemo(() => {
+ if (!leaderboard) return null;
+
+ const entries = leaderboard.entries ?? [];
+ const totalParticipants = leaderboard.totalParticipants;
+ const topPnl =
+ entries.length > 0 ? Math.max(...entries.map((e) => e.pnl)) : null;
+ const hasFullLeaderboard = entries.length >= PERPS_WINNERS_CAP;
+ return { totalParticipants, topPnl, hasFullLeaderboard };
+ }, [leaderboard]);
+
+ const isLeaderboardSkeletonVisible = isLeaderboardLoading && !leaderboard;
+ const isVolumeSkeletonVisible = isVolumeLoading && !totalNotionalVolume;
+
+ const hasError =
+ (hasLeaderboardError && !leaderboard) ||
+ (hasVolumeError && !totalNotionalVolume);
+
+ const totalParticipantsValue = stats
+ ? stats.totalParticipants.toLocaleString()
+ : '-';
+
+ const totalVolumeValue = totalNotionalVolume
+ ? formatCompactUsd(parseFloat(totalNotionalVolume))
+ : '-';
+
+ const topPnlValue =
+ stats?.topPnl != null ? formatSignedUsd(stats.topPnl) : '-';
+
+ const topPnlColor =
+ stats?.topPnl != null && stats.topPnl >= 0
+ ? TextColor.SuccessDefault
+ : TextColor.ErrorDefault;
+
+ const winnersValue = stats?.hasFullLeaderboard
+ ? String(PERPS_WINNERS_CAP)
+ : '-';
+
+ return (
+
+
+ {strings('rewards.perps_trading_campaign.stats_title')}
+
+ {hasError && (
+ {
+ onRetryLeaderboard?.();
+ onRetryVolume?.();
+ }}
+ confirmButtonLabel={strings(
+ 'rewards.perps_trading_campaign.stats_retry',
+ )}
+ />
+ )}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default PerpsTradingCampaignEndedStats;
diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx
index d103b8e8c58..2894eaf9e28 100644
--- a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx
+++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx
@@ -104,6 +104,16 @@ describe('PerpsTradingCampaignStatsHeader', () => {
expect(queryByTestId(TEST_IDS.QUALIFIED_ICON)).toBeNull();
});
+ it('hides the pending tag when the campaign is complete', () => {
+ const { queryByTestId } = render(
+ ,
+ );
+ expect(queryByTestId(TEST_IDS.PENDING_TAG)).toBeNull();
+ });
+
it('shows em dashes for rank and PnL when position is null', () => {
const { getByTestId } = render(
,
diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx
index 63c4bdee9a7..31d3623c69b 100644
--- a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx
+++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx
@@ -38,6 +38,8 @@ interface PerpsTradingCampaignStatsHeaderProps {
showPnl?: boolean;
/** When true, shows formatted `computedAt` time on the same row as PnL, right-aligned in alternative text color. */
showComputedAt?: boolean;
+ /** When true, suppresses the "Pending" tag — qualification is locked once the campaign ends. */
+ isCampaignComplete?: boolean;
}
const PerpsTradingCampaignStatsHeader: React.FC<
@@ -47,6 +49,7 @@ const PerpsTradingCampaignStatsHeader: React.FC<
isLoading = false,
showPnl = true,
showComputedAt = true,
+ isCampaignComplete = false,
}) => {
const tw = useTailwind();
@@ -80,7 +83,7 @@ const PerpsTradingCampaignStatsHeader: React.FC<
{strings('rewards.perps_trading_campaign.label_your_rank')}
- {isPending && (
+ {isPending && !isCampaignComplete && (
)}
{isQualified && (
diff --git a/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx b/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx
index 7023d15b341..6bf5b7a0cc9 100644
--- a/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx
+++ b/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx
@@ -14,7 +14,7 @@ import {
import { Skeleton } from '../../../../../component-library/components-temp/Skeleton';
interface CopyableFieldProps {
- label: string;
+ label?: string;
value?: string | null;
onCopy?: () => void;
valueLoading?: boolean;
@@ -40,9 +40,11 @@ const CopyableField: React.FC = ({
return (
-
- {label}
-
+ {label && (
+
+ {label}
+
+ )}
({
+ ToastContext: { Consumer: jest.fn(), Provider: jest.fn() },
+}));
+
+jest.mock('../../../../component-library/components/Toast/Toast.types', () => ({
+ ToastVariants: { Icon: 'Icon', App: 'App', Plain: 'Plain' },
+ ButtonIconVariant: { Icon: 'Icon' },
+}));
+
+jest.mock('../../../../component-library/components/Icons/Icon', () => ({
+ IconName: { Close: 'Close', Confirmation: 'Confirmation', Star: 'Star' },
+}));
+
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ useContext: jest.fn(),
+ useCallback: jest.fn((fn) => fn),
+ useMemo: jest.fn((fn) => fn()),
+}));
+
+jest.mock('react-redux', () => ({
+ useDispatch: jest.fn(),
+ useSelector: jest.fn(),
+}));
+
+jest.mock('@react-navigation/native', () => ({
+ useFocusEffect: jest.fn(),
+ useNavigation: jest.fn(),
+}));
+
+jest.mock('../../../../util/haptics', () => ({
+ playNotification: jest.fn(),
+ NotificationMoment: {
+ Success: 'Success',
+ Warning: 'Warning',
+ Error: 'Error',
+ },
+}));
+
+jest.mock('../../../../../locales/i18n', () => ({
+ strings: jest.fn((key: string, params?: Record) => {
+ if (params?.campaignName) return `${key}:${params.campaignName}`;
+ return key;
+ }),
+}));
+
+jest.mock('../../../../util/theme', () => {
+ const actual = jest.requireActual('../../../../util/theme');
+ return {
+ ...actual,
+ useAppThemeFromContext: () => actual.mockTheme,
+ };
+});
+
+jest.mock('../../../../reducers/rewards', () => ({
+ dismissCampaignOutcomeToast: jest.fn(),
+}));
+
+jest.mock('../../../../reducers/rewards/selectors', () => ({
+ selectCampaigns: jest.fn(),
+ selectDismissedCampaignOutcomeToasts: jest.fn(),
+}));
+
+jest.mock('../../../../selectors/rewards', () => ({
+ selectRewardsSubscriptionId: jest.fn(),
+}));
+
+jest.mock('../components/Campaigns/CampaignTile.utils', () => ({
+ getCampaignStatus: jest.fn(() => 'complete'),
+}));
+
+const mockDispatch = jest.fn();
+const mockNavigate = jest.fn();
+const mockShowToast = jest.fn();
+const mockCloseToast = jest.fn();
+const mockToastRef = {
+ current: { showToast: mockShowToast, closeToast: mockCloseToast },
+};
+
+const mockUseDispatch = useDispatch as jest.MockedFunction;
+const mockUseSelector = useSelector as jest.MockedFunction;
+const mockUseFocusEffect = useFocusEffect as jest.MockedFunction<
+ typeof useFocusEffect
+>;
+const mockUseNavigation = useNavigation as jest.MockedFunction<
+ typeof useNavigation
+>;
+const mockDismissCampaignOutcomeToast =
+ dismissCampaignOutcomeToast as jest.MockedFunction<
+ typeof dismissCampaignOutcomeToast
+ >;
+
+const SUBSCRIPTION_ID = 'sub-123';
+const CAMPAIGN_ID = 'campaign-456';
+const CAMPAIGN_NAME = 'Test Campaign';
+
+const mockUseOutcome = jest.fn();
+
+const makeCompletedCampaign = (id = CAMPAIGN_ID, endDate = '2025-01-01') => ({
+ id,
+ name: CAMPAIGN_NAME,
+ type: CampaignType.ONDO_HOLDING,
+ endDate,
+ startDate: '2024-01-01',
+});
+
+const WINNER_NAV = {
+ route: 'WinnerRoute',
+ params: { campaignId: CAMPAIGN_ID },
+};
+const NON_WINNER_NAV = {
+ route: 'NonWinnerRoute',
+ params: { campaignId: CAMPAIGN_ID },
+};
+
+const mockConfig = {
+ campaignType: CampaignType.ONDO_HOLDING,
+ useOutcome: mockUseOutcome,
+ getWinnerNavigation: jest.fn(() => WINNER_NAV),
+ getNonWinnerNavigation: jest.fn(() => NON_WINNER_NAV),
+};
+
+function setupDefaults({
+ campaigns = [makeCompletedCampaign()],
+ dismissed = {},
+ subscriptionId = SUBSCRIPTION_ID,
+ outcome = null,
+}: {
+ campaigns?: ReturnType[];
+ dismissed?: Record;
+ subscriptionId?: string | null;
+ outcome?: BaseCampaignParticipantOutcomeDto | null;
+} = {}) {
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === selectCampaigns) return campaigns;
+ if (selector === selectDismissedCampaignOutcomeToasts) return dismissed;
+ if (selector === selectRewardsSubscriptionId) return subscriptionId;
+ return undefined;
+ });
+ mockUseOutcome.mockReturnValue({
+ outcome,
+ isLoading: false,
+ hasError: false,
+ });
+}
+
+describe('useCampaignOutcomeToast', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseDispatch.mockReturnValue(mockDispatch);
+ mockUseNavigation.mockReturnValue({ navigate: mockNavigate } as never);
+ (useContext as jest.Mock).mockReturnValue({ toastRef: mockToastRef });
+ mockUseFocusEffect.mockImplementation((cb) => {
+ cb();
+ });
+ mockDismissCampaignOutcomeToast.mockReturnValue({
+ type: 'rewards/dismissCampaignOutcomeToast',
+ } as never);
+ mockConfig.getWinnerNavigation.mockReturnValue(WINNER_NAV);
+ mockConfig.getNonWinnerNavigation.mockReturnValue(NON_WINNER_NAV);
+ });
+
+ describe('does not show toast when', () => {
+ it('outcome is null', () => {
+ setupDefaults({ outcome: null });
+ renderHook(() => useCampaignOutcomeToast(mockConfig));
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+
+ it('no campaigns match the campaignType', () => {
+ setupDefaults({ campaigns: [] });
+ renderHook(() => useCampaignOutcomeToast(mockConfig));
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+
+ it('subscriptionId is missing', () => {
+ setupDefaults({
+ subscriptionId: null,
+ outcome: {
+ subscriptionId: '',
+ outcomeStatus: 'pending',
+ winnerVerificationCode: 'CODE',
+ },
+ });
+ renderHook(() => useCampaignOutcomeToast(mockConfig));
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+
+ it('winner toast was already dismissed', () => {
+ const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:winner`;
+ setupDefaults({
+ outcome: {
+ subscriptionId: SUBSCRIPTION_ID,
+ outcomeStatus: 'pending',
+ winnerVerificationCode: 'CODE',
+ },
+ dismissed: { [key]: true },
+ });
+ renderHook(() => useCampaignOutcomeToast(mockConfig));
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+
+ it('non_winner toast was already dismissed', () => {
+ const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:non_winner`;
+ setupDefaults({
+ outcome: {
+ subscriptionId: SUBSCRIPTION_ID,
+ outcomeStatus: 'finalized',
+ winnerVerificationCode: null,
+ },
+ dismissed: { [key]: true },
+ });
+ renderHook(() => useCampaignOutcomeToast(mockConfig));
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+
+ it('outcome is finalized with a verification code (neither variant)', () => {
+ setupDefaults({
+ outcome: {
+ subscriptionId: SUBSCRIPTION_ID,
+ outcomeStatus: 'finalized',
+ winnerVerificationCode: 'CODE',
+ },
+ });
+ renderHook(() => useCampaignOutcomeToast(mockConfig));
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('winner toast', () => {
+ const winnerOutcome: BaseCampaignParticipantOutcomeDto = {
+ subscriptionId: SUBSCRIPTION_ID,
+ outcomeStatus: 'pending',
+ winnerVerificationCode: 'WINNER-XYZ',
+ };
+
+ it('shows Plain variant toast with trophy startAccessory', () => {
+ setupDefaults({ outcome: winnerOutcome });
+ renderHook(() => useCampaignOutcomeToast(mockConfig));
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ variant: ToastVariants.Plain,
+ hasNoTimeout: true,
+ startAccessory: expect.anything(),
+ }),
+ );
+ });
+
+ it('uses consolidated winner locale keys', () => {
+ setupDefaults({ outcome: winnerOutcome });
+ renderHook(() => useCampaignOutcomeToast(mockConfig));
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ labelOptions: [
+ {
+ label: 'rewards.campaign_outcome_toast.winner.title',
+ isBold: true,
+ },
+ ],
+ descriptionOptions: {
+ description: `rewards.campaign_outcome_toast.winner.description:${CAMPAIGN_NAME}`,
+ },
+ linkButtonOptions: expect.objectContaining({
+ label: 'rewards.campaign_outcome_toast.winner.cta',
+ }),
+ }),
+ );
+ });
+
+ it('shows close button with correct config', () => {
+ setupDefaults({ outcome: winnerOutcome });
+ renderHook(() => useCampaignOutcomeToast(mockConfig));
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ closeButtonOptions: expect.objectContaining({
+ variant: ButtonIconVariant.Icon,
+ iconName: IconName.Close,
+ }),
+ }),
+ );
+ });
+
+ it('fires success haptic via playNotification', () => {
+ setupDefaults({ outcome: winnerOutcome });
+ renderHook(() => useCampaignOutcomeToast(mockConfig));
+ expect(playNotification).toHaveBeenCalledWith(NotificationMoment.Success);
+ });
+ });
+
+ describe('non-winner toast', () => {
+ const nonWinnerOutcome: BaseCampaignParticipantOutcomeDto = {
+ subscriptionId: SUBSCRIPTION_ID,
+ outcomeStatus: 'finalized',
+ winnerVerificationCode: null,
+ };
+
+ it('shows Icon variant toast with Confirmation icon', () => {
+ setupDefaults({ outcome: nonWinnerOutcome });
+ renderHook(() => useCampaignOutcomeToast(mockConfig));
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ variant: ToastVariants.Icon,
+ iconName: IconName.Confirmation,
+ backgroundColor: 'transparent',
+ hasNoTimeout: true,
+ }),
+ );
+ });
+
+ it('uses consolidated non_winner locale keys', () => {
+ setupDefaults({ outcome: nonWinnerOutcome });
+ renderHook(() => useCampaignOutcomeToast(mockConfig));
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ labelOptions: [
+ {
+ label: 'rewards.campaign_outcome_toast.non_winner.title',
+ isBold: true,
+ },
+ ],
+ descriptionOptions: {
+ description: `rewards.campaign_outcome_toast.non_winner.description:${CAMPAIGN_NAME}`,
+ },
+ linkButtonOptions: expect.objectContaining({
+ label: 'rewards.campaign_outcome_toast.non_winner.cta',
+ }),
+ }),
+ );
+ });
+
+ it('fires warning haptic via playNotification', () => {
+ setupDefaults({ outcome: nonWinnerOutcome });
+ renderHook(() => useCampaignOutcomeToast(mockConfig));
+ expect(playNotification).toHaveBeenCalledWith(NotificationMoment.Warning);
+ });
+ });
+
+ describe('handleDismiss', () => {
+ it('dispatches dismissCampaignOutcomeToast with variant winner and closes toast', () => {
+ setupDefaults({
+ outcome: {
+ subscriptionId: SUBSCRIPTION_ID,
+ outcomeStatus: 'pending',
+ winnerVerificationCode: 'CODE',
+ },
+ });
+ renderHook(() => useCampaignOutcomeToast(mockConfig));
+
+ const closeButtonOptions =
+ mockShowToast.mock.calls[0][0].closeButtonOptions;
+ closeButtonOptions.onPress();
+
+ expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({
+ campaignId: CAMPAIGN_ID,
+ subscriptionId: SUBSCRIPTION_ID,
+ variant: 'winner',
+ });
+ expect(mockCloseToast).toHaveBeenCalled();
+ });
+
+ it('dispatches dismissCampaignOutcomeToast with variant non_winner', () => {
+ setupDefaults({
+ outcome: {
+ subscriptionId: SUBSCRIPTION_ID,
+ outcomeStatus: 'finalized',
+ winnerVerificationCode: null,
+ },
+ });
+ renderHook(() => useCampaignOutcomeToast(mockConfig));
+
+ const closeButtonOptions =
+ mockShowToast.mock.calls[0][0].closeButtonOptions;
+ closeButtonOptions.onPress();
+
+ expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({
+ campaignId: CAMPAIGN_ID,
+ subscriptionId: SUBSCRIPTION_ID,
+ variant: 'non_winner',
+ });
+ });
+ });
+
+ describe('handleCta', () => {
+ it('navigates to winner route and dismisses for winner variant', () => {
+ setupDefaults({
+ outcome: {
+ subscriptionId: SUBSCRIPTION_ID,
+ outcomeStatus: 'pending',
+ winnerVerificationCode: 'CODE',
+ },
+ });
+ renderHook(() => useCampaignOutcomeToast(mockConfig));
+
+ const { onPress } = mockShowToast.mock.calls[0][0].linkButtonOptions;
+ onPress();
+
+ expect(mockNavigate).toHaveBeenCalledWith(
+ WINNER_NAV.route as never,
+ WINNER_NAV.params as never,
+ );
+ expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith(
+ expect.objectContaining({ variant: 'winner' }),
+ );
+ });
+
+ it('navigates to non-winner route and dismisses for non_winner variant', () => {
+ setupDefaults({
+ outcome: {
+ subscriptionId: SUBSCRIPTION_ID,
+ outcomeStatus: 'finalized',
+ winnerVerificationCode: null,
+ },
+ });
+ renderHook(() => useCampaignOutcomeToast(mockConfig));
+
+ const { onPress } = mockShowToast.mock.calls[0][0].linkButtonOptions;
+ onPress();
+
+ expect(mockNavigate).toHaveBeenCalledWith(
+ NON_WINNER_NAV.route as never,
+ NON_WINNER_NAV.params as never,
+ );
+ expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith(
+ expect.objectContaining({ variant: 'non_winner' }),
+ );
+ });
+ });
+
+ describe('cleanup on blur', () => {
+ it('calls closeToast in cleanup when screen blurs', () => {
+ let cleanupFn: (() => void) | undefined;
+ mockUseFocusEffect.mockImplementation((cb) => {
+ const cleanup = cb();
+ if (typeof cleanup === 'function') cleanupFn = cleanup;
+ });
+
+ setupDefaults({
+ outcome: {
+ subscriptionId: SUBSCRIPTION_ID,
+ outcomeStatus: 'pending',
+ winnerVerificationCode: 'CODE',
+ },
+ });
+ renderHook(() => useCampaignOutcomeToast(mockConfig));
+
+ expect(cleanupFn).toBeDefined();
+ cleanupFn?.();
+ expect(mockCloseToast).toHaveBeenCalled();
+ });
+
+ it('does not return cleanup when variant is null', () => {
+ let cleanupFn: (() => void) | undefined;
+ mockUseFocusEffect.mockImplementation((cb) => {
+ const result = cb();
+ if (typeof result === 'function') cleanupFn = result;
+ });
+
+ setupDefaults({ outcome: null });
+ renderHook(() => useCampaignOutcomeToast(mockConfig));
+
+ expect(cleanupFn).toBeUndefined();
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles null toastRef gracefully', () => {
+ (useContext as jest.Mock).mockReturnValue({ toastRef: null });
+ setupDefaults({
+ outcome: {
+ subscriptionId: SUBSCRIPTION_ID,
+ outcomeStatus: 'pending',
+ winnerVerificationCode: 'CODE',
+ },
+ });
+ expect(() =>
+ renderHook(() => useCampaignOutcomeToast(mockConfig)),
+ ).not.toThrow();
+ });
+ });
+});
diff --git a/app/components/UI/Rewards/hooks/useCampaignOutcomeToast.ts b/app/components/UI/Rewards/hooks/useCampaignOutcomeToast.ts
new file mode 100644
index 00000000000..4ea89f6df6e
--- /dev/null
+++ b/app/components/UI/Rewards/hooks/useCampaignOutcomeToast.ts
@@ -0,0 +1,174 @@
+import { useCallback, useContext, useMemo } from 'react';
+import {
+ useFocusEffect,
+ useNavigation,
+ type NavigationProp,
+ type ParamListBase,
+} from '@react-navigation/native';
+import { useDispatch, useSelector } from 'react-redux';
+import { strings } from '../../../../../locales/i18n';
+import { ToastContext } from '../../../../component-library/components/Toast';
+import type {
+ BaseCampaignParticipantOutcomeDto,
+ CampaignType,
+ CampaignDto,
+} from '../../../../core/Engine/controllers/rewards-controller/types';
+import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils';
+import { dismissCampaignOutcomeToast } from '../../../../reducers/rewards';
+import {
+ selectCampaigns,
+ selectDismissedCampaignOutcomeToasts,
+} from '../../../../reducers/rewards/selectors';
+import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
+import useRewardsToast from './useRewardsToast';
+
+export interface CampaignOutcomeToastConfig {
+ campaignType: CampaignType;
+ useOutcome: (id: string | undefined) => {
+ outcome: BaseCampaignParticipantOutcomeDto | null;
+ };
+ getWinnerNavigation: (campaign: CampaignDto) => {
+ route: string;
+ params: object;
+ };
+ getNonWinnerNavigation: (campaign: CampaignDto) => {
+ route: string;
+ params: object;
+ };
+}
+
+export function useCampaignOutcomeToast(
+ config: CampaignOutcomeToastConfig,
+): void {
+ const {
+ campaignType,
+ useOutcome,
+ getWinnerNavigation,
+ getNonWinnerNavigation,
+ } = config;
+
+ const dispatch = useDispatch();
+ const { toastRef } = useContext(ToastContext);
+ const { showToast, RewardsToastOptions } = useRewardsToast();
+ const navigation = useNavigation>();
+
+ const subscriptionId = useSelector(selectRewardsSubscriptionId);
+ const campaigns = useSelector(selectCampaigns);
+ const dismissed = useSelector(selectDismissedCampaignOutcomeToasts);
+
+ const targetCampaign = useMemo(() => {
+ const completed = campaigns
+ .filter(
+ (c) => c.type === campaignType && getCampaignStatus(c) === 'complete',
+ )
+ .sort(
+ (a, b) => new Date(b.endDate).getTime() - new Date(a.endDate).getTime(),
+ );
+ return completed[0] ?? null;
+ }, [campaigns, campaignType]);
+
+ const { outcome } = useOutcome(targetCampaign?.id);
+
+ // Standardized variant derivation: winner = has code and not yet finalized
+ const variant = useMemo((): 'winner' | 'non_winner' | null => {
+ if (!outcome) return null;
+ if (
+ outcome.winnerVerificationCode &&
+ outcome.outcomeStatus !== 'finalized'
+ ) {
+ return 'winner';
+ }
+ if (
+ outcome.outcomeStatus === 'finalized' &&
+ !outcome.winnerVerificationCode
+ ) {
+ return 'non_winner';
+ }
+ return null;
+ }, [outcome]);
+
+ const isDismissed = useMemo(() => {
+ if (!variant || !targetCampaign || !subscriptionId) return true;
+ const key = `${targetCampaign.id}:${subscriptionId}:${variant}`;
+ return dismissed[key] === true;
+ }, [variant, targetCampaign, subscriptionId, dismissed]);
+
+ const handleDismiss = useCallback(() => {
+ if (!variant || !targetCampaign || !subscriptionId) return;
+ dispatch(
+ dismissCampaignOutcomeToast({
+ campaignId: targetCampaign.id,
+ subscriptionId,
+ variant,
+ }),
+ );
+ toastRef?.current?.closeToast();
+ }, [variant, targetCampaign, subscriptionId, dispatch, toastRef]);
+
+ const handleCta = useCallback(() => {
+ if (!targetCampaign || !variant) return;
+ handleDismiss();
+ const nav =
+ variant === 'winner'
+ ? getWinnerNavigation(targetCampaign)
+ : getNonWinnerNavigation(targetCampaign);
+ navigation.navigate(nav.route, nav.params);
+ }, [
+ variant,
+ targetCampaign,
+ handleDismiss,
+ navigation,
+ getWinnerNavigation,
+ getNonWinnerNavigation,
+ ]);
+
+ useFocusEffect(
+ useCallback(() => {
+ if (!variant || isDismissed || !targetCampaign) return;
+
+ const isWinner = variant === 'winner';
+ if (isWinner) {
+ showToast(
+ RewardsToastOptions.outcomeWinner({
+ title: strings('rewards.campaign_outcome_toast.winner.title'),
+ description: strings(
+ 'rewards.campaign_outcome_toast.winner.description',
+ { campaignName: targetCampaign.name ?? '' },
+ ),
+ ctaLabel: strings('rewards.campaign_outcome_toast.winner.cta'),
+ onCtaPress: handleCta,
+ onClosePress: handleDismiss,
+ }),
+ );
+ } else {
+ showToast(
+ RewardsToastOptions.outcomeNonWinner({
+ title: strings('rewards.campaign_outcome_toast.non_winner.title'),
+ description: strings(
+ 'rewards.campaign_outcome_toast.non_winner.description',
+ { campaignName: targetCampaign.name ?? '' },
+ ),
+ ctaLabel: strings('rewards.campaign_outcome_toast.non_winner.cta'),
+ onCtaPress: handleCta,
+ onClosePress: handleDismiss,
+ }),
+ );
+ }
+
+ return () => {
+ toastRef?.current?.closeToast();
+ };
+ }, [
+ variant,
+ isDismissed,
+ targetCampaign,
+ toastRef,
+ showToast,
+ RewardsToastOptions,
+ handleCta,
+ handleDismiss,
+ ]),
+ );
+}
+
+export default useCampaignOutcomeToast;
diff --git a/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.test.ts b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.test.ts
new file mode 100644
index 00000000000..012cbe4549a
--- /dev/null
+++ b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.test.ts
@@ -0,0 +1,173 @@
+import { renderHook, act } from '@testing-library/react-hooks';
+import { useSelector } from 'react-redux';
+import Engine from '../../../../core/Engine';
+import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
+import { selectCampaignParticipantOptedIn } from '../../../../reducers/rewards/selectors';
+import { useCampaignParticipantOutcome } from './useCampaignParticipantOutcome';
+import type { BaseCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types';
+
+jest.mock('react-redux', () => ({
+ useSelector: jest.fn(),
+}));
+
+jest.mock('../../../../core/Engine', () => ({
+ controllerMessenger: { call: jest.fn() },
+}));
+
+jest.mock('../../../../selectors/rewards', () => ({
+ selectRewardsSubscriptionId: jest.fn(),
+}));
+
+jest.mock('../../../../reducers/rewards/selectors', () => ({
+ selectCampaignParticipantOptedIn: jest.fn(),
+}));
+
+const mockCall = Engine.controllerMessenger.call as jest.MockedFunction<
+ typeof Engine.controllerMessenger.call
+>;
+const mockUseSelector = useSelector as jest.MockedFunction;
+const mockSelectCampaignParticipantOptedIn =
+ selectCampaignParticipantOptedIn as jest.MockedFunction<
+ typeof selectCampaignParticipantOptedIn
+ >;
+
+const CAMPAIGN_ID = 'campaign-123';
+const SUBSCRIPTION_ID = 'sub-456';
+const MESSENGER_ACTION = 'RewardsController:getOndoCampaignParticipantOutcome';
+const CONFIG = { messengerAction: MESSENGER_ACTION };
+
+const MOCK_OUTCOME: BaseCampaignParticipantOutcomeDto = {
+ subscriptionId: SUBSCRIPTION_ID,
+ outcomeStatus: 'pending',
+ winnerVerificationCode: 'WINNER-XYZ',
+};
+
+function setupSelectors({
+ subscriptionId = SUBSCRIPTION_ID,
+ isOptedIn = true,
+}: {
+ subscriptionId?: string | null;
+ isOptedIn?: boolean;
+} = {}) {
+ const participantOptedInSelector = jest.fn().mockReturnValue(isOptedIn);
+ mockSelectCampaignParticipantOptedIn.mockReturnValue(
+ participantOptedInSelector,
+ );
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === selectRewardsSubscriptionId) return subscriptionId;
+ if (selector === participantOptedInSelector) return isOptedIn;
+ return undefined;
+ });
+}
+
+describe('useCampaignParticipantOutcome', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('fetches and returns outcome when subscriptionId, campaignId, and isOptedIn are truthy', async () => {
+ setupSelectors();
+ mockCall.mockResolvedValue(MOCK_OUTCOME);
+
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useCampaignParticipantOutcome(CAMPAIGN_ID, CONFIG),
+ );
+
+ await act(async () => {
+ await waitForNextUpdate();
+ });
+
+ expect(mockCall).toHaveBeenCalledWith(
+ MESSENGER_ACTION,
+ CAMPAIGN_ID,
+ SUBSCRIPTION_ID,
+ );
+ expect(result.current.outcome).toEqual(MOCK_OUTCOME);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.hasError).toBe(false);
+ });
+
+ it('returns null outcome when campaignId is undefined', async () => {
+ setupSelectors();
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useCampaignParticipantOutcome(undefined, CONFIG),
+ );
+ await act(async () => {
+ await waitForNextUpdate().catch(() => undefined);
+ });
+ expect(result.current.outcome).toBeNull();
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.hasError).toBe(false);
+ expect(mockCall).not.toHaveBeenCalled();
+ });
+
+ it('returns null outcome when subscriptionId is missing', async () => {
+ setupSelectors({ subscriptionId: null });
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useCampaignParticipantOutcome(CAMPAIGN_ID, CONFIG),
+ );
+ await act(async () => {
+ await waitForNextUpdate().catch(() => undefined);
+ });
+ expect(result.current.outcome).toBeNull();
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.hasError).toBe(false);
+ expect(mockCall).not.toHaveBeenCalled();
+ });
+
+ it('returns null outcome when user is not opted in', async () => {
+ setupSelectors({ isOptedIn: false });
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useCampaignParticipantOutcome(CAMPAIGN_ID, CONFIG),
+ );
+ await act(async () => {
+ await waitForNextUpdate().catch(() => undefined);
+ });
+ expect(result.current.outcome).toBeNull();
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.hasError).toBe(false);
+ expect(mockCall).not.toHaveBeenCalled();
+ });
+
+ it('sets hasError and clears outcome when the fetch throws', async () => {
+ setupSelectors();
+ mockCall.mockRejectedValue(new Error('fetch failed'));
+
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useCampaignParticipantOutcome(CAMPAIGN_ID, CONFIG),
+ );
+
+ await act(async () => {
+ await waitForNextUpdate();
+ });
+
+ expect(result.current.outcome).toBeNull();
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.hasError).toBe(true);
+ });
+
+ it('resets state when campaignId changes to undefined', async () => {
+ setupSelectors();
+ mockCall.mockResolvedValue(MOCK_OUTCOME);
+
+ const initialProps: { id: string | undefined } = { id: CAMPAIGN_ID };
+ const { result, waitForNextUpdate, rerender } = renderHook(
+ ({ id }: { id: string | undefined }) =>
+ useCampaignParticipantOutcome(id, CONFIG),
+ { initialProps },
+ );
+
+ await act(async () => {
+ await waitForNextUpdate();
+ });
+ expect(result.current.outcome).toEqual(MOCK_OUTCOME);
+
+ rerender({ id: undefined });
+ await act(async () => {
+ await waitForNextUpdate().catch(() => undefined);
+ });
+ expect(result.current.outcome).toBeNull();
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.hasError).toBe(false);
+ });
+});
diff --git a/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.ts b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.ts
new file mode 100644
index 00000000000..1f10639c665
--- /dev/null
+++ b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.ts
@@ -0,0 +1,68 @@
+import { useCallback, useEffect, useState } from 'react';
+import { useSelector } from 'react-redux';
+import Engine from '../../../../core/Engine';
+import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
+import { selectCampaignParticipantOptedIn } from '../../../../reducers/rewards/selectors';
+import type { BaseCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types';
+
+export interface UseCampaignParticipantOutcomeResult<
+ T extends BaseCampaignParticipantOutcomeDto,
+> {
+ outcome: T | null;
+ isLoading: boolean;
+ hasError: boolean;
+}
+
+export interface CampaignOutcomeFetchConfig {
+ messengerAction: string;
+}
+
+export function useCampaignParticipantOutcome<
+ T extends BaseCampaignParticipantOutcomeDto,
+>(
+ campaignId: string | undefined,
+ config: CampaignOutcomeFetchConfig,
+): UseCampaignParticipantOutcomeResult {
+ const subscriptionId = useSelector(selectRewardsSubscriptionId);
+ const isOptedIn = useSelector(
+ selectCampaignParticipantOptedIn(subscriptionId, campaignId),
+ );
+ const [outcome, setOutcome] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [hasError, setHasError] = useState(false);
+
+ const fetchOutcome = useCallback(async (): Promise => {
+ if (!subscriptionId || !campaignId || !isOptedIn) {
+ setOutcome(null);
+ setIsLoading(false);
+ setHasError(false);
+ return;
+ }
+
+ try {
+ setIsLoading(true);
+ setHasError(false);
+ const result = await Engine.controllerMessenger.call(
+ config.messengerAction as Parameters<
+ typeof Engine.controllerMessenger.call
+ >[0],
+ campaignId,
+ subscriptionId,
+ );
+ setOutcome(result as T);
+ } catch {
+ setOutcome(null);
+ setHasError(true);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [campaignId, subscriptionId, isOptedIn, config.messengerAction]);
+
+ useEffect(() => {
+ fetchOutcome();
+ }, [fetchOutcome]);
+
+ return { outcome, isLoading, hasError };
+}
+
+export default useCampaignParticipantOutcome;
diff --git a/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.test.ts b/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.test.ts
index c0bed6ad4a6..f424932ae16 100644
--- a/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.test.ts
+++ b/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.test.ts
@@ -3,11 +3,11 @@ import { waitFor } from '@testing-library/react-native';
import { useSelector, useDispatch } from 'react-redux';
import { useGetOndoCampaignActivity } from './useGetOndoCampaignActivity';
import Engine from '../../../../core/Engine';
+import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
import {
- selectRewardsSubscriptionId,
selectCampaignParticipantOptedIn,
-} from '../../../../selectors/rewards';
-import { selectOndoCampaignActivityById } from '../../../../reducers/rewards/selectors';
+ selectOndoCampaignActivityById,
+} from '../../../../reducers/rewards/selectors';
import { setOndoCampaignActivity } from '../../../../reducers/rewards';
import type { OndoGmActivityEntryDto } from '../../../../core/Engine/controllers/rewards-controller/types';
@@ -22,10 +22,10 @@ jest.mock('../../../../core/Engine', () => ({
jest.mock('../../../../selectors/rewards', () => ({
selectRewardsSubscriptionId: jest.fn(),
- selectCampaignParticipantOptedIn: jest.fn(),
}));
jest.mock('../../../../reducers/rewards/selectors', () => ({
+ selectCampaignParticipantOptedIn: jest.fn(),
selectOndoCampaignActivityById: jest.fn(),
}));
diff --git a/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.ts b/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.ts
index 3c28894340a..92faacd5610 100644
--- a/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.ts
+++ b/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.ts
@@ -1,11 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Engine from '../../../../core/Engine';
+import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
import {
- selectRewardsSubscriptionId,
selectCampaignParticipantOptedIn,
-} from '../../../../selectors/rewards';
-import { selectOndoCampaignActivityById } from '../../../../reducers/rewards/selectors';
+ selectOndoCampaignActivityById,
+} from '../../../../reducers/rewards/selectors';
import { setOndoCampaignActivity } from '../../../../reducers/rewards';
import type { OndoGmActivityEntryDto } from '../../../../core/Engine/controllers/rewards-controller/types';
diff --git a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts
index f3170271b60..2910af46256 100644
--- a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts
+++ b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts
@@ -2,11 +2,11 @@ import { renderHook, act } from '@testing-library/react-hooks';
import { useSelector, useDispatch } from 'react-redux';
import { useGetOndoLeaderboardPosition } from './useGetOndoLeaderboardPosition';
import Engine from '../../../../core/Engine';
+import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
import {
- selectRewardsSubscriptionId,
selectCampaignParticipantOptedIn,
-} from '../../../../selectors/rewards';
-import { selectOndoCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors';
+ selectOndoCampaignLeaderboardPositionById,
+} from '../../../../reducers/rewards/selectors';
import { setOndoCampaignLeaderboardPosition } from '../../../../reducers/rewards';
import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents';
import type { CampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types';
@@ -26,10 +26,10 @@ jest.mock('./useInvalidateByRewardEvents', () => ({
jest.mock('../../../../selectors/rewards', () => ({
selectRewardsSubscriptionId: jest.fn(),
- selectCampaignParticipantOptedIn: jest.fn(),
}));
jest.mock('../../../../reducers/rewards/selectors', () => ({
+ selectCampaignParticipantOptedIn: jest.fn(),
selectOndoCampaignLeaderboardPositionById: jest.fn(),
}));
@@ -83,16 +83,18 @@ interface SelectorState {
function setupSelectors(state: SelectorState) {
const isOptedIn = state.isOptedIn ?? true;
const mockPositionSelector = jest.fn().mockReturnValue(state.position);
- const mockOptedInSelector = jest.fn().mockReturnValue(isOptedIn);
+ const mockParticipantOptedInSelector = jest.fn().mockReturnValue(isOptedIn);
mockSelectCampaignLeaderboardPositionById.mockReturnValue(
mockPositionSelector,
);
- mockSelectCampaignParticipantOptedIn.mockReturnValue(mockOptedInSelector);
+ mockSelectCampaignParticipantOptedIn.mockReturnValue(
+ mockParticipantOptedInSelector,
+ );
mockUseSelector.mockImplementation((selector) => {
if (selector === selectRewardsSubscriptionId) return state.subscriptionId;
if (selector === mockPositionSelector) return state.position;
- if (selector === mockOptedInSelector) return isOptedIn;
+ if (selector === mockParticipantOptedInSelector) return isOptedIn;
return undefined;
});
}
diff --git a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts
index be4f0b96cc3..9b0bccab4f3 100644
--- a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts
+++ b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts
@@ -1,11 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Engine from '../../../../core/Engine';
+import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
import {
- selectRewardsSubscriptionId,
selectCampaignParticipantOptedIn,
-} from '../../../../selectors/rewards';
-import { selectOndoCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors';
+ selectOndoCampaignLeaderboardPositionById,
+} from '../../../../reducers/rewards/selectors';
import { setOndoCampaignLeaderboardPosition } from '../../../../reducers/rewards';
import type { CampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types';
import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents';
diff --git a/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.test.ts b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.test.ts
index 80300f1f62c..332ad4b7415 100644
--- a/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.test.ts
+++ b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.test.ts
@@ -3,11 +3,11 @@ import { waitFor } from '@testing-library/react-native';
import { useSelector, useDispatch } from 'react-redux';
import { useGetOndoPortfolioPosition } from './useGetOndoPortfolioPosition';
import Engine from '../../../../core/Engine';
+import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
import {
- selectRewardsSubscriptionId,
selectCampaignParticipantOptedIn,
-} from '../../../../selectors/rewards';
-import { selectOndoCampaignPortfolioById } from '../../../../reducers/rewards/selectors';
+ selectOndoCampaignPortfolioById,
+} from '../../../../reducers/rewards/selectors';
import { setOndoCampaignPortfolioPosition } from '../../../../reducers/rewards';
import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents';
import type { OndoGmPortfolioDto } from '../../../../core/Engine/controllers/rewards-controller/types';
@@ -27,10 +27,10 @@ jest.mock('./useInvalidateByRewardEvents', () => ({
jest.mock('../../../../selectors/rewards', () => ({
selectRewardsSubscriptionId: jest.fn(),
- selectCampaignParticipantOptedIn: jest.fn(),
}));
jest.mock('../../../../reducers/rewards/selectors', () => ({
+ selectCampaignParticipantOptedIn: jest.fn(),
selectOndoCampaignPortfolioById: jest.fn(),
}));
diff --git a/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.ts b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.ts
index 2270b597a59..e0f960916cc 100644
--- a/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.ts
+++ b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.ts
@@ -1,11 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Engine from '../../../../core/Engine';
+import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
import {
- selectRewardsSubscriptionId,
selectCampaignParticipantOptedIn,
-} from '../../../../selectors/rewards';
-import { selectOndoCampaignPortfolioById } from '../../../../reducers/rewards/selectors';
+ selectOndoCampaignPortfolioById,
+} from '../../../../reducers/rewards/selectors';
import { setOndoCampaignPortfolioPosition } from '../../../../reducers/rewards';
import type { OndoGmPortfolioDto } from '../../../../core/Engine/controllers/rewards-controller/types';
import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents';
diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts
index 320a3921c75..d85a3b027cf 100644
--- a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts
+++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts
@@ -2,11 +2,11 @@ import { renderHook, act } from '@testing-library/react-hooks';
import { useSelector, useDispatch } from 'react-redux';
import { useGetPerpsTradingCampaignLeaderboardPosition } from './useGetPerpsTradingCampaignLeaderboardPosition';
import Engine from '../../../../core/Engine';
+import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
import {
- selectRewardsSubscriptionId,
selectCampaignParticipantOptedIn,
-} from '../../../../selectors/rewards';
-import { selectPerpsTradingCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors';
+ selectPerpsTradingCampaignLeaderboardPositionById,
+} from '../../../../reducers/rewards/selectors';
import { setPerpsTradingCampaignLeaderboardPosition } from '../../../../reducers/rewards';
import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents';
import type { PerpsTradingCampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types';
@@ -26,10 +26,10 @@ jest.mock('./useInvalidateByRewardEvents', () => ({
jest.mock('../../../../selectors/rewards', () => ({
selectRewardsSubscriptionId: jest.fn(),
- selectCampaignParticipantOptedIn: jest.fn(),
}));
jest.mock('../../../../reducers/rewards/selectors', () => ({
+ selectCampaignParticipantOptedIn: jest.fn(),
selectPerpsTradingCampaignLeaderboardPositionById: jest.fn(),
}));
@@ -79,14 +79,16 @@ interface SelectorState {
function setupSelectors(state: SelectorState) {
const isOptedIn = state.isOptedIn ?? true;
const mockPositionSelector = jest.fn().mockReturnValue(state.position);
- const mockOptedInSelector = jest.fn().mockReturnValue(isOptedIn);
+ const mockParticipantOptedInSelector = jest.fn().mockReturnValue(isOptedIn);
mockSelectPositionById.mockReturnValue(mockPositionSelector);
- mockSelectCampaignParticipantOptedIn.mockReturnValue(mockOptedInSelector);
+ mockSelectCampaignParticipantOptedIn.mockReturnValue(
+ mockParticipantOptedInSelector,
+ );
mockUseSelector.mockImplementation((selector) => {
if (selector === selectRewardsSubscriptionId) return state.subscriptionId;
if (selector === mockPositionSelector) return state.position;
- if (selector === mockOptedInSelector) return isOptedIn;
+ if (selector === mockParticipantOptedInSelector) return isOptedIn;
return undefined;
});
}
diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts
index aaa206cc69f..aedd1ff533a 100644
--- a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts
+++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts
@@ -1,11 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Engine from '../../../../core/Engine';
+import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
import {
- selectRewardsSubscriptionId,
selectCampaignParticipantOptedIn,
-} from '../../../../selectors/rewards';
-import { selectPerpsTradingCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors';
+ selectPerpsTradingCampaignLeaderboardPositionById,
+} from '../../../../reducers/rewards/selectors';
import { setPerpsTradingCampaignLeaderboardPosition } from '../../../../reducers/rewards';
import type { PerpsTradingCampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types';
import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents';
diff --git a/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts b/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts
index f41f2351164..b614232da40 100644
--- a/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts
+++ b/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts
@@ -89,6 +89,14 @@ describe('useLinkAccountAddress', () => {
loading: jest.fn().mockReturnValue({
variant: 'loading',
}),
+ outcomeWinner: jest.fn().mockReturnValue({
+ variant: 'plain',
+ hapticsType: 'success',
+ }),
+ outcomeNonWinner: jest.fn().mockReturnValue({
+ variant: 'icon',
+ hapticsType: 'warning',
+ }),
};
const mockAccount: InternalAccount = {
diff --git a/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts b/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts
index 5e99c8b0f20..7c5c8603ec7 100644
--- a/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts
+++ b/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts
@@ -110,6 +110,14 @@ describe('useLinkAccountGroup', () => {
loading: jest.fn().mockReturnValue({
variant: 'loading',
}),
+ outcomeWinner: jest.fn().mockReturnValue({
+ variant: 'plain',
+ hapticsType: 'success',
+ }),
+ outcomeNonWinner: jest.fn().mockReturnValue({
+ variant: 'icon',
+ hapticsType: 'warning',
+ }),
};
// Mock account data
diff --git a/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.test.ts b/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.test.ts
index 5dc86d8617a..6a4189dfcfb 100644
--- a/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.test.ts
+++ b/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.test.ts
@@ -1,149 +1,65 @@
-import { renderHook, act } from '@testing-library/react-hooks';
-import { useSelector } from 'react-redux';
-import Engine from '../../../../core/Engine';
-import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
-import { selectCampaignParticipantStatus } from '../../../../reducers/rewards/selectors';
+import { renderHook } from '@testing-library/react-hooks';
import { useOndoCampaignParticipantOutcome } from './useOndoCampaignParticipantOutcome';
-import type { OndoGmCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types';
+import { useCampaignParticipantOutcome } from './useCampaignParticipantOutcome';
-jest.mock('react-redux', () => ({
- useSelector: jest.fn(),
+jest.mock('./useCampaignParticipantOutcome', () => ({
+ useCampaignParticipantOutcome: jest.fn(),
}));
-jest.mock('../../../../core/Engine', () => ({
- controllerMessenger: { call: jest.fn() },
-}));
-
-jest.mock('../../../../selectors/rewards', () => ({
- selectRewardsSubscriptionId: jest.fn(),
-}));
-
-jest.mock('../../../../reducers/rewards/selectors', () => ({
- selectCampaignParticipantStatus: jest.fn(),
-}));
-
-const mockCall = Engine.controllerMessenger.call as jest.MockedFunction<
- typeof Engine.controllerMessenger.call
->;
-const mockUseSelector = useSelector as jest.MockedFunction;
-const mockSelectCampaignParticipantStatus =
- selectCampaignParticipantStatus as jest.MockedFunction<
- typeof selectCampaignParticipantStatus
+const mockUseCampaignParticipantOutcome =
+ useCampaignParticipantOutcome as jest.MockedFunction<
+ typeof useCampaignParticipantOutcome
>;
const CAMPAIGN_ID = 'campaign-123';
-const SUBSCRIPTION_ID = 'sub-456';
-
-const MOCK_OUTCOME: OndoGmCampaignParticipantOutcomeDto = {
- subscriptionId: SUBSCRIPTION_ID,
- outcomeStatus: 'pending',
- winnerVerificationCode: 'WINNER-XYZ',
-};
-
-function setupSelectors({
- subscriptionId = SUBSCRIPTION_ID,
- isOptedIn = true,
-}: {
- subscriptionId?: string | null;
- isOptedIn?: boolean;
-} = {}) {
- const participantStatusSelector = jest
- .fn()
- .mockReturnValue(isOptedIn ? { optedIn: true } : null);
- mockSelectCampaignParticipantStatus.mockReturnValue(
- participantStatusSelector,
- );
- mockUseSelector.mockImplementation((selector) => {
- if (selector === selectRewardsSubscriptionId) return subscriptionId;
- if (selector === participantStatusSelector)
- return isOptedIn ? { optedIn: true } : null;
- return undefined;
- });
-}
describe('useOndoCampaignParticipantOutcome', () => {
beforeEach(() => {
jest.clearAllMocks();
- });
-
- it('returns null outcome and no loading when campaignId is undefined', async () => {
- setupSelectors();
- const { result, waitForNextUpdate } = renderHook(() =>
- useOndoCampaignParticipantOutcome(undefined),
- );
- await act(async () => {
- await waitForNextUpdate().catch(() => undefined);
+ mockUseCampaignParticipantOutcome.mockReturnValue({
+ outcome: null,
+ isLoading: false,
+ hasError: false,
});
- expect(result.current.outcome).toBeNull();
- expect(result.current.isLoading).toBe(false);
- expect(result.current.hasError).toBe(false);
- expect(mockCall).not.toHaveBeenCalled();
});
- it('returns null outcome when subscriptionId is missing', async () => {
- setupSelectors({ subscriptionId: null });
- const { result, waitForNextUpdate } = renderHook(() =>
- useOndoCampaignParticipantOutcome(CAMPAIGN_ID),
- );
- await act(async () => {
- await waitForNextUpdate().catch(() => undefined);
- });
- expect(result.current.outcome).toBeNull();
- expect(result.current.isLoading).toBe(false);
- expect(result.current.hasError).toBe(false);
- expect(mockCall).not.toHaveBeenCalled();
- });
+ it('delegates to useCampaignParticipantOutcome with the Ondo messenger action', () => {
+ renderHook(() => useOndoCampaignParticipantOutcome(CAMPAIGN_ID));
- it('returns null outcome when user is not opted in', async () => {
- setupSelectors({ isOptedIn: false });
- const { result, waitForNextUpdate } = renderHook(() =>
- useOndoCampaignParticipantOutcome(CAMPAIGN_ID),
+ expect(mockUseCampaignParticipantOutcome).toHaveBeenCalledWith(
+ CAMPAIGN_ID,
+ {
+ messengerAction: 'RewardsController:getOndoCampaignParticipantOutcome',
+ },
);
- await act(async () => {
- await waitForNextUpdate().catch(() => undefined);
- });
- expect(result.current.outcome).toBeNull();
- expect(result.current.isLoading).toBe(false);
- expect(result.current.hasError).toBe(false);
- expect(mockCall).not.toHaveBeenCalled();
});
- it('fetches outcome and returns it when all conditions are met', async () => {
- setupSelectors();
- mockCall.mockResolvedValue(MOCK_OUTCOME);
-
- const { result, waitForNextUpdate } = renderHook(() =>
- useOndoCampaignParticipantOutcome(CAMPAIGN_ID),
- );
+ it('passes undefined campaignId through to the generic hook', () => {
+ renderHook(() => useOndoCampaignParticipantOutcome(undefined));
- await act(async () => {
- await waitForNextUpdate();
+ expect(mockUseCampaignParticipantOutcome).toHaveBeenCalledWith(undefined, {
+ messengerAction: 'RewardsController:getOndoCampaignParticipantOutcome',
});
-
- expect(mockCall).toHaveBeenCalledWith(
- 'RewardsController:getOndoCampaignParticipantOutcome',
- CAMPAIGN_ID,
- SUBSCRIPTION_ID,
- );
- expect(result.current.outcome).toEqual(MOCK_OUTCOME);
- expect(result.current.isLoading).toBe(false);
- expect(result.current.hasError).toBe(false);
});
- it('sets hasError and clears outcome when the fetch throws', async () => {
- setupSelectors();
- mockCall.mockRejectedValue(new Error('fetch failed'));
+ it('returns the result from the generic hook', () => {
+ const mockOutcome = {
+ subscriptionId: 'sub-1',
+ outcomeStatus: 'pending' as const,
+ winnerVerificationCode: 'CODE',
+ };
+ mockUseCampaignParticipantOutcome.mockReturnValue({
+ outcome: mockOutcome,
+ isLoading: false,
+ hasError: false,
+ });
- const { result, waitForNextUpdate } = renderHook(() =>
+ const { result } = renderHook(() =>
useOndoCampaignParticipantOutcome(CAMPAIGN_ID),
);
- await act(async () => {
- await waitForNextUpdate();
- });
-
- expect(result.current.outcome).toBeNull();
+ expect(result.current.outcome).toEqual(mockOutcome);
expect(result.current.isLoading).toBe(false);
- expect(result.current.hasError).toBe(true);
+ expect(result.current.hasError).toBe(false);
});
});
diff --git a/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.ts b/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.ts
index eaabab7f48b..017c62bfc41 100644
--- a/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.ts
+++ b/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.ts
@@ -1,58 +1,19 @@
-import { useCallback, useEffect, useState } from 'react';
-import { useSelector } from 'react-redux';
-import Engine from '../../../../core/Engine';
-import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
-import { selectCampaignParticipantStatus } from '../../../../reducers/rewards/selectors';
import type { OndoGmCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types';
+import {
+ useCampaignParticipantOutcome,
+ type UseCampaignParticipantOutcomeResult,
+} from './useCampaignParticipantOutcome';
-export interface UseOndoCampaignParticipantOutcomeResult {
- outcome: OndoGmCampaignParticipantOutcomeDto | null;
- isLoading: boolean;
- hasError: boolean;
-}
+export type UseOndoCampaignParticipantOutcomeResult =
+ UseCampaignParticipantOutcomeResult;
export function useOndoCampaignParticipantOutcome(
campaignId: string | undefined,
): UseOndoCampaignParticipantOutcomeResult {
- const subscriptionId = useSelector(selectRewardsSubscriptionId);
- const isOptedIn =
- useSelector(selectCampaignParticipantStatus(subscriptionId, campaignId))
- ?.optedIn === true;
- const [outcome, setOutcome] =
- useState(null);
- const [isLoading, setIsLoading] = useState(false);
- const [hasError, setHasError] = useState(false);
-
- const fetchOutcome = useCallback(async (): Promise => {
- if (!subscriptionId || !campaignId || !isOptedIn) {
- setOutcome(null);
- setIsLoading(false);
- setHasError(false);
- return;
- }
-
- try {
- setIsLoading(true);
- setHasError(false);
- const result = await Engine.controllerMessenger.call(
- 'RewardsController:getOndoCampaignParticipantOutcome',
- campaignId,
- subscriptionId,
- );
- setOutcome(result);
- } catch {
- setOutcome(null);
- setHasError(true);
- } finally {
- setIsLoading(false);
- }
- }, [campaignId, subscriptionId, isOptedIn]);
-
- useEffect(() => {
- fetchOutcome();
- }, [fetchOutcome]);
-
- return { outcome, isLoading, hasError };
+ return useCampaignParticipantOutcome(
+ campaignId,
+ { messengerAction: 'RewardsController:getOndoCampaignParticipantOutcome' },
+ );
}
export default useOndoCampaignParticipantOutcome;
diff --git a/app/components/UI/Rewards/hooks/useOndoOutcomeToast.test.ts b/app/components/UI/Rewards/hooks/useOndoOutcomeToast.test.ts
index 7eff188438e..217d284cb64 100644
--- a/app/components/UI/Rewards/hooks/useOndoOutcomeToast.test.ts
+++ b/app/components/UI/Rewards/hooks/useOndoOutcomeToast.test.ts
@@ -1,554 +1,98 @@
import { renderHook } from '@testing-library/react-hooks';
-import { useContext } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
-import { useFocusEffect, useNavigation } from '@react-navigation/native';
-import {
- playSuccessNotification,
- playWarningNotification,
-} from '../../../../util/haptics';
import { useOndoOutcomeToast } from './useOndoOutcomeToast';
-import { dismissCampaignOutcomeToast } from '../../../../reducers/rewards';
-import {
- selectCampaigns,
- selectDismissedCampaignOutcomeToasts,
-} from '../../../../reducers/rewards/selectors';
-import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
+import { useCampaignOutcomeToast } from './useCampaignOutcomeToast';
import { useOndoCampaignParticipantOutcome } from './useOndoCampaignParticipantOutcome';
import {
CampaignType,
- type OndoGmCampaignParticipantOutcomeDto,
+ type CampaignDto,
} from '../../../../core/Engine/controllers/rewards-controller/types';
import Routes from '../../../../constants/navigation/Routes';
-import {
- ToastVariants,
- ButtonIconVariant,
-} from '../../../../component-library/components/Toast/Toast.types';
-import { IconName } from '../../../../component-library/components/Icons/Icon';
-
-jest.mock('react', () => ({
- ...jest.requireActual('react'),
- useContext: jest.fn(),
- useCallback: jest.fn((fn) => fn),
- useMemo: jest.fn((fn) => fn()),
-}));
-
-jest.mock('react-redux', () => ({
- useDispatch: jest.fn(),
- useSelector: jest.fn(),
-}));
-
-jest.mock('@react-navigation/native', () => ({
- useFocusEffect: jest.fn(),
- useNavigation: jest.fn(),
-}));
-
-jest.mock('../../../../util/haptics', () => ({
- playSuccessNotification: jest.fn(),
- playWarningNotification: jest.fn(),
-}));
-
-jest.mock('../../../../../locales/i18n', () => ({
- strings: jest.fn((key: string, params?: Record) => {
- if (params?.campaignName) return `${key}:${params.campaignName}`;
- return key;
- }),
-}));
-
-jest.mock('../../../../util/theme', () => {
- const actual = jest.requireActual('../../../../util/theme');
- return {
- ...actual,
- useAppThemeFromContext: () => actual.mockTheme,
- };
-});
-
-jest.mock('../../../../reducers/rewards', () => ({
- dismissCampaignOutcomeToast: jest.fn(),
-}));
-
-jest.mock('../../../../reducers/rewards/selectors', () => ({
- selectCampaigns: jest.fn(),
- selectDismissedCampaignOutcomeToasts: jest.fn(),
-}));
-jest.mock('../../../../selectors/rewards', () => ({
- selectRewardsSubscriptionId: jest.fn(),
+jest.mock('./useCampaignOutcomeToast', () => ({
+ useCampaignOutcomeToast: jest.fn(),
}));
jest.mock('./useOndoCampaignParticipantOutcome', () => ({
useOndoCampaignParticipantOutcome: jest.fn(),
}));
-const mockDispatch = jest.fn();
-const mockNavigate = jest.fn();
-const mockShowToast = jest.fn();
-const mockCloseToast = jest.fn();
-const mockToastRef = {
- current: { showToast: mockShowToast, closeToast: mockCloseToast },
-};
-
-const mockUseDispatch = useDispatch as jest.MockedFunction;
-const mockUseSelector = useSelector as jest.MockedFunction;
-const mockUseFocusEffect = useFocusEffect as jest.MockedFunction<
- typeof useFocusEffect
->;
-const mockUseNavigation = useNavigation as jest.MockedFunction<
- typeof useNavigation
->;
-const mockUseOndoCampaignParticipantOutcome =
- useOndoCampaignParticipantOutcome as jest.MockedFunction<
- typeof useOndoCampaignParticipantOutcome
- >;
-const mockDismissCampaignOutcomeToast =
- dismissCampaignOutcomeToast as jest.MockedFunction<
- typeof dismissCampaignOutcomeToast
+const mockUseCampaignOutcomeToast =
+ useCampaignOutcomeToast as jest.MockedFunction<
+ typeof useCampaignOutcomeToast
>;
-const SUBSCRIPTION_ID = 'sub-123';
-const CAMPAIGN_ID = 'campaign-456';
-const CAMPAIGN_NAME = 'Ondo Test Campaign';
-
-function makeParticipantOutcome(
- options: Pick & {
- winnerVerificationCode?: string | null;
- subscriptionId?: string;
- },
-): OndoGmCampaignParticipantOutcomeDto {
- return {
- subscriptionId: options.subscriptionId ?? SUBSCRIPTION_ID,
- outcomeStatus: options.outcomeStatus,
- winnerVerificationCode: options.winnerVerificationCode,
- };
-}
+const CAMPAIGN_ID = 'campaign-123';
+const CAMPAIGN_NAME = 'Ondo Campaign';
-const makeCompletedOndoCampaign = (
- id = CAMPAIGN_ID,
- endDate = '2025-01-01T00:00:00Z',
-) => ({
+const makeCampaign = (id = CAMPAIGN_ID): CampaignDto => ({
id,
name: CAMPAIGN_NAME,
type: CampaignType.ONDO_HOLDING,
- endDate,
- startDate: '2024-01-01T00:00:00Z',
+ endDate: '2025-01-01',
+ startDate: '2024-01-01',
+ termsAndConditions: null,
+ excludedRegions: [],
+ details: null,
+ featured: false,
+ showUpcomingDate: false,
});
-function setupDefaults({
- campaigns = [],
- dismissed = {},
- subscriptionId = SUBSCRIPTION_ID,
- outcome = null,
-}: {
- campaigns?: ReturnType[];
- dismissed?: Record;
- subscriptionId?: string | null;
- outcome?: OndoGmCampaignParticipantOutcomeDto | null;
-} = {}) {
- mockUseSelector.mockImplementation((selector) => {
- if (selector === selectCampaigns) return campaigns;
- if (selector === selectDismissedCampaignOutcomeToasts) return dismissed;
- if (selector === selectRewardsSubscriptionId) return subscriptionId;
- return undefined;
- });
- mockUseOndoCampaignParticipantOutcome.mockReturnValue({
- outcome,
- isLoading: false,
- hasError: false,
- });
-}
-
describe('useOndoOutcomeToast', () => {
beforeEach(() => {
jest.clearAllMocks();
- mockUseDispatch.mockReturnValue(mockDispatch);
- mockUseNavigation.mockReturnValue({ navigate: mockNavigate } as never);
- (useContext as jest.Mock).mockReturnValue({ toastRef: mockToastRef });
- mockUseFocusEffect.mockImplementation((cb) => {
- cb();
- });
- mockDismissCampaignOutcomeToast.mockReturnValue({
- type: 'rewards/dismissCampaignOutcomeToast',
- } as never);
- });
-
- describe('targetCampaign selection', () => {
- it('passes undefined to useOndoCampaignParticipantOutcome when no campaigns', () => {
- setupDefaults({ campaigns: [] });
- renderHook(() => useOndoOutcomeToast());
- expect(mockUseOndoCampaignParticipantOutcome).toHaveBeenCalledWith(
- undefined,
- );
- });
-
- it('passes completed ONDO campaign id to useOndoCampaignParticipantOutcome', () => {
- const campaign = makeCompletedOndoCampaign();
- setupDefaults({ campaigns: [campaign] });
- renderHook(() => useOndoOutcomeToast());
- expect(mockUseOndoCampaignParticipantOutcome).toHaveBeenCalledWith(
- CAMPAIGN_ID,
- );
- });
-
- it('selects the most recently ended campaign when multiple exist', () => {
- const older = makeCompletedOndoCampaign(
- 'campaign-old',
- '2024-06-01T00:00:00Z',
- );
- const newer = makeCompletedOndoCampaign(
- 'campaign-new',
- '2025-01-01T00:00:00Z',
- );
- setupDefaults({ campaigns: [older, newer] });
- renderHook(() => useOndoOutcomeToast());
- expect(mockUseOndoCampaignParticipantOutcome).toHaveBeenCalledWith(
- 'campaign-new',
- );
- });
- });
-
- describe('variant derivation', () => {
- it('does not show toast when outcome is null', () => {
- setupDefaults({
- campaigns: [makeCompletedOndoCampaign()],
- outcome: null,
- });
- renderHook(() => useOndoOutcomeToast());
- expect(mockShowToast).not.toHaveBeenCalled();
- });
-
- it('derives winner_verify when outcome has verification code and is not finalized', () => {
- setupDefaults({
- campaigns: [makeCompletedOndoCampaign()],
- outcome: makeParticipantOutcome({
- outcomeStatus: 'pending',
- winnerVerificationCode: 'WINNER-XYZ',
- }),
- });
- renderHook(() => useOndoOutcomeToast());
- expect(mockShowToast).toHaveBeenCalledWith(
- expect.objectContaining({ iconName: IconName.Star }),
- );
- });
-
- it('derives participant_no_winner when outcome is finalized with no verification code', () => {
- setupDefaults({
- campaigns: [makeCompletedOndoCampaign()],
- outcome: makeParticipantOutcome({
- outcomeStatus: 'finalized',
- winnerVerificationCode: null,
- }),
- });
- renderHook(() => useOndoOutcomeToast());
- expect(mockShowToast).toHaveBeenCalledWith(
- expect.objectContaining({ iconName: IconName.Info }),
- );
- });
-
- it('does not show toast when outcome is finalized with a verification code', () => {
- setupDefaults({
- campaigns: [makeCompletedOndoCampaign()],
- outcome: makeParticipantOutcome({
- outcomeStatus: 'finalized',
- winnerVerificationCode: 'WINNER-XYZ',
- }),
- });
- renderHook(() => useOndoOutcomeToast());
- expect(mockShowToast).not.toHaveBeenCalled();
- });
-
- it('does not show toast when outcome is pending with no verification code', () => {
- setupDefaults({
- campaigns: [makeCompletedOndoCampaign()],
- outcome: makeParticipantOutcome({
- outcomeStatus: 'pending',
- winnerVerificationCode: null,
- }),
- });
- renderHook(() => useOndoOutcomeToast());
- expect(mockShowToast).not.toHaveBeenCalled();
- });
});
- describe('dismissal check', () => {
- it('does not show toast when winner_verify toast was previously dismissed', () => {
- const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:winner_verify`;
- setupDefaults({
- campaigns: [makeCompletedOndoCampaign()],
- outcome: makeParticipantOutcome({
- outcomeStatus: 'pending',
- winnerVerificationCode: 'CODE',
- }),
- dismissed: { [key]: true },
- });
- renderHook(() => useOndoOutcomeToast());
- expect(mockShowToast).not.toHaveBeenCalled();
- });
-
- it('does not show toast when participant_no_winner toast was previously dismissed', () => {
- const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:participant_no_winner`;
- setupDefaults({
- campaigns: [makeCompletedOndoCampaign()],
- outcome: makeParticipantOutcome({
- outcomeStatus: 'finalized',
- winnerVerificationCode: null,
- }),
- dismissed: { [key]: true },
- });
- renderHook(() => useOndoOutcomeToast());
- expect(mockShowToast).not.toHaveBeenCalled();
- });
-
- it('shows toast when a different variant was dismissed', () => {
- const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:participant_no_winner`;
- setupDefaults({
- campaigns: [makeCompletedOndoCampaign()],
- outcome: makeParticipantOutcome({
- outcomeStatus: 'pending',
- winnerVerificationCode: 'CODE',
- }),
- dismissed: { [key]: true },
- });
- renderHook(() => useOndoOutcomeToast());
- expect(mockShowToast).toHaveBeenCalled();
- });
- });
-
- describe('toast configuration', () => {
- it('shows winner_verify toast with correct config', () => {
- setupDefaults({
- campaigns: [makeCompletedOndoCampaign()],
- outcome: makeParticipantOutcome({
- outcomeStatus: 'pending',
- winnerVerificationCode: 'CODE',
- }),
- });
- renderHook(() => useOndoOutcomeToast());
-
- expect(mockShowToast).toHaveBeenCalledWith(
- expect.objectContaining({
- variant: ToastVariants.Icon,
- iconName: IconName.Star,
- backgroundColor: 'transparent',
- hasNoTimeout: true,
- labelOptions: [
- {
- label: 'rewards.ondo_outcome_toast.winner_verify.title',
- isBold: true,
- },
- ],
- descriptionOptions: {
- description: `rewards.ondo_outcome_toast.winner_verify.description:${CAMPAIGN_NAME}`,
- },
- linkButtonOptions: expect.objectContaining({
- label: 'rewards.ondo_outcome_toast.winner_verify.cta',
- }),
- closeButtonOptions: expect.objectContaining({
- variant: ButtonIconVariant.Icon,
- iconName: IconName.Close,
- }),
- }),
- );
- });
-
- it('shows participant_no_winner toast with correct config', () => {
- setupDefaults({
- campaigns: [makeCompletedOndoCampaign()],
- outcome: makeParticipantOutcome({
- outcomeStatus: 'finalized',
- winnerVerificationCode: null,
- }),
- });
- renderHook(() => useOndoOutcomeToast());
-
- expect(mockShowToast).toHaveBeenCalledWith(
- expect.objectContaining({
- variant: ToastVariants.Icon,
- iconName: IconName.Info,
- backgroundColor: 'transparent',
- hasNoTimeout: true,
- labelOptions: [
- {
- label: 'rewards.ondo_outcome_toast.participant_no_winner.title',
- isBold: true,
- },
- ],
- descriptionOptions: {
- description: `rewards.ondo_outcome_toast.participant_no_winner.description:${CAMPAIGN_NAME}`,
- },
- linkButtonOptions: expect.objectContaining({
- label: 'rewards.ondo_outcome_toast.participant_no_winner.cta',
- }),
- }),
- );
- });
-
- it('fires Success haptic for winner_verify', () => {
- setupDefaults({
- campaigns: [makeCompletedOndoCampaign()],
- outcome: makeParticipantOutcome({
- outcomeStatus: 'pending',
- winnerVerificationCode: 'CODE',
- }),
- });
- renderHook(() => useOndoOutcomeToast());
- expect(playSuccessNotification).toHaveBeenCalled();
- });
-
- it('fires Warning haptic for participant_no_winner', () => {
- setupDefaults({
- campaigns: [makeCompletedOndoCampaign()],
- outcome: makeParticipantOutcome({
- outcomeStatus: 'finalized',
- winnerVerificationCode: null,
- }),
- });
- renderHook(() => useOndoOutcomeToast());
- expect(playWarningNotification).toHaveBeenCalled();
- });
+ it('calls useCampaignOutcomeToast with ONDO_HOLDING campaign type', () => {
+ renderHook(() => useOndoOutcomeToast());
+ expect(mockUseCampaignOutcomeToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ campaignType: CampaignType.ONDO_HOLDING,
+ }),
+ );
});
- describe('cleanup on blur', () => {
- it('calls closeToast in the cleanup function when screen blurs', () => {
- let cleanupFn: (() => void) | undefined;
- mockUseFocusEffect.mockImplementation((cb) => {
- const cleanup = cb();
- if (typeof cleanup === 'function') cleanupFn = cleanup;
- });
-
- setupDefaults({
- campaigns: [makeCompletedOndoCampaign()],
- outcome: makeParticipantOutcome({
- outcomeStatus: 'pending',
- winnerVerificationCode: 'CODE',
- }),
- });
- renderHook(() => useOndoOutcomeToast());
-
- expect(cleanupFn).toBeDefined();
- cleanupFn?.();
- expect(mockCloseToast).toHaveBeenCalledTimes(1);
- });
-
- it('does not return a cleanup function when variant is null', () => {
- let cleanupFn: (() => void) | undefined;
- mockUseFocusEffect.mockImplementation((cb) => {
- const result = cb();
- if (typeof result === 'function') cleanupFn = result;
- });
-
- setupDefaults({
- campaigns: [makeCompletedOndoCampaign()],
- outcome: null,
- });
- renderHook(() => useOndoOutcomeToast());
-
- expect(cleanupFn).toBeUndefined();
- expect(mockCloseToast).not.toHaveBeenCalled();
- });
+ it('passes useOndoCampaignParticipantOutcome as the useOutcome function', () => {
+ renderHook(() => useOndoOutcomeToast());
+ expect(mockUseCampaignOutcomeToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ useOutcome: useOndoCampaignParticipantOutcome,
+ }),
+ );
});
- describe('handleDismiss', () => {
- it('dispatches dismissCampaignOutcomeToast and closes toast when close button is pressed', () => {
- setupDefaults({
- campaigns: [makeCompletedOndoCampaign()],
- outcome: makeParticipantOutcome({
- outcomeStatus: 'pending',
- winnerVerificationCode: 'CODE',
- }),
- });
- renderHook(() => useOndoOutcomeToast());
-
- const closeButtonOptions =
- mockShowToast.mock.calls[0][0].closeButtonOptions;
- closeButtonOptions.onPress();
-
- expect(mockDispatch).toHaveBeenCalledWith(
- mockDismissCampaignOutcomeToast.mock.results[0]?.value,
- );
- expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({
- campaignId: CAMPAIGN_ID,
- subscriptionId: SUBSCRIPTION_ID,
- variant: 'winner_verify',
- });
- expect(mockCloseToast).toHaveBeenCalled();
+ it('getWinnerNavigation returns ONDO winning view route with campaignId and campaignName', () => {
+ renderHook(() => useOndoOutcomeToast());
+ const { getWinnerNavigation } =
+ mockUseCampaignOutcomeToast.mock.calls[0][0];
+ const nav = getWinnerNavigation(makeCampaign());
+ expect(nav).toEqual({
+ route: Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW,
+ params: { campaignId: CAMPAIGN_ID, campaignName: CAMPAIGN_NAME },
});
});
- describe('handleCta', () => {
- it('navigates to winning view and dismisses for winner_verify', () => {
- setupDefaults({
- campaigns: [makeCompletedOndoCampaign()],
- outcome: makeParticipantOutcome({
- outcomeStatus: 'pending',
- winnerVerificationCode: 'CODE',
- }),
- });
- renderHook(() => useOndoOutcomeToast());
-
- const linkButtonOptions =
- mockShowToast.mock.calls[0][0].linkButtonOptions;
- linkButtonOptions.onPress();
-
- expect(mockNavigate).toHaveBeenCalledWith(
- Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW,
- { campaignId: CAMPAIGN_ID },
- );
- expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({
- campaignId: CAMPAIGN_ID,
- subscriptionId: SUBSCRIPTION_ID,
- variant: 'winner_verify',
- });
+ it('getWinnerNavigation uses empty string for campaignName when name is null', () => {
+ renderHook(() => useOndoOutcomeToast());
+ const { getWinnerNavigation } =
+ mockUseCampaignOutcomeToast.mock.calls[0][0];
+ const nav = getWinnerNavigation({
+ ...makeCampaign(),
+ name: null as unknown as string,
});
-
- it('navigates to campaign details view and dismisses for participant_no_winner', () => {
- setupDefaults({
- campaigns: [makeCompletedOndoCampaign()],
- outcome: makeParticipantOutcome({
- outcomeStatus: 'finalized',
- winnerVerificationCode: null,
- }),
- });
- renderHook(() => useOndoOutcomeToast());
-
- const linkButtonOptions =
- mockShowToast.mock.calls[0][0].linkButtonOptions;
- linkButtonOptions.onPress();
-
- expect(mockNavigate).toHaveBeenCalledWith(
- Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW,
- { campaignId: CAMPAIGN_ID },
- );
- expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({
- campaignId: CAMPAIGN_ID,
- subscriptionId: SUBSCRIPTION_ID,
- variant: 'participant_no_winner',
- });
+ expect(nav.params).toEqual({
+ campaignId: CAMPAIGN_ID,
+ campaignName: '',
});
});
- describe('edge cases', () => {
- it('does not show toast when subscriptionId is missing', () => {
- setupDefaults({
- campaigns: [makeCompletedOndoCampaign()],
- outcome: makeParticipantOutcome({
- outcomeStatus: 'pending',
- winnerVerificationCode: 'CODE',
- }),
- subscriptionId: null,
- });
- renderHook(() => useOndoOutcomeToast());
- expect(mockShowToast).not.toHaveBeenCalled();
- });
-
- it('handles null toastRef gracefully', () => {
- (useContext as jest.Mock).mockReturnValue({ toastRef: null });
- setupDefaults({
- campaigns: [makeCompletedOndoCampaign()],
- outcome: makeParticipantOutcome({
- outcomeStatus: 'pending',
- winnerVerificationCode: 'CODE',
- }),
- });
- expect(() => renderHook(() => useOndoOutcomeToast())).not.toThrow();
+ it('getNonWinnerNavigation returns ONDO campaign details route', () => {
+ renderHook(() => useOndoOutcomeToast());
+ const { getNonWinnerNavigation } =
+ mockUseCampaignOutcomeToast.mock.calls[0][0];
+ const nav = getNonWinnerNavigation(makeCampaign());
+ expect(nav).toEqual({
+ route: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW,
+ params: { campaignId: CAMPAIGN_ID },
});
});
});
diff --git a/app/components/UI/Rewards/hooks/useOndoOutcomeToast.ts b/app/components/UI/Rewards/hooks/useOndoOutcomeToast.ts
index 87c295cf1f7..2a736f931c8 100644
--- a/app/components/UI/Rewards/hooks/useOndoOutcomeToast.ts
+++ b/app/components/UI/Rewards/hooks/useOndoOutcomeToast.ts
@@ -1,160 +1,21 @@
-import { useCallback, useContext, useMemo } from 'react';
-import { useFocusEffect, useNavigation } from '@react-navigation/native';
-import { useDispatch, useSelector } from 'react-redux';
-import {
- playSuccessNotification,
- playWarningNotification,
-} from '../../../../util/haptics';
-import Routes from '../../../../constants/navigation/Routes';
-import { strings } from '../../../../../locales/i18n';
-import { ToastContext } from '../../../../component-library/components/Toast';
-import {
- ButtonIconVariant,
- ToastVariants,
-} from '../../../../component-library/components/Toast/Toast.types';
-import { IconName } from '../../../../component-library/components/Icons/Icon';
-import { useAppThemeFromContext } from '../../../../util/theme';
import { CampaignType } from '../../../../core/Engine/controllers/rewards-controller/types';
-import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils';
-import { dismissCampaignOutcomeToast } from '../../../../reducers/rewards';
-import {
- selectCampaigns,
- selectDismissedCampaignOutcomeToasts,
-} from '../../../../reducers/rewards/selectors';
-import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
+import Routes from '../../../../constants/navigation/Routes';
+import { useCampaignOutcomeToast } from './useCampaignOutcomeToast';
import { useOndoCampaignParticipantOutcome } from './useOndoCampaignParticipantOutcome';
-export type OutcomeToastVariant = 'winner_verify' | 'participant_no_winner';
-
export function useOndoOutcomeToast(): void {
- const dispatch = useDispatch();
- const { toastRef } = useContext(ToastContext);
- const theme = useAppThemeFromContext();
- const navigation = useNavigation();
-
- const subscriptionId = useSelector(selectRewardsSubscriptionId);
- const campaigns = useSelector(selectCampaigns);
- const dismissed = useSelector(selectDismissedCampaignOutcomeToasts);
-
- const targetCampaign = useMemo(() => {
- const completed = campaigns
- .filter(
- (c) =>
- c.type === CampaignType.ONDO_HOLDING &&
- getCampaignStatus(c) === 'complete',
- )
- .sort(
- (a, b) => new Date(b.endDate).getTime() - new Date(a.endDate).getTime(),
- );
- return completed[0] ?? null;
- }, [campaigns]);
-
- const { outcome } = useOndoCampaignParticipantOutcome(targetCampaign?.id);
-
- const variant = useMemo((): OutcomeToastVariant | null => {
- if (!outcome) return null;
- if (
- outcome.winnerVerificationCode &&
- outcome.outcomeStatus !== 'finalized'
- ) {
- return 'winner_verify';
- }
- if (
- outcome.outcomeStatus === 'finalized' &&
- !outcome.winnerVerificationCode
- ) {
- return 'participant_no_winner';
- }
- return null;
- }, [outcome]);
-
- const isDismissed = useMemo(() => {
- if (!variant || !targetCampaign || !subscriptionId) return true;
- const key = `${targetCampaign.id}:${subscriptionId}:${variant}`;
- return dismissed[key] === true;
- }, [variant, targetCampaign, subscriptionId, dismissed]);
-
- const handleDismiss = useCallback(() => {
- if (!variant || !targetCampaign || !subscriptionId) return;
- dispatch(
- dismissCampaignOutcomeToast({
- campaignId: targetCampaign.id,
- subscriptionId,
- variant,
- }),
- );
- toastRef?.current?.closeToast();
- }, [variant, targetCampaign, subscriptionId, dispatch, toastRef]);
-
- const handleCta = useCallback(() => {
- if (!targetCampaign || !variant) return;
- handleDismiss();
- if (variant === 'winner_verify') {
- navigation.navigate(Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, {
- campaignId: targetCampaign.id,
- });
- } else {
- navigation.navigate(Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, {
- campaignId: targetCampaign.id,
- });
- }
- }, [variant, targetCampaign, handleDismiss, navigation]);
-
- useFocusEffect(
- useCallback(() => {
- if (!variant || isDismissed || !targetCampaign) return;
-
- const isWinner = variant === 'winner_verify';
- toastRef?.current?.showToast({
- variant: ToastVariants.Icon,
- iconName: isWinner ? IconName.Star : IconName.Info,
- iconColor: isWinner
- ? theme.colors.warning.default
- : theme.colors.success.default,
- backgroundColor: 'transparent',
- hasNoTimeout: true,
- labelOptions: [
- {
- label: strings(`rewards.ondo_outcome_toast.${variant}.title`),
- isBold: true,
- },
- ],
- descriptionOptions: {
- description: strings(
- `rewards.ondo_outcome_toast.${variant}.description`,
- { campaignName: targetCampaign.name },
- ),
- },
- linkButtonOptions: {
- label: strings(`rewards.ondo_outcome_toast.${variant}.cta`),
- onPress: handleCta,
- },
- closeButtonOptions: {
- variant: ButtonIconVariant.Icon,
- iconName: IconName.Close,
- onPress: handleDismiss,
- },
- });
- if (isWinner) {
- playSuccessNotification();
- } else {
- playWarningNotification();
- }
-
- return () => {
- toastRef?.current?.closeToast();
- };
- }, [
- variant,
- isDismissed,
- targetCampaign,
- toastRef,
- theme.colors.warning.default,
- theme.colors.success.default,
- handleCta,
- handleDismiss,
- ]),
- );
+ useCampaignOutcomeToast({
+ campaignType: CampaignType.ONDO_HOLDING,
+ useOutcome: useOndoCampaignParticipantOutcome,
+ getWinnerNavigation: (campaign) => ({
+ route: Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW,
+ params: { campaignId: campaign.id, campaignName: campaign.name ?? '' },
+ }),
+ getNonWinnerNavigation: (campaign) => ({
+ route: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW,
+ params: { campaignId: campaign.id },
+ }),
+ });
}
export default useOndoOutcomeToast;
diff --git a/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts b/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts
index c94087723b0..e97add67a7e 100644
--- a/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts
+++ b/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts
@@ -1,10 +1,12 @@
import { renderHook, act } from '@testing-library/react-hooks';
-import { useSelector } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import { useOptInToCampaign } from './useOptInToCampaign';
import Engine from '../../../../core/Engine';
import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
+import { setCampaignParticipantStatus } from '../../../../reducers/rewards';
jest.mock('react-redux', () => ({
+ useDispatch: jest.fn(),
useSelector: jest.fn(),
}));
@@ -16,10 +18,22 @@ jest.mock('../../../../selectors/rewards', () => ({
selectRewardsSubscriptionId: jest.fn(),
}));
+jest.mock('../../../../reducers/rewards', () => ({
+ setCampaignParticipantStatus: jest.fn((payload) => ({
+ type: 'rewards/setCampaignParticipantStatus',
+ payload,
+ })),
+}));
+
const mockCall = Engine.controllerMessenger.call as jest.MockedFunction<
typeof Engine.controllerMessenger.call
>;
const mockUseSelector = useSelector as jest.MockedFunction;
+const mockUseDispatch = useDispatch as jest.MockedFunction;
+const mockSetCampaignParticipantStatus =
+ setCampaignParticipantStatus as unknown as jest.MockedFunction<
+ typeof setCampaignParticipantStatus
+ >;
const SUB_ID = 'sub-123';
const CAMPAIGN_ID = 'camp-456';
@@ -33,8 +47,11 @@ function setupSelectors(subscriptionId: string | null) {
}
describe('useOptInToCampaign', () => {
+ const mockDispatch = jest.fn();
+
beforeEach(() => {
jest.clearAllMocks();
+ mockUseDispatch.mockReturnValue(mockDispatch);
});
it('returns null when subscriptionId is missing', async () => {
@@ -63,6 +80,18 @@ describe('useOptInToCampaign', () => {
CAMPAIGN_ID,
SUB_ID,
);
+ expect(mockDispatch).toHaveBeenCalledWith(
+ setCampaignParticipantStatus({
+ subscriptionId: SUB_ID,
+ campaignId: CAMPAIGN_ID,
+ status: STATUS,
+ }),
+ );
+ expect(mockSetCampaignParticipantStatus).toHaveBeenCalledWith({
+ subscriptionId: SUB_ID,
+ campaignId: CAMPAIGN_ID,
+ status: STATUS,
+ });
expect(returnValue).toEqual(STATUS);
expect(result.current.isOptingIn).toBe(false);
expect(result.current.optInError).toBeUndefined();
diff --git a/app/components/UI/Rewards/hooks/useOptInToCampaign.ts b/app/components/UI/Rewards/hooks/useOptInToCampaign.ts
index b3852646997..a0533e7889f 100644
--- a/app/components/UI/Rewards/hooks/useOptInToCampaign.ts
+++ b/app/components/UI/Rewards/hooks/useOptInToCampaign.ts
@@ -1,7 +1,8 @@
import { useCallback, useState } from 'react';
-import { useSelector } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import Engine from '../../../../core/Engine';
import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
+import { setCampaignParticipantStatus } from '../../../../reducers/rewards';
import type { CampaignParticipantStatusDto } from '../../../../core/Engine/controllers/rewards-controller/types';
export interface UseOptInToCampaignResult {
@@ -22,6 +23,7 @@ export interface UseOptInToCampaignResult {
*/
export const useOptInToCampaign = (): UseOptInToCampaignResult => {
const subscriptionId = useSelector(selectRewardsSubscriptionId);
+ const dispatch = useDispatch();
const [isOptingIn, setIsOptingIn] = useState(false);
const [optInError, setOptInError] = useState(undefined);
@@ -36,11 +38,19 @@ export const useOptInToCampaign = (): UseOptInToCampaignResult => {
try {
setIsOptingIn(true);
setOptInError(undefined);
- return await Engine.controllerMessenger.call(
+ const result = await Engine.controllerMessenger.call(
'RewardsController:optInToCampaign',
campaignId,
subscriptionId,
);
+ dispatch(
+ setCampaignParticipantStatus({
+ subscriptionId,
+ campaignId,
+ status: result,
+ }),
+ );
+ return result;
} catch (error) {
const message =
error instanceof Error ? error.message : 'Opt-in failed';
@@ -50,7 +60,7 @@ export const useOptInToCampaign = (): UseOptInToCampaignResult => {
setIsOptingIn(false);
}
},
- [subscriptionId],
+ [dispatch, subscriptionId],
);
const clearOptInError = useCallback(() => setOptInError(undefined), []);
diff --git a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts
new file mode 100644
index 00000000000..44c7d775a5c
--- /dev/null
+++ b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts
@@ -0,0 +1,98 @@
+import { renderHook } from '@testing-library/react-hooks';
+import { usePerpsTradingCampaignEndedOutcomeToast } from './usePerpsTradingCampaignEndedOutcomeToast';
+import { useCampaignOutcomeToast } from './useCampaignOutcomeToast';
+import { usePerpsTradingCampaignParticipantOutcome } from './usePerpsTradingCampaignParticipantOutcome';
+import {
+ CampaignType,
+ type CampaignDto,
+} from '../../../../core/Engine/controllers/rewards-controller/types';
+import Routes from '../../../../constants/navigation/Routes';
+
+jest.mock('./useCampaignOutcomeToast', () => ({
+ useCampaignOutcomeToast: jest.fn(),
+}));
+
+jest.mock('./usePerpsTradingCampaignParticipantOutcome', () => ({
+ usePerpsTradingCampaignParticipantOutcome: jest.fn(),
+}));
+
+const mockUseCampaignOutcomeToast =
+ useCampaignOutcomeToast as jest.MockedFunction<
+ typeof useCampaignOutcomeToast
+ >;
+
+const CAMPAIGN_ID = 'campaign-xyz';
+const CAMPAIGN_NAME = 'Perps Campaign';
+
+const makeCampaign = (id = CAMPAIGN_ID, name = CAMPAIGN_NAME): CampaignDto => ({
+ id,
+ name,
+ type: CampaignType.PERPS_TRADING,
+ endDate: '2025-01-01',
+ startDate: '2024-01-01',
+ termsAndConditions: null,
+ excludedRegions: [],
+ details: null,
+ featured: false,
+ showUpcomingDate: false,
+});
+
+describe('usePerpsTradingCampaignEndedOutcomeToast', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('calls useCampaignOutcomeToast with PERPS_TRADING campaign type', () => {
+ renderHook(() => usePerpsTradingCampaignEndedOutcomeToast());
+ expect(mockUseCampaignOutcomeToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ campaignType: CampaignType.PERPS_TRADING,
+ }),
+ );
+ });
+
+ it('passes usePerpsTradingCampaignParticipantOutcome as the useOutcome function', () => {
+ renderHook(() => usePerpsTradingCampaignEndedOutcomeToast());
+ expect(mockUseCampaignOutcomeToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ useOutcome: usePerpsTradingCampaignParticipantOutcome,
+ }),
+ );
+ });
+
+ it('getWinnerNavigation returns Perps winning view route with campaignId and campaignName', () => {
+ renderHook(() => usePerpsTradingCampaignEndedOutcomeToast());
+ const { getWinnerNavigation } =
+ mockUseCampaignOutcomeToast.mock.calls[0][0];
+ const nav = getWinnerNavigation(makeCampaign());
+ expect(nav).toEqual({
+ route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW,
+ params: { campaignId: CAMPAIGN_ID, campaignName: CAMPAIGN_NAME },
+ });
+ });
+
+ it('getWinnerNavigation uses empty string for campaignName when name is null', () => {
+ renderHook(() => usePerpsTradingCampaignEndedOutcomeToast());
+ const { getWinnerNavigation } =
+ mockUseCampaignOutcomeToast.mock.calls[0][0];
+ const nav = getWinnerNavigation({
+ ...makeCampaign(),
+ name: null as unknown as string,
+ });
+ expect(nav.params).toEqual({
+ campaignId: CAMPAIGN_ID,
+ campaignName: '',
+ });
+ });
+
+ it('getNonWinnerNavigation returns Perps details view route', () => {
+ renderHook(() => usePerpsTradingCampaignEndedOutcomeToast());
+ const { getNonWinnerNavigation } =
+ mockUseCampaignOutcomeToast.mock.calls[0][0];
+ const nav = getNonWinnerNavigation(makeCampaign());
+ expect(nav).toEqual({
+ route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW,
+ params: { campaignId: CAMPAIGN_ID },
+ });
+ });
+});
diff --git a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.ts b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.ts
new file mode 100644
index 00000000000..ae99229b1ba
--- /dev/null
+++ b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.ts
@@ -0,0 +1,21 @@
+import { CampaignType } from '../../../../core/Engine/controllers/rewards-controller/types';
+import Routes from '../../../../constants/navigation/Routes';
+import { useCampaignOutcomeToast } from './useCampaignOutcomeToast';
+import { usePerpsTradingCampaignParticipantOutcome } from './usePerpsTradingCampaignParticipantOutcome';
+
+export function usePerpsTradingCampaignEndedOutcomeToast(): void {
+ useCampaignOutcomeToast({
+ campaignType: CampaignType.PERPS_TRADING,
+ useOutcome: usePerpsTradingCampaignParticipantOutcome,
+ getWinnerNavigation: (campaign) => ({
+ route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW,
+ params: { campaignId: campaign.id, campaignName: campaign.name ?? '' },
+ }),
+ getNonWinnerNavigation: (campaign) => ({
+ route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW,
+ params: { campaignId: campaign.id },
+ }),
+ });
+}
+
+export default usePerpsTradingCampaignEndedOutcomeToast;
diff --git a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.test.ts b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.test.ts
new file mode 100644
index 00000000000..35795726f9f
--- /dev/null
+++ b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.test.ts
@@ -0,0 +1,68 @@
+import { renderHook } from '@testing-library/react-hooks';
+import { usePerpsTradingCampaignParticipantOutcome } from './usePerpsTradingCampaignParticipantOutcome';
+import { useCampaignParticipantOutcome } from './useCampaignParticipantOutcome';
+
+jest.mock('./useCampaignParticipantOutcome', () => ({
+ useCampaignParticipantOutcome: jest.fn(),
+}));
+
+const mockUseCampaignParticipantOutcome =
+ useCampaignParticipantOutcome as jest.MockedFunction<
+ typeof useCampaignParticipantOutcome
+ >;
+
+const CAMPAIGN_ID = 'campaign-xyz';
+
+describe('usePerpsTradingCampaignParticipantOutcome', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseCampaignParticipantOutcome.mockReturnValue({
+ outcome: null,
+ isLoading: false,
+ hasError: false,
+ });
+ });
+
+ it('delegates to useCampaignParticipantOutcome with the Perps messenger action', () => {
+ renderHook(() => usePerpsTradingCampaignParticipantOutcome(CAMPAIGN_ID));
+
+ expect(mockUseCampaignParticipantOutcome).toHaveBeenCalledWith(
+ CAMPAIGN_ID,
+ {
+ messengerAction:
+ 'RewardsController:getPerpsTradingCampaignParticipantOutcome',
+ },
+ );
+ });
+
+ it('passes undefined campaignId through to the generic hook', () => {
+ renderHook(() => usePerpsTradingCampaignParticipantOutcome(undefined));
+
+ expect(mockUseCampaignParticipantOutcome).toHaveBeenCalledWith(undefined, {
+ messengerAction:
+ 'RewardsController:getPerpsTradingCampaignParticipantOutcome',
+ });
+ });
+
+ it('returns the result from the generic hook', () => {
+ const mockOutcome = {
+ subscriptionId: 'sub-1',
+ outcomeStatus: 'pending' as const,
+ winnerVerificationCode: 'CODE',
+ rank: 3,
+ };
+ mockUseCampaignParticipantOutcome.mockReturnValue({
+ outcome: mockOutcome,
+ isLoading: false,
+ hasError: false,
+ });
+
+ const { result } = renderHook(() =>
+ usePerpsTradingCampaignParticipantOutcome(CAMPAIGN_ID),
+ );
+
+ expect(result.current.outcome).toEqual(mockOutcome);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.hasError).toBe(false);
+ });
+});
diff --git a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.ts b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.ts
new file mode 100644
index 00000000000..255885f2823
--- /dev/null
+++ b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.ts
@@ -0,0 +1,22 @@
+import type { PerpsTradingCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types';
+import {
+ useCampaignParticipantOutcome,
+ type UseCampaignParticipantOutcomeResult,
+} from './useCampaignParticipantOutcome';
+
+export type UsePerpsTradingCampaignParticipantOutcomeResult =
+ UseCampaignParticipantOutcomeResult;
+
+export function usePerpsTradingCampaignParticipantOutcome(
+ campaignId: string | undefined,
+): UsePerpsTradingCampaignParticipantOutcomeResult {
+ return useCampaignParticipantOutcome(
+ campaignId,
+ {
+ messengerAction:
+ 'RewardsController:getPerpsTradingCampaignParticipantOutcome',
+ },
+ );
+}
+
+export default usePerpsTradingCampaignParticipantOutcome;
diff --git a/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx b/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx
index 4f3ec45b6c7..aa29b8cbfb2 100644
--- a/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx
+++ b/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx
@@ -390,6 +390,68 @@ describe('useRewardsToast', () => {
expect(mockCloseToast).toHaveBeenCalledTimes(1);
});
+
+ it('returns outcomeWinner configuration with CTA and close handlers', () => {
+ const { result } = renderHook(() => useRewardsToast());
+ const onCta = jest.fn();
+ const onClose = jest.fn();
+ const config = result.current.RewardsToastOptions.outcomeWinner({
+ title: 'Winner title',
+ description: 'Winner body',
+ ctaLabel: 'Next',
+ onCtaPress: onCta,
+ onClosePress: onClose,
+ });
+
+ expect(config).toMatchObject({
+ variant: ToastVariants.Plain,
+ hasNoTimeout: true,
+ hapticsType: NotificationMoment.Success,
+ descriptionOptions: { description: 'Winner body' },
+ linkButtonOptions: { label: 'Next' },
+ });
+ expect(config.labelOptions).toEqual([
+ { label: 'Winner title', isBold: true },
+ ]);
+ expect(config.startAccessory).toBeDefined();
+ const { getByTestId } = render(config.startAccessory as ReactElement);
+ expect(getByTestId('rewards-nudge-start-accessory-box')).toBeDefined();
+ config.linkButtonOptions?.onPress?.();
+ config.closeButtonOptions?.onPress?.();
+ expect(onCta).toHaveBeenCalledTimes(1);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns outcomeNonWinner configuration with CTA and close handlers', () => {
+ const { result } = renderHook(() => useRewardsToast());
+ const onCta = jest.fn();
+ const onClose = jest.fn();
+ const config = result.current.RewardsToastOptions.outcomeNonWinner({
+ title: 'Thanks title',
+ description: 'Thanks body',
+ ctaLabel: 'Done',
+ onCtaPress: onCta,
+ onClosePress: onClose,
+ });
+
+ expect(config).toMatchObject({
+ variant: ToastVariants.Icon,
+ iconName: IconName.Confirmation,
+ iconColor: mockTheme.colors.success.default,
+ backgroundColor: 'transparent',
+ hasNoTimeout: true,
+ hapticsType: NotificationMoment.Warning,
+ descriptionOptions: { description: 'Thanks body' },
+ linkButtonOptions: { label: 'Done' },
+ });
+ expect(config.labelOptions).toEqual([
+ { label: 'Thanks title', isBold: true },
+ ]);
+ config.linkButtonOptions?.onPress?.();
+ config.closeButtonOptions?.onPress?.();
+ expect(onCta).toHaveBeenCalledTimes(1);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
});
describe('edge cases and error handling', () => {
diff --git a/app/components/UI/Rewards/hooks/useRewardsToast.tsx b/app/components/UI/Rewards/hooks/useRewardsToast.tsx
index db14b667bd8..48fa2bfe57c 100644
--- a/app/components/UI/Rewards/hooks/useRewardsToast.tsx
+++ b/app/components/UI/Rewards/hooks/useRewardsToast.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useContext, useMemo } from 'react';
import { ActivityIndicator } from 'react-native';
+import { Box } from '@metamask/design-system-react-native';
import { ToastContext } from '../../../../component-library/components/Toast';
import {
ButtonIconVariant,
@@ -18,12 +19,20 @@ import {
} from '../../../../util/haptics';
import { strings } from '../../../../../locales/i18n';
import RewardsNotificationIcon from '../../../../images/rewards/notification.svg';
-import { Box } from '@metamask/design-system-react-native';
+import RewardsTrophyIcon from '../../../../images/rewards/trophy.svg';
export type RewardsToastOptions = ToastOptions & {
hapticsType: HapticNotificationMoment;
};
+export interface OutcomeCtaToastParams {
+ title: string;
+ description: string;
+ ctaLabel: string;
+ onCtaPress: () => void;
+ onClosePress: () => void;
+}
+
export interface RewardsToastConfig {
success: (title: string, subtitle?: string) => RewardsToastOptions;
error: (title: string, subtitle?: string) => RewardsToastOptions;
@@ -32,6 +41,8 @@ export interface RewardsToastConfig {
enableNotificationsNudge: (
linkButtonOptions: ToastLinkButtonOptions,
) => RewardsToastOptions;
+ outcomeWinner: (params: OutcomeCtaToastParams) => RewardsToastOptions;
+ outcomeNonWinner: (params: OutcomeCtaToastParams) => RewardsToastOptions;
}
const getRewardsToastLabels = (title: string): ToastLabelOptions => {
@@ -183,6 +194,64 @@ const useRewardsToast = (): {
},
},
}),
+ outcomeWinner: ({
+ title,
+ description,
+ ctaLabel,
+ onCtaPress,
+ onClosePress,
+ }: OutcomeCtaToastParams) => ({
+ ...(REWARDS_TOASTS_DEFAULT_OPTIONS as RewardsToastOptions),
+ variant: ToastVariants.Plain,
+ hasNoTimeout: true,
+ hapticsType: NotificationMoment.Success,
+ startAccessory: (
+
+
+
+ ),
+ labelOptions: getRewardsToastLabels(title),
+ descriptionOptions: { description },
+ linkButtonOptions: {
+ label: ctaLabel,
+ onPress: onCtaPress,
+ },
+ closeButtonOptions: {
+ variant: ButtonIconVariant.Icon,
+ iconName: IconName.Close,
+ onPress: onClosePress,
+ },
+ }),
+ outcomeNonWinner: ({
+ title,
+ description,
+ ctaLabel,
+ onCtaPress,
+ onClosePress,
+ }: OutcomeCtaToastParams) => ({
+ variant: ToastVariants.Icon,
+ iconName: IconName.Confirmation,
+ iconColor: theme.colors.success.default,
+ backgroundColor: 'transparent',
+ hasNoTimeout: true,
+ hapticsType: NotificationMoment.Warning,
+ labelOptions: getRewardsToastLabels(title),
+ descriptionOptions: { description },
+ linkButtonOptions: {
+ label: ctaLabel,
+ onPress: onCtaPress,
+ },
+ closeButtonOptions: {
+ variant: ButtonIconVariant.Icon,
+ iconName: IconName.Close,
+ onPress: onClosePress,
+ },
+ }),
}),
[
theme.colors.success.default,
diff --git a/app/components/UI/Rewards/utils.ts b/app/components/UI/Rewards/utils.ts
index cf902295145..fff1df35443 100644
--- a/app/components/UI/Rewards/utils.ts
+++ b/app/components/UI/Rewards/utils.ts
@@ -106,6 +106,7 @@ export enum RewardsMetricsButtons {
VISIT_APP_STORE = 'visit_app_store',
BUY_MUSD = 'buy_musd',
SWAP_TO_MUSD = 'swap_to_musd',
+ COPY_WINNER_VERIFICATION_CODE = 'copy_winner_verification_code',
}
export const deriveAccountMetricProps = (account?: InternalAccount) => {
diff --git a/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx b/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx
index f3aa0ed469c..d029711cd68 100644
--- a/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx
+++ b/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx
@@ -124,9 +124,9 @@ const SecurityTrustEntryCard: React.FC = ({
)}
{config.label}
diff --git a/app/components/Views/confirmations/components/recipient-input/recipient-input.test.tsx b/app/components/Views/confirmations/components/recipient-input/recipient-input.test.tsx
index 95faf9246de..d91a1ce1a23 100644
--- a/app/components/Views/confirmations/components/recipient-input/recipient-input.test.tsx
+++ b/app/components/Views/confirmations/components/recipient-input/recipient-input.test.tsx
@@ -80,7 +80,6 @@ describe('RecipientInput', () => {
loading: false,
resolvedAddress: undefined,
toAddressError: undefined,
- toAddressErrorAllowAcknowledge: false,
toAddressValidated: undefined,
toAddressWarning: undefined,
});
diff --git a/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx b/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx
index 7a8aae5cbca..75220003613 100644
--- a/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx
+++ b/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx
@@ -10,6 +10,7 @@ import { useContacts } from '../../../hooks/send/useContacts';
import { useToAddressValidation } from '../../../hooks/send/useToAddressValidation';
import { useRecipientSelectionMetrics } from '../../../hooks/send/metrics/useRecipientSelectionMetrics';
import { useSendActions } from '../../../hooks/send/useSendActions';
+import { useSendAlerts } from '../../../hooks/send/alerts/useSendAlerts';
import { useSendType } from '../../../hooks/send/useSendType';
import { RecipientType } from '../../UI/recipient';
import { Recipient } from './recipient';
@@ -65,6 +66,10 @@ jest.mock('../../../hooks/send/useToAddressValidation', () => ({
useToAddressValidation: jest.fn(),
}));
+jest.mock('../../../hooks/send/alerts/useSendAlerts', () => ({
+ useSendAlerts: jest.fn(),
+}));
+
jest.mock('../../../hooks/send/metrics/useRecipientSelectionMetrics', () => ({
useRecipientSelectionMetrics: jest.fn(),
}));
@@ -149,21 +154,20 @@ jest.mock('../send-alert-modal', () => ({
isOpen,
onAcknowledge,
onClose,
- title,
- errorMessage,
+ alerts,
}: {
isOpen: boolean;
onAcknowledge: () => void;
onClose: () => void;
- title: string;
- errorMessage: string;
+ alerts: { title: string; message: string }[];
}) => {
const { View, Text, Pressable } = jest.requireActual('react-native');
- if (!isOpen) return null;
+ if (!isOpen || !alerts?.length) return null;
+ const first = alerts[0];
return (
- {title}
- {errorMessage}
+ {first.title}
+ {first.message}
Acknowledge
@@ -199,6 +203,7 @@ const mockUseRecipientSelectionMetrics = jest.mocked(
useRecipientSelectionMetrics,
);
const mockUseSendActions = jest.mocked(useSendActions);
+const mockUseSendAlerts = jest.mocked(useSendAlerts);
const mockUseSendType = jest.mocked(useSendType);
function createMockUseSendType(
@@ -239,11 +244,17 @@ describe('Recipient', () => {
loading: false,
resolvedAddress: undefined,
toAddressError: undefined,
- toAddressErrorAllowAcknowledge: false,
toAddressValidated: undefined,
toAddressWarning: undefined,
});
+ mockUseSendAlerts.mockReturnValue({
+ alerts: [],
+ hasUnacknowledgedAlerts: false,
+ acknowledgeAlerts: jest.fn(),
+ isAlertCheckPending: false,
+ });
+
mockUseRecipientSelectionMetrics.mockReturnValue({
captureRecipientSelected: mockCaptureRecipientSelected,
});
@@ -342,7 +353,6 @@ describe('Recipient', () => {
loading: false,
resolvedAddress: 'some_dummy_address',
toAddressError: undefined,
- toAddressErrorAllowAcknowledge: false,
toAddressValidated: undefined,
toAddressWarning: undefined,
});
@@ -435,7 +445,6 @@ describe('Recipient', () => {
loading: false,
resolvedAddress: undefined,
toAddressError: undefined,
- toAddressErrorAllowAcknowledge: false,
toAddressValidated: undefined,
toAddressWarning: 'Warning',
});
@@ -463,7 +472,6 @@ describe('Recipient', () => {
loading: false,
resolvedAddress: undefined,
toAddressError: 'Error',
- toAddressErrorAllowAcknowledge: false,
toAddressValidated: undefined,
toAddressWarning: 'Warning',
});
@@ -501,7 +509,6 @@ describe('Recipient', () => {
loading: true,
resolvedAddress: undefined,
toAddressError: undefined,
- toAddressErrorAllowAcknowledge: false,
toAddressValidated: undefined,
toAddressWarning: 'Warning',
});
@@ -569,7 +576,6 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => {
loading: false,
resolvedAddress: '0xresolved',
toAddressError: undefined,
- toAddressErrorAllowAcknowledge: false,
toAddressValidated: '0xother',
toAddressWarning: undefined,
});
@@ -585,7 +591,6 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => {
loading: false,
resolvedAddress: '0xresolved',
toAddressError: 'Invalid address',
- toAddressErrorAllowAcknowledge: false,
toAddressValidated: '0xvalid',
toAddressWarning: undefined,
});
@@ -601,7 +606,6 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => {
loading: false,
resolvedAddress: '0xresolved',
toAddressError: undefined,
- toAddressErrorAllowAcknowledge: false,
toAddressValidated: '0xvalid',
toAddressWarning: 'Warning',
});
@@ -617,7 +621,6 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => {
loading: true,
resolvedAddress: '0xresolved',
toAddressError: undefined,
- toAddressErrorAllowAcknowledge: false,
toAddressValidated: '0xvalid',
toAddressWarning: undefined,
});
@@ -634,7 +637,6 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => {
loading: false,
resolvedAddress: undefined,
toAddressError: undefined,
- toAddressErrorAllowAcknowledge: false,
toAddressValidated: undefined,
toAddressWarning: undefined,
});
@@ -668,7 +670,6 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => {
loading: false,
resolvedAddress: undefined,
toAddressError: undefined,
- toAddressErrorAllowAcknowledge: false,
toAddressValidated: undefined,
toAddressWarning: undefined,
});
@@ -706,7 +707,6 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => {
loading: false,
resolvedAddress: undefined,
toAddressError: 'Error',
- toAddressErrorAllowAcknowledge: false,
toAddressValidated: undefined,
toAddressWarning: undefined,
});
@@ -744,7 +744,10 @@ describe('SendAlertModal integration', () => {
let mockHandleSubmitPressLocal: jest.Mock;
const setupTokenContractScenario = (
- overrides: Partial> = {},
+ validationOverrides: Partial<
+ ReturnType
+ > = {},
+ sendAlertsOverrides: Partial> = {},
) => {
mockHandleSubmitPressLocal = jest.fn();
mockUseSendActions.mockReturnValue({
@@ -771,11 +774,24 @@ describe('SendAlertModal integration', () => {
mockUseToAddressValidation.mockReturnValue({
loading: false,
resolvedAddress: undefined,
- toAddressError: 'Token contract warning',
- toAddressErrorAllowAcknowledge: true,
+ toAddressError: undefined,
toAddressValidated: '0x1234567890123456789012345678901234567890',
toAddressWarning: undefined,
- ...overrides,
+ ...validationOverrides,
+ });
+
+ mockUseSendAlerts.mockReturnValue({
+ alerts: [
+ {
+ key: 'tokenContract',
+ title: 'Smart contract address',
+ message: 'You are sending to a smart contract address',
+ },
+ ],
+ hasUnacknowledgedAlerts: true,
+ acknowledgeAlerts: jest.fn(),
+ isAlertCheckPending: false,
+ ...sendAlertsOverrides,
});
mockUseRecipientSelectionMetrics.mockReturnValue({
@@ -793,7 +809,7 @@ describe('SendAlertModal integration', () => {
jest.clearAllMocks();
});
- it('opens alert modal when review pressed and toAddressErrorAllowAcknowledge is true', () => {
+ it('opens alert modal when review pressed and has unacknowledged send alerts', () => {
setupTokenContractScenario();
const { getByTestId } = renderWithProvider();
@@ -841,11 +857,16 @@ describe('SendAlertModal integration', () => {
);
});
- it('does not show alert modal when toAddressErrorAllowAcknowledge is false', () => {
- setupTokenContractScenario({
- toAddressError: 'Some error',
- toAddressErrorAllowAcknowledge: false,
- });
+ it('does not show alert modal when there are no unacknowledged send alerts', () => {
+ setupTokenContractScenario(
+ {
+ toAddressError: 'Some error',
+ },
+ {
+ alerts: [],
+ hasUnacknowledgedAlerts: false,
+ },
+ );
const { getByTestId, queryByTestId } = renderWithProvider();
@@ -892,11 +913,17 @@ describe('SendAlertModal integration', () => {
loading: false,
resolvedAddress: undefined,
toAddressError: undefined,
- toAddressErrorAllowAcknowledge: false,
toAddressValidated: '0xvalid',
toAddressWarning: undefined,
});
+ mockUseSendAlerts.mockReturnValue({
+ alerts: [],
+ hasUnacknowledgedAlerts: false,
+ acknowledgeAlerts: jest.fn(),
+ isAlertCheckPending: false,
+ });
+
mockUseAccounts.mockReturnValue(mockAccounts);
mockUseContacts.mockReturnValue(mockContacts);
mockUseSendType.mockReturnValue({
diff --git a/app/components/Views/confirmations/components/send/recipient/recipient.tsx b/app/components/Views/confirmations/components/send/recipient/recipient.tsx
index 3fce15c9076..178ff99b9ac 100644
--- a/app/components/Views/confirmations/components/send/recipient/recipient.tsx
+++ b/app/components/Views/confirmations/components/send/recipient/recipient.tsx
@@ -15,6 +15,7 @@ import Banner, {
} from '../../../../../../component-library/components/Banners/Banner';
import { useSendContext } from '../../../context/send-context/send-context';
import { RecipientInputMethod } from '../../../context/send-context/send-metrics-context';
+import { useSendAlerts } from '../../../hooks/send/alerts/useSendAlerts';
import { useRecipientSelectionMetrics } from '../../../hooks/send/metrics/useRecipientSelectionMetrics';
import { useAccounts } from '../../../hooks/send/useAccounts';
import { useContacts } from '../../../hooks/send/useContacts';
@@ -41,15 +42,20 @@ export const Recipient = () => {
const styles = styleSheet();
const {
toAddressError,
- toAddressErrorAllowAcknowledge,
toAddressWarning,
toAddressValidated,
loading,
resolvedAddress,
} = useToAddressValidation();
- const hasBlockingError =
- Boolean(toAddressError) && !toAddressErrorAllowAcknowledge;
+ const {
+ alerts,
+ hasUnacknowledgedAlerts,
+ acknowledgeAlerts,
+ isAlertCheckPending,
+ } = useSendAlerts();
+
+ const hasBlockingError = Boolean(toAddressError);
// This hook needs to be called to update ERC721 NFTs in send flow
// because that flow is triggered directly from the asset details page and user is redirected to the recipient page
useRouteParams();
@@ -98,15 +104,16 @@ export const Recipient = () => {
const handleAlertModalAcknowledge = useCallback(async () => {
setIsAlertModalOpen(false);
+ acknowledgeAlerts();
await proceedWithSubmit(false);
- }, [proceedWithSubmit]);
+ }, [acknowledgeAlerts, proceedWithSubmit]);
const handleReview = useCallback(
async (isPasted?: boolean) => {
if (hasBlockingError || isSubmittingTransaction) {
return;
}
- if (toAddressErrorAllowAcknowledge) {
+ if (hasUnacknowledgedAlerts) {
setIsAlertModalOpen(true);
return;
}
@@ -114,7 +121,7 @@ export const Recipient = () => {
},
[
hasBlockingError,
- toAddressErrorAllowAcknowledge,
+ hasUnacknowledgedAlerts,
isSubmittingTransaction,
proceedWithSubmit,
],
@@ -126,7 +133,9 @@ export const Recipient = () => {
pastedRecipient === toAddressValidated &&
!toAddressError &&
!toAddressWarning &&
- !loading
+ !loading &&
+ !isAlertCheckPending &&
+ !hasUnacknowledgedAlerts
) {
handleReview(true);
}
@@ -137,6 +146,8 @@ export const Recipient = () => {
toAddressValidated,
toAddressWarning,
loading,
+ isAlertCheckPending,
+ hasUnacknowledgedAlerts,
]);
const onRecipientSelected = useCallback(
@@ -240,7 +251,10 @@ export const Recipient = () => {
twClassName="w-full"
isDanger={!loading && hasBlockingError}
disabled={
- hasBlockingError || isSubmittingTransaction || loading
+ hasBlockingError ||
+ isSubmittingTransaction ||
+ loading ||
+ isAlertCheckPending
}
isLoading={isSubmittingTransaction || loading}
>
@@ -250,8 +264,7 @@ export const Recipient = () => {
)}
diff --git a/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.test.tsx b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.test.tsx
index 4327e5fbe17..e01f8ab64b0 100644
--- a/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.test.tsx
+++ b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.test.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import renderWithProvider from '../../../../../../util/test/renderWithProvider';
+import type { SendAlert } from '../../../hooks/send/alerts/types';
import { SendAlertModal } from './send-alert-modal';
jest.mock('../../../../../../../locales/i18n', () => ({
@@ -9,6 +10,8 @@ jest.mock('../../../../../../../locales/i18n', () => ({
const mockStrings: Record = {
'send.cancel': 'Cancel',
'send.i_understand': 'I understand',
+ 'send.alert_navigation_previous': 'Previous alert',
+ 'send.alert_navigation_next': 'Next alert',
};
return mockStrings[key] || key;
}),
@@ -70,11 +73,32 @@ jest.mock(
},
);
+const singleAlert: SendAlert[] = [
+ {
+ key: 'tokenContract',
+ title: 'Token Contract Address',
+ message: 'Sending to a token contract may result in lost tokens.',
+ },
+];
+
+const twoAlerts: SendAlert[] = [
+ {
+ key: 'tokenContract',
+ title: 'Smart contract address',
+ message: 'Token contract warning text.',
+ },
+ {
+ key: 'firstTimeInteraction',
+ title: 'New address',
+ message: 'First time message',
+ acknowledgeButtonLabel: 'Continue',
+ },
+];
+
describe('SendAlertModal', () => {
const defaultProps = {
isOpen: true,
- title: 'Token Contract Address',
- errorMessage: 'Sending to a token contract may result in lost tokens.',
+ alerts: singleAlert,
onAcknowledge: jest.fn(),
onClose: jest.fn(),
};
@@ -91,6 +115,14 @@ describe('SendAlertModal', () => {
expect(toJSON()).toBeNull();
});
+ it('returns null when alerts is empty', () => {
+ const { toJSON } = renderWithProvider(
+ ,
+ );
+
+ expect(toJSON()).toBeNull();
+ });
+
it('renders modal content when isOpen is true', () => {
const { getByText } = renderWithProvider(
,
@@ -102,22 +134,6 @@ describe('SendAlertModal', () => {
).toBeOnTheScreen();
});
- it('displays the title text', () => {
- const { getByText } = renderWithProvider(
- ,
- );
-
- expect(getByText('Custom Title')).toBeOnTheScreen();
- });
-
- it('displays the error message text', () => {
- const { getByText } = renderWithProvider(
- ,
- );
-
- expect(getByText('Custom error message')).toBeOnTheScreen();
- });
-
it('calls onClose when cancel button is pressed', () => {
const onClose = jest.fn();
const { getByTestId } = renderWithProvider(
@@ -129,7 +145,7 @@ describe('SendAlertModal', () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
- it('calls onAcknowledge when acknowledge button is pressed', () => {
+ it('calls onAcknowledge when acknowledge is pressed on last alert', () => {
const onAcknowledge = jest.fn();
const { getByTestId } = renderWithProvider(
,
@@ -140,37 +156,22 @@ describe('SendAlertModal', () => {
expect(onAcknowledge).toHaveBeenCalledTimes(1);
});
- it('does not call onAcknowledge when cancel is pressed', () => {
+ it('advances to second alert when multiple alerts and first acknowledge', () => {
const onAcknowledge = jest.fn();
- const onClose = jest.fn();
- const { getByTestId } = renderWithProvider(
+ const { getByText, getByTestId } = renderWithProvider(
,
);
- fireEvent.press(getByTestId('send-alert-modal-cancel-button'));
-
+ expect(getByText('Smart contract address')).toBeOnTheScreen();
+ fireEvent.press(getByTestId('send-alert-modal-acknowledge-button'));
+ expect(getByText('New address')).toBeOnTheScreen();
expect(onAcknowledge).not.toHaveBeenCalled();
- expect(onClose).toHaveBeenCalledTimes(1);
- });
-
- it('does not call onClose when acknowledge is pressed', () => {
- const onAcknowledge = jest.fn();
- const onClose = jest.fn();
- const { getByTestId } = renderWithProvider(
- ,
- );
fireEvent.press(getByTestId('send-alert-modal-acknowledge-button'));
-
- expect(onClose).not.toHaveBeenCalled();
expect(onAcknowledge).toHaveBeenCalledTimes(1);
});
});
diff --git a/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.tsx b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.tsx
index 5fa80651a23..aa65df44b5a 100644
--- a/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.tsx
+++ b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.tsx
@@ -1,8 +1,10 @@
-import React, { useRef } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Box,
BoxAlignItems,
BoxFlexDirection,
+ BoxJustifyContent,
+ ButtonIcon,
Icon,
IconColor,
IconName,
@@ -20,38 +22,138 @@ import {
ButtonSize,
ButtonVariants,
} from '../../../../../../component-library/components/Buttons/Button';
+import type { SendAlert } from '../../../hooks/send/alerts/types';
import { SendAlertModalProps } from './send-alert-modal.types';
+function PageNavigation({
+ alerts,
+ selectedIndex,
+ onBack,
+ onForward,
+}: {
+ alerts: SendAlert[];
+ selectedIndex: number;
+ onBack: () => void;
+ onForward: () => void;
+}) {
+ if (alerts.length <= 1) {
+ return null;
+ }
+
+ return (
+
+
+ {selectedIndex > 0 ? (
+
+ ) : (
+
+ )}
+
+
+ {selectedIndex < alerts.length - 1 ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
export const SendAlertModal = ({
isOpen,
- title,
- errorMessage,
+ alerts,
onAcknowledge,
onClose,
}: SendAlertModalProps) => {
const bottomSheetRef = useRef(null);
+ const [currentIndex, setCurrentIndex] = useState(0);
+
+ const alertKeys = alerts.map((a) => a.key).join('|');
+
+ useEffect(() => {
+ setCurrentIndex(0);
+ }, [isOpen, alertKeys]);
+
+ const safeIndex = Math.min(currentIndex, Math.max(alerts.length - 1, 0));
+ const currentAlert = alerts[safeIndex];
+
+ const goToPrevious = useCallback(() => {
+ setCurrentIndex((prev) => Math.max(prev - 1, 0));
+ }, []);
+
+ const goToNext = useCallback(() => {
+ setCurrentIndex((prev) => Math.min(prev + 1, alerts.length - 1));
+ }, [alerts.length]);
+
+ const isOnLastAlert = safeIndex >= Math.max(alerts.length - 1, 0);
+
+ const handleAcknowledgeStep = useCallback(() => {
+ if (isOnLastAlert) {
+ onAcknowledge();
+ return;
+ }
+ goToNext();
+ }, [goToNext, isOnLastAlert, onAcknowledge]);
if (!isOpen) {
return null;
}
+ if (!currentAlert) {
+ return null;
+ }
+
+ const acknowledgeLabel =
+ currentAlert.acknowledgeButtonLabel ?? strings('send.i_understand');
+
return (
+
- {title}
-
- {errorMessage}
-
+ {currentAlert.title}
+
+ {typeof currentAlert.message === 'string' ? (
+
+ {currentAlert.message}
+
+ ) : (
+ currentAlert.message
+ )}
+
void;
onClose: () => void;
}
diff --git a/app/components/Views/confirmations/hooks/send/alerts/types.ts b/app/components/Views/confirmations/hooks/send/alerts/types.ts
new file mode 100644
index 00000000000..a82e82b60f9
--- /dev/null
+++ b/app/components/Views/confirmations/hooks/send/alerts/types.ts
@@ -0,0 +1,8 @@
+import type { ReactNode } from 'react';
+
+export interface SendAlert {
+ key: string;
+ title: string;
+ message: ReactNode;
+ acknowledgeButtonLabel?: string;
+}
diff --git a/app/components/Views/confirmations/hooks/send/alerts/useFirstTimeInteractionSendAlert.test.tsx b/app/components/Views/confirmations/hooks/send/alerts/useFirstTimeInteractionSendAlert.test.tsx
new file mode 100644
index 00000000000..b8e97de0d16
--- /dev/null
+++ b/app/components/Views/confirmations/hooks/send/alerts/useFirstTimeInteractionSendAlert.test.tsx
@@ -0,0 +1,217 @@
+import { renderHook } from '@testing-library/react-hooks';
+import { useSelector } from 'react-redux';
+
+import { checkFirstTimeInteraction } from '../../../../../../util/transaction-controller';
+import { useAsyncResult } from '../../../../../hooks/useAsyncResult';
+import { useSendContext } from '../../../context/send-context/send-context';
+import { TrustSignalDisplayState } from '../../../types/trustSignals';
+import { useAddressTrustSignal } from '../../useAddressTrustSignals';
+import { useFirstTimeInteractionSendAlert } from './useFirstTimeInteractionSendAlert';
+
+jest.mock('react-redux', () => ({
+ useSelector: jest.fn(),
+}));
+
+jest.mock('../../../context/send-context/send-context', () => ({
+ useSendContext: jest.fn(),
+}));
+
+jest.mock('../../useAddressTrustSignals', () => ({
+ useAddressTrustSignal: jest.fn(),
+}));
+
+jest.mock('../../../../../hooks/useAsyncResult', () => ({
+ useAsyncResult: jest.fn(),
+}));
+
+jest.mock('../../../../../../util/transaction-controller', () => ({
+ checkFirstTimeInteraction: jest.fn(),
+}));
+
+jest.mock('../../../../../../../locales/i18n', () => ({
+ strings: (key: string) => {
+ const map: Record = {
+ 'send.new_address_title': 'New address',
+ 'send.new_address_message': 'First time message',
+ 'send.continue': 'Continue',
+ };
+ return map[key] || key;
+ },
+}));
+
+const mockUseSendContext = jest.mocked(useSendContext);
+const mockUseSelector = jest.mocked(useSelector);
+const mockUseAddressTrustSignal = jest.mocked(useAddressTrustSignal);
+const mockUseAsyncResult = jest.mocked(useAsyncResult);
+
+describe('useFirstTimeInteractionSendAlert', () => {
+ const TO = '0xRecipientAddress';
+ const FROM = '0xSenderAddress';
+ const CHAIN_ID = '0x1';
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockUseSendContext.mockReturnValue({
+ to: TO,
+ from: FROM,
+ chainId: CHAIN_ID,
+ } as unknown as ReturnType);
+
+ mockUseSelector.mockReturnValue([]);
+
+ mockUseAddressTrustSignal.mockReturnValue({
+ state: TrustSignalDisplayState.Unknown,
+ label: null,
+ });
+
+ mockUseAsyncResult.mockReturnValue({ pending: false, value: true });
+ });
+
+ it('returns alert when first-time interaction is detected', () => {
+ const { result } = renderHook(() => useFirstTimeInteractionSendAlert());
+
+ expect(result.current.alert).not.toBeNull();
+ expect(result.current.alert?.key).toBe('firstTimeInteraction');
+ expect(result.current.alert?.title).toBe('New address');
+ expect(result.current.alert?.acknowledgeButtonLabel).toBe('Continue');
+ expect(result.current.isPending).toBe(false);
+ });
+
+ it('returns null alert when to is missing', () => {
+ mockUseSendContext.mockReturnValue({
+ to: undefined,
+ from: FROM,
+ chainId: CHAIN_ID,
+ } as unknown as ReturnType);
+
+ const { result } = renderHook(() => useFirstTimeInteractionSendAlert());
+
+ expect(result.current.alert).toBeNull();
+ expect(result.current.isPending).toBe(false);
+ });
+
+ it('returns null alert when from is missing', () => {
+ mockUseSendContext.mockReturnValue({
+ to: TO,
+ from: undefined,
+ chainId: CHAIN_ID,
+ } as unknown as ReturnType);
+
+ const { result } = renderHook(() => useFirstTimeInteractionSendAlert());
+
+ expect(result.current.alert).toBeNull();
+ });
+
+ it('returns null alert when chainId is missing', () => {
+ mockUseSendContext.mockReturnValue({
+ to: TO,
+ from: FROM,
+ chainId: undefined,
+ } as unknown as ReturnType);
+
+ const { result } = renderHook(() => useFirstTimeInteractionSendAlert());
+
+ expect(result.current.alert).toBeNull();
+ });
+
+ it('returns null alert when to is an internal account', () => {
+ mockUseSelector.mockReturnValue([{ address: TO.toLowerCase() }]);
+
+ const { result } = renderHook(() => useFirstTimeInteractionSendAlert());
+
+ expect(result.current.alert).toBeNull();
+ });
+
+ it('returns null alert when address is verified', () => {
+ mockUseAddressTrustSignal.mockReturnValue({
+ state: TrustSignalDisplayState.Verified,
+ label: null,
+ });
+
+ const { result } = renderHook(() => useFirstTimeInteractionSendAlert());
+
+ expect(result.current.alert).toBeNull();
+ });
+
+ it('returns isPending true when trust signal is loading', () => {
+ mockUseAddressTrustSignal.mockReturnValue({
+ state: TrustSignalDisplayState.Loading,
+ label: null,
+ });
+
+ const { result } = renderHook(() => useFirstTimeInteractionSendAlert());
+
+ expect(result.current.alert).toBeNull();
+ expect(result.current.isPending).toBe(true);
+ });
+
+ it('returns isPending true when async check is pending', () => {
+ mockUseAsyncResult.mockReturnValue({ pending: true });
+
+ const { result } = renderHook(() => useFirstTimeInteractionSendAlert());
+
+ expect(result.current.alert).toBeNull();
+ expect(result.current.isPending).toBe(true);
+ });
+
+ it('returns null alert when isFirstTime is false', () => {
+ mockUseAsyncResult.mockReturnValue({ pending: false, value: false });
+
+ const { result } = renderHook(() => useFirstTimeInteractionSendAlert());
+
+ expect(result.current.alert).toBeNull();
+ expect(result.current.isPending).toBe(false);
+ });
+
+ it('returns null alert when isFirstTime is undefined', () => {
+ mockUseAsyncResult.mockReturnValue({ pending: false, value: undefined });
+
+ const { result } = renderHook(() => useFirstTimeInteractionSendAlert());
+
+ expect(result.current.alert).toBeNull();
+ });
+
+ it('passes correct arguments to checkFirstTimeInteraction via useAsyncResult', () => {
+ renderHook(() => useFirstTimeInteractionSendAlert());
+
+ expect(mockUseAsyncResult).toHaveBeenCalled();
+ const asyncFn = mockUseAsyncResult.mock.calls[0][0];
+
+ asyncFn();
+
+ expect(checkFirstTimeInteraction).toHaveBeenCalledWith({
+ from: FROM,
+ to: TO,
+ chainId: 1,
+ });
+ });
+
+ it('skips the async call when shouldSkip is true', async () => {
+ mockUseSendContext.mockReturnValue({
+ to: undefined,
+ from: FROM,
+ chainId: CHAIN_ID,
+ } as unknown as ReturnType);
+
+ renderHook(() => useFirstTimeInteractionSendAlert());
+
+ const asyncFn = mockUseAsyncResult.mock.calls[0][0];
+
+ await expect(asyncFn()).resolves.toBeUndefined();
+ expect(checkFirstTimeInteraction).not.toHaveBeenCalled();
+ });
+
+ it('returns isPending false when shouldSkip is true even if async is pending', () => {
+ mockUseSendContext.mockReturnValue({
+ to: undefined,
+ from: FROM,
+ chainId: CHAIN_ID,
+ } as unknown as ReturnType);
+ mockUseAsyncResult.mockReturnValue({ pending: true });
+
+ const { result } = renderHook(() => useFirstTimeInteractionSendAlert());
+
+ expect(result.current.isPending).toBe(false);
+ });
+});
diff --git a/app/components/Views/confirmations/hooks/send/alerts/useFirstTimeInteractionSendAlert.tsx b/app/components/Views/confirmations/hooks/send/alerts/useFirstTimeInteractionSendAlert.tsx
new file mode 100644
index 00000000000..4f3a21f1828
--- /dev/null
+++ b/app/components/Views/confirmations/hooks/send/alerts/useFirstTimeInteractionSendAlert.tsx
@@ -0,0 +1,102 @@
+import {
+ Box,
+ BoxFlexDirection,
+ BoxFlexWrap,
+ FontWeight,
+ Text,
+ TextVariant,
+} from '@metamask/design-system-react-native';
+import { InternalAccount } from '@metamask/keyring-internal-api';
+import { Hex, hexToNumber } from '@metamask/utils';
+import React, { useMemo } from 'react';
+import { useSelector } from 'react-redux';
+
+import { strings } from '../../../../../../../locales/i18n';
+import { selectInternalAccounts } from '../../../../../../selectors/accountsController';
+import { checkFirstTimeInteraction } from '../../../../../../util/transaction-controller';
+import { useAsyncResult } from '../../../../../hooks/useAsyncResult';
+import { useSendContext } from '../../../context/send-context/send-context';
+import { TrustSignalDisplayState } from '../../../types/trustSignals';
+import { useAddressTrustSignal } from '../../useAddressTrustSignals';
+import type { SendAlert } from './types';
+
+export function useFirstTimeInteractionSendAlert(): {
+ alert: SendAlert | null;
+ isPending: boolean;
+} {
+ const { to, from, chainId } = useSendContext();
+ const internalAccounts = useSelector(
+ selectInternalAccounts,
+ ) as InternalAccount[];
+
+ const trustSignalResult = useAddressTrustSignal(to ?? '', chainId ?? '');
+
+ const isInternalAccount = useMemo(() => {
+ if (!to) {
+ return false;
+ }
+ return internalAccounts.some(
+ (account) => account.address?.toLowerCase() === to.toLowerCase(),
+ );
+ }, [internalAccounts, to]);
+
+ const isVerifiedAddress =
+ trustSignalResult.state === TrustSignalDisplayState.Verified;
+
+ const isTrustSignalLoading =
+ trustSignalResult.state === TrustSignalDisplayState.Loading;
+
+ const shouldSkip =
+ !to ||
+ !from ||
+ !chainId ||
+ isInternalAccount ||
+ isVerifiedAddress ||
+ isTrustSignalLoading;
+
+ const { pending, value: isFirstTime } = useAsyncResult(async () => {
+ if (shouldSkip) {
+ return undefined;
+ }
+ const chainIdNum = hexToNumber(chainId as Hex);
+ return checkFirstTimeInteraction({ from, to, chainId: chainIdNum });
+ }, [to, from, chainId, shouldSkip]);
+
+ const isPending = isTrustSignalLoading || (!shouldSkip && pending);
+
+ if (shouldSkip || pending || isFirstTime !== true) {
+ return { alert: null, isPending };
+ }
+
+ const message = (
+
+
+ {strings('send.new_address_message')}{' '}
+
+
+ {to}
+
+
+ );
+
+ return {
+ alert: {
+ key: 'firstTimeInteraction',
+ title: strings('send.new_address_title'),
+ message,
+ acknowledgeButtonLabel: strings('send.continue'),
+ },
+ isPending: false,
+ };
+}
diff --git a/app/components/Views/confirmations/hooks/send/alerts/useSendAlerts.test.ts b/app/components/Views/confirmations/hooks/send/alerts/useSendAlerts.test.ts
new file mode 100644
index 00000000000..261e34fd501
--- /dev/null
+++ b/app/components/Views/confirmations/hooks/send/alerts/useSendAlerts.test.ts
@@ -0,0 +1,174 @@
+import { renderHook, act } from '@testing-library/react-hooks';
+
+import { useSendContext } from '../../../context/send-context/send-context';
+import { useFirstTimeInteractionSendAlert } from './useFirstTimeInteractionSendAlert';
+import { useTokenContractSendAlert } from './useTokenContractSendAlert';
+import { useSendAlerts } from './useSendAlerts';
+
+jest.mock('../../../context/send-context/send-context', () => ({
+ useSendContext: jest.fn(),
+}));
+
+jest.mock('./useFirstTimeInteractionSendAlert', () => ({
+ useFirstTimeInteractionSendAlert: jest.fn(),
+}));
+
+jest.mock('./useTokenContractSendAlert', () => ({
+ useTokenContractSendAlert: jest.fn(),
+}));
+
+const mockUseSendContext = jest.mocked(useSendContext);
+const mockUseFirstTimeInteraction = jest.mocked(
+ useFirstTimeInteractionSendAlert,
+);
+const mockUseTokenContract = jest.mocked(useTokenContractSendAlert);
+
+const TOKEN_ALERT = {
+ key: 'tokenContract',
+ title: 'Smart contract address',
+ message: 'Token contract warning',
+};
+
+const FIRST_TIME_ALERT = {
+ key: 'firstTimeInteraction',
+ title: 'New address',
+ message: 'First time message',
+ acknowledgeButtonLabel: 'Continue',
+};
+
+describe('useSendAlerts', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockUseSendContext.mockReturnValue({
+ to: '0xRecipient',
+ } as unknown as ReturnType);
+
+ mockUseTokenContract.mockReturnValue({
+ alert: null,
+ isPending: false,
+ });
+
+ mockUseFirstTimeInteraction.mockReturnValue({
+ alert: null,
+ isPending: false,
+ });
+ });
+
+ it('returns empty alerts when no sub-hooks produce alerts', () => {
+ const { result } = renderHook(() => useSendAlerts());
+
+ expect(result.current.alerts).toEqual([]);
+ expect(result.current.hasUnacknowledgedAlerts).toBe(false);
+ expect(result.current.isAlertCheckPending).toBe(false);
+ });
+
+ it('collects token contract alert', () => {
+ mockUseTokenContract.mockReturnValue({
+ alert: TOKEN_ALERT,
+ isPending: false,
+ });
+
+ const { result } = renderHook(() => useSendAlerts());
+
+ expect(result.current.alerts).toEqual([TOKEN_ALERT]);
+ expect(result.current.hasUnacknowledgedAlerts).toBe(true);
+ });
+
+ it('collects first-time interaction alert', () => {
+ mockUseFirstTimeInteraction.mockReturnValue({
+ alert: FIRST_TIME_ALERT,
+ isPending: false,
+ });
+
+ const { result } = renderHook(() => useSendAlerts());
+
+ expect(result.current.alerts).toEqual([FIRST_TIME_ALERT]);
+ expect(result.current.hasUnacknowledgedAlerts).toBe(true);
+ });
+
+ it('collects both alerts in order: token contract first', () => {
+ mockUseTokenContract.mockReturnValue({
+ alert: TOKEN_ALERT,
+ isPending: false,
+ });
+ mockUseFirstTimeInteraction.mockReturnValue({
+ alert: FIRST_TIME_ALERT,
+ isPending: false,
+ });
+
+ const { result } = renderHook(() => useSendAlerts());
+
+ expect(result.current.alerts).toEqual([TOKEN_ALERT, FIRST_TIME_ALERT]);
+ expect(result.current.hasUnacknowledgedAlerts).toBe(true);
+ });
+
+ it('reports isAlertCheckPending when token contract check is pending', () => {
+ mockUseTokenContract.mockReturnValue({
+ alert: null,
+ isPending: true,
+ });
+
+ const { result } = renderHook(() => useSendAlerts());
+
+ expect(result.current.isAlertCheckPending).toBe(true);
+ });
+
+ it('reports isAlertCheckPending when first-time check is pending', () => {
+ mockUseFirstTimeInteraction.mockReturnValue({
+ alert: null,
+ isPending: true,
+ });
+
+ const { result } = renderHook(() => useSendAlerts());
+
+ expect(result.current.isAlertCheckPending).toBe(true);
+ });
+
+ it('acknowledgeAlerts sets hasUnacknowledgedAlerts to false', () => {
+ mockUseTokenContract.mockReturnValue({
+ alert: TOKEN_ALERT,
+ isPending: false,
+ });
+
+ const { result } = renderHook(() => useSendAlerts());
+
+ expect(result.current.hasUnacknowledgedAlerts).toBe(true);
+
+ act(() => {
+ result.current.acknowledgeAlerts();
+ });
+
+ expect(result.current.hasUnacknowledgedAlerts).toBe(false);
+ });
+
+ it('resets acknowledged state when to changes', () => {
+ mockUseTokenContract.mockReturnValue({
+ alert: TOKEN_ALERT,
+ isPending: false,
+ });
+
+ const { result, rerender } = renderHook(() => useSendAlerts());
+
+ act(() => {
+ result.current.acknowledgeAlerts();
+ });
+
+ expect(result.current.hasUnacknowledgedAlerts).toBe(false);
+
+ mockUseSendContext.mockReturnValue({
+ to: '0xNewRecipient',
+ } as unknown as ReturnType);
+
+ rerender();
+
+ expect(result.current.hasUnacknowledgedAlerts).toBe(true);
+ });
+
+ it('returns hasUnacknowledgedAlerts false when alerts are empty even without acknowledging', () => {
+ const { result } = renderHook(() => useSendAlerts());
+
+ expect(result.current.alerts).toEqual([]);
+ expect(result.current.hasUnacknowledgedAlerts).toBe(false);
+ });
+});
diff --git a/app/components/Views/confirmations/hooks/send/alerts/useSendAlerts.ts b/app/components/Views/confirmations/hooks/send/alerts/useSendAlerts.ts
new file mode 100644
index 00000000000..7eca5ff4de2
--- /dev/null
+++ b/app/components/Views/confirmations/hooks/send/alerts/useSendAlerts.ts
@@ -0,0 +1,50 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+
+import { useSendContext } from '../../../context/send-context/send-context';
+import { useFirstTimeInteractionSendAlert } from './useFirstTimeInteractionSendAlert';
+import { useTokenContractSendAlert } from './useTokenContractSendAlert';
+import type { SendAlert } from './types';
+
+export function useSendAlerts(): {
+ alerts: SendAlert[];
+ hasUnacknowledgedAlerts: boolean;
+ acknowledgeAlerts: () => void;
+ isAlertCheckPending: boolean;
+} {
+ const { to } = useSendContext();
+ const { alert: tokenContractAlert, isPending: tokenContractPending } =
+ useTokenContractSendAlert();
+ const { alert: firstTimeAlert, isPending: firstTimePending } =
+ useFirstTimeInteractionSendAlert();
+ const [acknowledged, setAcknowledged] = useState(false);
+
+ const isAlertCheckPending = tokenContractPending || firstTimePending;
+
+ const alerts = useMemo(() => {
+ const result: SendAlert[] = [];
+ if (tokenContractAlert) {
+ result.push(tokenContractAlert);
+ }
+ if (firstTimeAlert) {
+ result.push(firstTimeAlert);
+ }
+ return result;
+ }, [tokenContractAlert, firstTimeAlert]);
+
+ useEffect(() => {
+ setAcknowledged(false);
+ }, [to]);
+
+ const acknowledgeAlerts = useCallback(() => {
+ setAcknowledged(true);
+ }, []);
+
+ const hasUnacknowledgedAlerts = alerts.length > 0 && !acknowledged;
+
+ return {
+ alerts,
+ hasUnacknowledgedAlerts,
+ acknowledgeAlerts,
+ isAlertCheckPending,
+ };
+}
diff --git a/app/components/Views/confirmations/hooks/send/alerts/useTokenContractSendAlert.test.ts b/app/components/Views/confirmations/hooks/send/alerts/useTokenContractSendAlert.test.ts
new file mode 100644
index 00000000000..89f494a76d8
--- /dev/null
+++ b/app/components/Views/confirmations/hooks/send/alerts/useTokenContractSendAlert.test.ts
@@ -0,0 +1,207 @@
+import { renderHook, act } from '@testing-library/react-hooks';
+
+import {
+ memoizedGetTokenStandardAndDetails,
+ type TokenDetails,
+} from '../../../utils/token';
+import { useSendContext } from '../../../context/send-context/send-context';
+import { useSendType } from '../useSendType';
+import { useTokenContractSendAlert } from './useTokenContractSendAlert';
+
+type TokenResult = TokenDetails | Record;
+
+jest.mock('../../../context/send-context/send-context', () => ({
+ useSendContext: jest.fn(),
+}));
+
+jest.mock('../useSendType', () => ({
+ useSendType: jest.fn(),
+}));
+
+jest.mock('../../../utils/token', () => ({
+ memoizedGetTokenStandardAndDetails: jest.fn(),
+}));
+
+jest.mock('../../../../../../core/Engine', () => ({
+ context: {
+ NetworkController: {
+ findNetworkClientIdByChainId: jest.fn().mockReturnValue('mainnet'),
+ },
+ },
+}));
+
+jest.mock('../../../../../../util/address', () => ({
+ isValidHexAddress: jest.fn((addr: string) => addr.startsWith('0x')),
+ toChecksumAddress: jest.fn((addr: string) => addr),
+}));
+
+jest.mock('../../../../../../../locales/i18n', () => ({
+ strings: (key: string) => {
+ const map: Record = {
+ 'send.smart_contract_address': 'Smart contract address',
+ 'send.smart_contract_address_warning': 'Token contract warning',
+ };
+ return map[key] || key;
+ },
+}));
+
+const mockUseSendContext = jest.mocked(useSendContext);
+const mockUseSendType = jest.mocked(useSendType);
+const mockGetTokenDetails = jest.mocked(memoizedGetTokenStandardAndDetails);
+
+describe('useTokenContractSendAlert', () => {
+ const TOKEN_CONTRACT = '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477';
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockUseSendContext.mockReturnValue({
+ to: TOKEN_CONTRACT,
+ chainId: '0x1',
+ asset: { address: '0xDifferentAddress' },
+ } as unknown as ReturnType);
+
+ mockUseSendType.mockReturnValue({
+ isEvmSendType: true,
+ } as unknown as ReturnType);
+
+ mockGetTokenDetails.mockResolvedValue({});
+ });
+
+ it('returns null alert when to is missing', () => {
+ mockUseSendContext.mockReturnValue({
+ to: undefined,
+ chainId: '0x1',
+ asset: {},
+ } as unknown as ReturnType);
+
+ const { result } = renderHook(() => useTokenContractSendAlert());
+
+ expect(result.current.alert).toBeNull();
+ expect(result.current.isPending).toBe(false);
+ });
+
+ it('returns null alert when chainId is missing', () => {
+ mockUseSendContext.mockReturnValue({
+ to: TOKEN_CONTRACT,
+ chainId: undefined,
+ asset: {},
+ } as unknown as ReturnType);
+
+ const { result } = renderHook(() => useTokenContractSendAlert());
+
+ expect(result.current.alert).toBeNull();
+ });
+
+ it('returns null alert when not an EVM send type', () => {
+ mockUseSendType.mockReturnValue({
+ isEvmSendType: false,
+ } as unknown as ReturnType);
+
+ const { result } = renderHook(() => useTokenContractSendAlert());
+
+ expect(result.current.alert).toBeNull();
+ });
+
+ it('returns null alert when to address equals asset address', () => {
+ mockUseSendContext.mockReturnValue({
+ to: TOKEN_CONTRACT,
+ chainId: '0x1',
+ asset: { address: TOKEN_CONTRACT },
+ } as unknown as ReturnType);
+
+ const { result } = renderHook(() => useTokenContractSendAlert());
+
+ expect(result.current.alert).toBeNull();
+ });
+
+ it('returns alert when address is a token contract', async () => {
+ mockGetTokenDetails.mockResolvedValue({
+ standard: 'ERC20',
+ } as Awaited>);
+
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useTokenContractSendAlert(),
+ );
+
+ await waitForNextUpdate();
+
+ expect(result.current.alert).not.toBeNull();
+ expect(result.current.alert?.key).toBe('tokenContract');
+ expect(result.current.alert?.title).toBe('Smart contract address');
+ expect(result.current.alert?.message).toBe('Token contract warning');
+ expect(result.current.isPending).toBe(false);
+ });
+
+ it('returns null alert when address is not a token contract', async () => {
+ mockGetTokenDetails.mockResolvedValue({});
+
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useTokenContractSendAlert(),
+ );
+
+ await waitForNextUpdate();
+
+ expect(result.current.alert).toBeNull();
+ expect(result.current.isPending).toBe(false);
+ });
+
+ it('returns null alert when token details lookup throws', async () => {
+ mockGetTokenDetails.mockRejectedValue(new Error('lookup failed'));
+
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useTokenContractSendAlert(),
+ );
+
+ await waitForNextUpdate();
+
+ expect(result.current.alert).toBeNull();
+ expect(result.current.isPending).toBe(false);
+ });
+
+ it('reports isPending while the token check is in progress', async () => {
+ let resolvePromise: (v: TokenResult) => void = () => undefined;
+ mockGetTokenDetails.mockReturnValue(
+ new Promise((resolve) => {
+ resolvePromise = resolve;
+ }),
+ );
+
+ const { result } = renderHook(() => useTokenContractSendAlert());
+
+ expect(result.current.isPending).toBe(true);
+
+ await act(async () => {
+ resolvePromise({});
+ });
+ });
+
+ it('cancels in-flight check when to changes', async () => {
+ let resolveFirst: (v: TokenResult) => void = () => undefined;
+ mockGetTokenDetails.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveFirst = resolve;
+ }),
+ );
+
+ const { result, rerender } = renderHook(() => useTokenContractSendAlert());
+
+ expect(result.current.isPending).toBe(true);
+
+ mockUseSendContext.mockReturnValue({
+ to: '0xNewAddress',
+ chainId: '0x1',
+ asset: { address: '0xDifferentAddress' },
+ } as unknown as ReturnType);
+
+ mockGetTokenDetails.mockResolvedValueOnce({});
+
+ rerender();
+
+ await act(async () => {
+ resolveFirst({ standard: 'ERC20' } as TokenResult);
+ });
+
+ expect(result.current.alert).toBeNull();
+ });
+});
diff --git a/app/components/Views/confirmations/hooks/send/alerts/useTokenContractSendAlert.ts b/app/components/Views/confirmations/hooks/send/alerts/useTokenContractSendAlert.ts
new file mode 100644
index 00000000000..6e45d92fbc4
--- /dev/null
+++ b/app/components/Views/confirmations/hooks/send/alerts/useTokenContractSendAlert.ts
@@ -0,0 +1,85 @@
+import { Hex } from '@metamask/utils';
+import { useEffect, useState } from 'react';
+
+import { strings } from '../../../../../../../locales/i18n';
+import Engine from '../../../../../../core/Engine';
+import {
+ isValidHexAddress,
+ toChecksumAddress,
+} from '../../../../../../util/address';
+import { memoizedGetTokenStandardAndDetails } from '../../../utils/token';
+import { useSendContext } from '../../../context/send-context/send-context';
+import { useSendType } from '../useSendType';
+import type { SendAlert } from './types';
+
+export function useTokenContractSendAlert(): {
+ alert: SendAlert | null;
+ isPending: boolean;
+} {
+ const { to, chainId, asset } = useSendContext();
+ const { isEvmSendType } = useSendType();
+ const [isTokenContract, setIsTokenContract] = useState(false);
+ const [checkComplete, setCheckComplete] = useState(true);
+
+ useEffect(() => {
+ let cancelled = false;
+ setIsTokenContract(false);
+
+ if (!to || !chainId || !isEvmSendType || !isValidHexAddress(to)) {
+ setCheckComplete(true);
+ return undefined;
+ }
+
+ if (to?.toLowerCase() === asset?.address?.toLowerCase()) {
+ setCheckComplete(true);
+ return undefined;
+ }
+
+ setCheckComplete(false);
+
+ const checksummedAddress = toChecksumAddress(to);
+ const { NetworkController } = Engine.context;
+ const networkClientId = NetworkController.findNetworkClientIdByChainId(
+ chainId as Hex,
+ );
+
+ memoizedGetTokenStandardAndDetails({
+ tokenAddress: checksummedAddress,
+ tokenId: undefined,
+ userAddress: undefined,
+ networkClientId,
+ })
+ .then((token) => {
+ if (!cancelled && token?.standard) {
+ setIsTokenContract(true);
+ }
+ })
+ .catch(() => {
+ // Not a token address
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setCheckComplete(true);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [to, chainId, isEvmSendType, asset?.address]);
+
+ const isPending = !checkComplete;
+
+ if (!isTokenContract) {
+ return { alert: null, isPending };
+ }
+
+ return {
+ alert: {
+ key: 'tokenContract',
+ title: strings('send.smart_contract_address'),
+ message: strings('send.smart_contract_address_warning'),
+ },
+ isPending: false,
+ };
+}
diff --git a/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts b/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts
index 560cbcfeb55..342f69961db 100644
--- a/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts
+++ b/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts
@@ -46,7 +46,6 @@ describe('useToAddressValidation', () => {
loading: false,
resolvedAddress: undefined,
toAddressError: undefined,
- toAddressErrorAllowAcknowledge: false,
toAddressValidated: undefined,
toAddressWarning: undefined,
});
@@ -64,7 +63,6 @@ describe('useToAddressValidation', () => {
loading: false,
resolvedAddress: undefined,
toAddressError: undefined,
- toAddressErrorAllowAcknowledge: false,
toAddressValidated: undefined,
toAddressWarning: undefined,
});
@@ -92,7 +90,6 @@ describe('useToAddressValidation', () => {
loading: false,
resolvedAddress: undefined,
toAddressError: 'Invalid address',
- toAddressErrorAllowAcknowledge: false,
toAddressValidated: '0x123',
toAddressWarning: undefined,
});
@@ -114,7 +111,6 @@ describe('useToAddressValidation', () => {
loading: false,
resolvedAddress: undefined,
toAddressError: 'Invalid address',
- toAddressErrorAllowAcknowledge: false,
toAddressValidated: 'dummy',
toAddressWarning: undefined,
});
@@ -143,7 +139,6 @@ describe('useToAddressValidation', () => {
loading: false,
resolvedAddress: undefined,
toAddressError: 'Invalid address',
- toAddressErrorAllowAcknowledge: false,
toAddressValidated: 'dummy',
toAddressWarning: undefined,
});
diff --git a/app/components/Views/confirmations/hooks/send/useToAddressValidation.ts b/app/components/Views/confirmations/hooks/send/useToAddressValidation.ts
index bdd9eca301d..40b4a22ee91 100644
--- a/app/components/Views/confirmations/hooks/send/useToAddressValidation.ts
+++ b/app/components/Views/confirmations/hooks/send/useToAddressValidation.ts
@@ -20,7 +20,6 @@ interface ValidationResult {
error?: string;
warning?: string;
resolvedAddress?: string;
- allowAcknowledge?: boolean;
}
export const useToAddressValidation = () => {
@@ -113,14 +112,12 @@ export const useToAddressValidation = () => {
error,
warning: toAddressWarning,
resolvedAddress,
- allowAcknowledge,
} = result ?? {};
return {
loading,
resolvedAddress,
toAddressError: error,
- toAddressErrorAllowAcknowledge: allowAcknowledge === true,
toAddressValidated,
toAddressWarning,
};
diff --git a/app/components/Views/confirmations/utils/send-address-validations.test.ts b/app/components/Views/confirmations/utils/send-address-validations.test.ts
index 48d3c05ae5d..99604d29ffe 100644
--- a/app/components/Views/confirmations/utils/send-address-validations.test.ts
+++ b/app/components/Views/confirmations/utils/send-address-validations.test.ts
@@ -7,8 +7,6 @@ import {
validateSolanaAddress,
validateTronAddress,
} from './send-address-validations';
-import { memoizedGetTokenStandardAndDetails, TokenDetailsERC20 } from './token';
-
jest.mock('./token', () => ({
memoizedGetTokenStandardAndDetails: jest.fn().mockResolvedValue(undefined),
}));
@@ -21,10 +19,6 @@ jest.mock('../../../../core/Engine', () => ({
},
}));
-const mockMemoizedGetTokenStandardAndDetails = jest.mocked(
- memoizedGetTokenStandardAndDetails,
-);
-
describe('validateHexAddress', () => {
it('returns error if address is burn address', async () => {
expect(
@@ -70,20 +64,13 @@ describe('validateHexAddress', () => {
).toStrictEqual({});
});
- it('returns warning if address is contract address', async () => {
- mockMemoizedGetTokenStandardAndDetails.mockResolvedValue({
- standard: 'ERC20',
- } as unknown as TokenDetailsERC20);
+ it('does not flag token contract addresses (handled in send flow alerts)', async () => {
expect(
await validateHexAddress(
'0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477',
'0x1',
),
- ).toStrictEqual({
- allowAcknowledge: true,
- error:
- 'This address is a token contract address. If you send tokens to this address, you will lose them.',
- });
+ ).toStrictEqual({});
});
});
diff --git a/app/components/Views/confirmations/utils/send-address-validations.ts b/app/components/Views/confirmations/utils/send-address-validations.ts
index d3cb04483c8..e9a4dff7645 100644
--- a/app/components/Views/confirmations/utils/send-address-validations.ts
+++ b/app/components/Views/confirmations/utils/send-address-validations.ts
@@ -2,14 +2,11 @@ import { Hex } from '@metamask/utils';
import { isAddress as isSolanaAddress } from '@solana/addresses';
import { strings } from '../../../../../locales/i18n';
-import Engine from '../../../../core/Engine';
-import { toChecksumAddress } from '../../../../util/address';
import {
collectConfusables,
getConfusablesExplanations,
hasZeroWidthPoints,
} from '../../../../util/confusables';
-import { memoizedGetTokenStandardAndDetails } from './token';
import {
isBtcMainnetAddress,
isTronAddress,
@@ -38,7 +35,6 @@ export const validateHexAddress = async (
): Promise<{
error?: string;
warning?: string;
- allowAcknowledge?: boolean;
}> => {
if (LOWER_CASED_BURN_ADDRESSES.includes(toAddress?.toLowerCase())) {
return {
@@ -52,30 +48,6 @@ export const validateHexAddress = async (
};
}
- const checksummedAddress = toChecksumAddress(toAddress);
- if (chainId) {
- const { NetworkController } = Engine.context;
-
- try {
- const networkClientId = NetworkController.findNetworkClientIdByChainId(
- chainId as Hex,
- );
- const token = await memoizedGetTokenStandardAndDetails({
- tokenAddress: checksummedAddress,
- tokenId: undefined,
- userAddress: undefined,
- networkClientId,
- });
- if (token?.standard) {
- return {
- error: strings('send.token_contract_warning'),
- allowAcknowledge: true,
- };
- }
- } catch {
- // Not a token address
- }
- }
return {};
};
diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts
index 323016d1752..c3851369f74 100644
--- a/app/constants/navigation/Routes.ts
+++ b/app/constants/navigation/Routes.ts
@@ -111,6 +111,8 @@ const Routes = {
REWARDS_SEASON_ONE_CAMPAIGN_DETAILS_VIEW: 'RewardsSeasonOneCampaignDetails',
REWARDS_CAMPAIGN_MECHANICS: 'RewardsCampaignMechanics',
REWARDS_ONDO_CAMPAIGN_LEADERBOARD: 'RewardsOndoCampaignLeaderboard',
+ REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW:
+ 'RewardsPerpsTradingCampaignWinning',
REWARDS_ONDO_CAMPAIGN_RWA_ASSET_SELECTOR: 'RewardsOndoRwaAssetSelector',
REWARDS_ONDO_CAMPAIGN_PORTFOLIO_VIEW: 'RewardsOndoCampaignPortfolioView',
REWARDS_ONDO_CAMPAIGN_STATS: 'RewardsOndoCampaignStats',
diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts b/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts
index be1f9d95265..05948debe06 100644
--- a/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts
+++ b/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts
@@ -514,6 +514,19 @@ export type RewardsControllerGetOndoCampaignDepositsAction = {
handler: RewardsController['getOndoCampaignDeposits'];
};
+/**
+ * Fetch the participant outcome for the current user in a completed Perps Trading campaign.
+ * Results are cached for 10 minutes using a private in-memory Map.
+ *
+ * @param campaignId - The campaign ID.
+ * @param subscriptionId - The subscription ID for authentication.
+ * @returns The participant outcome DTO, or null if unavailable.
+ */
+export type RewardsControllerGetPerpsTradingCampaignParticipantOutcomeAction = {
+ type: `RewardsController:getPerpsTradingCampaignParticipantOutcome`;
+ handler: RewardsController['getPerpsTradingCampaignParticipantOutcome'];
+};
+
/**
* Get the current user's position on the campaign leaderboard.
* This is an authenticated endpoint.
@@ -797,6 +810,7 @@ export type RewardsControllerMethodActions =
| RewardsControllerGetCampaignParticipantStatusAction
| RewardsControllerGetOndoCampaignLeaderboardAction
| RewardsControllerGetOndoCampaignDepositsAction
+ | RewardsControllerGetPerpsTradingCampaignParticipantOutcomeAction
| RewardsControllerGetOndoCampaignLeaderboardPositionAction
| RewardsControllerGetOndoCampaignParticipantOutcomeAction
| RewardsControllerGetOndoCampaignPortfolioPositionAction
diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts
index ae1187bb7ad..f9b379bbe7a 100644
--- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts
+++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts
@@ -20489,4 +20489,159 @@ describe('RewardsController', () => {
);
});
});
+
+ describe('getPerpsTradingCampaignParticipantOutcome', () => {
+ let perpsParticipantOutcomeMessenger: jest.Mocked;
+ const mockCampaignId = 'perps-outcome-campaign-1';
+ const mockSubscriptionId = 'sub-perps-outcome-1';
+ const mockOutcome = {
+ subscriptionId: mockSubscriptionId,
+ outcomeStatus: 'pending' as const,
+ winnerVerificationCode: 'VERIFY-123',
+ rank: 1,
+ };
+
+ beforeEach(() => {
+ perpsParticipantOutcomeMessenger = {
+ subscribe: jest.fn(),
+ call: jest.fn(),
+ registerActionHandler: jest.fn(),
+ registerMethodActionHandlers: jest.fn(),
+ unregisterActionHandler: jest.fn(),
+ publish: jest.fn(),
+ clearEventSubscriptions: jest.fn(),
+ registerInitialEventPayload: jest.fn(),
+ unsubscribe: jest.fn(),
+ } as unknown as jest.Mocked;
+ });
+
+ it('returns null when rewards feature flag is disabled', async () => {
+ const disabledController = new RewardsController({
+ messenger: perpsParticipantOutcomeMessenger,
+ state: getRewardsControllerDefaultState(),
+ isDisabled: () => true,
+ });
+
+ const result =
+ await disabledController.getPerpsTradingCampaignParticipantOutcome(
+ mockCampaignId,
+ mockSubscriptionId,
+ );
+
+ expect(result).toBeNull();
+ expect(perpsParticipantOutcomeMessenger.call).not.toHaveBeenCalled();
+ });
+
+ it('fetches outcome from API and caches result', async () => {
+ const ctrl = new RewardsController({
+ messenger: perpsParticipantOutcomeMessenger,
+ state: getRewardsControllerDefaultState(),
+ });
+
+ perpsParticipantOutcomeMessenger.call.mockResolvedValue(mockOutcome);
+
+ const result = await ctrl.getPerpsTradingCampaignParticipantOutcome(
+ mockCampaignId,
+ mockSubscriptionId,
+ );
+
+ expect(perpsParticipantOutcomeMessenger.call).toHaveBeenCalledWith(
+ 'RewardsDataService:getPerpsTradingCampaignParticipantOutcome',
+ mockCampaignId,
+ mockSubscriptionId,
+ );
+ expect(result).toEqual(mockOutcome);
+ });
+
+ it('returns cached outcome on second call within TTL', async () => {
+ const ctrl = new RewardsController({
+ messenger: perpsParticipantOutcomeMessenger,
+ state: getRewardsControllerDefaultState(),
+ });
+
+ perpsParticipantOutcomeMessenger.call.mockResolvedValue(mockOutcome);
+
+ await ctrl.getPerpsTradingCampaignParticipantOutcome(
+ mockCampaignId,
+ mockSubscriptionId,
+ );
+
+ perpsParticipantOutcomeMessenger.call.mockClear();
+
+ const result = await ctrl.getPerpsTradingCampaignParticipantOutcome(
+ mockCampaignId,
+ mockSubscriptionId,
+ );
+
+ expect(result).toEqual(mockOutcome);
+ expect(perpsParticipantOutcomeMessenger.call).not.toHaveBeenCalled();
+ });
+
+ it('returns null when API returns null and does not cache', async () => {
+ const ctrl = new RewardsController({
+ messenger: perpsParticipantOutcomeMessenger,
+ state: getRewardsControllerDefaultState(),
+ });
+
+ perpsParticipantOutcomeMessenger.call.mockResolvedValue(null);
+
+ const result = await ctrl.getPerpsTradingCampaignParticipantOutcome(
+ mockCampaignId,
+ mockSubscriptionId,
+ );
+
+ expect(result).toBeNull();
+
+ perpsParticipantOutcomeMessenger.call.mockClear();
+ perpsParticipantOutcomeMessenger.call.mockResolvedValue(mockOutcome);
+ const second = await ctrl.getPerpsTradingCampaignParticipantOutcome(
+ mockCampaignId,
+ mockSubscriptionId,
+ );
+ expect(perpsParticipantOutcomeMessenger.call).toHaveBeenCalledTimes(1);
+ expect(second).toEqual(mockOutcome);
+ });
+
+ it('returns null and logs on API error', async () => {
+ const ctrl = new RewardsController({
+ messenger: perpsParticipantOutcomeMessenger,
+ state: getRewardsControllerDefaultState(),
+ });
+
+ perpsParticipantOutcomeMessenger.call.mockRejectedValue(
+ new Error('Perps API error'),
+ );
+ mockLogger.error.mockClear();
+
+ const result = await ctrl.getPerpsTradingCampaignParticipantOutcome(
+ mockCampaignId,
+ mockSubscriptionId,
+ );
+
+ expect(result).toBeNull();
+ expect(mockLogger.error).toHaveBeenCalledWith(
+ expect.any(Error),
+ 'RewardsController: Failed to fetch Perps Trading participant outcome',
+ );
+ });
+
+ it('logs when fetching fresh outcome', async () => {
+ const ctrl = new RewardsController({
+ messenger: perpsParticipantOutcomeMessenger,
+ state: getRewardsControllerDefaultState(),
+ });
+
+ perpsParticipantOutcomeMessenger.call.mockResolvedValue(mockOutcome);
+ mockLogger.log.mockClear();
+
+ await ctrl.getPerpsTradingCampaignParticipantOutcome(
+ mockCampaignId,
+ mockSubscriptionId,
+ );
+
+ expect(mockLogger.log).toHaveBeenCalledWith(
+ 'RewardsController: Fetching Perps Trading campaign participant outcome',
+ );
+ });
+ });
});
diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts
index 33aa24818e1..65056cd9adb 100644
--- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts
+++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts
@@ -33,6 +33,7 @@ import {
type PerpsTradingCampaignLeaderboardPositionDto,
type PerpsTradingCampaignVolumeDto,
type PaginatedOndoGmActivityDto,
+ type PerpsTradingCampaignParticipantOutcomeDto,
type OndoGmActivityState,
type PointsEstimateHistoryEntry,
ClaimRewardDto,
@@ -154,6 +155,9 @@ const PERPS_TRADING_CAMPAIGN_LEADERBOARD_POSITION_CACHE_THRESHOLD_MS =
// Perps Trading Campaign volume cache threshold
const PERPS_TRADING_CAMPAIGN_VOLUME_CACHE_THRESHOLD_MS = 1000 * 60 * 1; // 1 minute
+// Perps Trading participant outcome cache threshold
+const PERPS_TRADING_PARTICIPANT_OUTCOME_CACHE_THRESHOLD_MS = 1000 * 60 * 10; // 10 minutes
+
// Opt-in status stale threshold for not opted-in accounts to force a fresh check
const NOT_OPTED_IN_OIS_STALE_CACHE_THRESHOLD_MS = 1000 * 60 * 60; // 1 hour
@@ -447,6 +451,7 @@ const MESSENGER_EXPOSED_METHODS = [
'getPerpsTradingCampaignLeaderboardPosition',
'getPerpsTradingCampaignVolume',
'getOptInStatus',
+ 'getPerpsTradingCampaignParticipantOutcome',
'getPerpsDiscountForAccount',
'getPointsEvents',
'getPointsEventsIfChanged',
@@ -503,6 +508,13 @@ export class RewardsController extends BaseController<
#isBitcoinOptinEnabled: () => boolean;
#isTronOptinEnabled: () => boolean;
#reauthPromises: Map> = new Map();
+ #perpsTradingParticipantOutcomeCache: Map<
+ string,
+ {
+ payload: PerpsTradingCampaignParticipantOutcomeDto | null;
+ lastFetched: number;
+ }
+ > = new Map();
/**
* Calculate tier status and next tier information
@@ -3695,6 +3707,58 @@ export class RewardsController extends BaseController<
return result;
}
+ /**
+ * Fetch the participant outcome for the current user in a completed Perps Trading campaign.
+ * Results are cached for 10 minutes using a private in-memory Map.
+ * @param campaignId - The campaign ID.
+ * @param subscriptionId - The subscription ID for authentication.
+ * @returns The participant outcome DTO, or null if unavailable.
+ */
+ async getPerpsTradingCampaignParticipantOutcome(
+ campaignId: string,
+ subscriptionId: string,
+ ): Promise {
+ if (!this.isRewardsFeatureEnabled()) {
+ return null;
+ }
+ const key = `${subscriptionId}:${campaignId}`;
+ try {
+ return await wrapWithCache(
+ {
+ key,
+ ttl: PERPS_TRADING_PARTICIPANT_OUTCOME_CACHE_THRESHOLD_MS,
+ readCache: (k) =>
+ this.#perpsTradingParticipantOutcomeCache.get(k) ?? undefined,
+ fetchFresh: async () =>
+ this.#withAuthRetry(async () => {
+ Logger.log(
+ 'RewardsController: Fetching Perps Trading campaign participant outcome',
+ );
+ return this.messenger.call(
+ 'RewardsDataService:getPerpsTradingCampaignParticipantOutcome',
+ campaignId,
+ subscriptionId,
+ );
+ }, subscriptionId),
+ writeCache: (k, payload) => {
+ if (payload !== null) {
+ this.#perpsTradingParticipantOutcomeCache.set(k, {
+ payload,
+ lastFetched: Date.now(),
+ });
+ }
+ },
+ },
+ );
+ } catch (error) {
+ Logger.error(
+ error as Error,
+ 'RewardsController: Failed to fetch Perps Trading participant outcome',
+ );
+ return null;
+ }
+ }
+
/**
* Get the current user's position on the campaign leaderboard.
* This is an authenticated endpoint.
diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts
index a7c8b574492..03f5e90af95 100644
--- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts
+++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts
@@ -5088,6 +5088,59 @@ describe('RewardsDataService', () => {
});
});
+ describe('getPerpsTradingCampaignParticipantOutcome', () => {
+ const mockCampaignId = 'perps-outcome-campaign-1';
+ const mockSubscriptionId = 'sub-perps-outcome-1';
+ const mockToken = 'test-bearer-token';
+ const mockOutcome = {
+ subscriptionId: mockSubscriptionId,
+ outcomeStatus: 'finalized' as const,
+ winnerVerificationCode: null,
+ rank: 3,
+ };
+
+ beforeEach(() => {
+ mockGetSubscriptionToken.mockResolvedValue({
+ success: true,
+ token: mockToken,
+ });
+ });
+
+ it('calls the authenticated perps outcome endpoint and returns data', async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: jest.fn().mockResolvedValue(mockOutcome),
+ } as unknown as Response);
+
+ const result = await service.getPerpsTradingCampaignParticipantOutcome(
+ mockCampaignId,
+ mockSubscriptionId,
+ );
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ `https://uat.rewards.test/perps-trading/${mockCampaignId}/outcome/me`,
+ expect.objectContaining({
+ method: 'GET',
+ headers: expect.objectContaining({
+ 'rewards-access-token': mockToken,
+ }),
+ }),
+ );
+ expect(result).toEqual(mockOutcome);
+ });
+
+ it('throws when response is not ok', async () => {
+ mockFetch.mockResolvedValue({ ok: false, status: 401 } as Response);
+
+ await expect(
+ service.getPerpsTradingCampaignParticipantOutcome(
+ mockCampaignId,
+ mockSubscriptionId,
+ ),
+ ).rejects.toThrow('Get Perps Trading participant outcome failed: 401');
+ });
+ });
+
describe('getPerpsTradingCampaignLeaderboard', () => {
const mockCampaignId = 'perps-campaign-api-1';
const mockLeaderboard = {
diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts
index 1170e963d60..28b22224532 100644
--- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts
+++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts
@@ -38,6 +38,7 @@ import type {
PerpsTradingCampaignLeaderboardDto,
PerpsTradingCampaignLeaderboardPositionDto,
PerpsTradingCampaignVolumeDto,
+ PerpsTradingCampaignParticipantOutcomeDto,
} from '../types';
import { getSubscriptionToken } from '../utils/multi-subscription-token-vault';
import Logger from '../../../../../util/Logger';
@@ -280,6 +281,10 @@ export interface RewardsDataServiceGetPerpsTradingCampaignVolumeAction {
type: `${typeof SERVICE_NAME}:getPerpsTradingCampaignVolume`;
handler: RewardsDataService['getPerpsTradingCampaignVolume'];
}
+export interface RewardsDataServiceGetPerpsTradingCampaignParticipantOutcomeAction {
+ type: `${typeof SERVICE_NAME}:getPerpsTradingCampaignParticipantOutcome`;
+ handler: RewardsDataService['getPerpsTradingCampaignParticipantOutcome'];
+}
export interface RewardsDataServiceGetRewardsEnvUrlAction {
type: `${typeof SERVICE_NAME}:getRewardsEnvUrl`;
@@ -355,7 +360,8 @@ export type RewardsDataServiceActions =
| RewardsDataServiceGetOndoCampaignParticipantOutcomeAction
| RewardsDataServiceGetPerpsTradingCampaignLeaderboardAction
| RewardsDataServiceGetPerpsTradingCampaignLeaderboardPositionAction
- | RewardsDataServiceGetPerpsTradingCampaignVolumeAction;
+ | RewardsDataServiceGetPerpsTradingCampaignVolumeAction
+ | RewardsDataServiceGetPerpsTradingCampaignParticipantOutcomeAction;
export type RewardsDataServiceMessenger = Messenger<
typeof SERVICE_NAME,
@@ -542,6 +548,10 @@ export class RewardsDataService {
`${SERVICE_NAME}:getPerpsTradingCampaignVolume`,
this.getPerpsTradingCampaignVolume.bind(this),
);
+ this.#messenger.registerActionHandler(
+ `${SERVICE_NAME}:getPerpsTradingCampaignParticipantOutcome`,
+ this.getPerpsTradingCampaignParticipantOutcome.bind(this),
+ );
this.#messenger.registerActionHandler(
`${SERVICE_NAME}:getRewardsEnvUrl`,
this.getRewardsEnvUrl.bind(this),
@@ -1827,4 +1837,22 @@ export class RewardsDataService {
return (await response.json()) as PerpsTradingCampaignVolumeDto;
}
+
+ async getPerpsTradingCampaignParticipantOutcome(
+ campaignId: string,
+ subscriptionId: string,
+ ): Promise {
+ const response = await this.makeRequest(
+ `/perps-trading/${campaignId}/outcome/me`,
+ { method: 'GET' },
+ subscriptionId,
+ );
+ if (!response.ok) {
+ throw new Error(
+ `Get Perps Trading participant outcome failed: ${response.status}`,
+ );
+ }
+
+ return (await response.json()) as PerpsTradingCampaignParticipantOutcomeDto;
+ }
}
diff --git a/app/core/Engine/controllers/rewards-controller/types.ts b/app/core/Engine/controllers/rewards-controller/types.ts
index 16cd347e36f..be6feb66841 100644
--- a/app/core/Engine/controllers/rewards-controller/types.ts
+++ b/app/core/Engine/controllers/rewards-controller/types.ts
@@ -93,8 +93,8 @@ export interface ApplyBonusCodeDto {
*/
export enum CampaignType {
ONDO_HOLDING = 'ONDO_HOLDING',
- SEASON_1 = 'SEASON_1',
PERPS_TRADING = 'PERPS_TRADING',
+ SEASON_1 = 'SEASON_1',
}
/**
@@ -567,16 +567,33 @@ export type OndoGmCampaignDepositsDto = {
totalUsdDeposited: string;
};
-export type OndoGmCampaignParticipantOutcomeStatus = 'pending' | 'finalized';
+export type CampaignParticipantOutcomeStatus = 'pending' | 'finalized';
-export interface OndoGmCampaignParticipantOutcomeDto {
+export interface BaseCampaignParticipantOutcomeDto {
subscriptionId: string;
- outcomeStatus: OndoGmCampaignParticipantOutcomeStatus;
+ outcomeStatus: CampaignParticipantOutcomeStatus;
winnerVerificationCode?: string | null;
+}
+
+/** @deprecated Use CampaignParticipantOutcomeStatus */
+export type OndoGmCampaignParticipantOutcomeStatus =
+ CampaignParticipantOutcomeStatus;
+
+export interface OndoGmCampaignParticipantOutcomeDto
+ extends BaseCampaignParticipantOutcomeDto {
tierRank?: number;
tier?: string;
}
+/** @deprecated Use CampaignParticipantOutcomeStatus */
+export type PerpsTradingCampaignParticipantOutcomeStatus =
+ CampaignParticipantOutcomeStatus;
+
+export interface PerpsTradingCampaignParticipantOutcomeDto
+ extends BaseCampaignParticipantOutcomeDto {
+ rank?: number | null;
+}
+
/**
* Cached portfolio payload (explicit shape for Json / StateConstraint compatibility).
*/
diff --git a/app/core/Engine/messengers/rewards-controller-messenger/index.ts b/app/core/Engine/messengers/rewards-controller-messenger/index.ts
index 043c69b146c..d6e48449142 100644
--- a/app/core/Engine/messengers/rewards-controller-messenger/index.ts
+++ b/app/core/Engine/messengers/rewards-controller-messenger/index.ts
@@ -71,6 +71,7 @@ import {
RewardsDataServiceGetPerpsTradingCampaignLeaderboardAction,
RewardsDataServiceGetPerpsTradingCampaignLeaderboardPositionAction,
RewardsDataServiceGetPerpsTradingCampaignVolumeAction,
+ RewardsDataServiceGetPerpsTradingCampaignParticipantOutcomeAction,
} from '../../controllers/rewards-controller/services/rewards-data-service';
import { RootMessenger } from '../../types';
@@ -126,7 +127,8 @@ type AllowedActions =
| RewardsDataServiceGetOndoCampaignParticipantOutcomeAction
| RewardsDataServiceGetPerpsTradingCampaignLeaderboardAction
| RewardsDataServiceGetPerpsTradingCampaignLeaderboardPositionAction
- | RewardsDataServiceGetPerpsTradingCampaignVolumeAction;
+ | RewardsDataServiceGetPerpsTradingCampaignVolumeAction
+ | RewardsDataServiceGetPerpsTradingCampaignParticipantOutcomeAction;
// Don't reexport as per guidelines
type AllowedEvents =
@@ -217,6 +219,7 @@ export function getRewardsControllerMessenger(
'RewardsDataService:getPerpsTradingCampaignLeaderboard',
'RewardsDataService:getPerpsTradingCampaignLeaderboardPosition',
'RewardsDataService:getPerpsTradingCampaignVolume',
+ 'RewardsDataService:getPerpsTradingCampaignParticipantOutcome',
],
events: [
'AccountTreeController:selectedAccountGroupChange',
diff --git a/app/reducers/rewards/index.test.ts b/app/reducers/rewards/index.test.ts
index 9ee33ec2753..59079e538a4 100644
--- a/app/reducers/rewards/index.test.ts
+++ b/app/reducers/rewards/index.test.ts
@@ -5857,35 +5857,33 @@ describe('ondoCampaignDeposits', () => {
});
describe('dismissCampaignOutcomeToast', () => {
- it('records winner_verify variant as dismissed', () => {
+ it('records winner variant as dismissed', () => {
const state = rewardsReducer(
initialState,
dismissCampaignOutcomeToast({
- campaignId: 'campaign-1',
- subscriptionId: 'sub-1',
- variant: 'winner_verify',
+ campaignId: 'perps-c-1',
+ subscriptionId: 'sub-9',
+ variant: 'winner',
}),
);
expect(
- state.dismissedCampaignOutcomeToasts['campaign-1:sub-1:winner_verify'],
+ state.dismissedCampaignOutcomeToasts['perps-c-1:sub-9:winner'],
).toBe(true);
});
- it('records participant_no_winner variant as dismissed', () => {
+ it('records non_winner variant as dismissed', () => {
const state = rewardsReducer(
initialState,
dismissCampaignOutcomeToast({
- campaignId: 'campaign-2',
- subscriptionId: 'sub-2',
- variant: 'participant_no_winner',
+ campaignId: 'ondo-c-1',
+ subscriptionId: 'sub-8',
+ variant: 'non_winner',
}),
);
expect(
- state.dismissedCampaignOutcomeToasts[
- 'campaign-2:sub-2:participant_no_winner'
- ],
+ state.dismissedCampaignOutcomeToasts['ondo-c-1:sub-8:non_winner'],
).toBe(true);
});
@@ -5895,7 +5893,7 @@ describe('ondoCampaignDeposits', () => {
dismissCampaignOutcomeToast({
campaignId: 'c1',
subscriptionId: 's1',
- variant: 'winner_verify',
+ variant: 'winner',
}),
);
state = rewardsReducer(
@@ -5903,16 +5901,14 @@ describe('ondoCampaignDeposits', () => {
dismissCampaignOutcomeToast({
campaignId: 'c2',
subscriptionId: 's2',
- variant: 'participant_no_winner',
+ variant: 'non_winner',
}),
);
- expect(state.dismissedCampaignOutcomeToasts['c1:s1:winner_verify']).toBe(
+ expect(state.dismissedCampaignOutcomeToasts['c1:s1:winner']).toBe(true);
+ expect(state.dismissedCampaignOutcomeToasts['c2:s2:non_winner']).toBe(
true,
);
- expect(
- state.dismissedCampaignOutcomeToasts['c2:s2:participant_no_winner'],
- ).toBe(true);
});
it('starts with empty dismissedCampaignOutcomeToasts in initial state', () => {
@@ -5925,7 +5921,7 @@ describe('ondoCampaignDeposits', () => {
const persisted: RewardsState = {
...initialState,
dismissedCampaignOutcomeToasts: {
- 'campaign-1:sub-1:winner_verify': true,
+ 'campaign-1:sub-1:winner': true,
},
};
@@ -5935,7 +5931,7 @@ describe('ondoCampaignDeposits', () => {
});
expect(state.dismissedCampaignOutcomeToasts).toEqual({
- 'campaign-1:sub-1:winner_verify': true,
+ 'campaign-1:sub-1:winner': true,
});
});
@@ -5958,7 +5954,7 @@ describe('ondoCampaignDeposits', () => {
...initialState,
candidateSubscriptionId: 'old-sub',
dismissedCampaignOutcomeToasts: {
- 'campaign-1:old-sub:winner_verify': true,
+ 'campaign-1:old-sub:winner': true,
},
};
@@ -5968,7 +5964,7 @@ describe('ondoCampaignDeposits', () => {
);
expect(state.dismissedCampaignOutcomeToasts).toEqual({
- 'campaign-1:old-sub:winner_verify': true,
+ 'campaign-1:old-sub:winner': true,
});
});
});
diff --git a/app/reducers/rewards/index.ts b/app/reducers/rewards/index.ts
index b733c5c10d8..e35e437aaa3 100644
--- a/app/reducers/rewards/index.ts
+++ b/app/reducers/rewards/index.ts
@@ -866,7 +866,7 @@ const rewardsSlice = createSlice({
action: PayloadAction<{
campaignId: string;
subscriptionId: string;
- variant: 'winner_verify' | 'participant_no_winner';
+ variant: 'winner' | 'non_winner';
}>,
) => {
const key = `${action.payload.campaignId}:${action.payload.subscriptionId}:${action.payload.variant}`;
diff --git a/app/reducers/rewards/selectors.test.ts b/app/reducers/rewards/selectors.test.ts
index 9bac9897bab..9519b4cb778 100644
--- a/app/reducers/rewards/selectors.test.ts
+++ b/app/reducers/rewards/selectors.test.ts
@@ -51,6 +51,7 @@ import {
selectCampaignsError,
selectCampaignParticipantStatuses,
selectCampaignParticipantStatus,
+ selectCampaignParticipantOptedIn,
selectCampaignParticipantCount,
selectIsRewardsVersionBlocked,
selectVersionGuardMinimumMobileVersion,
@@ -3285,6 +3286,39 @@ describe('Rewards selectors', () => {
});
});
+ describe('selectCampaignParticipantOptedIn', () => {
+ it('returns true when participant status is opted in', () => {
+ const state = createMockRootState({
+ campaignParticipantStatuses: {
+ 'sub-1:campaign-1': { optedIn: true, participantCount: 42 },
+ },
+ });
+ expect(
+ selectCampaignParticipantOptedIn('sub-1', 'campaign-1')(state),
+ ).toBe(true);
+ });
+
+ it('returns false when participant status is not opted in', () => {
+ const state = createMockRootState({
+ campaignParticipantStatuses: {
+ 'sub-1:campaign-1': { optedIn: false, participantCount: 0 },
+ },
+ });
+ expect(
+ selectCampaignParticipantOptedIn('sub-1', 'campaign-1')(state),
+ ).toBe(false);
+ });
+
+ it('returns false when status is missing', () => {
+ const state = createMockRootState({
+ campaignParticipantStatuses: {},
+ });
+ expect(
+ selectCampaignParticipantOptedIn('sub-1', 'campaign-1')(state),
+ ).toBe(false);
+ });
+ });
+
describe('selectCampaignParticipantCount', () => {
it('returns null when subscriptionId is undefined', () => {
const state = createMockRootState({
@@ -3829,8 +3863,8 @@ describe('Rewards selectors', () => {
it('returns the dismissed toasts map', () => {
const dismissed = {
- 'campaign-1:sub-1:winner_verify': true,
- 'campaign-2:sub-1:participant_no_winner': true,
+ 'campaign-1:sub-1:winner': true,
+ 'campaign-2:sub-1:non_winner': true,
};
const state = createMockRootState({
dismissedCampaignOutcomeToasts: dismissed,
@@ -3841,11 +3875,11 @@ describe('Rewards selectors', () => {
it('returns true for a dismissed toast key', () => {
const state = createMockRootState({
dismissedCampaignOutcomeToasts: {
- 'campaign-1:sub-1:winner_verify': true,
+ 'campaign-1:sub-1:winner': true,
},
});
const result = selectDismissedCampaignOutcomeToasts(state);
- expect(result['campaign-1:sub-1:winner_verify']).toBe(true);
+ expect(result['campaign-1:sub-1:winner']).toBe(true);
});
it('returns undefined for a key that has not been dismissed', () => {
@@ -3853,7 +3887,7 @@ describe('Rewards selectors', () => {
dismissedCampaignOutcomeToasts: {},
});
const result = selectDismissedCampaignOutcomeToasts(state);
- expect(result['campaign-1:sub-1:winner_verify']).toBeUndefined();
+ expect(result['campaign-1:sub-1:winner']).toBeUndefined();
});
});
});
diff --git a/app/reducers/rewards/selectors.ts b/app/reducers/rewards/selectors.ts
index 78fc6bc1d76..83c12f18cc9 100644
--- a/app/reducers/rewards/selectors.ts
+++ b/app/reducers/rewards/selectors.ts
@@ -188,6 +188,15 @@ export const selectCampaignParticipantStatus =
return state.rewards.campaignParticipantStatuses?.[key] ?? null;
};
+export const selectCampaignParticipantOptedIn =
+ (
+ subscriptionId: string | undefined | null,
+ campaignId: string | undefined | null,
+ ) =>
+ (state: RootState): boolean =>
+ selectCampaignParticipantStatus(subscriptionId, campaignId)(state)
+ ?.optedIn === true;
+
export const selectCampaignParticipantCount =
(subscriptionId: string | undefined, campaignId: string | undefined) =>
(state: RootState) => {
diff --git a/app/selectors/rewards/index.ts b/app/selectors/rewards/index.ts
index c4475cbb113..ca99456407f 100644
--- a/app/selectors/rewards/index.ts
+++ b/app/selectors/rewards/index.ts
@@ -43,17 +43,6 @@ export const selectRewardsSubscriptionId = createSelector(
},
);
-export const selectCampaignParticipantOptedIn =
- (subscriptionId: string | null, campaignId: string | undefined) =>
- (state: RootState): boolean => {
- if (!subscriptionId || !campaignId) return false;
- return (
- state.engine.backgroundState.RewardsController.campaignParticipantStatus[
- `${subscriptionId}:${campaignId}`
- ]?.optedIn === true
- );
- };
-
export const selectRewardsActiveAccountAddress = createSelector(
selectRewardsControllerState,
(rewardsControllerState): string | null => {
diff --git a/app/store/migrations/134.test.ts b/app/store/migrations/134.test.ts
new file mode 100644
index 00000000000..e485c9ca363
--- /dev/null
+++ b/app/store/migrations/134.test.ts
@@ -0,0 +1,168 @@
+import { captureException } from '@sentry/react-native';
+import migrate, { migrationVersion } from './134';
+import { ensureValidState } from './util';
+
+jest.mock('@sentry/react-native', () => ({
+ captureException: jest.fn(),
+}));
+
+jest.mock('./util', () => ({
+ ensureValidState: jest.fn(),
+}));
+
+const mockedEnsureValidState = jest.mocked(ensureValidState);
+const mockedCaptureException = jest.mocked(captureException);
+
+const SEI_MAINNET_CHAIN_ID = '0x531';
+
+interface SeiNetworkConfiguration {
+ blockExplorerUrls: string[];
+ chainId: string;
+ defaultBlockExplorerUrlIndex?: number;
+ defaultRpcEndpointIndex: number;
+ name: string;
+ nativeCurrency: string;
+ rpcEndpoints: {
+ networkClientId: string;
+ url: string;
+ type: string;
+ failoverUrls?: string[];
+ }[];
+}
+
+interface TestState {
+ engine: {
+ backgroundState: {
+ NetworkController?: {
+ networkConfigurationsByChainId?: Record<
+ string,
+ SeiNetworkConfiguration
+ >;
+ selectedNetworkClientId?: string;
+ [key: string]: unknown;
+ };
+ [key: string]: unknown;
+ };
+ };
+ [key: string]: unknown;
+}
+
+function buildSeiConfig(blockExplorerUrls: string[]): SeiNetworkConfiguration {
+ return {
+ blockExplorerUrls,
+ chainId: SEI_MAINNET_CHAIN_ID,
+ defaultBlockExplorerUrlIndex: 0,
+ defaultRpcEndpointIndex: 0,
+ name: 'Sei',
+ nativeCurrency: 'SEI',
+ rpcEndpoints: [
+ {
+ networkClientId: 'sei-mainnet',
+ url: 'https://sei-mainnet.infura.io/v3/fake',
+ type: 'custom',
+ },
+ ],
+ };
+}
+
+function buildState(seiConfig?: SeiNetworkConfiguration): TestState {
+ return {
+ engine: {
+ backgroundState: {
+ NetworkController: {
+ networkConfigurationsByChainId: seiConfig
+ ? { [SEI_MAINNET_CHAIN_ID]: seiConfig }
+ : {},
+ selectedNetworkClientId: 'mainnet',
+ },
+ },
+ },
+ };
+}
+
+describe(`Migration ${migrationVersion}: Replace Seitrace with Seiscan for Sei Mainnet`, () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockedEnsureValidState.mockReturnValue(true);
+ });
+
+ it('reports the expected migration version', () => {
+ expect(migrationVersion).toBe(134);
+ });
+
+ it('rewrites Seitrace block explorer URL to Seiscan for Sei Mainnet', () => {
+ const state = buildState(buildSeiConfig(['https://seitrace.com']));
+
+ const result = migrate(state) as TestState;
+
+ const seiConfig =
+ result.engine.backgroundState.NetworkController
+ ?.networkConfigurationsByChainId?.[SEI_MAINNET_CHAIN_ID];
+ expect(seiConfig?.blockExplorerUrls).toStrictEqual(['https://seiscan.io/']);
+ expect(mockedCaptureException).not.toHaveBeenCalled();
+ });
+
+ it('leaves state unchanged when Sei Mainnet is not configured', () => {
+ const state: TestState = {
+ engine: {
+ backgroundState: {
+ NetworkController: {
+ networkConfigurationsByChainId: {
+ '0x1': buildSeiConfig(['https://etherscan.io']),
+ },
+ selectedNetworkClientId: 'mainnet',
+ },
+ },
+ },
+ };
+ const snapshotBefore = JSON.stringify(state);
+
+ const result = migrate(state);
+
+ expect(JSON.stringify(result)).toBe(snapshotBefore);
+ expect(mockedCaptureException).not.toHaveBeenCalled();
+ });
+
+ it('silently skips when NetworkController is missing (upgrade-from-old-version)', () => {
+ const state = {
+ engine: {
+ backgroundState: {
+ SomeOtherController: { foo: 'bar' },
+ },
+ },
+ };
+ const snapshotBefore = JSON.stringify(state);
+
+ const result = migrate(state);
+
+ expect(JSON.stringify(result)).toBe(snapshotBefore);
+ expect(mockedCaptureException).not.toHaveBeenCalled();
+ });
+
+ it('does not touch user-customized block explorer URLs', () => {
+ const state = buildState(buildSeiConfig(['https://seistream.app']));
+
+ const result = migrate(state) as TestState;
+
+ const seiConfig =
+ result.engine.backgroundState.NetworkController
+ ?.networkConfigurationsByChainId?.[SEI_MAINNET_CHAIN_ID];
+ expect(seiConfig?.blockExplorerUrls).toStrictEqual([
+ 'https://seistream.app',
+ ]);
+ expect(mockedCaptureException).not.toHaveBeenCalled();
+ });
+
+ it('does not rewrite a URL whose host only starts with seitrace.com', () => {
+ const lookalike = 'https://seitrace.com.attacker.example/path';
+ const state = buildState(buildSeiConfig([lookalike]));
+
+ const result = migrate(state) as TestState;
+
+ const seiConfig =
+ result.engine.backgroundState.NetworkController
+ ?.networkConfigurationsByChainId?.[SEI_MAINNET_CHAIN_ID];
+ expect(seiConfig?.blockExplorerUrls).toStrictEqual([lookalike]);
+ expect(mockedCaptureException).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/store/migrations/134.ts b/app/store/migrations/134.ts
new file mode 100644
index 00000000000..c332a0e1b1b
--- /dev/null
+++ b/app/store/migrations/134.ts
@@ -0,0 +1,176 @@
+import { captureException } from '@sentry/react-native';
+import {
+ getErrorMessage,
+ hasProperty,
+ Hex,
+ isHexString,
+ isObject,
+} from '@metamask/utils';
+
+import { ensureValidState } from './util';
+
+/**
+ * Migration 134: replace the deprecated Seitrace block explorer URL
+ * (`seitrace.com`, being decommissioned) with its replacement Seiscan
+ * (`seiscan.io`) for Sei Mainnet on existing user installs.
+ *
+ * Users without Sei Mainnet configured: no-op (silent).
+ * Users who customized the explorer URL away from Seitrace: no-op
+ * (only entries that still point at `seitrace.com` are rewritten).
+ * Users missing `NetworkController` entirely: no-op (silent) — expected
+ * during upgrade-from-old-version.
+ */
+export const migrationVersion = 134;
+
+const SEI_MAINNET_CHAIN_ID: Hex = '0x531'; // 1329
+const OLD_HOSTNAME = 'seitrace.com';
+const NEW_HOSTNAME = 'seiscan.io';
+
+interface RpcEndpoint {
+ failoverUrls?: string[];
+ name?: string;
+ networkClientId: string;
+ url: string;
+ type: string;
+}
+
+interface NetworkConfiguration {
+ blockExplorerUrls: string[];
+ chainId: Hex;
+ defaultBlockExplorerUrlIndex?: number;
+ defaultRpcEndpointIndex: number;
+ name: string;
+ nativeCurrency: string;
+ rpcEndpoints: RpcEndpoint[];
+}
+
+const migration = (state: unknown): unknown => {
+ if (!ensureValidState(state, migrationVersion)) {
+ return state;
+ }
+
+ try {
+ const networkControllerState = validateNetworkController(state);
+ if (networkControllerState === undefined) {
+ return state;
+ }
+
+ const { networkConfigurationsByChainId } = networkControllerState;
+ if (!hasProperty(networkConfigurationsByChainId, SEI_MAINNET_CHAIN_ID)) {
+ return state;
+ }
+
+ const seiConfig = networkConfigurationsByChainId[SEI_MAINNET_CHAIN_ID];
+ if (!isValidNetworkConfiguration(seiConfig)) {
+ return state;
+ }
+
+ const rewritten = seiConfig.blockExplorerUrls.map((url) => {
+ try {
+ const parsed = new URL(url);
+ if (parsed.hostname === OLD_HOSTNAME) {
+ parsed.hostname = NEW_HOSTNAME;
+ return parsed.toString();
+ }
+ } catch {
+ // not a valid URL, leave as-is
+ }
+ return url;
+ });
+ const didChange = rewritten.some(
+ (url, index) => url !== seiConfig.blockExplorerUrls[index],
+ );
+
+ if (didChange) {
+ seiConfig.blockExplorerUrls = rewritten;
+ }
+ } catch (error) {
+ captureException(
+ new Error(
+ `Migration ${migrationVersion}: Failed to rewrite Sei Mainnet block explorer URL: ${getErrorMessage(
+ error,
+ )}`,
+ ),
+ );
+ }
+
+ return state;
+};
+
+export default migration;
+
+// Sentry logging is intentionally omitted — expected-missing states
+// (NetworkController absent, Sei not configured) are not errors.
+function validateNetworkController(state: {
+ engine: { backgroundState: Record };
+}):
+ | {
+ networkConfigurationsByChainId: Record;
+ selectedNetworkClientId: string;
+ }
+ | undefined {
+ if (!hasProperty(state.engine.backgroundState, 'NetworkController')) {
+ // Expected during upgrade-from-old-version — don't log.
+ return undefined;
+ }
+
+ const networkControllerState = state.engine.backgroundState.NetworkController;
+
+ if (!isValidNetworkControllerState(networkControllerState)) {
+ return undefined;
+ }
+
+ return networkControllerState;
+}
+
+function isValidNetworkControllerState(value: unknown): value is {
+ networkConfigurationsByChainId: Record;
+ selectedNetworkClientId: string;
+} {
+ if (!isObject(value)) {
+ return false;
+ }
+
+ if (
+ !hasProperty(value, 'networkConfigurationsByChainId') ||
+ !isValidNetworkConfigurationsByChainId(value.networkConfigurationsByChainId)
+ ) {
+ return false;
+ }
+
+ if (
+ !hasProperty(value, 'selectedNetworkClientId') ||
+ typeof value.selectedNetworkClientId !== 'string'
+ ) {
+ return false;
+ }
+
+ return true;
+}
+
+function isValidNetworkConfigurationsByChainId(
+ value: unknown,
+): value is Record {
+ return (
+ isObject(value) &&
+ Object.entries(value).every(
+ ([chainId]) => typeof chainId === 'string' && isHexString(chainId),
+ )
+ );
+}
+
+// Minimal validator — only chainId + blockExplorerUrls need to be sound
+// for this migration.
+function isValidNetworkConfiguration(
+ object: unknown,
+): object is NetworkConfiguration {
+ return (
+ isObject(object) &&
+ hasProperty(object, 'chainId') &&
+ typeof object.chainId === 'string' &&
+ isHexString(object.chainId) &&
+ hasProperty(object, 'blockExplorerUrls') &&
+ Array.isArray(object.blockExplorerUrls) &&
+ object.blockExplorerUrls.every((url) => typeof url === 'string')
+ );
+}
diff --git a/app/store/migrations/index.ts b/app/store/migrations/index.ts
index 43daf523d7a..947dbb5bdec 100644
--- a/app/store/migrations/index.ts
+++ b/app/store/migrations/index.ts
@@ -134,6 +134,7 @@ import migration130 from './130';
import migration131 from './131';
import migration132 from './132';
import migration133 from './133';
+import migration134 from './134';
// Add migrations above this line
import { ControllerStorage } from '../persistConfig';
@@ -287,6 +288,7 @@ export const migrationList: MigrationsList = {
131: migration131,
132: migration132,
133: migration133,
+ 134: migration134,
};
// Enable both synchronous and asynchronous migrations
diff --git a/app/util/networks/customNetworks.tsx b/app/util/networks/customNetworks.tsx
index a53aaf86221..7c2aaa208d2 100644
--- a/app/util/networks/customNetworks.tsx
+++ b/app/util/networks/customNetworks.tsx
@@ -159,7 +159,7 @@ export const PopularList = [
ticker: 'SEI',
warning: true,
rpcPrefs: {
- blockExplorerUrl: 'https://seitrace.com/',
+ blockExplorerUrl: 'https://seiscan.io/',
imageUrl: 'SEI',
imageSource: require('../../images/sei.png'),
},
diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json
index b6c21b31582..72358bb8306 100644
--- a/app/util/test/initial-background-state.json
+++ b/app/util/test/initial-background-state.json
@@ -421,7 +421,26 @@
"AssetsController": {
"assetPreferences": {},
"assetsBalance": {},
- "assetsInfo": {},
+ "assetsInfo": {
+ "eip155:1/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da": {
+ "decimals": 6,
+ "name": "MetaMask USD",
+ "symbol": "mUSD",
+ "type": "erc20"
+ },
+ "eip155:143/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da": {
+ "decimals": 6,
+ "name": "MetaMask USD",
+ "symbol": "mUSD",
+ "type": "erc20"
+ },
+ "eip155:59144/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da": {
+ "decimals": 6,
+ "name": "MetaMask USD",
+ "symbol": "mUSD",
+ "type": "erc20"
+ }
+ },
"assetsPrice": {},
"customAssets": {},
"selectedCurrency": "usd"
diff --git a/app/util/transaction-controller/index.test.ts b/app/util/transaction-controller/index.test.ts
index 3fa73204871..64827debf4f 100644
--- a/app/util/transaction-controller/index.test.ts
+++ b/app/util/transaction-controller/index.test.ts
@@ -4,6 +4,7 @@ import {
type TransactionMeta,
TransactionEnvelopeType,
IsAtomicBatchSupportedRequest,
+ getAccountAddressRelationship,
} from '@metamask/transaction-controller';
import { cloneDeep, omit } from 'lodash';
//eslint-disable-next-line import-x/no-namespace
@@ -27,9 +28,19 @@ const {
estimateGasFee,
getPreviousGasFromController,
getChainIdFromNetworkClientId,
+ checkFirstTimeInteraction,
...proxyMethods
} = TransactionControllerUtils;
+jest.mock('@metamask/transaction-controller', () => ({
+ ...jest.requireActual('@metamask/transaction-controller'),
+ getAccountAddressRelationship: jest.fn(),
+}));
+
+const mockGetAccountAddressRelationship = jest.mocked(
+ getAccountAddressRelationship,
+);
+
jest.mock('../../store', () => ({
store: {
getState: jest.fn(() => ({
@@ -868,4 +879,37 @@ describe('Transaction Controller Util', () => {
expect(result).toBe(mockResult);
});
});
+
+ describe('checkFirstTimeInteraction', () => {
+ const request = { from: '0xabc', to: '0xdef', chainId: 1 };
+
+ it('returns true when count is 0 (first time)', async () => {
+ mockGetAccountAddressRelationship.mockResolvedValueOnce({ count: 0 });
+ const result = await checkFirstTimeInteraction(request);
+ expect(result).toBe(true);
+ expect(mockGetAccountAddressRelationship).toHaveBeenCalledWith(request);
+ });
+
+ it('returns false when count is greater than 0', async () => {
+ mockGetAccountAddressRelationship.mockResolvedValueOnce({ count: 5 });
+ const result = await checkFirstTimeInteraction(request);
+ expect(result).toBe(false);
+ });
+
+ it('returns undefined when count is undefined', async () => {
+ mockGetAccountAddressRelationship.mockResolvedValueOnce({
+ count: undefined,
+ });
+ const result = await checkFirstTimeInteraction(request);
+ expect(result).toBeUndefined();
+ });
+
+ it('returns undefined when API call throws', async () => {
+ mockGetAccountAddressRelationship.mockRejectedValueOnce(
+ new Error('network error'),
+ );
+ const result = await checkFirstTimeInteraction(request);
+ expect(result).toBeUndefined();
+ });
+ });
});
diff --git a/app/util/transaction-controller/index.ts b/app/util/transaction-controller/index.ts
index 50a7b31f042..247c46bed62 100644
--- a/app/util/transaction-controller/index.ts
+++ b/app/util/transaction-controller/index.ts
@@ -6,6 +6,7 @@ import {
TransactionController as BaseTransactionController,
IsAtomicBatchSupportedRequest,
IsAtomicBatchSupportedResult,
+ getAccountAddressRelationship,
Result,
} from '@metamask/transaction-controller';
import { NetworkClientId } from '@metamask/network-controller';
@@ -330,6 +331,23 @@ export async function isAtomicBatchSupported(
return TransactionController?.isAtomicBatchSupported(request);
}
+/**
+ * Returns whether the sender has no prior on-chain interaction with `to` on `chainId`,
+ * or `undefined` when the relationship cannot be determined (API error or unknown count).
+ */
+export async function checkFirstTimeInteraction(request: {
+ from: string;
+ to: string;
+ chainId: number;
+}): Promise {
+ try {
+ const result = await getAccountAddressRelationship(request);
+ return result.count === undefined ? undefined : result.count === 0;
+ } catch {
+ return undefined;
+ }
+}
+
function sanitizeTransactionParamsGasValues(
transactionId: string,
requestedTransactionParamsToUpdate: Partial,
diff --git a/locales/languages/de.json b/locales/languages/de.json
index 46be6a42eed..28b98585ba4 100644
--- a/locales/languages/de.json
+++ b/locales/languages/de.json
@@ -8658,29 +8658,18 @@
"retry_button": "Erneut versuchen",
"refreshing": "Aktualisierung ..."
},
- "ondo_campaign_winning": {
- "you_won": "Sie haben gewonnen",
- "rank_label": "{{place}} Ort",
- "email_instructions": "Senden Sie eine E-Mail an ondocampaign@consensys.net mit Ihrem Code, um Ihren Preis zu erhalten.",
- "open_mail": "E-Mail öffnen",
- "skip_for_now": "Vorläufig überspringen",
- "mail_subject": "Ondo-Kampagnenpreis einfordern",
- "mail_body": "Mein Gewinncode: {{code}}",
- "winning_code": "Gewinncode",
- "close_a11y": "Schließen"
- },
- "ondo_outcome_banner": {
+ "campaign_outcome_banner": {
"winner_pending": {
- "title": "Sie haben gewonnen",
- "description": "Fordern Sie Ihren Preis noch heute ein.",
- "a11y": "Gewinnerdetails öffnen"
+ "title": "Sie haben gewonnen!",
+ "description": "Überprüfen Sie Ihren Gewinncode und fordern Sie Ihren Preis noch heute ein.",
+ "a11y": "Details anzeigen"
},
"participant_pending": {
"title": "Kampagne ist beendet.",
"description": "Wir ermitteln gerade die Ergebnisse. Schauen Sie bald wieder vorbei."
},
"participant_finalized": {
- "title": "Kampagnenergebnisse sind verfügbar",
+ "title": "Die Kampagnenergebnisse liegen vor.",
"description": "Sie haben dieses Mal nicht gewonnen. Überprüfen Sie die Rangliste, um Ihre Platzierung einzusehen."
},
"winner_finalized": {
@@ -8688,17 +8677,28 @@
"description": "Ihre Belohnung ist unterwegs – wir werden uns in Kürze bei Ihnen melden."
}
},
- "ondo_outcome_toast": {
- "winner_verify": {
+ "campaign_outcome_toast": {
+ "winner": {
"title": "Sie haben gewonnen 💰",
- "description": "Fordern Sie Ihren Preis aus der {{campaignName}} ein",
+ "description": "Fordern Sie Ihren Preis aus der {{campaignName}} noch heute ein.",
"cta": "Details anzeigen"
},
- "participant_no_winner": {
- "title": "Die Ergebnisse sind verfügbar. 🏅",
- "description": "Sehen Sie Ihre Platzierung in der {{campaignName}} ein",
+ "non_winner": {
+ "title": "Die Ergebnisse liegen vor 🏅",
+ "description": "Sehen Sie Ihre Platzierung in der {{campaignName}}.",
"cta": "Ansehen"
}
+ },
+ "campaign_winning": {
+ "you_won": "Sie haben gewonnen",
+ "rank_label": "{{place}} Ort",
+ "email_instructions": "Senden Sie eine E-Mail an {{email}} mit Ihrem Code, um Ihren Preis zu erhalten.",
+ "open_mail": "E-Mail öffnen",
+ "skip_for_now": "Vorläufig überspringen",
+ "mail_subject": "{{campaignName}} – Preis einfordern",
+ "mail_body": "Mein Gewinncode: {{code}}",
+ "winning_code": "Gewinncode",
+ "close_a11y": "Schließen"
}
},
"time": {
diff --git a/locales/languages/el.json b/locales/languages/el.json
index 8df088c6d71..13ec23005cd 100644
--- a/locales/languages/el.json
+++ b/locales/languages/el.json
@@ -8658,29 +8658,18 @@
"retry_button": "Επανάληψη",
"refreshing": "Ανανεώνεται..."
},
- "ondo_campaign_winning": {
- "you_won": "Κερδίσατε",
- "rank_label": "θέση {{place}}",
- "email_instructions": "Στείλτε email στο ondocampaign@consensys.net με τον κωδικό σας για να διεκδικήσετε το βραβείο σας.",
- "open_mail": "Άνοιγμα αλληλογραφίας",
- "skip_for_now": "Παράλειψη για τώρα",
- "mail_subject": "Διεκδίκηση βραβείου στην καμπάνια του Ondo",
- "mail_body": "Κωδικός νίκης: {{code}}",
- "winning_code": "Κωδικός νίκης",
- "close_a11y": "Κλείσιμο"
- },
- "ondo_outcome_banner": {
+ "campaign_outcome_banner": {
"winner_pending": {
- "title": "Κερδίσατε",
- "description": "Διεκδικήστε το βραβείο σας σήμερα.",
- "a11y": "Άνοιγμα λεπτομερειών νικητή"
+ "title": "Κερδίσατε!",
+ "description": "Επαληθεύστε τον κωδικό νίκης σας και διεκδικήστε το βραβείο σας σήμερα.",
+ "a11y": "Προβολή λεπτομερειών"
},
"participant_pending": {
"title": "Η καμπάνια έχει λήξει.",
"description": "Επεξεργαζόμαστε τα αποτελέσματα. Ελέγξτε ξανά σύντομα."
},
"participant_finalized": {
- "title": "Τα αποτελέσματα της καμπάνιας είναι έτοιμα",
+ "title": "Τα αποτελέσματα της καμπάνιας είναι έτοιμα.",
"description": "Δεν κερδίσατε αυτή τη φορά. Δείτε τον πίνακα κατάταξης για να δείτε σε ποια θέση τερματίσατε."
},
"winner_finalized": {
@@ -8688,17 +8677,28 @@
"description": "Το βραβείο σας είναι καθ’ οδόν — θα επικοινωνήσουμε μαζί σας σύντομα."
}
},
- "ondo_outcome_toast": {
- "winner_verify": {
+ "campaign_outcome_toast": {
+ "winner": {
"title": "Κερδίσατε 💰",
- "description": "Διεκδικήστε το βραβείο σας από την {{campaignName}}",
+ "description": "Διεκδικήστε το βραβείο σας από την {{campaignName}} σήμερα.",
"cta": "Δείτε λεπτομέρειες"
},
- "participant_no_winner": {
- "title": "Τα αποτελέσματα είναι έτοιμα. 🏅",
- "description": "Δείτε την κατάταξή σας στην {{campaignName}}",
+ "non_winner": {
+ "title": "Τα αποτελέσματα είναι έτοιμα 🏅",
+ "description": "Δείτε την κατάταξή σας στην {{campaignName}}.",
"cta": "Προβολή"
}
+ },
+ "campaign_winning": {
+ "you_won": "Κερδίσατε",
+ "rank_label": "θέση {{place}}",
+ "email_instructions": "Στείλτε email στο {{email}} με τον κωδικό σας για να διεκδικήσετε το βραβείο σας.",
+ "open_mail": "Άνοιγμα αλληλογραφίας",
+ "skip_for_now": "Παράλειψη για τώρα",
+ "mail_subject": "{{campaignName}} — διεκδίκηση βραβείου",
+ "mail_body": "Κωδικός νίκης: {{code}}",
+ "winning_code": "Κωδικός νίκης",
+ "close_a11y": "Κλείσιμο"
}
},
"time": {
diff --git a/locales/languages/en.json b/locales/languages/en.json
index aeb7ba9d85e..7ef2b7ed478 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -709,7 +709,11 @@
"smart_contract_address": "Smart contract address",
"smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.",
"i_understand": "I understand",
- "cancel": "Cancel"
+ "cancel": "Cancel",
+ "new_address_title": "New address",
+ "new_address_message": "You're sending to this address for the first time. Make sure that it's correct before you continue:",
+ "alert_navigation_previous": "Previous alert",
+ "alert_navigation_next": "Next alert"
},
"unified_ramp": {
"networks_filter_bar": {
@@ -8641,7 +8645,11 @@
"stats_qualify_for_rank_description": "Trade {{notionalRemaining}} more in notional volume to become eligible.",
"stats_error_title": "Unable to load stats",
"stats_error_description": "We had a problem loading your stats. Please try again.",
- "stats_retry": "Retry"
+ "stats_retry": "Retry",
+ "completed_label_total_participants": "Total participants",
+ "completed_label_total_volume": "Total volume",
+ "completed_label_top_pnl": "Top PnL",
+ "completed_label_winners": "Winners"
},
"campaigns_preview": {
"title": "Campaigns",
@@ -8667,29 +8675,29 @@
"retry_button": "Retry",
"refreshing": "Refreshing..."
},
- "ondo_campaign_winning": {
+ "campaign_winning": {
"you_won": "You won",
"rank_label": "{{place}} place",
- "email_instructions": "Email ondocampaign@consensys.net with your code to claim your prize.",
+ "email_instructions": "Email {{email}} with your code to claim your prize.",
"open_mail": "Open mail",
"skip_for_now": "Skip for now",
- "mail_subject": "Ondo campaign prize claim",
+ "mail_subject": "{{campaignName}} prize claim",
"mail_body": "My winning code: {{code}}",
"winning_code": "Winning code",
"close_a11y": "Close"
},
- "ondo_outcome_banner": {
+ "campaign_outcome_banner": {
"winner_pending": {
- "title": "You won",
- "description": "Claim your prize today.",
- "a11y": "Open winner details"
+ "title": "You won!",
+ "description": "Verify your winning code and claim your prize today.",
+ "a11y": "View details"
},
"participant_pending": {
"title": "Campaign has ended.",
"description": "We're determining the results. Check back soon."
},
"participant_finalized": {
- "title": "Campaign results are in",
+ "title": "Campaign results are in.",
"description": "You didn't win this time. Check the leaderboard to see where you finished."
},
"winner_finalized": {
@@ -8697,15 +8705,15 @@
"description": "Your reward is on its way — we'll be in touch shortly."
}
},
- "ondo_outcome_toast": {
- "winner_verify": {
+ "campaign_outcome_toast": {
+ "winner": {
"title": "You won 💰",
- "description": "Claim your prize from the {{campaignName}}",
+ "description": "Claim your prize from the {{campaignName}} today.",
"cta": "View details"
},
- "participant_no_winner": {
- "title": "The results are in. 🏅",
- "description": "See your ranking in the {{campaignName}}",
+ "non_winner": {
+ "title": "The results are in 🏅",
+ "description": "See your ranking in the {{campaignName}}.",
"cta": "View"
}
}
diff --git a/locales/languages/es.json b/locales/languages/es.json
index f228b7929ee..7a1d9ed8af8 100644
--- a/locales/languages/es.json
+++ b/locales/languages/es.json
@@ -8658,29 +8658,18 @@
"retry_button": "Reintentar",
"refreshing": "Actualizando..."
},
- "ondo_campaign_winning": {
- "you_won": "Tú ganas",
- "rank_label": "{{place}} lugar",
- "email_instructions": "Para reclamar tu premio, envía un correo electrónico a ondocampaign@consensys.net con tu código.",
- "open_mail": "Abrir correo",
- "skip_for_now": "Omitir por ahora",
- "mail_subject": "Reclamación de premio de la campaña de Ondo",
- "mail_body": "Mi código ganador: {{code}}",
- "winning_code": "Código ganador",
- "close_a11y": "Cerrar"
- },
- "ondo_outcome_banner": {
+ "campaign_outcome_banner": {
"winner_pending": {
- "title": "Tú ganas",
- "description": "Reclama tu premio hoy mismo.",
- "a11y": "Abrir detalles del ganador"
+ "title": "¡Has ganado!",
+ "description": "Verifica tu código ganador y reclama tu premio hoy.",
+ "a11y": "Ver detalles"
},
"participant_pending": {
"title": "La campaña terminó.",
"description": "Estamos determinando los resultados. Vuelve a consultar pronto."
},
"participant_finalized": {
- "title": "Los resultados de la campaña están en",
+ "title": "Ya hay resultados de la campaña.",
"description": "Esta vez no has ganado. Consulta la clasificación para ver en qué posición has quedado."
},
"winner_finalized": {
@@ -8688,17 +8677,28 @@
"description": "Tu recompensa está en camino; nos pondremos en contacto contigo en breve."
}
},
- "ondo_outcome_toast": {
- "winner_verify": {
+ "campaign_outcome_toast": {
+ "winner": {
"title": "Tú ganas 💰",
- "description": "Reclama tu premio en la {{campaignName}}",
+ "description": "Reclama tu premio de la {{campaignName}} hoy.",
"cta": "Ver detalles"
},
- "participant_no_winner": {
- "title": "Ya tenemos los resultados. 🏅",
- "description": "Consulta tu clasificación en {{campaignName}}",
+ "non_winner": {
+ "title": "Ya hay resultados 🏅",
+ "description": "Consulta tu posición en la {{campaignName}}.",
"cta": "Ver"
}
+ },
+ "campaign_winning": {
+ "you_won": "Tú ganas",
+ "rank_label": "{{place}} lugar",
+ "email_instructions": "Para reclamar tu premio, envía un correo electrónico a {{email}} con tu código.",
+ "open_mail": "Abrir correo",
+ "skip_for_now": "Omitir por ahora",
+ "mail_subject": "{{campaignName}} – reclamación de premio",
+ "mail_body": "Mi código ganador: {{code}}",
+ "winning_code": "Código ganador",
+ "close_a11y": "Cerrar"
}
},
"time": {
diff --git a/locales/languages/fr.json b/locales/languages/fr.json
index 5d699bb6f69..22621832f2d 100644
--- a/locales/languages/fr.json
+++ b/locales/languages/fr.json
@@ -8658,29 +8658,18 @@
"retry_button": "Réessayer",
"refreshing": "Actualisation en cours…"
},
- "ondo_campaign_winning": {
- "you_won": "Vous avez gagné",
- "rank_label": "{{place}} place",
- "email_instructions": "Envoyez votre code par e-mail à ondocampaign@consensys.net pour réclamer votre récompense.",
- "open_mail": "Ouvrir l’e-mail",
- "skip_for_now": "Ignorer pour l’instant",
- "mail_subject": "Réclamation du prix que vous avez gagné en participant à la campagne Ondo",
- "mail_body": "Mon code gagnant : {{code}}",
- "winning_code": "Code gagnant",
- "close_a11y": "Fermer"
- },
- "ondo_outcome_banner": {
+ "campaign_outcome_banner": {
"winner_pending": {
- "title": "Vous avez gagné",
- "description": "Réclamez votre prix dès aujourd’hui.",
- "a11y": "Afficher les détails du gagnant"
+ "title": "Vous avez gagné !",
+ "description": "Vérifiez votre code gagnant et réclamez votre récompense aujourd’hui.",
+ "a11y": "Voir les détails"
},
"participant_pending": {
"title": "La campagne est terminée.",
"description": "Nous sommes en train d’évaluer les résultats. Revenez bientôt."
},
"participant_finalized": {
- "title": "Les résultats de la campagne sont disponibles",
+ "title": "Les résultats de la campagne sont disponibles.",
"description": "Vous n’avez pas gagné cette fois-ci. Découvrez quelle place vous occupez au classement final."
},
"winner_finalized": {
@@ -8688,17 +8677,28 @@
"description": "Votre prix est en route. Nous vous contacterons bientôt."
}
},
- "ondo_outcome_toast": {
- "winner_verify": {
+ "campaign_outcome_toast": {
+ "winner": {
"title": "Vous avez gagné 💰",
- "description": "Réclamez le prix que vous avez gagné en participant à la campagne {{campaignName}}",
+ "description": "Réclamez votre récompense de la {{campaignName}} aujourd’hui.",
"cta": "Voir les détails"
},
- "participant_no_winner": {
- "title": "Les résultats sont disponibles. 🏅",
- "description": "Découvrez votre classement dans la campagne {{campaignName}}",
+ "non_winner": {
+ "title": "Les résultats sont disponibles 🏅",
+ "description": "Consultez votre classement dans la {{campaignName}}.",
"cta": "Afficher"
}
+ },
+ "campaign_winning": {
+ "you_won": "Vous avez gagné",
+ "rank_label": "{{place}} place",
+ "email_instructions": "Envoyez votre code par e-mail à {{email}} pour réclamer votre récompense.",
+ "open_mail": "Ouvrir l’e-mail",
+ "skip_for_now": "Ignorer pour l’instant",
+ "mail_subject": "{{campaignName}} – réclamation de prix",
+ "mail_body": "Mon code gagnant : {{code}}",
+ "winning_code": "Code gagnant",
+ "close_a11y": "Fermer"
}
},
"time": {
diff --git a/locales/languages/hi.json b/locales/languages/hi.json
index 962fd14ccc8..ced9d00e771 100644
--- a/locales/languages/hi.json
+++ b/locales/languages/hi.json
@@ -8658,29 +8658,18 @@
"retry_button": "फिर से प्रयास करें",
"refreshing": "रिफ्रेश हो रहा है..."
},
- "ondo_campaign_winning": {
- "you_won": "आप जीत गए",
- "rank_label": "{{place}} जगह",
- "email_instructions": "अपना इनाम पाने के लिए ondocampaign@consensys.net पर अपना कोड ईमेल करें।",
- "open_mail": "मेल खोलें",
- "skip_for_now": "अभी के लिए स्किप करें",
- "mail_subject": "Ondo कैंपेन प्राइज़ क्लेम",
- "mail_body": "मेरा विनिंग कोड: {{code}}",
- "winning_code": "विनिंग कोड",
- "close_a11y": "बंद करें"
- },
- "ondo_outcome_banner": {
+ "campaign_outcome_banner": {
"winner_pending": {
- "title": "आप जीत गए",
- "description": "आज ही अपना प्राइज़ क्लेम करें।",
- "a11y": "विनर की जानकारी खोलें"
+ "title": "आप जीत गए!",
+ "description": "अपना विजेता कोड सत्यापित करें और आज ही अपना इनाम दावा करें।",
+ "a11y": "विवरण देखें"
},
"participant_pending": {
"title": "कैंपेन खत्म हो गया है।",
"description": "हम रिज़ल्ट तय कर रहे हैं। जल्द ही वापस आकर देखें।"
},
"participant_finalized": {
- "title": "कैंपेन के रिज़ल्ट आ गए हैं",
+ "title": "कैंपेन के परिणाम आ गए हैं।",
"description": "आप इस बार नहीं जीते। लीडरबोर्ड देखें कि आप कहाँ पर हैं।"
},
"winner_finalized": {
@@ -8688,17 +8677,28 @@
"description": "आपका रिवॉर्ड आने वाला है — हम जल्द ही आपसे कॉन्टैक्ट करेंगे।"
}
},
- "ondo_outcome_toast": {
- "winner_verify": {
+ "campaign_outcome_toast": {
+ "winner": {
"title": "आप जीत गए 💰",
- "description": "{{campaignName}} से अपना प्राइज़ क्लेम करें",
+ "description": "आज ही {{campaignName}} से अपना इनाम दावा करें।",
"cta": "विवरण देखें"
},
- "participant_no_winner": {
- "title": "रिज़ल्ट आ गए हैं। 🏅",
- "description": "{{campaignName}} में अपनी रैंकिंग देखें",
+ "non_winner": {
+ "title": "परिणाम आ गए हैं 🏅",
+ "description": "{{campaignName}} में अपनी रैंकिंग देखें।",
"cta": "देखें"
}
+ },
+ "campaign_winning": {
+ "you_won": "आप जीत गए",
+ "rank_label": "{{place}} जगह",
+ "email_instructions": "अपना इनाम पाने के लिए {{email}} पर अपना कोड ईमेल करें।",
+ "open_mail": "मेल खोलें",
+ "skip_for_now": "अभी के लिए स्किप करें",
+ "mail_subject": "{{campaignName}} – पुरस्कार दावा",
+ "mail_body": "मेरा विनिंग कोड: {{code}}",
+ "winning_code": "विनिंग कोड",
+ "close_a11y": "बंद करें"
}
},
"time": {
diff --git a/locales/languages/id.json b/locales/languages/id.json
index 487c8399e66..b9384ae959a 100644
--- a/locales/languages/id.json
+++ b/locales/languages/id.json
@@ -8658,29 +8658,18 @@
"retry_button": "Coba lagi",
"refreshing": "Menyegarkan..."
},
- "ondo_campaign_winning": {
- "you_won": "Anda menang",
- "rank_label": "{{place}} tempat",
- "email_instructions": "Kirimkan kode Anda ke ondocampaign@consensys.net untuk mengklaim hadiah Anda.",
- "open_mail": "Buka surat",
- "skip_for_now": "Lewati untuk saat ini",
- "mail_subject": "Klaim hadiah kampanye Ondo",
- "mail_body": "Kode pemenang: {{code}}",
- "winning_code": "Kode pemenang",
- "close_a11y": "Tutup"
- },
- "ondo_outcome_banner": {
+ "campaign_outcome_banner": {
"winner_pending": {
- "title": "Anda menang",
- "description": "Klaim hadiah Anda hari ini.",
- "a11y": "Buka detail pemenang"
+ "title": "Anda menang!",
+ "description": "Verifikasi kode kemenangan Anda dan klaim hadiah Anda hari ini.",
+ "a11y": "Lihat detail"
},
"participant_pending": {
"title": "Kampanye telah berakhir.",
"description": "Kami sedang menentukan hasilnya. Periksa kembali sesaat lagi."
},
"participant_finalized": {
- "title": "Hasil kampanye telah keluar",
+ "title": "Hasil kampanye sudah tersedia.",
"description": "Anda tidak menang kali ini. Periksa papan peringkat untuk melihat posisi Anda."
},
"winner_finalized": {
@@ -8688,17 +8677,28 @@
"description": "Reward sedang dikirim — kami akan segera menghubungi Anda."
}
},
- "ondo_outcome_toast": {
- "winner_verify": {
+ "campaign_outcome_toast": {
+ "winner": {
"title": "Anda menang 💰",
- "description": "Klaim hadiah dari {{campaignName}}",
+ "description": "Klaim hadiah Anda dari {{campaignName}} hari ini.",
"cta": "Lihat detail"
},
- "participant_no_winner": {
- "title": "Hasilnya sudah keluar. 🏅",
- "description": "Lihat peringkat Anda di {{campaignName}}",
+ "non_winner": {
+ "title": "Hasil sudah tersedia 🏅",
+ "description": "Lihat peringkat Anda di {{campaignName}}.",
"cta": "Lihat"
}
+ },
+ "campaign_winning": {
+ "you_won": "Anda menang",
+ "rank_label": "{{place}} tempat",
+ "email_instructions": "Kirimkan kode Anda ke {{email}} untuk mengklaim hadiah Anda.",
+ "open_mail": "Buka surat",
+ "skip_for_now": "Lewati untuk saat ini",
+ "mail_subject": "Klaim hadiah {{campaignName}}",
+ "mail_body": "Kode pemenang: {{code}}",
+ "winning_code": "Kode pemenang",
+ "close_a11y": "Tutup"
}
},
"time": {
diff --git a/locales/languages/ja.json b/locales/languages/ja.json
index 9950f780af3..62c3a10836a 100644
--- a/locales/languages/ja.json
+++ b/locales/languages/ja.json
@@ -8658,29 +8658,18 @@
"retry_button": "再試行",
"refreshing": "更新中..."
},
- "ondo_campaign_winning": {
- "you_won": "予測的中",
- "rank_label": "{{place}}位",
- "email_instructions": "賞品を請求するには、ondocampaign@consensys.netにメールでコードを送信してください。",
- "open_mail": "メールを開く",
- "skip_for_now": "今はスキップ",
- "mail_subject": "Ondoキャンペーン賞品の請求",
- "mail_body": "勝利コード: {{code}}",
- "winning_code": "勝利コード",
- "close_a11y": "閉じる"
- },
- "ondo_outcome_banner": {
+ "campaign_outcome_banner": {
"winner_pending": {
- "title": "予測的中",
- "description": "今すぐ賞品を請求しましょう。",
- "a11y": "勝者の詳細を開く"
+ "title": "当選しました!",
+ "description": "当選コードを確認し、本日中に賞品を請求してください。",
+ "a11y": "詳細を表示"
},
"participant_pending": {
"title": "キャンペーンが終了しました。",
"description": "結果を判断しています。近々またご確認ください。"
},
"participant_finalized": {
- "title": "キャンペーンの結果が出ました",
+ "title": "キャンペーンの結果が出ました。",
"description": "今回は勝ちませんでした。リーダーボードで順位を確認しましょう。"
},
"winner_finalized": {
@@ -8688,17 +8677,28 @@
"description": "間もなく報酬を受け取れます。近々ご連絡します。"
}
},
- "ondo_outcome_toast": {
- "winner_verify": {
+ "campaign_outcome_toast": {
+ "winner": {
"title": "予測的中 💰",
- "description": "{{campaignName}}の賞品を請求",
+ "description": "本日、{{campaignName}}の賞品を請求してください。",
"cta": "詳細を表示"
},
- "participant_no_winner": {
- "title": "結果が出ました。🏅",
- "description": "{{campaignName}}でのランキングをご覧ください",
+ "non_winner": {
+ "title": "結果が出ました 🏅",
+ "description": "{{campaignName}}での順位を確認してください。",
"cta": "表示"
}
+ },
+ "campaign_winning": {
+ "you_won": "予測的中",
+ "rank_label": "{{place}}位",
+ "email_instructions": "賞品を請求するには、{{email}}にメールでコードを送信してください。",
+ "open_mail": "メールを開く",
+ "skip_for_now": "今はスキップ",
+ "mail_subject": "{{campaignName}} 賞品の請求",
+ "mail_body": "勝利コード: {{code}}",
+ "winning_code": "勝利コード",
+ "close_a11y": "閉じる"
}
},
"time": {
diff --git a/locales/languages/ko.json b/locales/languages/ko.json
index 7406260871f..01372f65546 100644
--- a/locales/languages/ko.json
+++ b/locales/languages/ko.json
@@ -8658,29 +8658,18 @@
"retry_button": "다시 시도",
"refreshing": "새로 고침 중..."
},
- "ondo_campaign_winning": {
- "you_won": "승리하셨습니다",
- "rank_label": "{{place}} 장소",
- "email_instructions": "상품을 받으려면 당첨 코드를 ondocampaign@consensys.net으로 이메일로 보내세요.",
- "open_mail": "메일 열기",
- "skip_for_now": "지금은 건너뛰기",
- "mail_subject": "Ondo 캠페인 상품 청구",
- "mail_body": "당첨 코드: {{code}}",
- "winning_code": "당첨 코드",
- "close_a11y": "닫기"
- },
- "ondo_outcome_banner": {
+ "campaign_outcome_banner": {
"winner_pending": {
- "title": "승리하셨습니다",
- "description": "오늘 상품을 청구하세요.",
- "a11y": "당첨자 세부 정보 열기"
+ "title": "당첨되셨습니다!",
+ "description": "당첨 코드를 확인하고 오늘 상품을 청구하세요.",
+ "a11y": "세부정보 보기"
},
"participant_pending": {
"title": "캠페인이 종료되었습니다.",
"description": "결과를 확인 중입니다. 곧 다시 확인해 주세요."
},
"participant_finalized": {
- "title": "캠페인 결과가 발표되었습니다",
+ "title": "캠페인 결과가 나왔습니다.",
"description": "이번에는 당첨되지 않았습니다. 리더보드에서 순위를 확인해 보세요."
},
"winner_finalized": {
@@ -8688,17 +8677,28 @@
"description": "보상이 지급될 예정입니다. 곧 연락드리겠습니다."
}
},
- "ondo_outcome_toast": {
- "winner_verify": {
+ "campaign_outcome_toast": {
+ "winner": {
"title": "승리하셨습니다 💰",
- "description": "{{campaignName}} 상품을 청구하세요",
+ "description": "오늘 {{campaignName}}에서 상품을 청구하세요.",
"cta": "세부 정보 보기"
},
- "participant_no_winner": {
- "title": "결과가 발표되었습니다. 🏅",
- "description": "{{campaignName}}에서 내 순위 보기",
+ "non_winner": {
+ "title": "결과가 나왔습니다 🏅",
+ "description": "{{campaignName}}에서 내 순위를 확인하세요.",
"cta": "보기"
}
+ },
+ "campaign_winning": {
+ "you_won": "승리하셨습니다",
+ "rank_label": "{{place}} 장소",
+ "email_instructions": "상품을 받으려면 당첨 코드를 {{email}}으로 이메일로 보내세요.",
+ "open_mail": "메일 열기",
+ "skip_for_now": "지금은 건너뛰기",
+ "mail_subject": "{{campaignName}} 상품 청구",
+ "mail_body": "당첨 코드: {{code}}",
+ "winning_code": "당첨 코드",
+ "close_a11y": "닫기"
}
},
"time": {
diff --git a/locales/languages/pt.json b/locales/languages/pt.json
index bc8a465f6fa..9e606f5776b 100644
--- a/locales/languages/pt.json
+++ b/locales/languages/pt.json
@@ -8658,29 +8658,18 @@
"retry_button": "Tentar novamente",
"refreshing": "Atualizando..."
},
- "ondo_campaign_winning": {
- "you_won": "Você ganhou",
- "rank_label": "{{place}} lugar",
- "email_instructions": "Envie um e-mail para ondocampaign@consensys.net com o seu código para reivindicar seu prêmio.",
- "open_mail": "Abrir e-mail",
- "skip_for_now": "Ignorar por enquanto",
- "mail_subject": "Reivindicação de prêmio da campanha Ondo",
- "mail_body": "Meu código ganhador: {{code}}",
- "winning_code": "Código ganhador",
- "close_a11y": "Fechar"
- },
- "ondo_outcome_banner": {
+ "campaign_outcome_banner": {
"winner_pending": {
- "title": "Você ganhou",
- "description": "Reivindique seu prêmio hoje mesmo.",
- "a11y": "Ver detalhes dos ganhadores"
+ "title": "Você ganhou!",
+ "description": "Verifique seu código vencedor e reivindique seu prêmio hoje.",
+ "a11y": "Ver detalhes"
},
"participant_pending": {
"title": "A campanha terminou.",
"description": "Estamos analisando os resultados. Volte em breve."
},
"participant_finalized": {
- "title": "Os resultados da campanha foram divulgados",
+ "title": "Os resultados da campanha estão disponíveis.",
"description": "Você não ganhou desta vez. Confira o placar de classificação para ver sua posição final."
},
"winner_finalized": {
@@ -8688,17 +8677,28 @@
"description": "Sua recompensa está a caminho. Entraremos em contato em breve."
}
},
- "ondo_outcome_toast": {
- "winner_verify": {
+ "campaign_outcome_toast": {
+ "winner": {
"title": "Você ganhou 💰",
- "description": "Reivindique seu prêmio da {{campaignName}}",
+ "description": "Reivindique seu prêmio da {{campaignName}} hoje.",
"cta": "Ver detalhes"
},
- "participant_no_winner": {
- "title": "Os resultados foram divulgados.🏅",
- "description": "Veja sua classificação na {{campaignName}}",
+ "non_winner": {
+ "title": "Os resultados estão disponíveis 🏅",
+ "description": "Veja sua classificação na {{campaignName}}.",
"cta": "Ver"
}
+ },
+ "campaign_winning": {
+ "you_won": "Você ganhou",
+ "rank_label": "{{place}} lugar",
+ "email_instructions": "Envie um e-mail para {{email}} com o seu código para reivindicar seu prêmio.",
+ "open_mail": "Abrir e-mail",
+ "skip_for_now": "Ignorar por enquanto",
+ "mail_subject": "{{campaignName}} – reivindicação de prêmio",
+ "mail_body": "Meu código ganhador: {{code}}",
+ "winning_code": "Código ganhador",
+ "close_a11y": "Fechar"
}
},
"time": {
diff --git a/locales/languages/ru.json b/locales/languages/ru.json
index ef6b6f31ae2..f292c81431e 100644
--- a/locales/languages/ru.json
+++ b/locales/languages/ru.json
@@ -8658,29 +8658,18 @@
"retry_button": "Повтор",
"refreshing": "Обновление..."
},
- "ondo_campaign_winning": {
- "you_won": "Вы выиграли",
- "rank_label": "{{place}} место",
- "email_instructions": "Отправьте письмо со своим кодом на адрес ondocampaign@consensys.net, чтобы получить приз.",
- "open_mail": "Открыть почту",
- "skip_for_now": "Пока пропустить",
- "mail_subject": "Получение приза кампании Ondo",
- "mail_body": "Мой выигрышный код: {{code}}",
- "winning_code": "Выигрышный код",
- "close_a11y": "Закрыть"
- },
- "ondo_outcome_banner": {
+ "campaign_outcome_banner": {
"winner_pending": {
- "title": "Вы выиграли",
- "description": "Получите свой приз сегодня.",
- "a11y": "Открыть детали победителя"
+ "title": "Вы выиграли!",
+ "description": "Подтвердите выигрышный код и получите приз сегодня.",
+ "a11y": "Подробнее"
},
"participant_pending": {
"title": "Кампания завершена.",
"description": "Мы определяем результаты. Загляните сюда позже."
},
"participant_finalized": {
- "title": "Результаты кампании подведены",
+ "title": "Результаты кампании готовы.",
"description": "В этот раз вы не выиграли. Проверьте таблицу лидеров, чтобы узнать свое место."
},
"winner_finalized": {
@@ -8688,17 +8677,28 @@
"description": "Ваша награда уже в пути — мы скоро свяжемся с вами."
}
},
- "ondo_outcome_toast": {
- "winner_verify": {
+ "campaign_outcome_toast": {
+ "winner": {
"title": "Вы выиграли 💰",
- "description": "Получите свой приз в кампании {{campaignName}}",
+ "description": "Получите приз из {{campaignName}} сегодня.",
"cta": "Просмотр подробностей"
},
- "participant_no_winner": {
- "title": "Результаты готовы. 🏅",
- "description": "Посмотрите свой рейтинг в кампании {{campaignName}}",
+ "non_winner": {
+ "title": "Результаты готовы 🏅",
+ "description": "Посмотрите своё место в {{campaignName}}.",
"cta": "Просмотр"
}
+ },
+ "campaign_winning": {
+ "you_won": "Вы выиграли",
+ "rank_label": "{{place}} место",
+ "email_instructions": "Отправьте письмо со своим кодом на адрес {{email}}, чтобы получить приз.",
+ "open_mail": "Открыть почту",
+ "skip_for_now": "Пока пропустить",
+ "mail_subject": "{{campaignName}} – получение приза",
+ "mail_body": "Мой выигрышный код: {{code}}",
+ "winning_code": "Выигрышный код",
+ "close_a11y": "Закрыть"
}
},
"time": {
diff --git a/locales/languages/tl.json b/locales/languages/tl.json
index a1cc9eb084e..c5b4425bcbd 100644
--- a/locales/languages/tl.json
+++ b/locales/languages/tl.json
@@ -8658,29 +8658,18 @@
"retry_button": "Subukang muli",
"refreshing": "Nire-refresh..."
},
- "ondo_campaign_winning": {
- "you_won": "Nanalo ka",
- "rank_label": "{{place}} lugar",
- "email_instructions": "I-email sa ondocampaign@consensys.net ang code mo para kunin ang premyo mo.",
- "open_mail": "Buksan ang mail",
- "skip_for_now": "Laktawan muna",
- "mail_subject": "Pagkuha ng premyo ng campaign ng Ondo",
- "mail_body": "Ang nanalong code: {{code}}",
- "winning_code": "Nanalong code",
- "close_a11y": "Isara"
- },
- "ondo_outcome_banner": {
+ "campaign_outcome_banner": {
"winner_pending": {
- "title": "Nanalo ka",
- "description": "Kunin ang premyo mo ngayon.",
- "a11y": "Ipakita ang mga detalye ng nanalo"
+ "title": "Nanalo ka!",
+ "description": "I-verify ang winning code mo at kunin ang premyo mo ngayon.",
+ "a11y": "Tingnan ang mga detalye"
},
"participant_pending": {
"title": "Tapos na ang campaign.",
"description": "Inaalam na namin ang resulta. Bumalik sa lalong madaling panahon."
},
"participant_finalized": {
- "title": "Lumabas na ang resulta ng campaign",
+ "title": "Lumabas na ang resulta ng campaign.",
"description": "Hindi ka nanalo sa pagkakataong ito. Tingnan ang leaderboard para malaman kung ano ang rank mo."
},
"winner_finalized": {
@@ -8688,17 +8677,28 @@
"description": "Parating na ang reward mo — makikipag-ugnayan kami sa iyo sa lalong madaling panahon."
}
},
- "ondo_outcome_toast": {
- "winner_verify": {
+ "campaign_outcome_toast": {
+ "winner": {
"title": "Nanalo ka 💰",
- "description": "Kunin ang premyo mo mula sa {{campaignName}}",
+ "description": "Kunin ang premyo mo mula sa {{campaignName}} ngayon.",
"cta": "Tingnan ang mga detalye"
},
- "participant_no_winner": {
- "title": "Lumabas na ang resulta. 🏅",
- "description": "Tingnan ang ranking mo sa {{campaignName}}",
+ "non_winner": {
+ "title": "Lumabas na ang resulta 🏅",
+ "description": "Tingnan ang ranking mo sa {{campaignName}}.",
"cta": "Tingnan"
}
+ },
+ "campaign_winning": {
+ "you_won": "Nanalo ka",
+ "rank_label": "{{place}} lugar",
+ "email_instructions": "I-email sa {{email}} ang code mo para kunin ang premyo mo.",
+ "open_mail": "Buksan ang mail",
+ "skip_for_now": "Laktawan muna",
+ "mail_subject": "{{campaignName}} – pagkuha ng premyo",
+ "mail_body": "Ang nanalong code: {{code}}",
+ "winning_code": "Nanalong code",
+ "close_a11y": "Isara"
}
},
"time": {
diff --git a/locales/languages/tr.json b/locales/languages/tr.json
index 3fc8b5216ac..2a71fe184a4 100644
--- a/locales/languages/tr.json
+++ b/locales/languages/tr.json
@@ -8658,29 +8658,18 @@
"retry_button": "Tekrar Dene",
"refreshing": "Yenileniyor..."
},
- "ondo_campaign_winning": {
- "you_won": "Kazancınız",
- "rank_label": "{{place}}. sıra",
- "email_instructions": "Ödülünüzü almak için kodunuzla ondocampaign@consensys.net adresine e-posta gönderin.",
- "open_mail": "Postayı aç",
- "skip_for_now": "Şimdilik atla",
- "mail_subject": "Ondo kampanyası ödül talebi",
- "mail_body": "Kazanma kodum: {{code}}",
- "winning_code": "Kazanma kodu",
- "close_a11y": "Kapat"
- },
- "ondo_outcome_banner": {
+ "campaign_outcome_banner": {
"winner_pending": {
- "title": "Kazancınız",
- "description": "Ödülünüzü bugün alın.",
- "a11y": "Kazanan bilgilerini aç"
+ "title": "Kazandınız!",
+ "description": "Kazanan kodunuzu doğrulayın ve ödülünüzü bugün talep edin.",
+ "a11y": "Ayrıntıları görüntüle"
},
"participant_pending": {
"title": "Kampanya sona erdi.",
"description": "Sonuçları belirliyoruz. Kısa bir süre tekrar kontrol edin."
},
"participant_finalized": {
- "title": "Kampanya sonuçları açıklandı",
+ "title": "Kampanya sonuçları hazır.",
"description": "Bu kez kazanamadınız. Nerede bitirdiğinizi görmek için liderlik tablosunu kontrol edin."
},
"winner_finalized": {
@@ -8688,17 +8677,28 @@
"description": "Ödülünüz yolda - kısa bir süre sonra iletişime geçeceğiz."
}
},
- "ondo_outcome_toast": {
- "winner_verify": {
+ "campaign_outcome_toast": {
+ "winner": {
"title": "Kazancınız 💰",
- "description": "{{campaignName}} adlı kampanyadan ödülünüzü alın",
+ "description": "Ödülünüzü bugün {{campaignName}} üzerinden talep edin.",
"cta": "Ayrıntıları görüntüle"
},
- "participant_no_winner": {
- "title": "Sonuçlar belli oldu. 🏅",
- "description": "{{campaignName}} adlı kampanyadaki sıralamanızı görün",
+ "non_winner": {
+ "title": "Sonuçlar hazır 🏅",
+ "description": "{{campaignName}} içinde sıralamanızı görün.",
"cta": "Görüntüle"
}
+ },
+ "campaign_winning": {
+ "you_won": "Kazancınız",
+ "rank_label": "{{place}}. sıra",
+ "email_instructions": "Ödülünüzü almak için kodunuzla {{email}} adresine e-posta gönderin.",
+ "open_mail": "Postayı aç",
+ "skip_for_now": "Şimdilik atla",
+ "mail_subject": "{{campaignName}} – ödül talebi",
+ "mail_body": "Kazanma kodum: {{code}}",
+ "winning_code": "Kazanma kodu",
+ "close_a11y": "Kapat"
}
},
"time": {
diff --git a/locales/languages/vi.json b/locales/languages/vi.json
index a3e14e0a16b..6fc67e4c02d 100644
--- a/locales/languages/vi.json
+++ b/locales/languages/vi.json
@@ -8658,29 +8658,18 @@
"retry_button": "Thử lại",
"refreshing": "Đang làm mới..."
},
- "ondo_campaign_winning": {
- "you_won": "Bạn đã thắng",
- "rank_label": "Hạng {{place}}",
- "email_instructions": "Gửi email đến ondocampaign@consensys.net kèm mã của bạn để nhận thưởng.",
- "open_mail": "Mở thư",
- "skip_for_now": "Bỏ qua bây giờ",
- "mail_subject": "Nhận thưởng chiến dịch Ondo",
- "mail_body": "Mã trúng thưởng của tôi: {{code}}",
- "winning_code": "Mã trúng thưởng",
- "close_a11y": "Đóng"
- },
- "ondo_outcome_banner": {
+ "campaign_outcome_banner": {
"winner_pending": {
- "title": "Bạn đã thắng",
- "description": "Nhận giải thưởng của bạn ngay hôm nay.",
- "a11y": "Mở thông tin người chiến thắng"
+ "title": "Bạn đã thắng!",
+ "description": "Xác minh mã trúng thưởng và nhận giải thưởng hôm nay.",
+ "a11y": "Xem chi tiết"
},
"participant_pending": {
"title": "Chiến dịch đã kết thúc.",
"description": "Chúng tôi đang xác định kết quả. Hãy quay lại sau."
},
"participant_finalized": {
- "title": "Kết quả chiến dịch đã có",
+ "title": "Đã có kết quả chiến dịch.",
"description": "Lần này bạn đã không giành chiến thắng. Hãy kiểm tra bảng xếp hạng để xem bạn đứng ở vị trí nào."
},
"winner_finalized": {
@@ -8688,17 +8677,28 @@
"description": "Phần thưởng của bạn đang được gửi — chúng tôi sẽ sớm liên hệ với bạn."
}
},
- "ondo_outcome_toast": {
- "winner_verify": {
+ "campaign_outcome_toast": {
+ "winner": {
"title": "Bạn đã thắng 💰",
- "description": "Nhận giải thưởng của bạn từ {{campaignName}}",
+ "description": "Nhận thưởng từ {{campaignName}} hôm nay.",
"cta": "Xem chi tiết"
},
- "participant_no_winner": {
- "title": "Kết quả đã có. 🏅",
- "description": "Xem thứ hạng của bạn trong {{campaignName}}",
+ "non_winner": {
+ "title": "Đã có kết quả 🏅",
+ "description": "Xem thứ hạng của bạn trong {{campaignName}}.",
"cta": "Xem"
}
+ },
+ "campaign_winning": {
+ "you_won": "Bạn đã thắng",
+ "rank_label": "Hạng {{place}}",
+ "email_instructions": "Gửi email đến {{email}} kèm mã của bạn để nhận thưởng.",
+ "open_mail": "Mở thư",
+ "skip_for_now": "Bỏ qua bây giờ",
+ "mail_subject": "{{campaignName}} – nhận thưởng",
+ "mail_body": "Mã trúng thưởng của tôi: {{code}}",
+ "winning_code": "Mã trúng thưởng",
+ "close_a11y": "Đóng"
}
},
"time": {
diff --git a/locales/languages/zh.json b/locales/languages/zh.json
index bee8f24db28..f7a0a2db289 100644
--- a/locales/languages/zh.json
+++ b/locales/languages/zh.json
@@ -8658,29 +8658,18 @@
"retry_button": "重试",
"refreshing": "正在刷新……"
},
- "ondo_campaign_winning": {
- "you_won": "您已获胜",
- "rank_label": "第 {{place}} 名",
- "email_instructions": "请将您的代码发送至 ondocampaign@consensys.net 领取奖品。",
- "open_mail": "打开邮件",
- "skip_for_now": "暂时跳过",
- "mail_subject": "领取 Ondo 活动奖品",
- "mail_body": "我的获奖码:{{code}}",
- "winning_code": "获奖码",
- "close_a11y": "关闭"
- },
- "ondo_outcome_banner": {
+ "campaign_outcome_banner": {
"winner_pending": {
- "title": "您已获胜",
- "description": "立即领取您的奖品。",
- "a11y": "打开获奖者详情"
+ "title": "您中奖了!",
+ "description": "验证您的中奖代码,并在今天领取奖品。",
+ "a11y": "查看详情"
},
"participant_pending": {
"title": "活动已结束。",
"description": "我们正在确定结果。请稍后再来查看。"
},
"participant_finalized": {
- "title": "活动结果已出炉",
+ "title": "活动结果已公布。",
"description": "您这次未中奖。查看排行榜了解您的最终排名。"
},
"winner_finalized": {
@@ -8688,17 +8677,28 @@
"description": "您的奖励正在发放中——我们将很快联系您。"
}
},
- "ondo_outcome_toast": {
- "winner_verify": {
+ "campaign_outcome_toast": {
+ "winner": {
"title": "您已获胜 💰",
- "description": "领取您在 {{campaignName}} 中的奖品",
+ "description": "今天从 {{campaignName}} 领取您的奖品。",
"cta": "查看详情"
},
- "participant_no_winner": {
- "title": "结果已揭晓。🏅",
- "description": "查看您在 {{campaignName}} 中的排名",
+ "non_winner": {
+ "title": "结果已公布 🏅",
+ "description": "查看您在 {{campaignName}} 中的排名。",
"cta": "查看"
}
+ },
+ "campaign_winning": {
+ "you_won": "您已获胜",
+ "rank_label": "第 {{place}} 名",
+ "email_instructions": "请将您的代码发送至 {{email}} 领取奖品。",
+ "open_mail": "打开邮件",
+ "skip_for_now": "暂时跳过",
+ "mail_subject": "{{campaignName}} 奖品领取",
+ "mail_body": "我的获奖码:{{code}}",
+ "winning_code": "获奖码",
+ "close_a11y": "关闭"
}
},
"time": {
diff --git a/package.json b/package.json
index 2aa61ed46e5..ae9cdbd7feb 100644
--- a/package.json
+++ b/package.json
@@ -129,6 +129,10 @@
"run-system-tests:ios-login": "yarn playwright test --project system-ios-login --config tests/playwright.system.config.ts",
"run-system-tests:ios-onboarding": "yarn playwright test --project system-ios-onboarding --config tests/playwright.system.config.ts",
"run-system-tests:all": "yarn playwright test --config tests/playwright.system.config.ts",
+ "run-system-tests:android-login-emu": "yarn playwright test --project system-android-login-emu --config tests/playwright.system-emulator.config.ts",
+ "run-system-tests:android-onboarding-emu": "yarn playwright test --project system-android-onboarding-emu --config tests/playwright.system-emulator.config.ts",
+ "run-system-tests:ios-login-sim": "yarn playwright test --project system-ios-login-sim --config tests/playwright.system-emulator.config.ts",
+ "run-system-tests:ios-onboarding-sim": "yarn playwright test --project system-ios-onboarding-sim --config tests/playwright.system-emulator.config.ts",
"test:depcheck": "yarn depcheck",
"test:tgz-check": "./scripts/tgz-check.sh",
"test:attribution-check": "./scripts/attributions-check.sh",
@@ -339,7 +343,7 @@
"@metamask/superstruct": "^3.2.1",
"@metamask/swappable-obj-proxy": "^2.1.0",
"@metamask/transaction-controller": "^65.0.0",
- "@metamask/transaction-pay-controller": "^20.0.0",
+ "@metamask/transaction-pay-controller": "^21.0.0",
"@metamask/tron-wallet-snap": "^1.25.3",
"@metamask/utils": "^11.11.0",
"@myx-trade/sdk": "^0.1.265",
diff --git a/tests/api-mocking/mock-e2e-allowlist.ts b/tests/api-mocking/mock-e2e-allowlist.ts
index 13e69847e37..9749085bc6c 100644
--- a/tests/api-mocking/mock-e2e-allowlist.ts
+++ b/tests/api-mocking/mock-e2e-allowlist.ts
@@ -6,16 +6,9 @@ export const ALLOWLISTED_HOSTS = [
'127.0.0.1',
'localhost',
'10.0.2.2', // Android emulator host
- 'api.tenderly.co',
- 'rpc.tenderly.co',
- 'virtual.mainnet.rpc.tenderly.co',
- 'virtual.linea.rpc.tenderly.co',
'gamma-api.polymarket.com',
'*.polymarket.com',
'metamask.github.io', // Test-snaps and test-dapp pages loaded in browser
];
-export const ALLOWLISTED_URLS = [
- // Temporarily allow existing live requests during migration
- 'https://metamask.github.io/test-dapp/metamask-fox.svg',
-];
+export const ALLOWLISTED_URLS: string[] = [];
diff --git a/tests/api-mocking/mock-responses/defaults/rpc-endpoints.ts b/tests/api-mocking/mock-responses/defaults/rpc-endpoints.ts
index 93b11ef3525..c22266761b0 100644
--- a/tests/api-mocking/mock-responses/defaults/rpc-endpoints.ts
+++ b/tests/api-mocking/mock-responses/defaults/rpc-endpoints.ts
@@ -42,5 +42,23 @@ export const DEFAULT_RPC_ENDPOINT_MOCKS: MockEventsObject = {
result: '0x0',
},
},
+ {
+ urlEndpoint: /^https:\/\/virtual\.mainnet\.rpc\.tenderly\.co\/.+$/,
+ responseCode: 200,
+ response: {
+ jsonrpc: '2.0',
+ id: 1,
+ result: '0x0',
+ },
+ },
+ {
+ urlEndpoint: 'https://testnet-rpc.monad.xyz/',
+ responseCode: 200,
+ response: {
+ jsonrpc: '2.0',
+ id: 1,
+ result: '0x0',
+ },
+ },
],
};
diff --git a/tests/api-mocking/mock-responses/defaults/static-assets.ts b/tests/api-mocking/mock-responses/defaults/static-assets.ts
index 939cc679b9a..377bf3ac28f 100644
--- a/tests/api-mocking/mock-responses/defaults/static-assets.ts
+++ b/tests/api-mocking/mock-responses/defaults/static-assets.ts
@@ -23,5 +23,11 @@ export const STATIC_ASSETS_MOCKS: MockEventsObject = {
responseCode: 200,
response: MINIMAL_SVG,
},
+ {
+ urlEndpoint:
+ /^https:\/\/metamask\.github\.io\/test-dapp\/metamask-fox\.svg$/,
+ responseCode: 200,
+ response: MINIMAL_SVG,
+ },
],
};
diff --git a/tests/api-mocking/mock-responses/tx-sentinel-networks-map.ts b/tests/api-mocking/mock-responses/tx-sentinel-networks-map.ts
index 0a28e014b13..ce1085fd0dd 100644
--- a/tests/api-mocking/mock-responses/tx-sentinel-networks-map.ts
+++ b/tests/api-mocking/mock-responses/tx-sentinel-networks-map.ts
@@ -64,7 +64,7 @@ export const TX_SENTINEL_NETWORKS_MAP = {
decimals: 18,
},
network: 'sei-mainnet',
- explorer: 'https://seitrace.com',
+ explorer: 'https://seiscan.io',
confirmations: true,
smartTransactions: false,
relayTransactions: false,
diff --git a/tests/helpers/tenderly/tenderly.js b/tests/helpers/tenderly/tenderly.js
deleted file mode 100644
index db5ca8a501a..00000000000
--- a/tests/helpers/tenderly/tenderly.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import axios from 'axios';
-
-export default class Tenderly {
- static async addFunds(rpcURL, account, amount = '0xDE0B6B3A764000000') {
- const data = {
- jsonrpc: '2.0',
- method: 'tenderly_setBalance',
- params: [[account], amount],
- id: '1234',
- };
-
- const response = await axios.post(rpcURL, data, {
- headers: {
- 'Content-Type': 'application/json',
- },
- });
-
- if (response.data.error) {
- // eslint-disable-next-line no-console
- console.log(
- `ERROR: Failed to add funds to Tenderly VirtualTestNet\n${response.data.error}`,
- );
- return null;
- }
- }
-}
diff --git a/tests/performance/README.md b/tests/performance/README.md
index 4e5a6ae53ae..4190b2967a1 100644
--- a/tests/performance/README.md
+++ b/tests/performance/README.md
@@ -218,7 +218,22 @@ npx playwright test --grep "@PerformanceLogin.*@PerformanceLaunch" --project and
## Test Tags
-Tests are tagged with area-specific tags defined in `tests/tags.performance.js`. These tags allow for selective test execution based on which areas of the app are affected by code changes.
+Tags are defined in `tests/tags.performance.js` and embedded in `test.describe()` names. They are runner-agnostic — any runner with `--grep` support can filter by them.
+
+### Test Type Tags
+
+These tags control which Playwright config picks up a test:
+
+| Tag | Description | Config that filters for it |
+| -------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
+| `@Performance` | Test measures performance (uses `TimerHelper`, quality gates enforced) | `playwright.config.ts` (`grep: /@Performance/`) |
+| `@System` | Test verifies functionality (no quality gates or metrics) | `playwright.system.config.ts` / `playwright.system-emulator.config.ts` (`grep: /@System/`) |
+
+Most existing tests are tagged with **both** `@Performance @System` — they measure perf and also serve as system smoke tests. A test can use just one tag if it should only run in one suite.
+
+### Area Tags
+
+These tags categorize tests by feature area and can be used with `--grep` for ad-hoc filtering:
| Tag | Description |
| -------------------------- | ------------------------------------------------------------- |
@@ -232,18 +247,41 @@ Tests are tagged with area-specific tags defined in `tests/tags.performance.js`.
| `@PerformancePredict` | Predict market performance (market list, details, deposits) |
| `@PerformancePreps` | Perpetuals trading performance (positions, add funds, orders) |
-Tags are imported into test files from `tests/tags.performance.js`:
+### Tagging Convention
+
+Import type tags and area tags from `tests/tags.performance.js`:
```typescript
-import { PerformanceLogin, PerformanceSwaps } from '../../tags.performance.js';
+import {
+ Performance,
+ System,
+ PerformanceLogin,
+ PerformanceSwaps,
+} from '../../tags.performance.js';
-perfTest.describe(`${PerformanceLogin} ${PerformanceSwaps}`, () => {
- perfTest(
- 'Swap flow performance',
- async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => {
- // test implementation
- },
- );
+// Both perf and system test (most common):
+perfTest.describe(
+ `${Performance} ${System} ${PerformanceLogin} ${PerformanceSwaps}`,
+ () => {
+ perfTest(
+ 'Swap flow performance',
+ async (
+ { currentDeviceDetails, driver, performanceTracker },
+ testInfo,
+ ) => {
+ // test implementation with TimerHelper and thresholds
+ },
+ );
+ },
+);
+
+// System-only test (functional verification, no perf measurement):
+test.describe(`${System} ${PerformanceLogin}`, () => {
+ test('Verify wallet loads after login', async ({ driver }) => {
+ // No TimerHelper — pure functional check
+ await loginToAppPlaywright();
+ await WalletView.waitForAccountName('Account 1');
+ });
});
```
diff --git a/tests/performance/login/asset-balances.spec.ts b/tests/performance/login/asset-balances.spec.ts
index 40f65f13d5a..e1266b2b634 100644
--- a/tests/performance/login/asset-balances.spec.ts
+++ b/tests/performance/login/asset-balances.spec.ts
@@ -6,12 +6,14 @@ import WalletView from '../../page-objects/wallet/WalletView';
import { loginToAppPlaywright } from '../../flows/wallet.flow';
import {
+ Performance,
+ System,
PerformanceLogin,
PerformanceAssetLoading,
} from '../../tags.performance.js';
/* Scenario: Aggregated Balance Loading Time, SRP 1 + SRP 2 + SRP 3 */
-test.describe(`${PerformanceLogin} ${PerformanceAssetLoading}`, () => {
+test.describe(`${Performance} ${System} ${PerformanceLogin} ${PerformanceAssetLoading}`, () => {
test(
'Aggregated Balance Loading Time, SRP 1 + SRP 2 + SRP 3',
{ tag: '@assets-dev-team' },
diff --git a/tests/performance/login/asset-view.spec.ts b/tests/performance/login/asset-view.spec.ts
index 9d1eb1dffe6..588245772d5 100644
--- a/tests/performance/login/asset-view.spec.ts
+++ b/tests/performance/login/asset-view.spec.ts
@@ -5,45 +5,52 @@ import { asPlaywrightElement, PlaywrightAssertions } from '../../framework';
import WalletView from '../../page-objects/wallet/WalletView';
import TokenOverview from '../../page-objects/wallet/TokenOverview';
import {
+ Performance,
PerformanceLogin,
PerformanceAssetLoading,
} from '../../tags.performance.js';
/* Scenario 8: Asset View, SRP 1 + SRP 2 + SRP 3 */
-perfTest.describe(`${PerformanceLogin} ${PerformanceAssetLoading}`, () => {
- perfTest(
- 'Asset View, SRP 1 + SRP 2 + SRP 3',
- { tag: '@assets-dev-team' },
- async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => {
- await loginToAppPlaywright();
+perfTest.describe(
+ `${Performance} ${PerformanceLogin} ${PerformanceAssetLoading}`,
+ () => {
+ perfTest(
+ 'Asset View, SRP 1 + SRP 2 + SRP 3',
+ { tag: '@assets-dev-team' },
+ async (
+ { currentDeviceDetails, driver, performanceTracker },
+ testInfo,
+ ) => {
+ await loginToAppPlaywright();
- const assetViewScreen = new TimerHelper(
- 'Time since the user clicks on the asset view button until the user sees the token overview screen',
- { ios: 600, android: 4500 },
- currentDeviceDetails.platform,
- );
+ const assetViewScreen = new TimerHelper(
+ 'Time since the user clicks on the asset view button until the user sees the token overview screen',
+ { ios: 600, android: 4500 },
+ currentDeviceDetails.platform,
+ );
- await WalletView.tapOnTokensSection();
- await WalletView.tapOnToken('USDC');
+ await WalletView.tapOnTokensSection();
+ await WalletView.tapOnToken('USDC');
- await assetViewScreen.measure(async () => {
- await PlaywrightAssertions.expectElementToBeVisible(
- asPlaywrightElement(TokenOverview.container),
- );
- await PlaywrightAssertions.expectElementToBeVisible(
- asPlaywrightElement(TokenOverview.sendButton),
- );
- // Replicating the logic of the old spec to wait for the todays change to be visible isTodaysChangeVisible method in the TokenOverview wdio screen object
- await PlaywrightAssertions.expectElementToBeVisibleWithSettle(
- asPlaywrightElement(TokenOverview.todaysChange),
- {
- timeout: 10000,
- settleMs: 500,
- },
- );
- });
+ await assetViewScreen.measure(async () => {
+ await PlaywrightAssertions.expectElementToBeVisible(
+ asPlaywrightElement(TokenOverview.container),
+ );
+ await PlaywrightAssertions.expectElementToBeVisible(
+ asPlaywrightElement(TokenOverview.sendButton),
+ );
+ // Replicating the logic of the old spec to wait for the todays change to be visible isTodaysChangeVisible method in the TokenOverview wdio screen object
+ await PlaywrightAssertions.expectElementToBeVisibleWithSettle(
+ asPlaywrightElement(TokenOverview.todaysChange),
+ {
+ timeout: 10000,
+ settleMs: 500,
+ },
+ );
+ });
- performanceTracker.addTimer(assetViewScreen);
- },
- );
-});
+ performanceTracker.addTimer(assetViewScreen);
+ },
+ );
+ },
+);
diff --git a/tests/performance/login/cross-chain-swap-flow.spec.ts b/tests/performance/login/cross-chain-swap-flow.spec.ts
index edd434c2a84..8d30e4f2dd3 100644
--- a/tests/performance/login/cross-chain-swap-flow.spec.ts
+++ b/tests/performance/login/cross-chain-swap-flow.spec.ts
@@ -1,12 +1,17 @@
import { test } from '../../framework/fixture';
import TimerHelper from '../../framework/TimerHelper.js';
-import { PerformanceLogin, PerformanceSwaps } from '../../tags.performance.js';
+import {
+ Performance,
+ System,
+ PerformanceLogin,
+ PerformanceSwaps,
+} from '../../tags.performance.js';
import { loginToAppPlaywright } from '../../flows/wallet.flow.js';
import WalletView from '../../page-objects/wallet/WalletView.js';
import QuoteView from '../../page-objects/swaps/QuoteView.js';
/* Scenario 7: Cross-chain swap flow - ETH to SOL - 50+ accounts, SRP 1 + SRP 2 + SRP 3 */
-test.describe(`${PerformanceLogin} ${PerformanceSwaps}`, () => {
+test.describe(`${Performance} ${System} ${PerformanceLogin} ${PerformanceSwaps}`, () => {
test(
'Cross-chain swap flow - ETH to SOL - 50+ accounts, SRP 1 + SRP 2 + SRP 3',
{ tag: '@swap-bridge-dev-team' },
diff --git a/tests/performance/login/eth-swap-flow.spec.ts b/tests/performance/login/eth-swap-flow.spec.ts
index 1e1db2e94af..98250f5034b 100644
--- a/tests/performance/login/eth-swap-flow.spec.ts
+++ b/tests/performance/login/eth-swap-flow.spec.ts
@@ -1,12 +1,17 @@
import { test } from '../../framework/fixture';
import TimerHelper from '../../framework/TimerHelper';
-import { PerformanceLogin, PerformanceSwaps } from '../../tags.performance.js';
+import {
+ Performance,
+ System,
+ PerformanceLogin,
+ PerformanceSwaps,
+} from '../../tags.performance.js';
import { loginToAppPlaywright } from '../../flows/wallet.flow';
import WalletView from '../../page-objects/wallet/WalletView';
import QuoteView from '../../page-objects/swaps/QuoteView';
/* Scenario 6: Swap flow - ETH to LINK, SRP 1 + SRP 2 + SRP 3 */
-test.describe(`${PerformanceLogin} ${PerformanceSwaps}`, () => {
+test.describe(`${Performance} ${System} ${PerformanceLogin} ${PerformanceSwaps}`, () => {
test(
'Swap flow - ETH to LINK, SRP 1 + SRP 2 + SRP 3',
{ tag: '@swap-bridge-dev-team' },
diff --git a/tests/performance/login/import-multiple-srps.spec.ts b/tests/performance/login/import-multiple-srps.spec.ts
index ff1b94c7a2d..24942bd68ae 100644
--- a/tests/performance/login/import-multiple-srps.spec.ts
+++ b/tests/performance/login/import-multiple-srps.spec.ts
@@ -7,95 +7,100 @@ import AddAccountBottomSheet from '../../page-objects/wallet/AddAccountBottomShe
import AccountListBottomSheet from '../../page-objects/wallet/AccountListBottomSheet';
import WalletView from '../../page-objects/wallet/WalletView';
import {
+ Performance,
+ System,
PerformanceAccountList,
PerformanceLogin,
} from '../../tags.performance.js';
import PlaywrightGestures from '../../framework/PlaywrightGestures';
/* Scenario 4: Import SRP with +50 accounts, SRP 1, SRP 2, SRP 3 */
-perfTest.describe(`${PerformanceLogin} ${PerformanceAccountList}`, () => {
- perfTest.setTimeout(30 * 60 * 1000);
+perfTest.describe(
+ `${Performance} ${System} ${PerformanceLogin} ${PerformanceAccountList}`,
+ () => {
+ perfTest.setTimeout(30 * 60 * 1000);
- perfTest(
- 'Import SRP with +50 accounts, SRP 1, SRP 2, SRP 3',
- { tag: '@accounts-team' },
- async ({ currentDeviceDetails, driver, performanceTracker }) => {
- const importedSrp = process.env.TEST_SRP_2;
+ perfTest(
+ 'Import SRP with +50 accounts, SRP 1, SRP 2, SRP 3',
+ { tag: '@accounts-team' },
+ async ({ currentDeviceDetails, driver, performanceTracker }) => {
+ const importedSrp = process.env.TEST_SRP_2;
- if (!importedSrp) {
- throw new Error(
- 'TEST_SRP_2 environment variable is required for this performance test.',
- );
- }
-
- await loginToAppPlaywright();
+ if (!importedSrp) {
+ throw new Error(
+ 'TEST_SRP_2 environment variable is required for this performance test.',
+ );
+ }
- const accountListTimer = new TimerHelper(
- 'Time since the user clicks on "Account list" button until the account list is visible',
- { ios: 2500, android: 3000 },
- currentDeviceDetails.platform,
- );
- const addAccountTimer = new TimerHelper(
- 'Time since the user clicks on "Add account" button until the next modal is visible',
- { ios: 1000, android: 1700 },
- currentDeviceDetails.platform,
- );
- const importSrpTimer = new TimerHelper(
- 'Time since the user clicks on "Import SRP" button until SRP field is displayed',
- { ios: 1700, android: 1700 },
- currentDeviceDetails.platform,
- );
- const walletReadyTimer = new TimerHelper(
- 'Time since the user clicks on "Continue" button on SRP screen until Wallet main screen is visible',
- { ios: 5000, android: 2000 },
- currentDeviceDetails.platform,
- );
+ await loginToAppPlaywright();
- await WalletView.tapIdenticon();
- await accountListTimer.measure(async () => {
- await PlaywrightAssertions.expectElementToBeVisible(
- asPlaywrightElement(AccountListBottomSheet.accountList),
- {
- description: 'Account list should be visible',
- },
+ const accountListTimer = new TimerHelper(
+ 'Time since the user clicks on "Account list" button until the account list is visible',
+ { ios: 2500, android: 3000 },
+ currentDeviceDetails.platform,
);
- });
-
- await AccountListBottomSheet.waitForAccountSyncToComplete();
- await AccountListBottomSheet.tapAddAccountButton();
- await addAccountTimer.measure(async () => {
- await PlaywrightAssertions.expectElementToBeVisible(
- asPlaywrightElement(AddAccountBottomSheet.importSrpButton),
- {
- description: 'Add account bottom sheet should be visible',
- },
+ const addAccountTimer = new TimerHelper(
+ 'Time since the user clicks on "Add account" button until the next modal is visible',
+ { ios: 1000, android: 1700 },
+ currentDeviceDetails.platform,
+ );
+ const importSrpTimer = new TimerHelper(
+ 'Time since the user clicks on "Import SRP" button until SRP field is displayed',
+ { ios: 1700, android: 1700 },
+ currentDeviceDetails.platform,
+ );
+ const walletReadyTimer = new TimerHelper(
+ 'Time since the user clicks on "Continue" button on SRP screen until Wallet main screen is visible',
+ { ios: 5000, android: 2000 },
+ currentDeviceDetails.platform,
);
- });
- await AddAccountBottomSheet.tapImportSrp();
- await importSrpTimer.measure(async () => {
- await ImportWalletView.isScreenTitleVisible(false);
- });
+ await WalletView.tapIdenticon();
+ await accountListTimer.measure(async () => {
+ await PlaywrightAssertions.expectElementToBeVisible(
+ asPlaywrightElement(AccountListBottomSheet.accountList),
+ {
+ description: 'Account list should be visible',
+ },
+ );
+ });
- await ImportWalletView.typeSecretRecoveryPhrase(importedSrp, false);
- await PlaywrightGestures.hideKeyboard();
- await ImportWalletView.tapContinueButton(false);
+ await AccountListBottomSheet.waitForAccountSyncToComplete();
+ await AccountListBottomSheet.tapAddAccountButton();
+ await addAccountTimer.measure(async () => {
+ await PlaywrightAssertions.expectElementToBeVisible(
+ asPlaywrightElement(AddAccountBottomSheet.importSrpButton),
+ {
+ description: 'Add account bottom sheet should be visible',
+ },
+ );
+ });
- await walletReadyTimer.measure(async () => {
- await PlaywrightAssertions.expectElementToBeVisible(
- asPlaywrightElement(WalletView.accountIcon),
- {
- description:
- 'Wallet main screen should be visible after importing SRP',
- },
- );
- });
+ await AddAccountBottomSheet.tapImportSrp();
+ await importSrpTimer.measure(async () => {
+ await ImportWalletView.isScreenTitleVisible(false);
+ });
+
+ await ImportWalletView.typeSecretRecoveryPhrase(importedSrp, false);
+ await PlaywrightGestures.hideKeyboard();
+ await ImportWalletView.tapContinueButton(false);
- performanceTracker.addTimers(
- accountListTimer,
- addAccountTimer,
- importSrpTimer,
- walletReadyTimer,
- );
- },
- );
-});
+ await walletReadyTimer.measure(async () => {
+ await PlaywrightAssertions.expectElementToBeVisible(
+ asPlaywrightElement(WalletView.accountIcon),
+ {
+ description:
+ 'Wallet main screen should be visible after importing SRP',
+ },
+ );
+ });
+
+ performanceTracker.addTimers(
+ accountListTimer,
+ addAccountTimer,
+ importSrpTimer,
+ walletReadyTimer,
+ );
+ },
+ );
+ },
+);
diff --git a/tests/performance/login/launch-times/cold-start-to-login.spec.ts b/tests/performance/login/launch-times/cold-start-to-login.spec.ts
index 3630db824a9..08c59502c1d 100644
--- a/tests/performance/login/launch-times/cold-start-to-login.spec.ts
+++ b/tests/performance/login/launch-times/cold-start-to-login.spec.ts
@@ -9,6 +9,7 @@ import { loginToAppPlaywright } from '../../../flows/wallet.flow';
import LoginView from '../../../page-objects/wallet/LoginView';
import WalletView from '../../../page-objects/wallet/WalletView';
import {
+ Performance,
PerformanceLogin,
PerformanceLaunch,
} from '../../../tags.performance.js';
@@ -25,43 +26,50 @@ import {
* The test measures:
* 1. Time to relaunch the app and display the login screen
*/
-perfTest.describe(`${PerformanceLogin} ${PerformanceLaunch}`, () => {
- perfTest(
- 'Cold Start: Measure ColdStart To Login Screen',
- { tag: '@metamask-mobile-platform' },
- async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => {
- await loginToAppPlaywright();
- await PlaywrightAssertions.expectElementToBeVisible(
- asPlaywrightElement(WalletView.accountIcon),
- {
- timeout: 15000,
- description: 'Wallet account icon should be visible before relaunch',
- },
- );
- await WalletView.waitForBalanceToStabilize();
- await PlaywrightGestures.terminateApp(currentDeviceDetails);
-
- const timer1 = new TimerHelper(
- 'Time since the the app is launched, until login screen appears',
- { ios: 3000, android: 4000 },
- currentDeviceDetails.platform,
- );
-
- await PlaywrightGestures.activateApp(currentDeviceDetails);
- await timer1.measure(async () => {
+perfTest.describe(
+ `${Performance} ${PerformanceLogin} ${PerformanceLaunch}`,
+ () => {
+ perfTest(
+ 'Cold Start: Measure ColdStart To Login Screen',
+ { tag: '@metamask-mobile-platform' },
+ async (
+ { currentDeviceDetails, driver, performanceTracker },
+ testInfo,
+ ) => {
+ await loginToAppPlaywright();
await PlaywrightAssertions.expectElementToBeVisible(
- asPlaywrightElement(LoginView.container),
+ asPlaywrightElement(WalletView.accountIcon),
{
- description: 'Login title should be visible',
+ timeout: 15000,
+ description:
+ 'Wallet account icon should be visible before relaunch',
},
);
- });
+ await WalletView.waitForBalanceToStabilize();
+ await PlaywrightGestures.terminateApp(currentDeviceDetails);
+
+ const timer1 = new TimerHelper(
+ 'Time since the the app is launched, until login screen appears',
+ { ios: 3000, android: 4000 },
+ currentDeviceDetails.platform,
+ );
+
+ await PlaywrightGestures.activateApp(currentDeviceDetails);
+ await timer1.measure(async () => {
+ await PlaywrightAssertions.expectElementToBeVisible(
+ asPlaywrightElement(LoginView.container),
+ {
+ description: 'Login title should be visible',
+ },
+ );
+ });
- performanceTracker.addTimers(timer1);
+ performanceTracker.addTimers(timer1);
- console.log('Cold Start to Login Screen Performance Test completed');
- console.log(`Cold Start to Login Screen: ${timer1.getDuration()}ms`);
- console.log(`Total Time: ${timer1.getDuration() ?? 0}ms`);
- },
- );
-});
+ console.log('Cold Start to Login Screen Performance Test completed');
+ console.log(`Cold Start to Login Screen: ${timer1.getDuration()}ms`);
+ console.log(`Total Time: ${timer1.getDuration() ?? 0}ms`);
+ },
+ );
+ },
+);
diff --git a/tests/performance/login/launch-times/warm-start-login-to-wallet.spec.ts b/tests/performance/login/launch-times/warm-start-login-to-wallet.spec.ts
index bd5deae3b07..a0a610340dc 100644
--- a/tests/performance/login/launch-times/warm-start-login-to-wallet.spec.ts
+++ b/tests/performance/login/launch-times/warm-start-login-to-wallet.spec.ts
@@ -10,6 +10,7 @@ import { getPasswordForScenario } from '../../../framework/utils/TestConstants.j
import LoginView from '../../../page-objects/wallet/LoginView';
import WalletView from '../../../page-objects/wallet/WalletView';
import {
+ Performance,
PerformanceLogin,
PerformanceLaunch,
} from '../../../tags.performance.js';
@@ -26,53 +27,59 @@ import {
* The test measures:
* 1. Time to tap Unlock and display the wallet screen again
*/
-perfTest.describe(`${PerformanceLogin} ${PerformanceLaunch}`, () => {
- perfTest(
- 'Measure Warm Start: Login To Wallet Screen',
- { tag: '@metamask-mobile-platform' },
- async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => {
- await loginToAppPlaywright();
- await PlaywrightAssertions.expectElementToBeVisible(
- asPlaywrightElement(WalletView.totalBalance),
- {
- description:
- 'Wallet account icon should be visible before warm start',
- },
- );
-
- await PlaywrightGestures.backgroundApp(35);
- await PlaywrightGestures.activateApp(currentDeviceDetails);
- await PlaywrightAssertions.expectElementToBeVisible(
- asPlaywrightElement(LoginView.passwordInput),
- {
- description: 'Login title should be visible',
- },
- );
- const loginPassword = getPasswordForScenario('login');
- await LoginView.enterPassword(loginPassword);
-
- const timer1 = new TimerHelper(
- 'Time since the user clicks on unlock button, until the app unlocks',
- { ios: 2500, android: 2500 },
- currentDeviceDetails.platform,
- );
+perfTest.describe(
+ `${Performance} ${PerformanceLogin} ${PerformanceLaunch}`,
+ () => {
+ perfTest(
+ 'Measure Warm Start: Login To Wallet Screen',
+ { tag: '@metamask-mobile-platform' },
+ async (
+ { currentDeviceDetails, driver, performanceTracker },
+ testInfo,
+ ) => {
+ await loginToAppPlaywright();
+ await PlaywrightAssertions.expectElementToBeVisible(
+ asPlaywrightElement(WalletView.totalBalance),
+ {
+ description:
+ 'Wallet account icon should be visible before warm start',
+ },
+ );
- await LoginView.tapLoginButton();
- await timer1.measure(async () => {
+ await PlaywrightGestures.backgroundApp(35);
+ await PlaywrightGestures.activateApp(currentDeviceDetails);
await PlaywrightAssertions.expectElementToBeVisible(
- asPlaywrightElement(WalletView.container),
+ asPlaywrightElement(LoginView.passwordInput),
{
- description: 'Wallet balance should be visible',
+ description: 'Login title should be visible',
},
);
- // await WalletView.waitForBalanceToStabilize();
- });
+ const loginPassword = getPasswordForScenario('login');
+ await LoginView.enterPassword(loginPassword);
+
+ const timer1 = new TimerHelper(
+ 'Time since the user clicks on unlock button, until the app unlocks',
+ { ios: 2500, android: 2500 },
+ currentDeviceDetails.platform,
+ );
+
+ await LoginView.tapLoginButton();
+ await timer1.measure(async () => {
+ await PlaywrightAssertions.expectElementToBeVisible(
+ asPlaywrightElement(WalletView.container),
+ {
+ description: 'Wallet balance should be visible',
+ },
+ );
+ // await WalletView.waitForBalanceToStabilize();
+ });
- performanceTracker.addTimers(timer1);
+ performanceTracker.addTimers(timer1);
- console.log('Warm Start Login to Wallet Performance Test completed');
- console.log(`Warm Start Login to Wallet: ${timer1.getDuration()}ms`);
- console.log(`Total Time: ${timer1.getDuration() ?? 0}ms`);
- },
- );
-});
+ console.log('Warm Start Login to Wallet Performance Test completed');
+ console.log(`Warm Start Login to Wallet: ${timer1.getDuration()}ms`);
+ console.log(`Total Time: ${timer1.getDuration() ?? 0}ms`);
+ },
+ );
+ },
+);
diff --git a/tests/performance/login/launch-times/warm-start-to-login.spec.ts b/tests/performance/login/launch-times/warm-start-to-login.spec.ts
index e9189ed8cb9..b8f09a0a95f 100644
--- a/tests/performance/login/launch-times/warm-start-to-login.spec.ts
+++ b/tests/performance/login/launch-times/warm-start-to-login.spec.ts
@@ -9,6 +9,7 @@ import { loginToAppPlaywright } from '../../../flows/wallet.flow';
import LoginView from '../../../page-objects/wallet/LoginView';
import WalletView from '../../../page-objects/wallet/WalletView';
import {
+ Performance,
PerformanceLogin,
PerformanceLaunch,
} from '../../../tags.performance.js';
@@ -25,43 +26,49 @@ import {
* The test measures:
* 1. Time to foreground the app and display the login screen
*/
-perfTest.describe(`${PerformanceLogin} ${PerformanceLaunch}`, () => {
- perfTest(
- 'Measure Warm Start: Warm Start to Login Screen',
- { tag: '@metamask-mobile-platform' },
- async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => {
- await loginToAppPlaywright();
- await PlaywrightAssertions.expectElementToBeVisible(
- asPlaywrightElement(WalletView.accountIcon),
- {
- description:
- 'Wallet account icon should be visible before warm start',
- },
- );
-
- const timer1 = new TimerHelper(
- 'Time since the user open the app again and the login screen appears',
- { ios: 2500, android: 3000 },
- currentDeviceDetails.platform,
- );
-
- await PlaywrightGestures.backgroundApp(35);
- await PlaywrightGestures.activateApp(currentDeviceDetails);
-
- await timer1.measure(async () => {
+perfTest.describe(
+ `${Performance} ${PerformanceLogin} ${PerformanceLaunch}`,
+ () => {
+ perfTest(
+ 'Measure Warm Start: Warm Start to Login Screen',
+ { tag: '@metamask-mobile-platform' },
+ async (
+ { currentDeviceDetails, driver, performanceTracker },
+ testInfo,
+ ) => {
+ await loginToAppPlaywright();
await PlaywrightAssertions.expectElementToBeVisible(
- asPlaywrightElement(LoginView.container),
+ asPlaywrightElement(WalletView.accountIcon),
{
- description: 'Login title should be visible',
+ description:
+ 'Wallet account icon should be visible before warm start',
},
);
- });
- performanceTracker.addTimers(timer1);
+ const timer1 = new TimerHelper(
+ 'Time since the user open the app again and the login screen appears',
+ { ios: 2500, android: 3000 },
+ currentDeviceDetails.platform,
+ );
+
+ await PlaywrightGestures.backgroundApp(35);
+ await PlaywrightGestures.activateApp(currentDeviceDetails);
+
+ await timer1.measure(async () => {
+ await PlaywrightAssertions.expectElementToBeVisible(
+ asPlaywrightElement(LoginView.container),
+ {
+ description: 'Login title should be visible',
+ },
+ );
+ });
+
+ performanceTracker.addTimers(timer1);
- console.log('Warm Start to Login Screen Performance Test completed');
- console.log(`Warm Start to Login Screen: ${timer1.getDuration()}ms`);
- console.log(`Total Time: ${timer1.getDuration() ?? 0}ms`);
- },
- );
-});
+ console.log('Warm Start to Login Screen Performance Test completed');
+ console.log(`Warm Start to Login Screen: ${timer1.getDuration()}ms`);
+ console.log(`Total Time: ${timer1.getDuration() ?? 0}ms`);
+ },
+ );
+ },
+);
diff --git a/tests/performance/login/perps-add-funds.spec.ts b/tests/performance/login/perps-add-funds.spec.ts
index d85c6d22195..90076f244f8 100644
--- a/tests/performance/login/perps-add-funds.spec.ts
+++ b/tests/performance/login/perps-add-funds.spec.ts
@@ -1,6 +1,6 @@
import { test } from '../../framework/fixture';
import TimerHelper from '../../framework/TimerHelper';
-import { PerformancePreps } from '../../tags.performance.js';
+import { Performance, PerformancePreps } from '../../tags.performance.js';
import { loginToAppPlaywright } from '../../flows/wallet.flow';
import TabBarComponent from '../../page-objects/wallet/TabBarComponent';
import PerpsOnboarding from '../../page-objects/Perps/PerpsOnboarding';
@@ -10,7 +10,7 @@ import PlaywrightAssertions from '../../framework/PlaywrightAssertions';
import { asPlaywrightElement } from '../../framework/EncapsulatedElement';
/* Scenario 5: Perps add funds */
-test.describe(PerformancePreps, () => {
+test.describe(`${Performance} ${PerformancePreps}`, () => {
test(
'Perps add funds',
{ tag: '@mm-perps-engineering-team' },
diff --git a/tests/performance/login/perps-position-management.spec.ts b/tests/performance/login/perps-position-management.spec.ts
index e04159d5b39..2e0622a0893 100644
--- a/tests/performance/login/perps-position-management.spec.ts
+++ b/tests/performance/login/perps-position-management.spec.ts
@@ -1,7 +1,7 @@
import { test } from '../../framework/fixture';
import TimerHelper from '../../framework/TimerHelper';
-import { PerformancePreps } from '../../tags.performance.js';
+import { Performance, PerformancePreps } from '../../tags.performance.js';
import {
loginToAppPlaywright,
selectAccountByDevice,
@@ -21,7 +21,7 @@ import PlaywrightAssertions from '../../framework/PlaywrightAssertions';
import { asPlaywrightElement } from '../../framework/EncapsulatedElement';
/* Scenario 5: Perps onboarding + add funds 10 USD ARB.USDC + Open Position + Close Position */
-test.describe(PerformancePreps, () => {
+test.describe(`${Performance} ${PerformancePreps}`, () => {
test(
'Perps open position and close it',
{ tag: '@mm-perps-engineering-team' },
diff --git a/tests/performance/login/predict/predict-available-balance.spec.ts b/tests/performance/login/predict/predict-available-balance.spec.ts
index 0e44e0a7da0..3d23de4dbac 100644
--- a/tests/performance/login/predict/predict-available-balance.spec.ts
+++ b/tests/performance/login/predict/predict-available-balance.spec.ts
@@ -5,7 +5,7 @@ import { asPlaywrightElement, PlaywrightAssertions } from '../../../framework';
import TabBarComponent from '../../../page-objects/wallet/TabBarComponent';
import WalletActionsBottomSheet from '../../../page-objects/wallet/WalletActionsBottomSheet';
import PredictMarketList from '../../../page-objects/Predict/PredictMarketList';
-import { PerformancePredict } from '../../../tags.performance.js';
+import { Performance, PerformancePredict } from '../../../tags.performance.js';
/*
* Scenario: Predict Available Balance Performance Test
@@ -21,7 +21,7 @@ import { PerformancePredict } from '../../../tags.performance.js';
* 1. Time to navigate to Predict tab
* 2. Time to verify available balance info is displayed
*/
-perfTest.describe(PerformancePredict, () => {
+perfTest.describe(`${Performance} ${PerformancePredict}`, () => {
perfTest(
'Predict Available Balance - Complete Flow Performance',
{ tag: '@team-predict' },
diff --git a/tests/performance/login/predict/predict-deposit.spec.ts b/tests/performance/login/predict/predict-deposit.spec.ts
index cbb84b92072..64720f868d1 100644
--- a/tests/performance/login/predict/predict-deposit.spec.ts
+++ b/tests/performance/login/predict/predict-deposit.spec.ts
@@ -6,7 +6,7 @@ import TabBarComponent from '../../../page-objects/wallet/TabBarComponent';
import WalletActionsBottomSheet from '../../../page-objects/wallet/WalletActionsBottomSheet';
import TransactionPayConfirmation from '../../../page-objects/Confirmation/TransactionPayConfirmation';
import PredictMarketList from '../../../page-objects/Predict/PredictMarketList';
-import { PerformancePredict } from '../../../tags.performance.js';
+import { Performance, PerformancePredict } from '../../../tags.performance.js';
/*
* Scenario: Predict Deposit Performance Test
@@ -24,7 +24,7 @@ import { PerformancePredict } from '../../../tags.performance.js';
* 4. Time to proceed to confirmation screen
* 5. Time to verify deposit info (fees, amount) appears
*/
-perfTest.describe(PerformancePredict, () => {
+perfTest.describe(`${Performance} ${PerformancePredict}`, () => {
perfTest(
'Predict Deposit - Complete Flow Performance',
{ tag: '@team-predict' },
diff --git a/tests/performance/login/predict/predict-market-details.spec.ts b/tests/performance/login/predict/predict-market-details.spec.ts
index 4d4cdfa951b..7d28502496d 100644
--- a/tests/performance/login/predict/predict-market-details.spec.ts
+++ b/tests/performance/login/predict/predict-market-details.spec.ts
@@ -6,7 +6,7 @@ import TabBarComponent from '../../../page-objects/wallet/TabBarComponent';
import WalletActionsBottomSheet from '../../../page-objects/wallet/WalletActionsBottomSheet';
import PredictMarketList from '../../../page-objects/Predict/PredictMarketList';
import PredictDetailsPage from '../../../page-objects/Predict/PredictDetailsPage';
-import { PerformancePredict } from '../../../tags.performance.js';
+import { Performance, PerformancePredict } from '../../../tags.performance.js';
/*
* Scenario: Predict Market Details Performance Test
@@ -23,7 +23,7 @@ import { PerformancePredict } from '../../../tags.performance.js';
* 3. Time to open About tab content
* 4. Time to open Outcomes tab content when available
*/
-perfTest.describe(PerformancePredict, () => {
+perfTest.describe(`${Performance} ${PerformancePredict}`, () => {
perfTest.setTimeout(10 * 60 * 1000);
perfTest(
diff --git a/tests/performance/mm-connect/connection-evm-account.spec.ts b/tests/performance/mm-connect/connection-evm-account.spec.ts
index 960106682a0..c7747a5abd4 100644
--- a/tests/performance/mm-connect/connection-evm-account.spec.ts
+++ b/tests/performance/mm-connect/connection-evm-account.spec.ts
@@ -1,4 +1,5 @@
import { test } from '../../framework/fixture';
+import { Performance } from '../../tags.performance.js';
import { loginToAppPlaywright } from '../../flows/wallet.flow';
import WalletView from '../../page-objects/wallet/WalletView';
@@ -38,151 +39,153 @@ const playgroundServer = new DappServer({
dappVariant: DappVariants.BROWSER_PLAYGROUND,
});
-test.beforeAll(async () => {
- playgroundServer.setServerPort(DAPP_PORT);
- await playgroundServer.start();
- await waitForDappServerReady(DAPP_PORT);
- setupAdbReverse(DAPP_PORT);
-});
-
-test.afterAll(async () => {
- cleanupAdbReverse(DAPP_PORT);
- await playgroundServer.stop();
-});
-
-// Test steps (in order):
-//
-// 1. LOGIN AND NAVIGATE TO DAPP
-// - Login to app, ensure account groups finished loading
-// - Launch mobile browser and navigate to the playground dapp
-//
-// 2. CONNECT VIA LEGACY EVM (WITH ACCOUNT 3 ADDED)
-// - Tap Connect (Legacy)
-// - In MetaMask: tap Edit Accounts, add Account 3, tap Update, tap Connect (cooldown 2s)
-// Account 3 must be authorized so MetaMask can switch to it later
-// - Assert: connected true, chainId '0x1', active account is Account 1
-// (0x19a7Ad8256ab119655f1D758348501d598fC1C94)
-//
-// 3. SWITCH TO ACCOUNT 3 IN METAMASK
-// - In MetaMask: tap identicon → select Account 3 from the account list
-// - Assert: dapp reflects Account 3 as the active account
-// (0xE2bEca5CaDC60b61368987728b4229822e6CDa83)
-//
-// 4. REFRESH BROWSER AND VERIFY ACCOUNT PERSISTS
-// - Refresh mobile browser (native action)
-// - Assert: connected true, chainId '0x1', active account is still Account 3
-// (0xE2bEca5CaDC60b61368987728b4229822e6CDa83)
-//
-// 5. PERSONAL SIGN TO VERIFY WALLET-SIDE ACCOUNT
-// - Tap personal sign
-// - In MetaMask: tap Cancel (cooldown 2s)
-// Canceling verifies Account 3 appears as the signer in the modal (wallet-side check)
-// - Assert: response value 'rejected'
-//
-// 6. CLEANUP
-// - Tap disconnect to reset dapp state
-
-test('@metamask/connect-evm - Account switching and wallet-side verification', async ({
- currentDeviceDetails,
- driver,
-}) => {
- const platform = currentDeviceDetails.platform;
- const useBrowserStackLocal =
- process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true';
- const DAPP_URL = useBrowserStackLocal
- ? `http://bs-local.com:${DAPP_PORT}`
- : getDappUrlForBrowser(platform);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await loginToAppPlaywright();
- await ensureAccountGroupsFinishedLoading(currentDeviceDetails);
- await launchMobileBrowser();
- await navigateToDapp(DAPP_URL);
- });
- await sleep(5000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.tapConnectLegacy();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await unlockIfLockScreenVisible();
- await DappConnectionModal.tapEditAccountsButton();
- await DappConnectionModal.tapAccountButton('Account 3');
- await DappConnectionModal.tapUpdateAccountsButton();
- await DappConnectionModal.tapConnectButton({
- shouldCooldown: true,
- timeToCooldown: 2000,
- });
+test.describe(Performance, () => {
+ test.beforeAll(async () => {
+ playgroundServer.setServerPort(DAPP_PORT);
+ await playgroundServer.start();
+ await waitForDappServerReady(DAPP_PORT);
+ setupAdbReverse(DAPP_PORT);
});
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
+ test.afterAll(async () => {
+ cleanupAdbReverse(DAPP_PORT);
+ await playgroundServer.stop();
+ });
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertConnected(true);
- await BrowserPlaygroundDapp.assertChainIdValue('0x1');
- await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS);
- }, DAPP_URL);
+ // Test steps (in order):
+ //
+ // 1. LOGIN AND NAVIGATE TO DAPP
+ // - Login to app, ensure account groups finished loading
+ // - Launch mobile browser and navigate to the playground dapp
+ //
+ // 2. CONNECT VIA LEGACY EVM (WITH ACCOUNT 3 ADDED)
+ // - Tap Connect (Legacy)
+ // - In MetaMask: tap Edit Accounts, add Account 3, tap Update, tap Connect (cooldown 2s)
+ // Account 3 must be authorized so MetaMask can switch to it later
+ // - Assert: connected true, chainId '0x1', active account is Account 1
+ // (0x19a7Ad8256ab119655f1D758348501d598fC1C94)
+ //
+ // 3. SWITCH TO ACCOUNT 3 IN METAMASK
+ // - In MetaMask: tap identicon → select Account 3 from the account list
+ // - Assert: dapp reflects Account 3 as the active account
+ // (0xE2bEca5CaDC60b61368987728b4229822e6CDa83)
+ //
+ // 4. REFRESH BROWSER AND VERIFY ACCOUNT PERSISTS
+ // - Refresh mobile browser (native action)
+ // - Assert: connected true, chainId '0x1', active account is still Account 3
+ // (0xE2bEca5CaDC60b61368987728b4229822e6CDa83)
+ //
+ // 5. PERSONAL SIGN TO VERIFY WALLET-SIDE ACCOUNT
+ // - Tap personal sign
+ // - In MetaMask: tap Cancel (cooldown 2s)
+ // Canceling verifies Account 3 appears as the signer in the modal (wallet-side check)
+ // - Assert: response value 'rejected'
+ //
+ // 6. CLEANUP
+ // - Tap disconnect to reset dapp state
+
+ test('@metamask/connect-evm - Account switching and wallet-side verification', async ({
+ currentDeviceDetails,
+ driver,
+ }) => {
+ const platform = currentDeviceDetails.platform;
+ const useBrowserStackLocal =
+ process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true';
+ const DAPP_URL = useBrowserStackLocal
+ ? `http://bs-local.com:${DAPP_PORT}`
+ : getDappUrlForBrowser(platform);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await loginToAppPlaywright();
+ await ensureAccountGroupsFinishedLoading(currentDeviceDetails);
+ await launchMobileBrowser();
+ await navigateToDapp(DAPP_URL);
+ });
+ await sleep(5000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.tapConnectLegacy();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await unlockIfLockScreenVisible();
+ await DappConnectionModal.tapEditAccountsButton();
+ await DappConnectionModal.tapAccountButton('Account 3');
+ await DappConnectionModal.tapUpdateAccountsButton();
+ await DappConnectionModal.tapConnectButton({
+ shouldCooldown: true,
+ timeToCooldown: 2000,
+ });
+ });
- await PlaywrightContextHelpers.withNativeAction(async () => {
- // Wait here to make sure UI is visible before attempted interaction
await sleep(1000);
- // We're only using Android for now
- await PlaywrightUtilities.launchApp({
- packageName: APP_PACKAGE_IDS.ANDROID,
- });
- await unlockIfLockScreenVisible();
+ await switchToMobileBrowser();
+ await sleep(1000);
- // Change selected account to Account 3 in MetaMask
- await WalletView.tapIdenticon();
- await AccountListBottomSheet.tapAccountByName('Account 3');
- });
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertConnected(true);
+ await BrowserPlaygroundDapp.assertChainIdValue('0x1');
+ await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS);
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ // Wait here to make sure UI is visible before attempted interaction
+ await sleep(1000);
+ // We're only using Android for now
+ await PlaywrightUtilities.launchApp({
+ packageName: APP_PACKAGE_IDS.ANDROID,
+ });
+ await unlockIfLockScreenVisible();
+
+ // Change selected account to Account 3 in MetaMask
+ await WalletView.tapIdenticon();
+ await AccountListBottomSheet.tapAccountByName('Account 3');
+ });
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
- await PlaywrightContextHelpers.withWebAction(async () => {
- // Verify account changed to Account 3
- await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_3_ADDRESS);
- }, DAPP_URL);
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ // Verify account changed to Account 3
+ await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_3_ADDRESS);
+ }, DAPP_URL);
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await refreshMobileBrowser();
- });
- await sleep(2000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertConnected(true);
- await BrowserPlaygroundDapp.assertChainIdValue('0x1');
- await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_3_ADDRESS);
- await BrowserPlaygroundDapp.tapPersonalSign();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await SignModal.tapCancelButton({
- shouldCooldown: true,
- timeToCooldown: 2000,
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await refreshMobileBrowser();
+ });
+ await sleep(2000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertConnected(true);
+ await BrowserPlaygroundDapp.assertChainIdValue('0x1');
+ await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_3_ADDRESS);
+ await BrowserPlaygroundDapp.tapPersonalSign();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await SignModal.tapCancelButton({
+ shouldCooldown: true,
+ timeToCooldown: 2000,
+ });
});
- });
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertResponseValue('rejected');
- }, DAPP_URL);
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertResponseValue('rejected');
+ }, DAPP_URL);
- //
- // Reset dapp state
- //
+ //
+ // Reset dapp state
+ //
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.tapDisconnect();
- }, DAPP_URL);
-});
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.tapDisconnect();
+ }, DAPP_URL);
+ });
+}); // end describe
diff --git a/tests/performance/mm-connect/connection-evm-rejection.spec.ts b/tests/performance/mm-connect/connection-evm-rejection.spec.ts
index eab3bcae747..22a6014a1df 100644
--- a/tests/performance/mm-connect/connection-evm-rejection.spec.ts
+++ b/tests/performance/mm-connect/connection-evm-rejection.spec.ts
@@ -1,4 +1,5 @@
import { test } from '../../framework/fixture';
+import { Performance } from '../../tags.performance.js';
import { loginToAppPlaywright } from '../../flows/wallet.flow';
import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp';
@@ -32,197 +33,199 @@ const playgroundServer = new DappServer({
dappVariant: DappVariants.BROWSER_PLAYGROUND,
});
-test.beforeAll(async () => {
- playgroundServer.setServerPort(DAPP_PORT);
- await playgroundServer.start();
- await waitForDappServerReady(DAPP_PORT);
- setupAdbReverse(DAPP_PORT);
-});
-
-test.afterAll(async () => {
- cleanupAdbReverse(DAPP_PORT);
- await playgroundServer.stop();
-});
-
-// Test steps (in order):
-//
-// 1. LOGIN AND NAVIGATE TO DAPP
-// - Login to app, ensure account groups finished loading
-// - Launch mobile browser and navigate to the playground dapp
-//
-// 2. CONNECT VIA LEGACY EVM
-// - Tap Connect (Legacy)
-// - In MetaMask: tap Connect (cooldown 2s)
-// - Assert: connected true, chainId '0x1', active account is Account 1
-// (0x19a7Ad8256ab119655f1D758348501d598fC1C94)
-//
-// 3. INITIAL REJECTION
-// - Tap personal sign
-// - In MetaMask: tap Cancel (cooldown 2s)
-// - Assert: response value 'rejected'
-//
-// 4. DISCONNECT AND RECONNECT — FIRST CYCLE
-// - Disconnect, assert connected false, tap Connect (Legacy)
-// - In MetaMask: tap Connect (cooldown 2s)
-// - Assert: connected true, chainId '0x1', active account is Account 1
-// - Tap personal sign; tap Cancel in MetaMask (cooldown 2s)
-// - Assert: response value 'rejected'
-//
-// 5. DISCONNECT AND RECONNECT — SECOND CYCLE
-// - Disconnect (no connected false assertion), tap Connect (Legacy)
-// - In MetaMask: tap Connect (cooldown 2s)
-// - Assert: connected true, chainId '0x1', active account is Account 1
-// - Tap personal sign; tap Cancel in MetaMask (cooldown 2s)
-// - Assert: response value 'rejected'
-//
-// 6. CLEANUP
-// - Tap disconnect to reset dapp state
-
-test('@metamask/connect-evm - Rejection response value verification', async ({
- currentDeviceDetails,
- driver,
-}) => {
- const platform = currentDeviceDetails.platform;
- const useBrowserStackLocal =
- process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true';
- const DAPP_URL = useBrowserStackLocal
- ? `http://bs-local.com:${DAPP_PORT}`
- : getDappUrlForBrowser(platform);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await loginToAppPlaywright();
- await ensureAccountGroupsFinishedLoading(currentDeviceDetails);
- await launchMobileBrowser();
- await navigateToDapp(DAPP_URL);
- });
- await sleep(5000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.tapConnectLegacy();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await unlockIfLockScreenVisible();
- await DappConnectionModal.tapConnectButton({
- shouldCooldown: true,
- timeToCooldown: 2000,
- });
+test.describe(Performance, () => {
+ test.beforeAll(async () => {
+ playgroundServer.setServerPort(DAPP_PORT);
+ await playgroundServer.start();
+ await waitForDappServerReady(DAPP_PORT);
+ setupAdbReverse(DAPP_PORT);
});
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertConnected(true);
- await BrowserPlaygroundDapp.assertChainIdValue('0x1');
- await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS);
- await BrowserPlaygroundDapp.tapPersonalSign();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await SignModal.tapCancelButton({
- shouldCooldown: true,
- timeToCooldown: 2000,
- });
+ test.afterAll(async () => {
+ cleanupAdbReverse(DAPP_PORT);
+ await playgroundServer.stop();
});
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertResponseValue('rejected');
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.tapDisconnect();
- await BrowserPlaygroundDapp.assertConnected(false);
- await BrowserPlaygroundDapp.tapConnectLegacy();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await DappConnectionModal.tapConnectButton({
- shouldCooldown: true,
- timeToCooldown: 2000,
+ // Test steps (in order):
+ //
+ // 1. LOGIN AND NAVIGATE TO DAPP
+ // - Login to app, ensure account groups finished loading
+ // - Launch mobile browser and navigate to the playground dapp
+ //
+ // 2. CONNECT VIA LEGACY EVM
+ // - Tap Connect (Legacy)
+ // - In MetaMask: tap Connect (cooldown 2s)
+ // - Assert: connected true, chainId '0x1', active account is Account 1
+ // (0x19a7Ad8256ab119655f1D758348501d598fC1C94)
+ //
+ // 3. INITIAL REJECTION
+ // - Tap personal sign
+ // - In MetaMask: tap Cancel (cooldown 2s)
+ // - Assert: response value 'rejected'
+ //
+ // 4. DISCONNECT AND RECONNECT — FIRST CYCLE
+ // - Disconnect, assert connected false, tap Connect (Legacy)
+ // - In MetaMask: tap Connect (cooldown 2s)
+ // - Assert: connected true, chainId '0x1', active account is Account 1
+ // - Tap personal sign; tap Cancel in MetaMask (cooldown 2s)
+ // - Assert: response value 'rejected'
+ //
+ // 5. DISCONNECT AND RECONNECT — SECOND CYCLE
+ // - Disconnect (no connected false assertion), tap Connect (Legacy)
+ // - In MetaMask: tap Connect (cooldown 2s)
+ // - Assert: connected true, chainId '0x1', active account is Account 1
+ // - Tap personal sign; tap Cancel in MetaMask (cooldown 2s)
+ // - Assert: response value 'rejected'
+ //
+ // 6. CLEANUP
+ // - Tap disconnect to reset dapp state
+
+ test('@metamask/connect-evm - Rejection response value verification', async ({
+ currentDeviceDetails,
+ driver,
+ }) => {
+ const platform = currentDeviceDetails.platform;
+ const useBrowserStackLocal =
+ process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true';
+ const DAPP_URL = useBrowserStackLocal
+ ? `http://bs-local.com:${DAPP_PORT}`
+ : getDappUrlForBrowser(platform);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await loginToAppPlaywright();
+ await ensureAccountGroupsFinishedLoading(currentDeviceDetails);
+ await launchMobileBrowser();
+ await navigateToDapp(DAPP_URL);
});
- });
-
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertConnected(true);
- await BrowserPlaygroundDapp.assertChainIdValue('0x1');
- await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS);
- await BrowserPlaygroundDapp.tapPersonalSign();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await SignModal.tapCancelButton({
- shouldCooldown: true,
- timeToCooldown: 2000,
+ await sleep(5000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.tapConnectLegacy();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await unlockIfLockScreenVisible();
+ await DappConnectionModal.tapConnectButton({
+ shouldCooldown: true,
+ timeToCooldown: 2000,
+ });
});
- });
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertConnected(true);
+ await BrowserPlaygroundDapp.assertChainIdValue('0x1');
+ await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS);
+ await BrowserPlaygroundDapp.tapPersonalSign();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await SignModal.tapCancelButton({
+ shouldCooldown: true,
+ timeToCooldown: 2000,
+ });
+ });
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertResponseValue('rejected');
- }, DAPP_URL);
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertResponseValue('rejected');
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.tapDisconnect();
+ await BrowserPlaygroundDapp.assertConnected(false);
+ await BrowserPlaygroundDapp.tapConnectLegacy();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await DappConnectionModal.tapConnectButton({
+ shouldCooldown: true,
+ timeToCooldown: 2000,
+ });
+ });
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.tapDisconnect();
- await BrowserPlaygroundDapp.tapConnectLegacy();
- }, DAPP_URL);
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertConnected(true);
+ await BrowserPlaygroundDapp.assertChainIdValue('0x1');
+ await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS);
+ await BrowserPlaygroundDapp.tapPersonalSign();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await SignModal.tapCancelButton({
+ shouldCooldown: true,
+ timeToCooldown: 2000,
+ });
+ });
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await DappConnectionModal.tapConnectButton({
- shouldCooldown: true,
- timeToCooldown: 2000,
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertResponseValue('rejected');
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.tapDisconnect();
+ await BrowserPlaygroundDapp.tapConnectLegacy();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await DappConnectionModal.tapConnectButton({
+ shouldCooldown: true,
+ timeToCooldown: 2000,
+ });
});
- });
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertConnected(true);
- await BrowserPlaygroundDapp.assertChainIdValue('0x1');
- await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS);
- await BrowserPlaygroundDapp.tapPersonalSign();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await SignModal.tapCancelButton({
- shouldCooldown: true,
- timeToCooldown: 2000,
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertConnected(true);
+ await BrowserPlaygroundDapp.assertChainIdValue('0x1');
+ await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS);
+ await BrowserPlaygroundDapp.tapPersonalSign();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await SignModal.tapCancelButton({
+ shouldCooldown: true,
+ timeToCooldown: 2000,
+ });
});
- });
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertResponseValue('rejected');
- }, DAPP_URL);
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertResponseValue('rejected');
+ }, DAPP_URL);
- //
- // Reset dapp state
- //
+ //
+ // Reset dapp state
+ //
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.tapDisconnect();
- }, DAPP_URL);
-});
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.tapDisconnect();
+ }, DAPP_URL);
+ });
+}); // end describe
diff --git a/tests/performance/mm-connect/connection-evm-session-timeout.spec.ts b/tests/performance/mm-connect/connection-evm-session-timeout.spec.ts
index 52c8d803ecc..b66b9169904 100644
--- a/tests/performance/mm-connect/connection-evm-session-timeout.spec.ts
+++ b/tests/performance/mm-connect/connection-evm-session-timeout.spec.ts
@@ -1,4 +1,5 @@
import { test } from '../../framework/fixture';
+import { Performance } from '../../tags.performance.js';
import { loginToAppPlaywright } from '../../flows/wallet.flow';
import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp';
@@ -35,158 +36,160 @@ const playgroundServer = new DappServer({
dappVariant: DappVariants.BROWSER_PLAYGROUND,
});
-test.beforeAll(async () => {
- playgroundServer.setServerPort(DAPP_PORT);
- await playgroundServer.start();
- await waitForDappServerReady(DAPP_PORT);
- setupAdbReverse(DAPP_PORT);
-});
-
-test.afterAll(async () => {
- cleanupAdbReverse(DAPP_PORT);
- await playgroundServer.stop();
-});
-
-// Test steps (in order):
-//
-// 1. LOGIN AND NAVIGATE TO DAPP
-// - Login to app, ensure account groups finished loading
-// - Launch mobile browser and navigate to the playground dapp
-//
-// 2. CONNECT VIA LEGACY EVM
-// - Tap Connect (Legacy)
-// - In MetaMask: tap Connect (cooldown 2s)
-// - Assert: connected true, chainId '0x1'
-//
-// 3. INCOMPLETE SESSION — NOT INTERACTING WITH MODAL
-// - Tap disconnect, then tap Connect (Legacy) again
-// - In MetaMask: open approval modal but purposely do NOT interact (sleep 2s)
-// - Switch to browser (sleep 2s), refresh mobile browser, wait 2s
-// - Assert: connected false (incomplete session started timing out)
-// - Sleep 10s to let session fully time out
-// - Assert: still connected false
-//
-// 4. RECONNECT AFTER SESSION TIMEOUT
-// - Tap Connect (Legacy)
-// - In MetaMask: tap Connect (cooldown 2s)
-// - Assert: connected true, chainId '0x1'
-//
-// 5. READ-ONLY METHOD WITH APP TERMINATED
-// - Terminate the MetaMask app (PlaywrightGestures.terminateApp)
-// - Tap getBalance in the dapp; sleep 10s for RPC response
-// - Assert: response value contains 'Balance:' prefix
-// (confirms read-only calls go directly to the RPC endpoint, not the wallet)
-//
-// 6. CLEANUP
-// - Tap disconnect to reset dapp state
-
-// This test is currently being skipped as the mobile app displays a double prompt.
-test.skip('@metamask/connect-evm - Incomplete session timeout and read-only methods', async ({
- currentDeviceDetails,
- driver,
-}) => {
- const platform = currentDeviceDetails.platform;
- const useBrowserStackLocal =
- process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true';
- const DAPP_URL = useBrowserStackLocal
- ? `http://bs-local.com:${DAPP_PORT}`
- : getDappUrlForBrowser(platform);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await loginToAppPlaywright();
- await ensureAccountGroupsFinishedLoading(currentDeviceDetails);
- await launchMobileBrowser();
- await navigateToDapp(DAPP_URL);
+test.describe(Performance, () => {
+ test.beforeAll(async () => {
+ playgroundServer.setServerPort(DAPP_PORT);
+ await playgroundServer.start();
+ await waitForDappServerReady(DAPP_PORT);
+ setupAdbReverse(DAPP_PORT);
});
- await sleep(5000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.tapConnectLegacy();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await unlockIfLockScreenVisible();
- await DappConnectionModal.tapConnectButton({
- shouldCooldown: true,
- timeToCooldown: 2000,
- });
- });
-
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertConnected(true);
- await BrowserPlaygroundDapp.assertChainIdValue('0x1');
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.tapDisconnect();
- await BrowserPlaygroundDapp.tapConnectLegacy();
- }, DAPP_URL);
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- // Purposely not interacting with the approval but we still spend some time
- // on the app
- await sleep(2000);
- });
-
- await sleep(2000);
- await switchToMobileBrowser();
- await sleep(1000);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await refreshMobileBrowser();
+ test.afterAll(async () => {
+ cleanupAdbReverse(DAPP_PORT);
+ await playgroundServer.stop();
});
- await sleep(2000);
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertConnected(false);
- }, DAPP_URL);
+ // Test steps (in order):
+ //
+ // 1. LOGIN AND NAVIGATE TO DAPP
+ // - Login to app, ensure account groups finished loading
+ // - Launch mobile browser and navigate to the playground dapp
+ //
+ // 2. CONNECT VIA LEGACY EVM
+ // - Tap Connect (Legacy)
+ // - In MetaMask: tap Connect (cooldown 2s)
+ // - Assert: connected true, chainId '0x1'
+ //
+ // 3. INCOMPLETE SESSION — NOT INTERACTING WITH MODAL
+ // - Tap disconnect, then tap Connect (Legacy) again
+ // - In MetaMask: open approval modal but purposely do NOT interact (sleep 2s)
+ // - Switch to browser (sleep 2s), refresh mobile browser, wait 2s
+ // - Assert: connected false (incomplete session started timing out)
+ // - Sleep 10s to let session fully time out
+ // - Assert: still connected false
+ //
+ // 4. RECONNECT AFTER SESSION TIMEOUT
+ // - Tap Connect (Legacy)
+ // - In MetaMask: tap Connect (cooldown 2s)
+ // - Assert: connected true, chainId '0x1'
+ //
+ // 5. READ-ONLY METHOD WITH APP TERMINATED
+ // - Terminate the MetaMask app (PlaywrightGestures.terminateApp)
+ // - Tap getBalance in the dapp; sleep 10s for RPC response
+ // - Assert: response value contains 'Balance:' prefix
+ // (confirms read-only calls go directly to the RPC endpoint, not the wallet)
+ //
+ // 6. CLEANUP
+ // - Tap disconnect to reset dapp state
+
+ // This test is currently being skipped as the mobile app displays a double prompt.
+ test.skip('@metamask/connect-evm - Incomplete session timeout and read-only methods', async ({
+ currentDeviceDetails,
+ driver,
+ }) => {
+ const platform = currentDeviceDetails.platform;
+ const useBrowserStackLocal =
+ process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true';
+ const DAPP_URL = useBrowserStackLocal
+ ? `http://bs-local.com:${DAPP_PORT}`
+ : getDappUrlForBrowser(platform);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await loginToAppPlaywright();
+ await ensureAccountGroupsFinishedLoading(currentDeviceDetails);
+ await launchMobileBrowser();
+ await navigateToDapp(DAPP_URL);
+ });
+ await sleep(5000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.tapConnectLegacy();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await unlockIfLockScreenVisible();
+ await DappConnectionModal.tapConnectButton({
+ shouldCooldown: true,
+ timeToCooldown: 2000,
+ });
+ });
- await sleep(10000);
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertConnected(true);
+ await BrowserPlaygroundDapp.assertChainIdValue('0x1');
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.tapDisconnect();
+ await BrowserPlaygroundDapp.tapConnectLegacy();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ // Purposely not interacting with the approval but we still spend some time
+ // on the app
+ await sleep(2000);
+ });
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertConnected(false);
- await BrowserPlaygroundDapp.tapConnectLegacy();
- }, DAPP_URL);
+ await sleep(2000);
+ await switchToMobileBrowser();
+ await sleep(1000);
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await DappConnectionModal.tapConnectButton({
- shouldCooldown: true,
- timeToCooldown: 2000,
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await refreshMobileBrowser();
});
- });
-
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
+ await sleep(2000);
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertConnected(true);
- await BrowserPlaygroundDapp.assertChainIdValue('0x1');
- }, DAPP_URL);
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertConnected(false);
+ }, DAPP_URL);
- //
- // Read-only method should hit rpc endpoint instead of wallet
- //
- await PlaywrightGestures.terminateApp(currentDeviceDetails);
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.tapGetBalance();
await sleep(10000);
- // Balance response should contain "Balance:" prefix
- await BrowserPlaygroundDapp.assertResponseValue('Balance:');
- }, DAPP_URL);
- //
- // Reset dapp state
- //
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertConnected(false);
+ await BrowserPlaygroundDapp.tapConnectLegacy();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await DappConnectionModal.tapConnectButton({
+ shouldCooldown: true,
+ timeToCooldown: 2000,
+ });
+ });
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.tapDisconnect();
- }, DAPP_URL);
-});
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertConnected(true);
+ await BrowserPlaygroundDapp.assertChainIdValue('0x1');
+ }, DAPP_URL);
+
+ //
+ // Read-only method should hit rpc endpoint instead of wallet
+ //
+ await PlaywrightGestures.terminateApp(currentDeviceDetails);
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.tapGetBalance();
+ await sleep(10000);
+ // Balance response should contain "Balance:" prefix
+ await BrowserPlaygroundDapp.assertResponseValue('Balance:');
+ }, DAPP_URL);
+
+ //
+ // Reset dapp state
+ //
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.tapDisconnect();
+ }, DAPP_URL);
+ });
+}); // end describe
diff --git a/tests/performance/mm-connect/connection-evm-sign.spec.ts b/tests/performance/mm-connect/connection-evm-sign.spec.ts
index 2f5a098e558..81d98dd8c23 100644
--- a/tests/performance/mm-connect/connection-evm-sign.spec.ts
+++ b/tests/performance/mm-connect/connection-evm-sign.spec.ts
@@ -1,4 +1,5 @@
import { test } from '../../framework/fixture';
+import { Performance } from '../../tags.performance.js';
import { loginToAppPlaywright } from '../../flows/wallet.flow';
import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp';
@@ -33,197 +34,199 @@ const playgroundServer = new DappServer({
dappVariant: DappVariants.BROWSER_PLAYGROUND,
});
-test.beforeAll(async () => {
- playgroundServer.setServerPort(DAPP_PORT);
- await playgroundServer.start();
- await waitForDappServerReady(DAPP_PORT);
- setupAdbReverse(DAPP_PORT);
-});
-
-test.afterAll(async () => {
- cleanupAdbReverse(DAPP_PORT);
- await playgroundServer.stop();
-});
+test.describe(Performance, () => {
+ test.beforeAll(async () => {
+ playgroundServer.setServerPort(DAPP_PORT);
+ await playgroundServer.start();
+ await waitForDappServerReady(DAPP_PORT);
+ setupAdbReverse(DAPP_PORT);
+ });
-// Test steps (in order):
-//
-// 1. LOGIN AND NAVIGATE TO DAPP
-// - Login to app, ensure account groups finished loading
-// - Launch mobile browser and navigate to the playground dapp
-//
-// 2. CONNECT VIA LEGACY EVM (WITH ACCOUNT 3)
-// - Tap Connect (Legacy)
-// - In MetaMask: tap Edit Accounts, add Account 3, tap Update, tap Connect (cooldown 2s)
-// - Assert: connected true, chainId '0x1', active account is Account 1
-// (0x19a7Ad8256ab119655f1D758348501d598fC1C94)
-//
-// 3. SIGN MESSAGE ON ETHEREUM — CONFIRM
-// - Tap personal sign
-// - In MetaMask: tap Confirm (cooldown 2s)
-// - Assert: response value matches Account 1 signature hash
-// (0x361c13288b4ab02d50974efddf9e4e7ca651b81c298b614be908c4754abb1dd8
-// 328224645a1a8d0fab561c4b855c7bdcebea15db5ae8d1778a1ea791dbd05c2a1b)
-//
-// 4. SEND TRANSACTION ON ETHEREUM — CANCEL
-// - Tap send transaction
-// - In MetaMask: assert network text 'Ethereum', tap Cancel (cooldown 2s)
-// - Assert: response value 'denied'
-//
-// 5. SWITCH TO POLYGON AND SEND TRANSACTION — CANCEL
-// - Assert response 'denied', tap switch to Polygon
-// - In MetaMask: assert network text 'Polygon', tap Confirm chain switch (cooldown 2s)
-// - Assert: chainId '0x89'
-// - Tap send transaction
-// - In MetaMask: assert network text 'Polygon', tap Cancel (cooldown 2s)
-//
-// 6. SWITCH TO MAINNET AND SEND TRANSACTION — CANCEL
-// - Tap switch to Mainnet, assert chainId '0x1'
-// - Tap send transaction
-// - In MetaMask: assert network text 'Ethereum', tap Cancel (cooldown 2s)
-//
-// 7. CLEANUP
-// - Tap disconnect to reset dapp state
-
-test('@metamask/connect-evm - Sign and transaction cancel flows', async ({
- currentDeviceDetails,
- driver,
-}) => {
- const platform = currentDeviceDetails.platform;
- const useBrowserStackLocal =
- process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true';
- const DAPP_URL = useBrowserStackLocal
- ? `http://bs-local.com:${DAPP_PORT}`
- : getDappUrlForBrowser(platform);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await loginToAppPlaywright();
- await ensureAccountGroupsFinishedLoading(currentDeviceDetails);
- await launchMobileBrowser();
- await navigateToDapp(DAPP_URL);
+ test.afterAll(async () => {
+ cleanupAdbReverse(DAPP_PORT);
+ await playgroundServer.stop();
});
- await sleep(5000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.tapConnectLegacy();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await unlockIfLockScreenVisible();
- await DappConnectionModal.tapEditAccountsButton();
- await DappConnectionModal.tapAccountButton('Account 3');
- await DappConnectionModal.tapUpdateAccountsButton();
- await DappConnectionModal.tapConnectButton({
- shouldCooldown: true,
- timeToCooldown: 2000,
+
+ // Test steps (in order):
+ //
+ // 1. LOGIN AND NAVIGATE TO DAPP
+ // - Login to app, ensure account groups finished loading
+ // - Launch mobile browser and navigate to the playground dapp
+ //
+ // 2. CONNECT VIA LEGACY EVM (WITH ACCOUNT 3)
+ // - Tap Connect (Legacy)
+ // - In MetaMask: tap Edit Accounts, add Account 3, tap Update, tap Connect (cooldown 2s)
+ // - Assert: connected true, chainId '0x1', active account is Account 1
+ // (0x19a7Ad8256ab119655f1D758348501d598fC1C94)
+ //
+ // 3. SIGN MESSAGE ON ETHEREUM — CONFIRM
+ // - Tap personal sign
+ // - In MetaMask: tap Confirm (cooldown 2s)
+ // - Assert: response value matches Account 1 signature hash
+ // (0x361c13288b4ab02d50974efddf9e4e7ca651b81c298b614be908c4754abb1dd8
+ // 328224645a1a8d0fab561c4b855c7bdcebea15db5ae8d1778a1ea791dbd05c2a1b)
+ //
+ // 4. SEND TRANSACTION ON ETHEREUM — CANCEL
+ // - Tap send transaction
+ // - In MetaMask: assert network text 'Ethereum', tap Cancel (cooldown 2s)
+ // - Assert: response value 'denied'
+ //
+ // 5. SWITCH TO POLYGON AND SEND TRANSACTION — CANCEL
+ // - Assert response 'denied', tap switch to Polygon
+ // - In MetaMask: assert network text 'Polygon', tap Confirm chain switch (cooldown 2s)
+ // - Assert: chainId '0x89'
+ // - Tap send transaction
+ // - In MetaMask: assert network text 'Polygon', tap Cancel (cooldown 2s)
+ //
+ // 6. SWITCH TO MAINNET AND SEND TRANSACTION — CANCEL
+ // - Tap switch to Mainnet, assert chainId '0x1'
+ // - Tap send transaction
+ // - In MetaMask: assert network text 'Ethereum', tap Cancel (cooldown 2s)
+ //
+ // 7. CLEANUP
+ // - Tap disconnect to reset dapp state
+
+ test('@metamask/connect-evm - Sign and transaction cancel flows', async ({
+ currentDeviceDetails,
+ driver,
+ }) => {
+ const platform = currentDeviceDetails.platform;
+ const useBrowserStackLocal =
+ process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true';
+ const DAPP_URL = useBrowserStackLocal
+ ? `http://bs-local.com:${DAPP_PORT}`
+ : getDappUrlForBrowser(platform);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await loginToAppPlaywright();
+ await ensureAccountGroupsFinishedLoading(currentDeviceDetails);
+ await launchMobileBrowser();
+ await navigateToDapp(DAPP_URL);
+ });
+ await sleep(5000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.tapConnectLegacy();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await unlockIfLockScreenVisible();
+ await DappConnectionModal.tapEditAccountsButton();
+ await DappConnectionModal.tapAccountButton('Account 3');
+ await DappConnectionModal.tapUpdateAccountsButton();
+ await DappConnectionModal.tapConnectButton({
+ shouldCooldown: true,
+ timeToCooldown: 2000,
+ });
});
- });
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertConnected(true);
- await BrowserPlaygroundDapp.assertChainIdValue('0x1');
- await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS);
- await BrowserPlaygroundDapp.tapPersonalSign();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await SignModal.tapConfirmButton({
- shouldCooldown: true,
- timeToCooldown: 2000,
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertConnected(true);
+ await BrowserPlaygroundDapp.assertChainIdValue('0x1');
+ await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS);
+ await BrowserPlaygroundDapp.tapPersonalSign();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await SignModal.tapConfirmButton({
+ shouldCooldown: true,
+ timeToCooldown: 2000,
+ });
});
- });
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertResponseValue(
- // Account 1 signed the message
- '0x361c13288b4ab02d50974efddf9e4e7ca651b81c298b614be908c4754abb1dd8328224645a1a8d0fab561c4b855c7bdcebea15db5ae8d1778a1ea791dbd05c2a1b',
- );
- await BrowserPlaygroundDapp.tapSendTransaction();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await SignModal.assertNetworkText('Ethereum');
- await SignModal.tapCancelButton({
- shouldCooldown: true,
- timeToCooldown: 2000,
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertResponseValue(
+ // Account 1 signed the message
+ '0x361c13288b4ab02d50974efddf9e4e7ca651b81c298b614be908c4754abb1dd8328224645a1a8d0fab561c4b855c7bdcebea15db5ae8d1778a1ea791dbd05c2a1b',
+ );
+ await BrowserPlaygroundDapp.tapSendTransaction();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await SignModal.assertNetworkText('Ethereum');
+ await SignModal.tapCancelButton({
+ shouldCooldown: true,
+ timeToCooldown: 2000,
+ });
});
- });
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- // Note: Error message may differ slightly in browser playground
- await BrowserPlaygroundDapp.assertResponseValue('denied');
- await BrowserPlaygroundDapp.tapSwitchToPolygon();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await SwitchChainModal.assertNetworkText('Polygon');
- await SwitchChainModal.tapConnectButton({
- shouldCooldown: true,
- timeToCooldown: 2000,
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ // Note: Error message may differ slightly in browser playground
+ await BrowserPlaygroundDapp.assertResponseValue('denied');
+ await BrowserPlaygroundDapp.tapSwitchToPolygon();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await SwitchChainModal.assertNetworkText('Polygon');
+ await SwitchChainModal.tapConnectButton({
+ shouldCooldown: true,
+ timeToCooldown: 2000,
+ });
});
- });
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertChainIdValue('0x89');
- await BrowserPlaygroundDapp.tapSendTransaction();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await SignModal.assertNetworkText('Polygon');
- await SignModal.tapCancelButton({
- shouldCooldown: true,
- timeToCooldown: 2000,
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertChainIdValue('0x89');
+ await BrowserPlaygroundDapp.tapSendTransaction();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await SignModal.assertNetworkText('Polygon');
+ await SignModal.tapCancelButton({
+ shouldCooldown: true,
+ timeToCooldown: 2000,
+ });
});
- });
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.tapSwitchToMainnet();
- await BrowserPlaygroundDapp.assertChainIdValue('0x1');
- await BrowserPlaygroundDapp.tapSendTransaction();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await SignModal.assertNetworkText('Ethereum');
- await SignModal.tapCancelButton({
- shouldCooldown: true,
- timeToCooldown: 2000,
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.tapSwitchToMainnet();
+ await BrowserPlaygroundDapp.assertChainIdValue('0x1');
+ await BrowserPlaygroundDapp.tapSendTransaction();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await SignModal.assertNetworkText('Ethereum');
+ await SignModal.tapCancelButton({
+ shouldCooldown: true,
+ timeToCooldown: 2000,
+ });
});
- });
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
- //
- // Reset dapp state
- //
+ //
+ // Reset dapp state
+ //
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.tapDisconnect();
- }, DAPP_URL);
-});
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.tapDisconnect();
+ }, DAPP_URL);
+ });
+}); // end describe
diff --git a/tests/performance/mm-connect/connection-multichain.spec.ts b/tests/performance/mm-connect/connection-multichain.spec.ts
index cc3971c759a..86869e11af0 100644
--- a/tests/performance/mm-connect/connection-multichain.spec.ts
+++ b/tests/performance/mm-connect/connection-multichain.spec.ts
@@ -1,4 +1,5 @@
import { test } from '../../framework/fixture';
+import { Performance } from '../../tags.performance.js';
import TimerHelper from '../../framework/TimerHelper';
import { loginToAppPlaywright } from '../../flows/wallet.flow';
import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp';
@@ -35,76 +36,80 @@ const playgroundServer = new DappServer({
dappVariant: DappVariants.BROWSER_PLAYGROUND,
});
-// Start local playground server before all tests
-test.beforeAll(async () => {
- playgroundServer.setServerPort(DAPP_PORT);
- await playgroundServer.start();
- await waitForDappServerReady(DAPP_PORT);
- setupAdbReverse(DAPP_PORT);
-});
+test.describe(Performance, () => {
+ // Start local playground server before all tests
+ test.beforeAll(async () => {
+ playgroundServer.setServerPort(DAPP_PORT);
+ await playgroundServer.start();
+ await waitForDappServerReady(DAPP_PORT);
+ setupAdbReverse(DAPP_PORT);
+ });
-// Stop local playground server after all tests
-test.afterAll(async () => {
- cleanupAdbReverse(DAPP_PORT);
- await playgroundServer.stop();
-});
+ // Stop local playground server after all tests
+ test.afterAll(async () => {
+ cleanupAdbReverse(DAPP_PORT);
+ await playgroundServer.stop();
+ });
-test('@metamask/connect-multichain - Connect via Multichain API to Local Browser Playground', async ({
- currentDeviceDetails,
- driver,
-}) => {
- const useBrowserStackLocal =
- process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true';
- const DAPP_URL = useBrowserStackLocal
- ? `http://bs-local.com:${DAPP_PORT}`
- : getDappUrlForBrowser(currentDeviceDetails.platform);
+ test('@metamask/connect-multichain - Connect via Multichain API to Local Browser Playground', async ({
+ currentDeviceDetails,
+ driver,
+ }) => {
+ const useBrowserStackLocal =
+ process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true';
+ const DAPP_URL = useBrowserStackLocal
+ ? `http://bs-local.com:${DAPP_PORT}`
+ : getDappUrlForBrowser(currentDeviceDetails.platform);
- //
- // Login and navigate to dapp
- //
+ //
+ // Login and navigate to dapp
+ //
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await loginToAppPlaywright();
- await launchMobileBrowser();
- await navigateToDapp(DAPP_URL);
- });
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await loginToAppPlaywright();
+ await launchMobileBrowser();
+ await navigateToDapp(DAPP_URL);
+ });
- //
- // Connect via Multichain API
- //
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.waitForConnectButtonVisible(15000);
- await BrowserPlaygroundDapp.tapConnect();
- }, DAPP_URL);
+ //
+ // Connect via Multichain API
+ //
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.waitForConnectButtonVisible(15000);
+ await BrowserPlaygroundDapp.tapConnect();
+ }, DAPP_URL);
- // Handle connection approval in MetaMask
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await unlockIfLockScreenVisible();
- await DappConnectionModal.tapConnectButton();
- });
+ // Handle connection approval in MetaMask
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await unlockIfLockScreenVisible();
+ await DappConnectionModal.tapConnectButton();
+ });
- // Switch back to browser
- await switchToMobileBrowser();
- await sleep(500);
+ // Switch back to browser
+ await switchToMobileBrowser();
+ await sleep(500);
- //
- // Verify connection
- //
+ //
+ // Verify connection
+ //
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertMultichainConnected(true);
- await PlaywrightGestures.scrollIntoView(
- await asPlaywrightElement(BrowserPlaygroundDapp.getScopeCard('eip155:1')),
- );
- await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1');
- }, DAPP_URL);
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertMultichainConnected(true);
+ await PlaywrightGestures.scrollIntoView(
+ await asPlaywrightElement(
+ BrowserPlaygroundDapp.getScopeCard('eip155:1'),
+ ),
+ );
+ await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1');
+ }, DAPP_URL);
- //
- // Cleanup - disconnect
- //
+ //
+ // Cleanup - disconnect
+ //
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.tapDisconnect();
- }, DAPP_URL);
-});
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.tapDisconnect();
+ }, DAPP_URL);
+ });
+}); // end describe
diff --git a/tests/performance/mm-connect/connection-multiclient-resilience.spec.ts b/tests/performance/mm-connect/connection-multiclient-resilience.spec.ts
index 502b8e73f21..53ef06eafd5 100644
--- a/tests/performance/mm-connect/connection-multiclient-resilience.spec.ts
+++ b/tests/performance/mm-connect/connection-multiclient-resilience.spec.ts
@@ -1,4 +1,5 @@
import { test } from '../../framework/fixture';
+import { Performance } from '../../tags.performance.js';
import { loginToAppPlaywright } from '../../flows/wallet.flow';
import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp';
@@ -47,248 +48,250 @@ const playgroundServer = new DappServer({
dappVariant: DappVariants.BROWSER_PLAYGROUND,
});
-// Start local playground server before all tests
-test.beforeAll(async () => {
- // Set port and start the server directly (bypassing Detox-specific utilities)
- playgroundServer.setServerPort(DAPP_PORT);
- await playgroundServer.start();
- await waitForDappServerReady(DAPP_PORT);
+test.describe(Performance, () => {
+ // Start local playground server before all tests
+ test.beforeAll(async () => {
+ // Set port and start the server directly (bypassing Detox-specific utilities)
+ playgroundServer.setServerPort(DAPP_PORT);
+ await playgroundServer.start();
+ await waitForDappServerReady(DAPP_PORT);
- // Set up adb reverse for Android emulator access
- setupAdbReverse(DAPP_PORT);
-});
-
-// Stop local playground server after all tests
-test.afterAll(async () => {
- cleanupAdbReverse(DAPP_PORT);
- await playgroundServer.stop();
-});
+ // Set up adb reverse for Android emulator access
+ setupAdbReverse(DAPP_PORT);
+ });
-// Test steps (in order):
-//
-// 1. DISCONNECT SOLANA, VERIFY EVM PERSISTS
-// - Tap Solana disconnect
-// - Assert: solana scope gone, Solana disconnected
-// - Assert: eip155:1 scope still visible, legacy EVM connected, wagmi connected
-// - Wagmi personal sign -> confirm -> assert signature starts with 0x
-//
-// 2. RECONNECT SOLANA, VERIFY EVM PERSISTS
-// - Tap Solana connect -> approve in MetaMask
-// - Assert: Solana scope visible, Solana connected with account 1
-// - Solana sign message -> confirm -> assert correct signed result
-// - Assert: eip155:1 scope still visible, legacy EVM connected, wagmi connected
-// - Wagmi personal sign -> confirm -> assert signature starts with 0x
-//
-// 3. CONCURRENT CONNECT: PENDING APPROVAL + KILL APP RESILIENCE
-// - Disconnect both Solana and wagmi, then tap Solana connect (initiates approval)
-// - In MetaMask: terminate app without accepting approval, relaunch and log in
-// - Tap wagmi connect -> approve in MetaMask
-// - Assert: eip155:1 connected, legacy EVM connected, wagmi connected
-// - Assert: Solana connected (the pending Solana session from step 7 was fulfilled)
-//
-// 4. CLEANUP
-// - Tap Solana disconnect and legacy EVM disconnect
-test('@metamask/connect-multichain (multiple clients) - Disconnect, reconnect, and resilience via Multichain API', async ({
- currentDeviceDetails,
- driver,
-}) => {
- const platform = currentDeviceDetails.platform;
- const useBrowserStackLocal =
- process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true';
- const DAPP_URL = useBrowserStackLocal
- ? `http://bs-local.com:${DAPP_PORT}`
- : getDappUrlForBrowser(platform);
+ // Stop local playground server after all tests
+ test.afterAll(async () => {
+ cleanupAdbReverse(DAPP_PORT);
+ await playgroundServer.stop();
+ });
+ // Test steps (in order):
+ //
+ // 1. DISCONNECT SOLANA, VERIFY EVM PERSISTS
+ // - Tap Solana disconnect
+ // - Assert: solana scope gone, Solana disconnected
+ // - Assert: eip155:1 scope still visible, legacy EVM connected, wagmi connected
+ // - Wagmi personal sign -> confirm -> assert signature starts with 0x
//
- // Login and navigate to dapp
- // (relies on connection state established by the preceding multiclient test)
+ // 2. RECONNECT SOLANA, VERIFY EVM PERSISTS
+ // - Tap Solana connect -> approve in MetaMask
+ // - Assert: Solana scope visible, Solana connected with account 1
+ // - Solana sign message -> confirm -> assert correct signed result
+ // - Assert: eip155:1 scope still visible, legacy EVM connected, wagmi connected
+ // - Wagmi personal sign -> confirm -> assert signature starts with 0x
//
+ // 3. CONCURRENT CONNECT: PENDING APPROVAL + KILL APP RESILIENCE
+ // - Disconnect both Solana and wagmi, then tap Solana connect (initiates approval)
+ // - In MetaMask: terminate app without accepting approval, relaunch and log in
+ // - Tap wagmi connect -> approve in MetaMask
+ // - Assert: eip155:1 connected, legacy EVM connected, wagmi connected
+ // - Assert: Solana connected (the pending Solana session from step 7 was fulfilled)
+ //
+ // 4. CLEANUP
+ // - Tap Solana disconnect and legacy EVM disconnect
+ test('@metamask/connect-multichain (multiple clients) - Disconnect, reconnect, and resilience via Multichain API', async ({
+ currentDeviceDetails,
+ driver,
+ }) => {
+ const platform = currentDeviceDetails.platform;
+ const useBrowserStackLocal =
+ process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true';
+ const DAPP_URL = useBrowserStackLocal
+ ? `http://bs-local.com:${DAPP_PORT}`
+ : getDappUrlForBrowser(platform);
+
+ //
+ // Login and navigate to dapp
+ // (relies on connection state established by the preceding multiclient test)
+ //
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await loginToAppPlaywright();
+ await ensureAccountGroupsFinishedLoading(currentDeviceDetails);
+ await launchMobileBrowser();
+ await navigateToDapp(DAPP_URL);
+ });
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await loginToAppPlaywright();
- await ensureAccountGroupsFinishedLoading(currentDeviceDetails);
- await launchMobileBrowser();
- await navigateToDapp(DAPP_URL);
- });
+ await sleep(1000);
- await sleep(1000);
-
- // Tap the Connect button (multichain API - default scopes)
- await PlaywrightContextHelpers.withWebAction(async () => {
- // Note: the Solana wallet standard provider itself has an issue where it does not
- // listen for wallet_sessionChanged events, so we need to use the Solana's connect button
- // as the entrypoint for now.
- await BrowserPlaygroundDapp.tapSolanaConnect();
- }, DAPP_URL);
-
- // Handle connection approval in MetaMask
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await unlockIfLockScreenVisible();
- await DappConnectionModal.tapConnectButton();
- });
+ // Tap the Connect button (multichain API - default scopes)
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ // Note: the Solana wallet standard provider itself has an issue where it does not
+ // listen for wallet_sessionChanged events, so we need to use the Solana's connect button
+ // as the entrypoint for now.
+ await BrowserPlaygroundDapp.tapSolanaConnect();
+ }, DAPP_URL);
+
+ // Handle connection approval in MetaMask
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await unlockIfLockScreenVisible();
+ await DappConnectionModal.tapConnectButton();
+ });
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
- //
- // Step 1: Disconnect Solana, verify EVM persists
- //
- await PlaywrightContextHelpers.withWebAction(async () => {
- // Disconnect Solana
- await BrowserPlaygroundDapp.tapSolanaDisconnect();
-
- await BrowserPlaygroundDapp.assertScopeCardNotVisible(
- SOLANA_MAINNET_CAIP_CHAIN_ID,
- );
- await BrowserPlaygroundDapp.assertSolanaConnected(false);
-
- // Make sure EVM is still connected
- await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1');
- await BrowserPlaygroundDapp.assertConnected(true);
- await BrowserPlaygroundDapp.assertWagmiConnected(true);
- // Verify wagmi personal sign works when wagmi is connected
- await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask');
- await PlaywrightGestures.hideKeyboard();
- await BrowserPlaygroundDapp.tapWagmiSignMessage();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await SignModal.tapConfirmButton();
- });
+ //
+ // Step 1: Disconnect Solana, verify EVM persists
+ //
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ // Disconnect Solana
+ await BrowserPlaygroundDapp.tapSolanaDisconnect();
+
+ await BrowserPlaygroundDapp.assertScopeCardNotVisible(
+ SOLANA_MAINNET_CAIP_CHAIN_ID,
+ );
+ await BrowserPlaygroundDapp.assertSolanaConnected(false);
+
+ // Make sure EVM is still connected
+ await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1');
+ await BrowserPlaygroundDapp.assertConnected(true);
+ await BrowserPlaygroundDapp.assertWagmiConnected(true);
+ // Verify wagmi personal sign works when wagmi is connected
+ await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask');
+ await PlaywrightGestures.hideKeyboard();
+ await BrowserPlaygroundDapp.tapWagmiSignMessage();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await SignModal.tapConfirmButton();
+ });
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x');
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x');
- // Reconnect Solana
- await BrowserPlaygroundDapp.tapSolanaConnect();
- }, DAPP_URL);
+ // Reconnect Solana
+ await BrowserPlaygroundDapp.tapSolanaConnect();
+ }, DAPP_URL);
- // Reconnecting Solana takes a bit of time to trigger the deeplink, so we need to wait for it to complete
- await sleep(3500);
+ // Reconnecting Solana takes a bit of time to trigger the deeplink, so we need to wait for it to complete
+ await sleep(3500);
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- // Reconnecting Solana takes a bit of time, so we need to wait for it to complete
- await DappConnectionModal.tapConnectButton({
- shouldCooldown: true,
- timeToCooldown: 4000,
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ // Reconnecting Solana takes a bit of time, so we need to wait for it to complete
+ await DappConnectionModal.tapConnectButton({
+ shouldCooldown: true,
+ timeToCooldown: 4000,
+ });
});
- });
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertScopeCardVisible(
- SOLANA_MAINNET_CAIP_CHAIN_ID,
- );
- await BrowserPlaygroundDapp.assertSolanaConnected(true);
- await BrowserPlaygroundDapp.assertSolanaActiveAccount(
- ACCOUNT_1_SOLANA_ADDRESS,
- );
- // Verify solana sign works when solana is connected
- await PlaywrightGestures.scrollIntoView(
- await asPlaywrightElement(BrowserPlaygroundDapp.solanaCard),
- { scrollParams: { direction: 'down' } },
- );
- await BrowserPlaygroundDapp.tapSolanaSignMessage();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await SnapSignModal.tapConfirmButton();
- });
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertSolanaSignedMessageResult(
- ACCOUNT_1_SOLANA_SIGNED_MESSAGE_RESULT,
- );
-
- // Make sure EVM is still connected
- await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1');
- await BrowserPlaygroundDapp.assertConnected(true);
- await BrowserPlaygroundDapp.assertWagmiConnected(true);
- // Verify wagmi personal sign works when wagmi is connected
- await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask');
- await PlaywrightGestures.hideKeyboard();
- await BrowserPlaygroundDapp.tapWagmiSignMessage();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await SignModal.tapConfirmButton();
- });
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertScopeCardVisible(
+ SOLANA_MAINNET_CAIP_CHAIN_ID,
+ );
+ await BrowserPlaygroundDapp.assertSolanaConnected(true);
+ await BrowserPlaygroundDapp.assertSolanaActiveAccount(
+ ACCOUNT_1_SOLANA_ADDRESS,
+ );
+ // Verify solana sign works when solana is connected
+ await PlaywrightGestures.scrollIntoView(
+ await asPlaywrightElement(BrowserPlaygroundDapp.solanaCard),
+ { scrollParams: { direction: 'down' } },
+ );
+ await BrowserPlaygroundDapp.tapSolanaSignMessage();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await SnapSignModal.tapConfirmButton();
+ });
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x');
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertSolanaSignedMessageResult(
+ ACCOUNT_1_SOLANA_SIGNED_MESSAGE_RESULT,
+ );
+
+ // Make sure EVM is still connected
+ await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1');
+ await BrowserPlaygroundDapp.assertConnected(true);
+ await BrowserPlaygroundDapp.assertWagmiConnected(true);
+ // Verify wagmi personal sign works when wagmi is connected
+ await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask');
+ await PlaywrightGestures.hideKeyboard();
+ await BrowserPlaygroundDapp.tapWagmiSignMessage();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await SignModal.tapConfirmButton();
+ });
- // Setup for concurrent connect test
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
- await BrowserPlaygroundDapp.tapSolanaDisconnect();
- await BrowserPlaygroundDapp.tapWagmiDisconnect();
- await BrowserPlaygroundDapp.tapSolanaConnect();
- }, DAPP_URL);
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x');
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ // Setup for concurrent connect test
- // Purposely terminate the app without accepting the approval
- await PlaywrightGestures.terminateApp(currentDeviceDetails);
- await PlaywrightGestures.activateApp(currentDeviceDetails);
- await loginToAppPlaywright();
- await sleep(1000);
- });
+ await BrowserPlaygroundDapp.tapSolanaDisconnect();
+ await BrowserPlaygroundDapp.tapWagmiDisconnect();
+ await BrowserPlaygroundDapp.tapSolanaConnect();
+ }, DAPP_URL);
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.tapConnectWagmi();
- }, DAPP_URL);
+ // Purposely terminate the app without accepting the approval
+ await PlaywrightGestures.terminateApp(currentDeviceDetails);
+ await PlaywrightGestures.activateApp(currentDeviceDetails);
+ await loginToAppPlaywright();
+ await sleep(1000);
+ });
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await DappConnectionModal.tapConnectButton();
- });
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.tapConnectWagmi();
+ }, DAPP_URL);
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1');
- await BrowserPlaygroundDapp.assertConnected(true);
- await BrowserPlaygroundDapp.assertWagmiConnected(true);
- // Currently this is only possible if the solana connection attempt (the first one that initiated) was successful.
- await BrowserPlaygroundDapp.assertSolanaConnected(true);
- }, DAPP_URL);
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await DappConnectionModal.tapConnectButton();
+ });
- //
- // Cleanup - disconnect
- //
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
- await PlaywrightContextHelpers.withWebAction(async () => {
- // Note: the Solana wallet standard provider itself has an issue where it does not
- // listen for wallet_sessionChanged events, so we need to use the Solana's disconnect button
- // to ensure the solana react hook state is reset correctly.
- await BrowserPlaygroundDapp.tapSolanaDisconnect();
- await BrowserPlaygroundDapp.tapDisconnect();
- }, DAPP_URL);
-});
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1');
+ await BrowserPlaygroundDapp.assertConnected(true);
+ await BrowserPlaygroundDapp.assertWagmiConnected(true);
+ // Currently this is only possible if the solana connection attempt (the first one that initiated) was successful.
+ await BrowserPlaygroundDapp.assertSolanaConnected(true);
+ }, DAPP_URL);
+
+ //
+ // Cleanup - disconnect
+ //
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ // Note: the Solana wallet standard provider itself has an issue where it does not
+ // listen for wallet_sessionChanged events, so we need to use the Solana's disconnect button
+ // to ensure the solana react hook state is reset correctly.
+ await BrowserPlaygroundDapp.tapSolanaDisconnect();
+ await BrowserPlaygroundDapp.tapDisconnect();
+ }, DAPP_URL);
+ });
+}); // end describe
diff --git a/tests/performance/mm-connect/connection-multiclient.spec.ts b/tests/performance/mm-connect/connection-multiclient.spec.ts
index 626a2e23856..178d712e1b6 100644
--- a/tests/performance/mm-connect/connection-multiclient.spec.ts
+++ b/tests/performance/mm-connect/connection-multiclient.spec.ts
@@ -1,4 +1,5 @@
import { test } from '../../framework/fixture';
+import { Performance } from '../../tags.performance.js';
import { loginToAppPlaywright } from '../../flows/wallet.flow';
import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp';
@@ -47,273 +48,279 @@ const playgroundServer = new DappServer({
dappVariant: DappVariants.BROWSER_PLAYGROUND,
});
-// Start local playground server before all tests
-test.beforeAll(async () => {
- // Set port and start the server directly (bypassing Detox-specific utilities)
- playgroundServer.setServerPort(DAPP_PORT);
- await playgroundServer.start();
- await waitForDappServerReady(DAPP_PORT);
+test.describe(Performance, () => {
+ // Start local playground server before all tests
+ test.beforeAll(async () => {
+ // Set port and start the server directly (bypassing Detox-specific utilities)
+ playgroundServer.setServerPort(DAPP_PORT);
+ await playgroundServer.start();
+ await waitForDappServerReady(DAPP_PORT);
- // Set up adb reverse for Android emulator access
- setupAdbReverse(DAPP_PORT);
-});
-
-// Stop local playground server after all tests
-test.afterAll(async () => {
- cleanupAdbReverse(DAPP_PORT);
- await playgroundServer.stop();
-});
+ // Set up adb reverse for Android emulator access
+ setupAdbReverse(DAPP_PORT);
+ });
-// Test steps (in order):
-//
-// 1. Login and navigate to the local browser playground dapp
-//
-// 2. INITIAL MULTICHAIN CONNECTION
-// - Tap Solana connect (used as multichain entrypoint due to wallet_sessionChanged limitation)
-// - Approve connection in MetaMask
-// - Assert: multichain connected, eip155:1 scope visible, solana mainnet scope visible
-// - Assert: legacy EVM connected (chainId 0x1, account 1), wagmi connected (chainId 1, account 1)
-// - Wagmi personal sign -> confirm -> assert signature starts with 0x
-// - Assert: Solana connected with account 1
-// - Solana sign message -> confirm via SnapSignModal -> assert correct signed result
-// - Legacy EVM personal sign -> confirm -> assert correct signature
-//
-// 3. DISCONNECT EVM, VERIFY SOLANA PERSISTS
-// - Tap wagmi disconnect
-// - Assert: multichain still connected, eip155:1 scope gone, solana scope still visible
-// - Assert: legacy EVM disconnected, wagmi disconnected, Solana still connected
-//
-// 4. RECONNECT EVM, VERIFY SOLANA PERSISTS
-// - Tap wagmi connect -> approve in MetaMask
-// - Assert: eip155:1 scope visible, legacy EVM connected, wagmi connected
-// - Wagmi personal sign -> confirm -> assert signature starts with 0x
-// - Assert: Solana scope still visible, Solana still connected
-// - Solana sign message -> confirm -> assert correct signed result
-test('@metamask/connect-multichain (multiple clients) - Connect multiple clients via Multichain API to Local Browser Playground', async ({
- currentDeviceDetails,
-}) => {
- // Get platform-specific URL
- const platform = currentDeviceDetails.platform;
- const useBrowserStackLocal =
- process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true';
- const DAPP_URL = useBrowserStackLocal
- ? `http://bs-local.com:${DAPP_PORT}`
- : getDappUrlForBrowser(platform);
+ // Stop local playground server after all tests
+ test.afterAll(async () => {
+ cleanupAdbReverse(DAPP_PORT);
+ await playgroundServer.stop();
+ });
+ // Test steps (in order):
//
- // Login and navigate to dapp
+ // 1. Login and navigate to the local browser playground dapp
//
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await loginToAppPlaywright();
- await ensureAccountGroupsFinishedLoading(currentDeviceDetails);
- await launchMobileBrowser();
- await navigateToDapp(DAPP_URL);
- });
-
- await sleep(1000);
-
+ // 2. INITIAL MULTICHAIN CONNECTION
+ // - Tap Solana connect (used as multichain entrypoint due to wallet_sessionChanged limitation)
+ // - Approve connection in MetaMask
+ // - Assert: multichain connected, eip155:1 scope visible, solana mainnet scope visible
+ // - Assert: legacy EVM connected (chainId 0x1, account 1), wagmi connected (chainId 1, account 1)
+ // - Wagmi personal sign -> confirm -> assert signature starts with 0x
+ // - Assert: Solana connected with account 1
+ // - Solana sign message -> confirm via SnapSignModal -> assert correct signed result
+ // - Legacy EVM personal sign -> confirm -> assert correct signature
//
- // Connect via Multichain API
+ // 3. DISCONNECT EVM, VERIFY SOLANA PERSISTS
+ // - Tap wagmi disconnect
+ // - Assert: multichain still connected, eip155:1 scope gone, solana scope still visible
+ // - Assert: legacy EVM disconnected, wagmi disconnected, Solana still connected
//
-
- // Tap the Connect button (multichain API - default scopes)
- await PlaywrightContextHelpers.withWebAction(async () => {
- // Note: the Solana wallet standard provider itself has an issue where it does not
- // listen for wallet_sessionChanged events, so we need to use the Solana's connect button
- // as the entrypoint for now.
- await BrowserPlaygroundDapp.tapSolanaConnect();
- }, DAPP_URL);
-
- // Handle connection approval in MetaMask
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await unlockIfLockScreenVisible();
- await DappConnectionModal.tapConnectButton();
- });
-
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertMultichainConnected(true);
- await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1');
- await BrowserPlaygroundDapp.assertScopeCardVisible(
- SOLANA_MAINNET_CAIP_CHAIN_ID,
- );
-
- await BrowserPlaygroundDapp.assertConnected(true);
- await BrowserPlaygroundDapp.assertChainIdValue('0x1');
- await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_EVM_ADDRESS);
-
- await BrowserPlaygroundDapp.assertWagmiConnected(true);
- await BrowserPlaygroundDapp.assertWagmiChainIdValue('1');
- await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_EVM_ADDRESS);
- // Verify wagmi personal sign works when wagmi is connected
- await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask');
- await PlaywrightGestures.hideKeyboard();
- await BrowserPlaygroundDapp.tapWagmiSignMessage();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await SignModal.tapConfirmButton();
+ // 4. RECONNECT EVM, VERIFY SOLANA PERSISTS
+ // - Tap wagmi connect -> approve in MetaMask
+ // - Assert: eip155:1 scope visible, legacy EVM connected, wagmi connected
+ // - Wagmi personal sign -> confirm -> assert signature starts with 0x
+ // - Assert: Solana scope still visible, Solana still connected
+ // - Solana sign message -> confirm -> assert correct signed result
+ test('@metamask/connect-multichain (multiple clients) - Connect multiple clients via Multichain API to Local Browser Playground', async ({
+ currentDeviceDetails,
+ }) => {
+ // Get platform-specific URL
+ const platform = currentDeviceDetails.platform;
+ const useBrowserStackLocal =
+ process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true';
+ const DAPP_URL = useBrowserStackLocal
+ ? `http://bs-local.com:${DAPP_PORT}`
+ : getDappUrlForBrowser(platform);
+
+ //
+ // Login and navigate to dapp
+ //
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await loginToAppPlaywright();
+ await ensureAccountGroupsFinishedLoading(currentDeviceDetails);
+ await launchMobileBrowser();
+ await navigateToDapp(DAPP_URL);
+ });
+
+ await sleep(1000);
+
+ //
+ // Connect via Multichain API
+ //
+
+ // Tap the Connect button (multichain API - default scopes)
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ // Note: the Solana wallet standard provider itself has an issue where it does not
+ // listen for wallet_sessionChanged events, so we need to use the Solana's connect button
+ // as the entrypoint for now.
+ await BrowserPlaygroundDapp.tapSolanaConnect();
+ }, DAPP_URL);
+
+ // Handle connection approval in MetaMask
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await unlockIfLockScreenVisible();
+ await DappConnectionModal.tapConnectButton();
+ });
+
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertMultichainConnected(true);
+ await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1');
+ await BrowserPlaygroundDapp.assertScopeCardVisible(
+ SOLANA_MAINNET_CAIP_CHAIN_ID,
+ );
+
+ await BrowserPlaygroundDapp.assertConnected(true);
+ await BrowserPlaygroundDapp.assertChainIdValue('0x1');
+ await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_EVM_ADDRESS);
+
+ await BrowserPlaygroundDapp.assertWagmiConnected(true);
+ await BrowserPlaygroundDapp.assertWagmiChainIdValue('1');
+ await BrowserPlaygroundDapp.assertWagmiActiveAccount(
+ ACCOUNT_1_EVM_ADDRESS,
+ );
+ // Verify wagmi personal sign works when wagmi is connected
+ await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask');
+ await PlaywrightGestures.hideKeyboard();
+ await BrowserPlaygroundDapp.tapWagmiSignMessage();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await SignModal.tapConfirmButton();
+ });
+
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x');
+
+ await BrowserPlaygroundDapp.assertSolanaConnected(true);
+ await BrowserPlaygroundDapp.assertSolanaActiveAccount(
+ ACCOUNT_1_SOLANA_ADDRESS,
+ );
+ // Verify solana sign works when solana is connected
+ await PlaywrightGestures.scrollIntoView(
+ await asPlaywrightElement(BrowserPlaygroundDapp.solanaCard),
+ );
+ await BrowserPlaygroundDapp.tapSolanaSignMessage();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await SnapSignModal.tapConfirmButton();
+ });
+
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertSolanaSignedMessageResult(
+ ACCOUNT_1_SOLANA_SIGNED_MESSAGE_RESULT,
+ );
+
+ await PlaywrightGestures.scrollIntoView(
+ await asPlaywrightElement(BrowserPlaygroundDapp.legacyEvmCard),
+ { scrollParams: { direction: 'down' } },
+ );
+ // Test EVM sign (legacy personal sign) when EVM is connected
+ await BrowserPlaygroundDapp.tapPersonalSign();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await SignModal.tapConfirmButton();
+ });
+
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertResponseValue(
+ '0x361c13288b4ab02d50974efddf9e4e7ca651b81c298b614be908c4754abb1dd8328224645a1a8d0fab561c4b855c7bdcebea15db5ae8d1778a1ea791dbd05c2a1b',
+ );
+
+ // Disconnect EVM
+ await PlaywrightGestures.scrollIntoView(
+ await asPlaywrightElement(BrowserPlaygroundDapp.wagmiDisconnectButton),
+ );
+ await BrowserPlaygroundDapp.tapWagmiDisconnect();
+
+ await PlaywrightGestures.scrollIntoView(
+ await asPlaywrightElement(BrowserPlaygroundDapp.connectedScopesSection),
+ { scrollParams: { direction: 'down' } },
+ );
+ await BrowserPlaygroundDapp.assertMultichainConnected(true);
+ await BrowserPlaygroundDapp.assertScopeCardNotVisible('eip155:1');
+ await BrowserPlaygroundDapp.assertScopeCardVisible(
+ SOLANA_MAINNET_CAIP_CHAIN_ID,
+ );
+
+ await BrowserPlaygroundDapp.assertConnected(false);
+ await BrowserPlaygroundDapp.assertWagmiConnected(false);
+ await BrowserPlaygroundDapp.assertSolanaConnected(true);
+
+ // Reconnect EVM
+ await PlaywrightGestures.scrollIntoView(
+ await asPlaywrightElement(BrowserPlaygroundDapp.connectWagmiButton),
+ { scrollParams: { direction: 'down' } },
+ );
+ await BrowserPlaygroundDapp.tapConnectWagmi();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await DappConnectionModal.tapConnectButton({ shouldCooldown: true });
+ });
+
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await PlaywrightGestures.scrollIntoView(
+ await asPlaywrightElement(BrowserPlaygroundDapp.wagmiCard),
+ { scrollParams: { direction: 'up' } },
+ );
+
+ await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1');
+
+ await BrowserPlaygroundDapp.assertConnected(true);
+ await BrowserPlaygroundDapp.assertChainIdValue('0x1');
+ await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_EVM_ADDRESS);
+
+ await BrowserPlaygroundDapp.assertWagmiConnected(true);
+ await BrowserPlaygroundDapp.assertWagmiChainIdValue('1');
+ await BrowserPlaygroundDapp.assertWagmiActiveAccount(
+ ACCOUNT_1_EVM_ADDRESS,
+ );
+ // Verify wagmi personal sign works when wagmi is connected
+ await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask');
+ await PlaywrightGestures.hideKeyboard();
+ await BrowserPlaygroundDapp.tapWagmiSignMessage();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await SignModal.tapConfirmButton();
+ });
+
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x');
+
+ // Make sure solana is still connected
+ await BrowserPlaygroundDapp.assertScopeCardVisible(
+ SOLANA_MAINNET_CAIP_CHAIN_ID,
+ );
+ await BrowserPlaygroundDapp.assertSolanaConnected(true);
+ await BrowserPlaygroundDapp.assertSolanaActiveAccount(
+ ACCOUNT_1_SOLANA_ADDRESS,
+ );
+ // Verify solana sign works when solana is connected
+ await BrowserPlaygroundDapp.tapSolanaSignMessage();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await SnapSignModal.tapConfirmButton();
+ });
+
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await PlaywrightGestures.scrollIntoView(
+ await asPlaywrightElement(
+ BrowserPlaygroundDapp.solanaSignedMessageResult,
+ ),
+ );
+ await BrowserPlaygroundDapp.assertSolanaSignedMessageResult(
+ ACCOUNT_1_SOLANA_SIGNED_MESSAGE_RESULT,
+ );
+ }, DAPP_URL);
});
-
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x');
-
- await BrowserPlaygroundDapp.assertSolanaConnected(true);
- await BrowserPlaygroundDapp.assertSolanaActiveAccount(
- ACCOUNT_1_SOLANA_ADDRESS,
- );
- // Verify solana sign works when solana is connected
- await PlaywrightGestures.scrollIntoView(
- await asPlaywrightElement(BrowserPlaygroundDapp.solanaCard),
- );
- await BrowserPlaygroundDapp.tapSolanaSignMessage();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await SnapSignModal.tapConfirmButton();
- });
-
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertSolanaSignedMessageResult(
- ACCOUNT_1_SOLANA_SIGNED_MESSAGE_RESULT,
- );
-
- await PlaywrightGestures.scrollIntoView(
- await asPlaywrightElement(BrowserPlaygroundDapp.legacyEvmCard),
- { scrollParams: { direction: 'down' } },
- );
- // Test EVM sign (legacy personal sign) when EVM is connected
- await BrowserPlaygroundDapp.tapPersonalSign();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await SignModal.tapConfirmButton();
- });
-
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertResponseValue(
- '0x361c13288b4ab02d50974efddf9e4e7ca651b81c298b614be908c4754abb1dd8328224645a1a8d0fab561c4b855c7bdcebea15db5ae8d1778a1ea791dbd05c2a1b',
- );
-
- // Disconnect EVM
- await PlaywrightGestures.scrollIntoView(
- await asPlaywrightElement(BrowserPlaygroundDapp.wagmiDisconnectButton),
- );
- await BrowserPlaygroundDapp.tapWagmiDisconnect();
-
- await PlaywrightGestures.scrollIntoView(
- await asPlaywrightElement(BrowserPlaygroundDapp.connectedScopesSection),
- { scrollParams: { direction: 'down' } },
- );
- await BrowserPlaygroundDapp.assertMultichainConnected(true);
- await BrowserPlaygroundDapp.assertScopeCardNotVisible('eip155:1');
- await BrowserPlaygroundDapp.assertScopeCardVisible(
- SOLANA_MAINNET_CAIP_CHAIN_ID,
- );
-
- await BrowserPlaygroundDapp.assertConnected(false);
- await BrowserPlaygroundDapp.assertWagmiConnected(false);
- await BrowserPlaygroundDapp.assertSolanaConnected(true);
-
- // Reconnect EVM
- await PlaywrightGestures.scrollIntoView(
- await asPlaywrightElement(BrowserPlaygroundDapp.connectWagmiButton),
- { scrollParams: { direction: 'down' } },
- );
- await BrowserPlaygroundDapp.tapConnectWagmi();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await DappConnectionModal.tapConnectButton({ shouldCooldown: true });
- });
-
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await PlaywrightGestures.scrollIntoView(
- await asPlaywrightElement(BrowserPlaygroundDapp.wagmiCard),
- { scrollParams: { direction: 'up' } },
- );
-
- await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1');
-
- await BrowserPlaygroundDapp.assertConnected(true);
- await BrowserPlaygroundDapp.assertChainIdValue('0x1');
- await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_EVM_ADDRESS);
-
- await BrowserPlaygroundDapp.assertWagmiConnected(true);
- await BrowserPlaygroundDapp.assertWagmiChainIdValue('1');
- await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_EVM_ADDRESS);
- // Verify wagmi personal sign works when wagmi is connected
- await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask');
- await PlaywrightGestures.hideKeyboard();
- await BrowserPlaygroundDapp.tapWagmiSignMessage();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await SignModal.tapConfirmButton();
- });
-
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x');
-
- // Make sure solana is still connected
- await BrowserPlaygroundDapp.assertScopeCardVisible(
- SOLANA_MAINNET_CAIP_CHAIN_ID,
- );
- await BrowserPlaygroundDapp.assertSolanaConnected(true);
- await BrowserPlaygroundDapp.assertSolanaActiveAccount(
- ACCOUNT_1_SOLANA_ADDRESS,
- );
- // Verify solana sign works when solana is connected
- await BrowserPlaygroundDapp.tapSolanaSignMessage();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await SnapSignModal.tapConfirmButton();
- });
-
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await PlaywrightGestures.scrollIntoView(
- await asPlaywrightElement(
- BrowserPlaygroundDapp.solanaSignedMessageResult,
- ),
- );
- await BrowserPlaygroundDapp.assertSolanaSignedMessageResult(
- ACCOUNT_1_SOLANA_SIGNED_MESSAGE_RESULT,
- );
- }, DAPP_URL);
-});
+}); // end describe
diff --git a/tests/performance/mm-connect/connection-wagmi-chains.spec.ts b/tests/performance/mm-connect/connection-wagmi-chains.spec.ts
index ea88ce63c57..3ac86fe3e59 100644
--- a/tests/performance/mm-connect/connection-wagmi-chains.spec.ts
+++ b/tests/performance/mm-connect/connection-wagmi-chains.spec.ts
@@ -1,4 +1,5 @@
import { test } from '../../framework/fixture';
+import { Performance } from '../../tags.performance.js';
import { loginToAppPlaywright } from '../../flows/wallet.flow';
import WalletView from '../../page-objects/wallet/WalletView';
@@ -45,236 +46,238 @@ const playgroundServer = new DappServer({
dappVariant: DappVariants.BROWSER_PLAYGROUND,
});
-// Start local playground server before all tests
-test.beforeAll(async () => {
- playgroundServer.setServerPort(DAPP_PORT);
- await playgroundServer.start();
- await waitForDappServerReady(DAPP_PORT);
- setupAdbReverse(DAPP_PORT);
-});
-
-// Stop local playground server after all tests
-test.afterAll(async () => {
- cleanupAdbReverse(DAPP_PORT);
- await playgroundServer.stop();
-});
+test.describe(Performance, () => {
+ // Start local playground server before all tests
+ test.beforeAll(async () => {
+ playgroundServer.setServerPort(DAPP_PORT);
+ await playgroundServer.start();
+ await waitForDappServerReady(DAPP_PORT);
+ setupAdbReverse(DAPP_PORT);
+ });
-// Test steps (in order):
-//
-// 1. LOGIN AND NAVIGATE TO DAPP
-// - Login to app, ensure account groups finished loading
-// - Launch mobile browser and navigate to the playground dapp
-//
-// 2. CONNECT VIA WAGMI
-// - Tap Connect (Wagmi)
-// - In MetaMask: add Account 3, unselect OP Mainnet from networks, tap Connect
-// - Assert: wagmi connected, chainId 1, active account is Account 1
-//
-// 3. SWITCH TO SEPOLIA AND SIGN
-// - Switch chain to Sepolia (11155111), assert chainId 11155111
-// - Type 'Hello Sepolia' and tap sign
-// - In MetaMask: cancel sign request on Sepolia network
-//
-// 4. SWITCH TO OP MAINNET (requires approval) AND CHANGE ACCOUNT
-// - Tap switch chain to OP Mainnet (10)
-// - In MetaMask: approve chain switch modal showing 'OP'
-// - Assert: chainId 10
-// - Type 'Hello OP' and tap sign; cancel in MetaMask
-// - In MetaMask: navigate to account list and select Account 3
-//
-// 5. VERIFY ACCOUNT CHANGE AND ADD CELO CHAIN
-// - Assert: wagmi active account is now Account 3
-// - Tap switch chain to Celo (42220) — triggers add chain flow
-// - In MetaMask: assert chain shows '42220' and 'Celo', confirm add chain
-// - Assert: chainId 42220
-// - Type 'Hello Celo' and tap sign; cancel in MetaMask
-//
-// 6. CLEANUP
-// - Tap disconnect to clean up
-test('@metamask/connect-evm (wagmi) - Chain switching via Wagmi', async ({
- currentDeviceDetails,
- driver,
-}) => {
- const platform = currentDeviceDetails.platform;
- const useBrowserStackLocal =
- process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true';
- const DAPP_URL = useBrowserStackLocal
- ? `http://bs-local.com:${DAPP_PORT}`
- : getDappUrlForBrowser(platform);
+ // Stop local playground server after all tests
+ test.afterAll(async () => {
+ cleanupAdbReverse(DAPP_PORT);
+ await playgroundServer.stop();
+ });
+ // Test steps (in order):
//
- // Login and navigate to dapp
+ // 1. LOGIN AND NAVIGATE TO DAPP
+ // - Login to app, ensure account groups finished loading
+ // - Launch mobile browser and navigate to the playground dapp
//
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await loginToAppPlaywright();
- await ensureAccountGroupsFinishedLoading(currentDeviceDetails);
- await launchMobileBrowser();
- await navigateToDapp(DAPP_URL);
- });
-
- await sleep(5000);
-
+ // 2. CONNECT VIA WAGMI
+ // - Tap Connect (Wagmi)
+ // - In MetaMask: add Account 3, unselect OP Mainnet from networks, tap Connect
+ // - Assert: wagmi connected, chainId 1, active account is Account 1
//
- // Connect via WAGMI
+ // 3. SWITCH TO SEPOLIA AND SIGN
+ // - Switch chain to Sepolia (11155111), assert chainId 11155111
+ // - Type 'Hello Sepolia' and tap sign
+ // - In MetaMask: cancel sign request on Sepolia network
//
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.tapConnectWagmi();
- }, DAPP_URL);
-
- // Handle connection approval in MetaMask
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await unlockIfLockScreenVisible();
- await DappConnectionModal.tapEditAccountsButton();
- // Select account 3 in addition to Account 1
- await DappConnectionModal.tapAccountButton('Account 3');
- await DappConnectionModal.tapUpdateAccountsButton();
- await DappConnectionModal.tapPermissionsTabButton();
- // Unselect OP Mainnet
- await DappConnectionModal.tapEditNetworksButton();
- await DappConnectionModal.tapNetworkButton('OP');
- await DappConnectionModal.tapUpdateNetworksButton();
- await DappConnectionModal.tapConnectButton({
- shouldCooldown: true,
- timeToCooldown: 2000,
- });
- });
-
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
-
+ // 4. SWITCH TO OP MAINNET (requires approval) AND CHANGE ACCOUNT
+ // - Tap switch chain to OP Mainnet (10)
+ // - In MetaMask: approve chain switch modal showing 'OP'
+ // - Assert: chainId 10
+ // - Type 'Hello OP' and tap sign; cancel in MetaMask
+ // - In MetaMask: navigate to account list and select Account 3
//
- // Verify connection and switch to Sepolia
+ // 5. VERIFY ACCOUNT CHANGE AND ADD CELO CHAIN
+ // - Assert: wagmi active account is now Account 3
+ // - Tap switch chain to Celo (42220) — triggers add chain flow
+ // - In MetaMask: assert chain shows '42220' and 'Celo', confirm add chain
+ // - Assert: chainId 42220
+ // - Type 'Hello Celo' and tap sign; cancel in MetaMask
//
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertWagmiConnected(true);
- await BrowserPlaygroundDapp.assertWagmiChainIdValue('1');
- await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS);
- // Switch to Sepolia
- await BrowserPlaygroundDapp.tapWagmiSwitchChain(11155111);
- await BrowserPlaygroundDapp.assertWagmiChainIdValue('11155111');
- // Sign a message on Sepolia
- await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello Sepolia');
- await PlaywrightGestures.hideKeyboard();
- await BrowserPlaygroundDapp.tapWagmiSignMessage({
- shouldCooldown: true,
- timeToCooldown: 2000,
+ // 6. CLEANUP
+ // - Tap disconnect to clean up
+ test('@metamask/connect-evm (wagmi) - Chain switching via Wagmi', async ({
+ currentDeviceDetails,
+ driver,
+ }) => {
+ const platform = currentDeviceDetails.platform;
+ const useBrowserStackLocal =
+ process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true';
+ const DAPP_URL = useBrowserStackLocal
+ ? `http://bs-local.com:${DAPP_PORT}`
+ : getDappUrlForBrowser(platform);
+
+ //
+ // Login and navigate to dapp
+ //
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await loginToAppPlaywright();
+ await ensureAccountGroupsFinishedLoading(currentDeviceDetails);
+ await launchMobileBrowser();
+ await navigateToDapp(DAPP_URL);
});
- }, DAPP_URL);
-
- // Cancel sign request
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await SignModal.assertNetworkText('Sepolia');
- await SignModal.tapCancelButton();
- });
-
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
- //
- // Switch to OP Mainnet (requires approval since unselected earlier)
- //
+ await sleep(5000);
+
+ //
+ // Connect via WAGMI
+ //
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.tapConnectWagmi();
+ }, DAPP_URL);
+
+ // Handle connection approval in MetaMask
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await unlockIfLockScreenVisible();
+ await DappConnectionModal.tapEditAccountsButton();
+ // Select account 3 in addition to Account 1
+ await DappConnectionModal.tapAccountButton('Account 3');
+ await DappConnectionModal.tapUpdateAccountsButton();
+ await DappConnectionModal.tapPermissionsTabButton();
+ // Unselect OP Mainnet
+ await DappConnectionModal.tapEditNetworksButton();
+ await DappConnectionModal.tapNetworkButton('OP');
+ await DappConnectionModal.tapUpdateNetworksButton();
+ await DappConnectionModal.tapConnectButton({
+ shouldCooldown: true,
+ timeToCooldown: 2000,
+ });
+ });
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.tapWagmiSwitchChain(10); // OP Mainnet
- }, DAPP_URL);
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await SwitchChainModal.assertNetworkText('OP');
- await SwitchChainModal.tapConnectButton({
- shouldCooldown: true,
- timeToCooldown: 2000,
+ //
+ // Verify connection and switch to Sepolia
+ //
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertWagmiConnected(true);
+ await BrowserPlaygroundDapp.assertWagmiChainIdValue('1');
+ await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS);
+ // Switch to Sepolia
+ await BrowserPlaygroundDapp.tapWagmiSwitchChain(11155111);
+ await BrowserPlaygroundDapp.assertWagmiChainIdValue('11155111');
+ // Sign a message on Sepolia
+ await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello Sepolia');
+ await PlaywrightGestures.hideKeyboard();
+ await BrowserPlaygroundDapp.tapWagmiSignMessage({
+ shouldCooldown: true,
+ timeToCooldown: 2000,
+ });
+ }, DAPP_URL);
+
+ // Cancel sign request
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await SignModal.assertNetworkText('Sepolia');
+ await SignModal.tapCancelButton();
});
- });
-
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertWagmiChainIdValue('10');
- await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello OP');
- await PlaywrightGestures.hideKeyboard();
- await BrowserPlaygroundDapp.tapWagmiSignMessage();
- }, DAPP_URL);
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await SignModal.assertNetworkText('OP');
- await SignModal.tapCancelButton();
+ //
+ // Switch to OP Mainnet (requires approval since unselected earlier)
+ //
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.tapWagmiSwitchChain(10); // OP Mainnet
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await SwitchChainModal.assertNetworkText('OP');
+ await SwitchChainModal.tapConnectButton({
+ shouldCooldown: true,
+ timeToCooldown: 2000,
+ });
+ });
- // Wait here to make sure UI is visible before attempted interaction
+ await sleep(1000);
+ await switchToMobileBrowser();
await sleep(1000);
- // Change selected account to Account 3 in MetaMask
- await WalletView.tapIdenticon();
- await AccountListBottomSheet.tapAccountByName('Account 3');
- // Forcefully waiting for accounts to be synced
- await sleep(2500);
- });
-
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertWagmiChainIdValue('10');
+ await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello OP');
+ await PlaywrightGestures.hideKeyboard();
+ await BrowserPlaygroundDapp.tapWagmiSignMessage();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await SignModal.assertNetworkText('OP');
+ await SignModal.tapCancelButton();
+
+ // Wait here to make sure UI is visible before attempted interaction
+ await sleep(1000);
+
+ // Change selected account to Account 3 in MetaMask
+ await WalletView.tapIdenticon();
+ await AccountListBottomSheet.tapAccountByName('Account 3');
+ // Forcefully waiting for accounts to be synced
+ await sleep(2500);
+ });
- //
- // Verify account change and add CELO chain
- //
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_3_ADDRESS);
- // Try to switch to Celo (will trigger add chain)
- await BrowserPlaygroundDapp.tapWagmiSwitchChain(42220);
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await AddChainModal.assertText('42220');
- await AddChainModal.assertText('Celo');
- await AddChainModal.tapConfirmButton({
- shouldCooldown: true,
- timeToCooldown: 2000,
+ //
+ // Verify account change and add CELO chain
+ //
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_3_ADDRESS);
+ // Try to switch to Celo (will trigger add chain)
+ await BrowserPlaygroundDapp.tapWagmiSwitchChain(42220);
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await AddChainModal.assertText('42220');
+ await AddChainModal.assertText('Celo');
+ await AddChainModal.tapConfirmButton({
+ shouldCooldown: true,
+ timeToCooldown: 2000,
+ });
});
- });
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertWagmiChainIdValue('42220');
- await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello Celo');
- await PlaywrightGestures.hideKeyboard();
- await BrowserPlaygroundDapp.tapWagmiSignMessage({
- shouldCooldown: true,
- timeToCooldown: 2000,
- });
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await SignModal.assertNetworkText('Celo');
- await SignModal.tapCancelButton({
- shouldCooldown: true,
- timeToCooldown: 2000,
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertWagmiChainIdValue('42220');
+ await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello Celo');
+ await PlaywrightGestures.hideKeyboard();
+ await BrowserPlaygroundDapp.tapWagmiSignMessage({
+ shouldCooldown: true,
+ timeToCooldown: 2000,
+ });
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await SignModal.assertNetworkText('Celo');
+ await SignModal.tapCancelButton({
+ shouldCooldown: true,
+ timeToCooldown: 2000,
+ });
});
- });
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
- //
- // Reset dapp state
- //
+ //
+ // Reset dapp state
+ //
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.tapDisconnect();
- }, DAPP_URL);
-});
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.tapDisconnect();
+ }, DAPP_URL);
+ });
+}); // end describe
diff --git a/tests/performance/mm-connect/connection-wagmi.spec.ts b/tests/performance/mm-connect/connection-wagmi.spec.ts
index 3f3d410e455..0d32aeea7b3 100644
--- a/tests/performance/mm-connect/connection-wagmi.spec.ts
+++ b/tests/performance/mm-connect/connection-wagmi.spec.ts
@@ -1,4 +1,5 @@
import { test } from '../../framework/fixture';
+import { Performance } from '../../tags.performance.js';
import { loginToAppPlaywright } from '../../flows/wallet.flow';
import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp';
@@ -41,235 +42,237 @@ const playgroundServer = new DappServer({
dappVariant: DappVariants.BROWSER_PLAYGROUND,
});
-// Start local playground server before all tests
-test.beforeAll(async () => {
- playgroundServer.setServerPort(DAPP_PORT);
- await playgroundServer.start();
- await waitForDappServerReady(DAPP_PORT);
- setupAdbReverse(DAPP_PORT);
-});
-
-// Stop local playground server after all tests
-test.afterAll(async () => {
- cleanupAdbReverse(DAPP_PORT);
- await playgroundServer.stop();
-});
+test.describe(Performance, () => {
+ // Start local playground server before all tests
+ test.beforeAll(async () => {
+ playgroundServer.setServerPort(DAPP_PORT);
+ await playgroundServer.start();
+ await waitForDappServerReady(DAPP_PORT);
+ setupAdbReverse(DAPP_PORT);
+ });
-// Test steps (in order):
-//
-// 1. LOGIN AND NAVIGATE TO DAPP
-// - Login to app, ensure account groups finished loading
-// - Launch mobile browser and navigate to the playground dapp
-//
-// 2. CONNECT VIA WAGMI
-// - Tap Connect (Wagmi)
-// - In MetaMask: approve connection
-// - Assert: wagmi connected, chainId 1, active account is Account 1
-//
-// 3. SIGN MESSAGE ON ETHEREUM
-// - Type 'Hello MetaMask' and tap sign
-// - In MetaMask: confirm sign request on Ethereum network
-// - Assert: signature result starts with '0x'
-//
-// 4. REFRESH BROWSER RECONNECT
-// - Refresh mobile browser
-// - Assert: wagmi still connected, chainId 1, active account is Account 1
-// - Type 'After refresh' and tap sign; cancel in MetaMask
-//
-// 5. DISCONNECT AND RECONNECT
-// - Tap disconnect, assert wagmi disconnected
-// - Tap Connect (Wagmi), approve in MetaMask
-// - Assert: wagmi connected, chainId 1, active account is Account 1
-//
-// 6. INCOMPLETE SESSION TIMEOUT
-// - Disconnect, then tap Connect (Wagmi)
-// - In MetaMask: open approval but do NOT interact
-// - Refresh mobile browser, wait 10s for session to time out
-// - Assert: wagmi disconnected
-// - Tap Connect (Wagmi) again, approve in MetaMask
-// - Assert: wagmi connected, chainId 1, active account is Account 1
-//
-// 7. RESET DAPP STATE
-// - Tap disconnect to clean up
-
-// This test is currently failing. See 250.
-test.skip('@metamask/connect-evm (wagmi) - Session stability via Wagmi', async ({
- currentDeviceDetails,
- driver,
-}) => {
- const platform = currentDeviceDetails.platform;
- const useBrowserStackLocal =
- process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true';
- const DAPP_URL = useBrowserStackLocal
- ? `http://bs-local.com:${DAPP_PORT}`
- : getDappUrlForBrowser(platform);
+ // Stop local playground server after all tests
+ test.afterAll(async () => {
+ cleanupAdbReverse(DAPP_PORT);
+ await playgroundServer.stop();
+ });
+ // Test steps (in order):
//
- // Login and navigate to dapp
+ // 1. LOGIN AND NAVIGATE TO DAPP
+ // - Login to app, ensure account groups finished loading
+ // - Launch mobile browser and navigate to the playground dapp
//
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await loginToAppPlaywright();
- await ensureAccountGroupsFinishedLoading(currentDeviceDetails);
- await launchMobileBrowser();
- await navigateToDapp(DAPP_URL);
- });
-
- await sleep(5000);
-
+ // 2. CONNECT VIA WAGMI
+ // - Tap Connect (Wagmi)
+ // - In MetaMask: approve connection
+ // - Assert: wagmi connected, chainId 1, active account is Account 1
+ //
+ // 3. SIGN MESSAGE ON ETHEREUM
+ // - Type 'Hello MetaMask' and tap sign
+ // - In MetaMask: confirm sign request on Ethereum network
+ // - Assert: signature result starts with '0x'
+ //
+ // 4. REFRESH BROWSER RECONNECT
+ // - Refresh mobile browser
+ // - Assert: wagmi still connected, chainId 1, active account is Account 1
+ // - Type 'After refresh' and tap sign; cancel in MetaMask
//
- // Connect via WAGMI
+ // 5. DISCONNECT AND RECONNECT
+ // - Tap disconnect, assert wagmi disconnected
+ // - Tap Connect (Wagmi), approve in MetaMask
+ // - Assert: wagmi connected, chainId 1, active account is Account 1
//
+ // 6. INCOMPLETE SESSION TIMEOUT
+ // - Disconnect, then tap Connect (Wagmi)
+ // - In MetaMask: open approval but do NOT interact
+ // - Refresh mobile browser, wait 10s for session to time out
+ // - Assert: wagmi disconnected
+ // - Tap Connect (Wagmi) again, approve in MetaMask
+ // - Assert: wagmi connected, chainId 1, active account is Account 1
+ //
+ // 7. RESET DAPP STATE
+ // - Tap disconnect to clean up
+
+ // This test is currently failing. See 250.
+ test.skip('@metamask/connect-evm (wagmi) - Session stability via Wagmi', async ({
+ currentDeviceDetails,
+ driver,
+ }) => {
+ const platform = currentDeviceDetails.platform;
+ const useBrowserStackLocal =
+ process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true';
+ const DAPP_URL = useBrowserStackLocal
+ ? `http://bs-local.com:${DAPP_PORT}`
+ : getDappUrlForBrowser(platform);
+
+ //
+ // Login and navigate to dapp
+ //
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await loginToAppPlaywright();
+ await ensureAccountGroupsFinishedLoading(currentDeviceDetails);
+ await launchMobileBrowser();
+ await navigateToDapp(DAPP_URL);
+ });
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.tapConnectWagmi();
- }, DAPP_URL);
+ await sleep(5000);
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await unlockIfLockScreenVisible();
- await DappConnectionModal.tapConnectButton();
- });
+ //
+ // Connect via WAGMI
+ //
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.tapConnectWagmi();
+ }, DAPP_URL);
- //
- // Verify connection and sign message on Ethereum
- //
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await unlockIfLockScreenVisible();
+ await DappConnectionModal.tapConnectButton();
+ });
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertWagmiConnected(true);
- await BrowserPlaygroundDapp.assertWagmiChainIdValue('1');
- await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS);
- await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask');
- await PlaywrightGestures.hideKeyboard();
- await BrowserPlaygroundDapp.tapWagmiSignMessage();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await SignModal.assertNetworkText('Ethereum');
- await SignModal.tapConfirmButton();
- });
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
+
+ //
+ // Verify connection and sign message on Ethereum
+ //
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertWagmiConnected(true);
+ await BrowserPlaygroundDapp.assertWagmiChainIdValue('1');
+ await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS);
+ await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask');
+ await PlaywrightGestures.hideKeyboard();
+ await BrowserPlaygroundDapp.tapWagmiSignMessage();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await SignModal.assertNetworkText('Ethereum');
+ await SignModal.tapConfirmButton();
+ });
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x');
- }, DAPP_URL);
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x');
+ }, DAPP_URL);
- //
- // Resume from refresh
- //
+ //
+ // Resume from refresh
+ //
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await refreshMobileBrowser();
- });
- await sleep(2000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertWagmiConnected(true);
- // Note: Chain may reset to 1 after refresh
- await BrowserPlaygroundDapp.assertWagmiChainIdValue('1');
- await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS);
- await BrowserPlaygroundDapp.typeWagmiSignMessage('After refresh');
- await PlaywrightGestures.hideKeyboard();
- await BrowserPlaygroundDapp.tapWagmiSignMessage();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await SignModal.tapCancelButton();
- });
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await refreshMobileBrowser();
+ });
+ await sleep(2000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertWagmiConnected(true);
+ // Note: Chain may reset to 1 after refresh
+ await BrowserPlaygroundDapp.assertWagmiChainIdValue('1');
+ await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS);
+ await BrowserPlaygroundDapp.typeWagmiSignMessage('After refresh');
+ await PlaywrightGestures.hideKeyboard();
+ await BrowserPlaygroundDapp.tapWagmiSignMessage();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await SignModal.tapCancelButton();
+ });
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
- //
- // Terminate and connect
- //
+ //
+ // Terminate and connect
+ //
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.tapDisconnect();
- await BrowserPlaygroundDapp.assertWagmiConnected(false);
- await BrowserPlaygroundDapp.tapConnectWagmi();
- }, DAPP_URL);
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.tapDisconnect();
+ await BrowserPlaygroundDapp.assertWagmiConnected(false);
+ await BrowserPlaygroundDapp.tapConnectWagmi();
+ }, DAPP_URL);
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- await DappConnectionModal.tapConnectButton();
- });
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ await DappConnectionModal.tapConnectButton();
+ });
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertWagmiConnected(true);
- await BrowserPlaygroundDapp.assertWagmiChainIdValue('1');
- await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS);
- }, DAPP_URL);
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertWagmiConnected(true);
+ await BrowserPlaygroundDapp.assertWagmiChainIdValue('1');
+ await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS);
+ }, DAPP_URL);
- //
- // Wait for incomplete session timeout on refresh and reconnect after
- //
+ //
+ // Wait for incomplete session timeout on refresh and reconnect after
+ //
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.tapDisconnect();
- await BrowserPlaygroundDapp.tapConnectWagmi();
- }, DAPP_URL);
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.tapDisconnect();
+ await BrowserPlaygroundDapp.tapConnectWagmi();
+ }, DAPP_URL);
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- // Purposely not interacting with the approval
- });
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ // Purposely not interacting with the approval
+ });
- await sleep(2000);
- await switchToMobileBrowser();
- await sleep(1000);
+ await sleep(2000);
+ await switchToMobileBrowser();
+ await sleep(1000);
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await refreshMobileBrowser();
- });
- await sleep(2000);
-
- // After timeout, should be disconnected
- await sleep(10000);
-
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertWagmiConnected(false);
- await BrowserPlaygroundDapp.tapConnectWagmi();
- }, DAPP_URL);
-
- await PlaywrightContextHelpers.withNativeAction(async () => {
- await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
- // TODO: We're having a double connect prompt. After approving the
- // connection, a second prompt with empty accounts is shown.
- await DappConnectionModal.tapConnectButton({
- shouldCooldown: true,
- timeToCooldown: 2000,
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await refreshMobileBrowser();
+ });
+ await sleep(2000);
+
+ // After timeout, should be disconnected
+ await sleep(10000);
+
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertWagmiConnected(false);
+ await BrowserPlaygroundDapp.tapConnectWagmi();
+ }, DAPP_URL);
+
+ await PlaywrightContextHelpers.withNativeAction(async () => {
+ await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask();
+ // TODO: We're having a double connect prompt. After approving the
+ // connection, a second prompt with empty accounts is shown.
+ await DappConnectionModal.tapConnectButton({
+ shouldCooldown: true,
+ timeToCooldown: 2000,
+ });
});
- });
- await sleep(1000);
- await switchToMobileBrowser();
- await sleep(1000);
+ await sleep(1000);
+ await switchToMobileBrowser();
+ await sleep(1000);
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.assertWagmiConnected(true);
- await BrowserPlaygroundDapp.assertWagmiChainIdValue('1');
- await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS);
- }, DAPP_URL);
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.assertWagmiConnected(true);
+ await BrowserPlaygroundDapp.assertWagmiChainIdValue('1');
+ await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS);
+ }, DAPP_URL);
- //
- // Reset dapp state
- //
+ //
+ // Reset dapp state
+ //
- await PlaywrightContextHelpers.withWebAction(async () => {
- await BrowserPlaygroundDapp.tapDisconnect();
- }, DAPP_URL);
-});
+ await PlaywrightContextHelpers.withWebAction(async () => {
+ await BrowserPlaygroundDapp.tapDisconnect();
+ }, DAPP_URL);
+ });
+}); // end describe
diff --git a/tests/performance/mm-connect/legacy-evm-rn-connect.spec.ts b/tests/performance/mm-connect/legacy-evm-rn-connect.spec.ts
index 55c04a0bd22..2561487efb2 100644
--- a/tests/performance/mm-connect/legacy-evm-rn-connect.spec.ts
+++ b/tests/performance/mm-connect/legacy-evm-rn-connect.spec.ts
@@ -1,4 +1,5 @@
import { test } from '../../framework/fixture';
+import { Performance } from '../../tags.performance.js';
import { loginToAppPlaywright } from '../../flows/wallet.flow';
import RNPlaygroundDapp from '../../page-objects/MMConnect/RNPlaygroundDapp';
@@ -17,160 +18,162 @@ async function returnToPlayground() {
await RNPlaygroundDapp.ensureInPlayground();
}
-test('@metamask/connect-legacy-evm-rn - Connect via Legacy EVM, sign, send transaction, and switch chains', async ({
- currentDeviceDetails,
- driver,
-}) => {
- // When running on BrowserStack we skip the test if the RN playground is not installed
- test.skip(
- currentDeviceDetails.isBrowserstack &&
- !process.env.BROWSERSTACK_RN_PLAYGROUND_URL,
- 'Skipped: BROWSERSTACK_RN_PLAYGROUND_URL is not set',
- );
-
- // handle local installs of the RN playground
- if (!currentDeviceDetails.isBrowserstack) {
- ensurePlaygroundInstalled(currentDeviceDetails);
- }
-
- //
- // 1. Login to MetaMask wallet
- //
- await loginToAppPlaywright();
-
- //
- // 2. Switch to the RN playground and connect via Legacy EVM
- //
- await RNPlaygroundDapp.switchToPlayground();
- await RNPlaygroundDapp.waitForPlaygroundReady();
-
- await RNPlaygroundDapp.tapConnectLegacy();
- await sleep(3000);
-
- await unlockIfLockScreenVisible();
- await sleep(5000);
- await DappConnectionModal.tapConnectButton({
- shouldCooldown: true,
- timeToCooldown: 3000,
- });
-
- //
- // 3. Verify accountsChanged — Legacy EVM card visible with accounts
- //
-
- await returnToPlayground();
- await sleep(2000);
-
- await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.appTitle, {
- scrollParams: { direction: 'down' },
- });
- await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.legacyEvmCard);
- await RNPlaygroundDapp.assertLegacyEvmConnected();
- await RNPlaygroundDapp.assertLegacyEvmHasAccounts();
- await RNPlaygroundDapp.assertLegacyEvmActiveAccount();
-
- const initialChainId = await RNPlaygroundDapp.getLegacyEvmChainId();
- console.log(`Initial chain ID: ${initialChainId}`);
-
- //
- // 4. personal_sign — request, approve, verify result
- //
- await RNPlaygroundDapp.scrollToElement(
- RNPlaygroundDapp.legacyEvmBtnPersonalSign,
- );
- await RNPlaygroundDapp.tapLegacyEvmButton(
- RNPlaygroundDapp.legacyEvmBtnPersonalSign,
- );
- await sleep(3000);
-
- await unlockIfLockScreenVisible();
- await sleep(1000);
- await SignModal.tapConfirmButton({
- shouldCooldown: true,
- timeToCooldown: 3000,
- });
-
- await returnToPlayground();
- await sleep(1000);
-
- // Verify signature was returned (hex string starting with 0x)
- await RNPlaygroundDapp.scrollToElement(
- RNPlaygroundDapp.legacyEvmResponseText,
- );
- const signResponse = await RNPlaygroundDapp.getLegacyEvmResponseText();
- console.log(`personal_sign response: ${signResponse}`);
- console.log(`personal_sign contains 0x: ${signResponse.includes('0x')}`);
-
- //
- // 5. eth_sendTransaction — request, cancel (to avoid spending funds)
- //
- await RNPlaygroundDapp.scrollToElement(
- RNPlaygroundDapp.legacyEvmBtnSendTransaction,
- );
- await RNPlaygroundDapp.tapLegacyEvmButton(
- RNPlaygroundDapp.legacyEvmBtnSendTransaction,
- );
- await sleep(3000);
-
- await unlockIfLockScreenVisible();
- await sleep(1000);
-
- // Cancel the transaction to avoid spending real funds
- await SignModal.tapCancelButton({
- shouldCooldown: true,
- timeToCooldown: 3000,
- });
-
- await returnToPlayground();
- await sleep(1000);
-
- // The dapp should show an error (user rejected) in the response
-
- await RNPlaygroundDapp.scrollToElement(
- RNPlaygroundDapp.legacyEvmResponseText,
- {
+test.describe(Performance, () => {
+ test('@metamask/connect-legacy-evm-rn - Connect via Legacy EVM, sign, send transaction, and switch chains', async ({
+ currentDeviceDetails,
+ driver,
+ }) => {
+ // When running on BrowserStack we skip the test if the RN playground is not installed
+ test.skip(
+ currentDeviceDetails.isBrowserstack &&
+ !process.env.BROWSERSTACK_RN_PLAYGROUND_URL,
+ 'Skipped: BROWSERSTACK_RN_PLAYGROUND_URL is not set',
+ );
+
+ // handle local installs of the RN playground
+ if (!currentDeviceDetails.isBrowserstack) {
+ ensurePlaygroundInstalled(currentDeviceDetails);
+ }
+
+ //
+ // 1. Login to MetaMask wallet
+ //
+ await loginToAppPlaywright();
+
+ //
+ // 2. Switch to the RN playground and connect via Legacy EVM
+ //
+ await RNPlaygroundDapp.switchToPlayground();
+ await RNPlaygroundDapp.waitForPlaygroundReady();
+
+ await RNPlaygroundDapp.tapConnectLegacy();
+ await sleep(3000);
+
+ await unlockIfLockScreenVisible();
+ await sleep(5000);
+ await DappConnectionModal.tapConnectButton({
+ shouldCooldown: true,
+ timeToCooldown: 3000,
+ });
+
+ //
+ // 3. Verify accountsChanged — Legacy EVM card visible with accounts
+ //
+
+ await returnToPlayground();
+ await sleep(2000);
+
+ await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.appTitle, {
scrollParams: { direction: 'down' },
- percent: 0.5,
- },
- );
-
- const txResponse = await RNPlaygroundDapp.getLegacyEvmResponseText();
- console.log(`eth_sendTransaction (cancelled) response: ${txResponse}`);
- console.log(
- `eth_sendTransaction contains denied: ${txResponse.toLowerCase().includes('denied')}`,
- );
-
- //
- // 6. Chain switching from the dapp — wallet_switchEthereumChain
- // Switch to Polygon from the dapp, verify the chain ID updates.
- //
-
- await RNPlaygroundDapp.scrollToElement(
- RNPlaygroundDapp.legacyEvmBtnSwitchPolygon,
- );
- await RNPlaygroundDapp.tapLegacyEvmButton(
- RNPlaygroundDapp.legacyEvmBtnSwitchPolygon,
- );
- await sleep(3000);
-
- // The switch opens MetaMask with a network approval dialog.
- // The SwitchChainApproval dialog uses "connect-button" as its confirm testID.
- await unlockIfLockScreenVisible();
- await sleep(1000);
- await DappConnectionModal.tapConnectButton({
- shouldCooldown: true,
- timeToCooldown: 3000,
+ });
+ await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.legacyEvmCard);
+ await RNPlaygroundDapp.assertLegacyEvmConnected();
+ await RNPlaygroundDapp.assertLegacyEvmHasAccounts();
+ await RNPlaygroundDapp.assertLegacyEvmActiveAccount();
+
+ const initialChainId = await RNPlaygroundDapp.getLegacyEvmChainId();
+ console.log(`Initial chain ID: ${initialChainId}`);
+
+ //
+ // 4. personal_sign — request, approve, verify result
+ //
+ await RNPlaygroundDapp.scrollToElement(
+ RNPlaygroundDapp.legacyEvmBtnPersonalSign,
+ );
+ await RNPlaygroundDapp.tapLegacyEvmButton(
+ RNPlaygroundDapp.legacyEvmBtnPersonalSign,
+ );
+ await sleep(3000);
+
+ await unlockIfLockScreenVisible();
+ await sleep(1000);
+ await SignModal.tapConfirmButton({
+ shouldCooldown: true,
+ timeToCooldown: 3000,
+ });
+
+ await returnToPlayground();
+ await sleep(1000);
+
+ // Verify signature was returned (hex string starting with 0x)
+ await RNPlaygroundDapp.scrollToElement(
+ RNPlaygroundDapp.legacyEvmResponseText,
+ );
+ const signResponse = await RNPlaygroundDapp.getLegacyEvmResponseText();
+ console.log(`personal_sign response: ${signResponse}`);
+ console.log(`personal_sign contains 0x: ${signResponse.includes('0x')}`);
+
+ //
+ // 5. eth_sendTransaction — request, cancel (to avoid spending funds)
+ //
+ await RNPlaygroundDapp.scrollToElement(
+ RNPlaygroundDapp.legacyEvmBtnSendTransaction,
+ );
+ await RNPlaygroundDapp.tapLegacyEvmButton(
+ RNPlaygroundDapp.legacyEvmBtnSendTransaction,
+ );
+ await sleep(3000);
+
+ await unlockIfLockScreenVisible();
+ await sleep(1000);
+
+ // Cancel the transaction to avoid spending real funds
+ await SignModal.tapCancelButton({
+ shouldCooldown: true,
+ timeToCooldown: 3000,
+ });
+
+ await returnToPlayground();
+ await sleep(1000);
+
+ // The dapp should show an error (user rejected) in the response
+
+ await RNPlaygroundDapp.scrollToElement(
+ RNPlaygroundDapp.legacyEvmResponseText,
+ {
+ scrollParams: { direction: 'down' },
+ percent: 0.5,
+ },
+ );
+
+ const txResponse = await RNPlaygroundDapp.getLegacyEvmResponseText();
+ console.log(`eth_sendTransaction (cancelled) response: ${txResponse}`);
+ console.log(
+ `eth_sendTransaction contains denied: ${txResponse.toLowerCase().includes('denied')}`,
+ );
+
+ //
+ // 6. Chain switching from the dapp — wallet_switchEthereumChain
+ // Switch to Polygon from the dapp, verify the chain ID updates.
+ //
+
+ await RNPlaygroundDapp.scrollToElement(
+ RNPlaygroundDapp.legacyEvmBtnSwitchPolygon,
+ );
+ await RNPlaygroundDapp.tapLegacyEvmButton(
+ RNPlaygroundDapp.legacyEvmBtnSwitchPolygon,
+ );
+ await sleep(3000);
+
+ // The switch opens MetaMask with a network approval dialog.
+ // The SwitchChainApproval dialog uses "connect-button" as its confirm testID.
+ await unlockIfLockScreenVisible();
+ await sleep(1000);
+ await DappConnectionModal.tapConnectButton({
+ shouldCooldown: true,
+ timeToCooldown: 3000,
+ });
+
+ await returnToPlayground();
+ await sleep(2000);
+
+ // Verify chain ID updated to Polygon (0x89)
+ await RNPlaygroundDapp.scrollToElement(
+ RNPlaygroundDapp.legacyEvmChainIdValue,
+ { scrollParams: { direction: 'down' } },
+ );
+ const polygonChainId = await RNPlaygroundDapp.getLegacyEvmChainId();
+ console.log(`Chain ID after dapp switch to Polygon: ${polygonChainId}`);
+ console.log(`Chain ID contains 0x89: ${polygonChainId.includes('0x89')}`);
});
-
- await returnToPlayground();
- await sleep(2000);
-
- // Verify chain ID updated to Polygon (0x89)
- await RNPlaygroundDapp.scrollToElement(
- RNPlaygroundDapp.legacyEvmChainIdValue,
- { scrollParams: { direction: 'down' } },
- );
- const polygonChainId = await RNPlaygroundDapp.getLegacyEvmChainId();
- console.log(`Chain ID after dapp switch to Polygon: ${polygonChainId}`);
- console.log(`Chain ID contains 0x89: ${polygonChainId.includes('0x89')}`);
-});
+}); // end describe
diff --git a/tests/performance/mm-connect/multichain-rn-evm.spec.ts b/tests/performance/mm-connect/multichain-rn-evm.spec.ts
index 6576f35939f..250be3e0f8d 100644
--- a/tests/performance/mm-connect/multichain-rn-evm.spec.ts
+++ b/tests/performance/mm-connect/multichain-rn-evm.spec.ts
@@ -1,4 +1,5 @@
import { test } from '../../framework/fixture';
+import { Performance } from '../../tags.performance.js';
import { loginToAppPlaywright } from '../../flows/wallet.flow';
import RNPlaygroundDapp from '../../page-objects/MMConnect/RNPlaygroundDapp';
@@ -77,145 +78,153 @@ async function returnToPlayground() {
// - Assert session is disconnected
// - Switch to MetaMask and unlock if needed to confirm no active session
-test('@metamask/connect-multichain-rn-evm - Connect across 3 EVM chains, invoke read/write methods, and disconnect', async ({
- currentDeviceDetails,
- driver,
-}) => {
- // When running on BrowserStack we skip the test if the RN playground is not installed
- test.skip(
- currentDeviceDetails.isBrowserstack &&
- !process.env.BROWSERSTACK_RN_PLAYGROUND_URL,
- 'Skipped: BROWSERSTACK_RN_PLAYGROUND_URL is not set',
- );
-
- // handle local installs of the RN playground
- if (!currentDeviceDetails.isBrowserstack) {
- ensurePlaygroundInstalled(currentDeviceDetails);
- }
-
- //
- // 1. Login to MetaMask wallet
- //
- await loginToAppPlaywright();
- await PlaywrightAssertions.expectElementToBeVisible(
- await asPlaywrightElement(WalletView.container),
- { timeout: 15000 },
- );
-
- await ensureAccountGroupsFinishedLoading(currentDeviceDetails);
-
- //
- // 2. Switch to the RN playground and select networks
- //
- await RNPlaygroundDapp.switchToPlayground();
- await RNPlaygroundDapp.waitForPlaygroundReady();
-
- // Ethereum (eip155:1) is selected by default; add two more EVM networks
- await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.LINEA);
- await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.POLYGON);
-
- //
- // 3. Connect via Multichain API
- //
- await RNPlaygroundDapp.tapConnect();
- await sleep(3000);
-
- await unlockIfLockScreenVisible();
- await sleep(5000);
- await DappConnectionModal.tapConnectButton({
- shouldCooldown: true,
- timeToCooldown: 3000,
- });
-
- //
- // 4. Return to playground, verify EVM connections, and invoke read requests
- //
- await returnToPlayground();
- await RNPlaygroundDapp.assertConnected();
-
- for (const chain of EVM_CHAINS) {
- await RNPlaygroundDapp.scrollToElement(
- RNPlaygroundDapp.getInvokeButton(chain),
- {
- percent: 0.5,
- },
+test.describe(Performance, () => {
+ test('@metamask/connect-multichain-rn-evm - Connect across 3 EVM chains, invoke read/write methods, and disconnect', async ({
+ currentDeviceDetails,
+ driver,
+ }) => {
+ // When running on BrowserStack we skip the test if the RN playground is not installed
+ test.skip(
+ currentDeviceDetails.isBrowserstack &&
+ !process.env.BROWSERSTACK_RN_PLAYGROUND_URL,
+ 'Skipped: BROWSERSTACK_RN_PLAYGROUND_URL is not set',
);
- await RNPlaygroundDapp.tapInvoke(chain);
- await sleep(5000);
- await RNPlaygroundDapp.scrollToElement(
- RNPlaygroundDapp.getResultCode(chain, 'eth_blockNumber'),
- {
- percent: 0.5,
- },
- );
- await RNPlaygroundDapp.assertResultCodeContains(
- chain,
- 'eth_blockNumber',
- '0x',
+ // handle local installs of the RN playground
+ if (!currentDeviceDetails.isBrowserstack) {
+ ensurePlaygroundInstalled(currentDeviceDetails);
+ }
+
+ //
+ // 1. Login to MetaMask wallet
+ //
+ await loginToAppPlaywright();
+ await PlaywrightAssertions.expectElementToBeVisible(
+ await asPlaywrightElement(WalletView.container),
+ { timeout: 15000 },
);
- // Test the write request
- await RNPlaygroundDapp.selectMethod(chain, 'personal_sign', 10, 2, 'down');
+ await ensureAccountGroupsFinishedLoading(currentDeviceDetails);
- await RNPlaygroundDapp.scrollToElement(
- RNPlaygroundDapp.getInvokeButton(chain),
- {
- percent: 0.5,
- },
- );
- await RNPlaygroundDapp.tapInvoke(chain);
+ //
+ // 2. Switch to the RN playground and select networks
+ //
+ await RNPlaygroundDapp.switchToPlayground();
+ await RNPlaygroundDapp.waitForPlaygroundReady();
+
+ // Ethereum (eip155:1) is selected by default; add two more EVM networks
+ await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.LINEA);
+ await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.POLYGON);
+
+ //
+ // 3. Connect via Multichain API
+ //
+ await RNPlaygroundDapp.tapConnect();
await sleep(3000);
- // Handle MetaMask sign approval
await unlockIfLockScreenVisible();
- await sleep(1000);
-
- // Verify request was routed to the correct network
- const networkName = NETWORK_DISPLAY_NAMES[chain];
- if (networkName) {
- try {
- await SignModal.assertNetworkText(networkName);
- } catch {
- // Network label may not appear for all signing modals; continue
- }
- }
-
- await SignModal.tapConfirmButton({
+ await sleep(5000);
+ await DappConnectionModal.tapConnectButton({
shouldCooldown: true,
timeToCooldown: 3000,
});
+
+ //
+ // 4. Return to playground, verify EVM connections, and invoke read requests
+ //
await returnToPlayground();
+ await RNPlaygroundDapp.assertConnected();
+
+ for (const chain of EVM_CHAINS) {
+ await RNPlaygroundDapp.scrollToElement(
+ RNPlaygroundDapp.getInvokeButton(chain),
+ {
+ percent: 0.5,
+ },
+ );
+ await RNPlaygroundDapp.tapInvoke(chain);
+ await sleep(5000);
+
+ await RNPlaygroundDapp.scrollToElement(
+ RNPlaygroundDapp.getResultCode(chain, 'eth_blockNumber'),
+ {
+ percent: 0.5,
+ },
+ );
+ await RNPlaygroundDapp.assertResultCodeContains(
+ chain,
+ 'eth_blockNumber',
+ '0x',
+ );
+
+ // Test the write request
+ await RNPlaygroundDapp.selectMethod(
+ chain,
+ 'personal_sign',
+ 10,
+ 2,
+ 'down',
+ );
+
+ await RNPlaygroundDapp.scrollToElement(
+ RNPlaygroundDapp.getInvokeButton(chain),
+ {
+ percent: 0.5,
+ },
+ );
+ await RNPlaygroundDapp.tapInvoke(chain);
+ await sleep(3000);
+
+ // Handle MetaMask sign approval
+ await unlockIfLockScreenVisible();
+ await sleep(1000);
+
+ // Verify request was routed to the correct network
+ const networkName = NETWORK_DISPLAY_NAMES[chain];
+ if (networkName) {
+ try {
+ await SignModal.assertNetworkText(networkName);
+ } catch {
+ // Network label may not appear for all signing modals; continue
+ }
+ }
- // Verify a signature was returned (hex string starting with 0x)
- await RNPlaygroundDapp.scrollToElement(
- RNPlaygroundDapp.getResultCode(chain, 'personal_sign'),
- {
- percent: 0.5,
- scrollParams: { direction: 'up' },
- },
- );
- await RNPlaygroundDapp.assertResultCodeContains(
- chain,
- 'personal_sign',
- '0x',
- );
- }
-
- // Eager swipe up as the disconnect button sits at the top of the Dapp
- await PlaywrightGestures.swipe({
- scrollParams: { direction: 'down' },
- duration: 100,
- from: { x: 100, y: 300 },
- to: { x: 100, y: 1700 },
- percent: 0.5,
- });
+ await SignModal.tapConfirmButton({
+ shouldCooldown: true,
+ timeToCooldown: 3000,
+ });
+ await returnToPlayground();
+
+ // Verify a signature was returned (hex string starting with 0x)
+ await RNPlaygroundDapp.scrollToElement(
+ RNPlaygroundDapp.getResultCode(chain, 'personal_sign'),
+ {
+ percent: 0.5,
+ scrollParams: { direction: 'up' },
+ },
+ );
+ await RNPlaygroundDapp.assertResultCodeContains(
+ chain,
+ 'personal_sign',
+ '0x',
+ );
+ }
- // Scroll back to the top where the disconnect button lives
- await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.disconnectButton, {
- scrollParams: { direction: 'down' },
- percent: 0.5,
+ // Eager swipe up as the disconnect button sits at the top of the Dapp
+ await PlaywrightGestures.swipe({
+ scrollParams: { direction: 'down' },
+ duration: 100,
+ from: { x: 100, y: 300 },
+ to: { x: 100, y: 1700 },
+ percent: 0.5,
+ });
+
+ // Scroll back to the top where the disconnect button lives
+ await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.disconnectButton, {
+ scrollParams: { direction: 'down' },
+ percent: 0.5,
+ });
+ await RNPlaygroundDapp.tapDisconnect();
+ await RNPlaygroundDapp.assertDisconnected();
});
- await RNPlaygroundDapp.tapDisconnect();
- await RNPlaygroundDapp.assertDisconnected();
-});
+}); // end describe
diff --git a/tests/performance/mm-connect/multichain-rn-solana.spec.ts b/tests/performance/mm-connect/multichain-rn-solana.spec.ts
index 7b7f48be8a2..3db661cff79 100644
--- a/tests/performance/mm-connect/multichain-rn-solana.spec.ts
+++ b/tests/performance/mm-connect/multichain-rn-solana.spec.ts
@@ -1,4 +1,5 @@
import { test } from '../../framework/fixture';
+import { Performance } from '../../tags.performance.js';
import { loginToAppPlaywright } from '../../flows/wallet.flow';
import RNPlaygroundDapp from '../../page-objects/MMConnect/RNPlaygroundDapp';
@@ -65,122 +66,124 @@ async function returnToPlayground() {
// - Assert session is disconnected
// - Switch to MetaMask and unlock if needed to confirm no active session
-test('@metamask/connect-multichain-rn-solana - Connect with Solana, invoke signMessage, and disconnect', async ({
- currentDeviceDetails,
- driver,
-}) => {
- // When running on BrowserStack we skip the test if the RN playground is not installed
- test.skip(
- currentDeviceDetails.isBrowserstack &&
- !process.env.BROWSERSTACK_RN_PLAYGROUND_URL,
- 'Skipped: BROWSERSTACK_RN_PLAYGROUND_URL is not set',
- );
-
- // handle local installs of the RN playground
- if (!currentDeviceDetails.isBrowserstack) {
- ensurePlaygroundInstalled(currentDeviceDetails);
- }
-
- //
- // 1. Login to MetaMask wallet
- //
- await loginToAppPlaywright();
- await PlaywrightAssertions.expectElementToBeVisible(
- await asPlaywrightElement(WalletView.container),
- { timeout: 15000 },
- );
-
- await ensureAccountGroupsFinishedLoading(currentDeviceDetails);
-
- //
- // 2. Switch to the RN playground and select networks
- //
- await RNPlaygroundDapp.switchToPlayground();
- await RNPlaygroundDapp.waitForPlaygroundReady();
-
- // Ethereum (eip155:1) is selected by default; add three more networks
- await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.LINEA);
- await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.POLYGON);
- await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.SOLANA);
-
- //
- // 3. Connect via Multichain API
- //
- await RNPlaygroundDapp.tapConnect();
- await sleep(3000);
-
- await unlockIfLockScreenVisible();
- await sleep(5000);
- await DappConnectionModal.tapConnectButton({
- shouldCooldown: true,
- timeToCooldown: 3000,
- });
-
- //
- // 4. Return to playground and verify Solana connection is active
- //
- await returnToPlayground();
-
- await RNPlaygroundDapp.assertConnected();
-
- await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.appTitle, {
- scrollParams: { direction: 'down' },
- percent: 0.5,
- });
-
- await RNPlaygroundDapp.scrollToElement(
- RNPlaygroundDapp.getScopeCard(CHAINS.SOLANA),
- {
+test.describe(Performance, () => {
+ test('@metamask/connect-multichain-rn-solana - Connect with Solana, invoke signMessage, and disconnect', async ({
+ currentDeviceDetails,
+ driver,
+ }) => {
+ // When running on BrowserStack we skip the test if the RN playground is not installed
+ test.skip(
+ currentDeviceDetails.isBrowserstack &&
+ !process.env.BROWSERSTACK_RN_PLAYGROUND_URL,
+ 'Skipped: BROWSERSTACK_RN_PLAYGROUND_URL is not set',
+ );
+
+ // handle local installs of the RN playground
+ if (!currentDeviceDetails.isBrowserstack) {
+ ensurePlaygroundInstalled(currentDeviceDetails);
+ }
+
+ //
+ // 1. Login to MetaMask wallet
+ //
+ await loginToAppPlaywright();
+ await PlaywrightAssertions.expectElementToBeVisible(
+ await asPlaywrightElement(WalletView.container),
+ { timeout: 15000 },
+ );
+
+ await ensureAccountGroupsFinishedLoading(currentDeviceDetails);
+
+ //
+ // 2. Switch to the RN playground and select networks
+ //
+ await RNPlaygroundDapp.switchToPlayground();
+ await RNPlaygroundDapp.waitForPlaygroundReady();
+
+ // Ethereum (eip155:1) is selected by default; add three more networks
+ await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.LINEA);
+ await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.POLYGON);
+ await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.SOLANA);
+
+ //
+ // 3. Connect via Multichain API
+ //
+ await RNPlaygroundDapp.tapConnect();
+ await sleep(3000);
+
+ await unlockIfLockScreenVisible();
+ await sleep(5000);
+ await DappConnectionModal.tapConnectButton({
+ shouldCooldown: true,
+ timeToCooldown: 3000,
+ });
+
+ //
+ // 4. Return to playground and verify Solana connection is active
+ //
+ await returnToPlayground();
+
+ await RNPlaygroundDapp.assertConnected();
+
+ await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.appTitle, {
+ scrollParams: { direction: 'down' },
percent: 0.5,
- },
- );
- await RNPlaygroundDapp.assertScopeCardVisible(CHAINS.SOLANA);
-
- //
- // 5. Solana write request — signMessage
- //
- await RNPlaygroundDapp.scrollToElement(
- RNPlaygroundDapp.getMethodSelect(CHAINS.SOLANA),
- {
- percent: 0.5,
- },
- );
- await RNPlaygroundDapp.selectMethod(CHAINS.SOLANA, 'signMessage');
-
- await RNPlaygroundDapp.scrollToElement(
- RNPlaygroundDapp.getInvokeButton(CHAINS.SOLANA),
- {
- percent: 0.5,
- },
- );
- await RNPlaygroundDapp.tapInvoke(CHAINS.SOLANA);
- await sleep(3000);
-
- await unlockIfLockScreenVisible();
- await sleep(1000);
- await SnapSignModal.tapConfirmButton({
- shouldCooldown: true,
- timeToCooldown: 3000,
- });
- await returnToPlayground();
-
- await RNPlaygroundDapp.scrollToElement(
- RNPlaygroundDapp.getResultCode(CHAINS.SOLANA, 'signMessage'),
- {
+ });
+
+ await RNPlaygroundDapp.scrollToElement(
+ RNPlaygroundDapp.getScopeCard(CHAINS.SOLANA),
+ {
+ percent: 0.5,
+ },
+ );
+ await RNPlaygroundDapp.assertScopeCardVisible(CHAINS.SOLANA);
+
+ //
+ // 5. Solana write request — signMessage
+ //
+ await RNPlaygroundDapp.scrollToElement(
+ RNPlaygroundDapp.getMethodSelect(CHAINS.SOLANA),
+ {
+ percent: 0.5,
+ },
+ );
+ await RNPlaygroundDapp.selectMethod(CHAINS.SOLANA, 'signMessage');
+
+ await RNPlaygroundDapp.scrollToElement(
+ RNPlaygroundDapp.getInvokeButton(CHAINS.SOLANA),
+ {
+ percent: 0.5,
+ },
+ );
+ await RNPlaygroundDapp.tapInvoke(CHAINS.SOLANA);
+ await sleep(3000);
+
+ await unlockIfLockScreenVisible();
+ await sleep(1000);
+ await SnapSignModal.tapConfirmButton({
+ shouldCooldown: true,
+ timeToCooldown: 3000,
+ });
+ await returnToPlayground();
+
+ await RNPlaygroundDapp.scrollToElement(
+ RNPlaygroundDapp.getResultCode(CHAINS.SOLANA, 'signMessage'),
+ {
+ percent: 0.5,
+ },
+ );
+ await RNPlaygroundDapp.waitForResult(CHAINS.SOLANA, 'signMessage');
+
+ //
+ // 6. Disconnect (wallet_revokeSession) and verify session termination
+ //
+
+ // Scroll back to the top where the disconnect button lives
+ await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.disconnectButton, {
+ scrollParams: { direction: 'down' },
percent: 0.5,
- },
- );
- await RNPlaygroundDapp.waitForResult(CHAINS.SOLANA, 'signMessage');
-
- //
- // 6. Disconnect (wallet_revokeSession) and verify session termination
- //
-
- // Scroll back to the top where the disconnect button lives
- await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.disconnectButton, {
- scrollParams: { direction: 'down' },
- percent: 0.5,
+ });
+ await RNPlaygroundDapp.tapDisconnect();
+ await RNPlaygroundDapp.assertDisconnected();
});
- await RNPlaygroundDapp.tapDisconnect();
- await RNPlaygroundDapp.assertDisconnected();
-});
+}); // end describe
diff --git a/tests/performance/onboarding/import-wallet.spec.ts b/tests/performance/onboarding/import-wallet.spec.ts
index 13c05aacd0c..7ffd6b10762 100644
--- a/tests/performance/onboarding/import-wallet.spec.ts
+++ b/tests/performance/onboarding/import-wallet.spec.ts
@@ -1,7 +1,7 @@
import { test } from '../../framework/fixture';
import TimerHelper from '../../framework/TimerHelper';
import { getPasswordForScenario } from '../../framework/utils/TestConstants.js';
-import { PerformanceOnboarding } from '../../tags.performance.js';
+import { Performance, PerformanceOnboarding } from '../../tags.performance.js';
import OnboardingView from '../../page-objects/Onboarding/OnboardingView';
import {
asPlaywrightElement,
@@ -21,7 +21,7 @@ import { fetchProductionFeatureFlags } from '../feature-flag-helper';
const testEnvironment = process.env.E2E_PERFORMANCE_BUILD_VARIANT || '';
/* Scenario 4: Imported wallet with +50 accounts */
-test.describe(PerformanceOnboarding, () => {
+test.describe(`${Performance} ${PerformanceOnboarding}`, () => {
test.setTimeout(240000);
test(
'Onboarding Import SRP with +50 accounts, SRP 3',
diff --git a/tests/performance/onboarding/imported-wallet-account-creation.spec.ts b/tests/performance/onboarding/imported-wallet-account-creation.spec.ts
index 686d434b373..c0d00d04aef 100644
--- a/tests/performance/onboarding/imported-wallet-account-creation.spec.ts
+++ b/tests/performance/onboarding/imported-wallet-account-creation.spec.ts
@@ -1,5 +1,6 @@
import { test } from '../../framework/fixture';
import {
+ Performance,
PerformanceOnboarding,
PerformanceAccountList,
} from '../../tags.performance.js';
@@ -10,7 +11,7 @@ import TimerHelper from '../../framework/TimerHelper';
import { onboardingFlowImportSRPPlaywright } from '../../flows/wallet.flow';
/* Scenario 1: Imported wallet with 50+ accounts + account creation */
-test.describe(`${PerformanceOnboarding} ${PerformanceAccountList}`, () => {
+test.describe(`${Performance} ${PerformanceOnboarding} ${PerformanceAccountList}`, () => {
test.skip(
'Account creation with 50+ accounts, SRP 1 + SRP 2 + SRP 3',
{ tag: '@metamask-onboarding-team' },
diff --git a/tests/performance/onboarding/launch-times/cold-start-after-wallet-import.spec.ts b/tests/performance/onboarding/launch-times/cold-start-after-wallet-import.spec.ts
index bc87a9bbbfd..c6bcb582327 100644
--- a/tests/performance/onboarding/launch-times/cold-start-after-wallet-import.spec.ts
+++ b/tests/performance/onboarding/launch-times/cold-start-after-wallet-import.spec.ts
@@ -1,5 +1,6 @@
import { test } from '../../../framework/fixture';
import {
+ Performance,
PerformanceOnboarding,
PerformanceLaunch,
} from '../../../tags.performance.js';
@@ -14,7 +15,7 @@ import {
import TimerHelper from '../../../framework/TimerHelper';
import WalletView from '../../../page-objects/wallet/WalletView';
-test.describe(`${PerformanceOnboarding} ${PerformanceLaunch}`, () => {
+test.describe(`${Performance} ${PerformanceOnboarding} ${PerformanceLaunch}`, () => {
test(
'Cold Start after importing a wallet',
{ tag: '@metamask-mobile-platform' },
diff --git a/tests/performance/onboarding/launch-times/cold-start-to-onboarding.spec.ts b/tests/performance/onboarding/launch-times/cold-start-to-onboarding.spec.ts
index 11a741806d0..14f9a750018 100644
--- a/tests/performance/onboarding/launch-times/cold-start-to-onboarding.spec.ts
+++ b/tests/performance/onboarding/launch-times/cold-start-to-onboarding.spec.ts
@@ -1,6 +1,7 @@
import { test } from '../../../framework/fixture';
import TimerHelper from '../../../framework/TimerHelper.js';
import {
+ Performance,
PerformanceOnboarding,
PerformanceLaunch,
} from '../../../tags.performance.js';
@@ -8,7 +9,7 @@ import PlaywrightAssertions from '../../../framework/PlaywrightAssertions';
import OnboardingView from '../../../page-objects/Onboarding/OnboardingView';
import { asPlaywrightElement } from '../../../framework/EncapsulatedElement';
-test.describe(`${PerformanceOnboarding} ${PerformanceLaunch}`, () => {
+test.describe(`${Performance} ${PerformanceOnboarding} ${PerformanceLaunch}`, () => {
test(
'Measure Cold Start To Onboarding Screen',
{ tag: '@metamask-mobile-platform' },
diff --git a/tests/performance/onboarding/new-wallet-account-creation.spec.ts b/tests/performance/onboarding/new-wallet-account-creation.spec.ts
index c2181fe7718..1651c43953b 100644
--- a/tests/performance/onboarding/new-wallet-account-creation.spec.ts
+++ b/tests/performance/onboarding/new-wallet-account-creation.spec.ts
@@ -2,6 +2,8 @@ import { test } from '../../framework/fixture';
import TimerHelper from '../../framework/TimerHelper';
import { getPasswordForScenario } from '../../framework/utils/TestConstants.js';
import {
+ Performance,
+ System,
PerformanceOnboarding,
PerformanceAccountList,
} from '../../tags.performance.js';
@@ -25,7 +27,7 @@ import PredictModalView from '../../page-objects/Predict/PredictModalView.js';
const testEnvironment = process.env.E2E_PERFORMANCE_BUILD_VARIANT || '';
/* Scenario 2: Account creation after fresh install */
-test.describe(`${PerformanceOnboarding} ${PerformanceAccountList}`, () => {
+test.describe(`${Performance} ${System} ${PerformanceOnboarding} ${PerformanceAccountList}`, () => {
test(
'Account creation after fresh install',
{ tag: '@metamask-onboarding-team' },
diff --git a/tests/performance/onboarding/seedless-apple-onboarding.spec.ts b/tests/performance/onboarding/seedless-apple-onboarding.spec.ts
index 762e5dcd47f..7a884cb78aa 100644
--- a/tests/performance/onboarding/seedless-apple-onboarding.spec.ts
+++ b/tests/performance/onboarding/seedless-apple-onboarding.spec.ts
@@ -3,7 +3,11 @@ import TimerHelper from '../../framework/TimerHelper';
import { asPlaywrightElement, PlaywrightAssertions } from '../../framework';
import { getPasswordForScenario } from '../../framework/utils/TestConstants.js';
import { dismisspredictionsModalPlaywright } from '../../flows/wallet.flow';
-import { PerformanceOnboarding } from '../../tags.performance.js';
+import {
+ Performance,
+ System,
+ PerformanceOnboarding,
+} from '../../tags.performance.js';
import OnboardingView from '../../page-objects/Onboarding/OnboardingView';
import OnboardingSheet from '../../page-objects/Onboarding/OnboardingSheet';
import SocialLoginView from '../../page-objects/Onboarding/SocialLoginView';
@@ -28,7 +32,7 @@ const waitForFirstSuccessful = async (promises: Promise[]): Promise =>
});
/* Seedless Onboarding: Apple Login */
-test.describe(PerformanceOnboarding, () => {
+test.describe(`${Performance} ${System} ${PerformanceOnboarding}`, () => {
test.setTimeout(240000);
test(
diff --git a/tests/performance/onboarding/seedless-google-onboarding.spec.ts b/tests/performance/onboarding/seedless-google-onboarding.spec.ts
index 809471d5faf..18086dd9f4b 100644
--- a/tests/performance/onboarding/seedless-google-onboarding.spec.ts
+++ b/tests/performance/onboarding/seedless-google-onboarding.spec.ts
@@ -3,7 +3,11 @@ import TimerHelper from '../../framework/TimerHelper';
import { asPlaywrightElement, PlaywrightAssertions } from '../../framework';
import { getPasswordForScenario } from '../../framework/utils/TestConstants.js';
import { dismisspredictionsModalPlaywright } from '../../flows/wallet.flow';
-import { PerformanceOnboarding } from '../../tags.performance.js';
+import {
+ Performance,
+ System,
+ PerformanceOnboarding,
+} from '../../tags.performance.js';
import OnboardingView from '../../page-objects/Onboarding/OnboardingView';
import OnboardingSheet from '../../page-objects/Onboarding/OnboardingSheet';
import SocialLoginView from '../../page-objects/Onboarding/SocialLoginView';
@@ -28,7 +32,7 @@ const waitForFirstSuccessful = async (promises: Promise[]): Promise =>
});
/* Seedless Onboarding: Google Login */
-test.describe(PerformanceOnboarding, () => {
+test.describe(`${Performance} ${System} ${PerformanceOnboarding}`, () => {
test.setTimeout(240000);
test(
diff --git a/tests/playwright.config.ts b/tests/playwright.config.ts
index d69cbd58723..180221ba3d5 100644
--- a/tests/playwright.config.ts
+++ b/tests/playwright.config.ts
@@ -5,6 +5,7 @@ export default defineConfig({
testDir: './',
fullyParallel: false,
timeout: 7 * 60 * 1000, //7 minutes until we introduce fixtures
+ grep: /@Performance/,
reporter: [
[
'html',
diff --git a/tests/playwright.system-emulator.config.ts b/tests/playwright.system-emulator.config.ts
new file mode 100644
index 00000000000..0a07e60f663
--- /dev/null
+++ b/tests/playwright.system-emulator.config.ts
@@ -0,0 +1,127 @@
+import dotenv from 'dotenv';
+dotenv.config({ path: '.e2e.env' });
+
+import { Platform, ProviderName } from './framework/types';
+import { defineConfig } from './framework/config';
+
+// Activate system test mode — disables quality gates, Sentry, and perf reporting
+process.env.SYSTEM_TEST_MODE = 'true';
+
+// ---------- Default build paths (match Detox / standard build output) ----------
+const DEFAULT_ANDROID_APK =
+ 'android/app/build/outputs/apk/prod/debug/app-prod-debug.apk';
+const DEFAULT_IOS_APP =
+ 'ios/build/Build/Products/Release-iphonesimulator/MetaMask.app';
+
+/**
+ * System test config for local emulators / simulators.
+ *
+ * Same test specs and SYSTEM_TEST_MODE as playwright.system.config.ts (BrowserStack),
+ * but runs on a local Android emulator or iOS simulator via Appium.
+ *
+ * All environment variables have sensible defaults so you can run system tests
+ * right after a standard debug build (`yarn build:android:main:e2e` / `yarn build:ios:main:e2e`).
+ *
+ * Environment variables (all optional — defaults shown):
+ * - ANDROID_APK_PATH — Path to APK for login tests (default: prod debug APK)
+ * - ANDROID_CLEAN_APK_PATH — Path to clean APK for onboarding (default: same as ANDROID_APK_PATH)
+ * - IOS_APP_PATH — Path to .app for login tests (default: Release-iphonesimulator/MetaMask.app)
+ * - IOS_CLEAN_APP_PATH — Path to clean .app for onboarding (default: same as IOS_APP_PATH)
+ * - ANDROID_AVD_NAME — AVD name (default: 'Pixel_5_Pro_API_34')
+ * - IOS_SIMULATOR_NAME — Simulator name (default: 'iPhone 15 Pro')
+ *
+ * Usage:
+ * yarn run-system-tests:android-login-emu
+ * ANDROID_APK_PATH=/path/to/app.apk yarn run-system-tests:android-login-emu
+ * yarn run-system-tests:ios-login-sim
+ */
+export default defineConfig({
+ testDir: './',
+ fullyParallel: false,
+ timeout: 7 * 60 * 1000,
+ retries: 1,
+ grep: /@System/,
+ reporter: [
+ [
+ 'html',
+ { open: 'never', outputFolder: './test-reports/system-test-report' },
+ ],
+ ['list'],
+ ],
+ use: {
+ trace: 'on-first-retry',
+ },
+
+ projects: [
+ {
+ name: 'system-android-login-emu',
+ testMatch: '**/performance/login/**/*.spec.ts',
+ use: {
+ platform: Platform.ANDROID,
+ device: {
+ provider: ProviderName.EMULATOR,
+ name: process.env.ANDROID_AVD_NAME || 'Pixel_5_Pro_API_34',
+ },
+ app: {
+ packageName: 'io.metamask',
+ launchableActivity: 'io.metamask.MainActivity',
+ buildPath: process.env.ANDROID_APK_PATH || DEFAULT_ANDROID_APK,
+ },
+ },
+ },
+ {
+ name: 'system-android-onboarding-emu',
+ testMatch: '**/performance/onboarding/**/*.spec.ts',
+ testIgnore: '**/performance/onboarding/seedless-*.spec.ts',
+ use: {
+ platform: Platform.ANDROID,
+ device: {
+ provider: ProviderName.EMULATOR,
+ name: process.env.ANDROID_AVD_NAME || 'Pixel_5_Pro_API_34',
+ },
+ app: {
+ packageName: 'io.metamask',
+ launchableActivity: 'io.metamask.MainActivity',
+ buildPath:
+ process.env.ANDROID_CLEAN_APK_PATH ||
+ process.env.ANDROID_APK_PATH ||
+ DEFAULT_ANDROID_APK,
+ },
+ },
+ },
+ {
+ name: 'system-ios-login-sim',
+ testMatch: '**/performance/login/**/*.spec.ts',
+ use: {
+ platform: Platform.IOS,
+ device: {
+ provider: ProviderName.SIMULATOR,
+ name: process.env.IOS_SIMULATOR_NAME || 'iPhone 15 Pro',
+ },
+ app: {
+ appId: 'io.metamask.MetaMask',
+ buildPath: process.env.IOS_APP_PATH || DEFAULT_IOS_APP,
+ },
+ },
+ },
+ {
+ name: 'system-ios-onboarding-sim',
+ testMatch: '**/performance/onboarding/**/*.spec.ts',
+ testIgnore: '**/performance/onboarding/seedless-*.spec.ts',
+ use: {
+ platform: Platform.IOS,
+ device: {
+ provider: ProviderName.SIMULATOR,
+ name: process.env.IOS_SIMULATOR_NAME || 'iPhone 15 Pro',
+ },
+ app: {
+ appId: 'io.metamask.MetaMask',
+ buildPath:
+ process.env.IOS_CLEAN_APP_PATH ||
+ process.env.IOS_APP_PATH ||
+ DEFAULT_IOS_APP,
+ },
+ },
+ },
+ ],
+});
diff --git a/tests/playwright.system.config.ts b/tests/playwright.system.config.ts
index 9068705b705..55ae90be3fa 100644
--- a/tests/playwright.system.config.ts
+++ b/tests/playwright.system.config.ts
@@ -11,6 +11,7 @@ export default defineConfig({
fullyParallel: false,
timeout: 7 * 60 * 1000,
retries: 1,
+ grep: /@System/,
reporter: [
[
'html',
diff --git a/tests/resources/blacklistURLs.json b/tests/resources/blacklistURLs.json
index 7458fdfd8e1..6241cb3c04c 100644
--- a/tests/resources/blacklistURLs.json
+++ b/tests/resources/blacklistURLs.json
@@ -6,7 +6,6 @@
".*api.etherscan.io/.*",
".*static.metafi.codefi.network/.*",
".*static.cx.metamask.io/.*",
- ".*rpc.tenderly.co/.*",
".*api-goerli.etherscan.io/.*",
".*gateway.pinata.cloud/.*",
".*stale.*",
diff --git a/tests/resources/networks.e2e.js b/tests/resources/networks.e2e.js
index 745b0f0c691..3994bec42d3 100644
--- a/tests/resources/networks.e2e.js
+++ b/tests/resources/networks.e2e.js
@@ -192,7 +192,7 @@ const CustomNetworks = {
rpcUrl: 'https://sei-mainnet.infura.io',
nickname: 'Sei Testnet',
ticker: 'SEI',
- BlockExplorerUrl: 'https://seitrace.com/',
+ BlockExplorerUrl: 'https://seiscan.io/',
},
},
};
diff --git a/tests/tags.performance.js b/tests/tags.performance.js
index b6afd002229..11898a85efa 100644
--- a/tests/tags.performance.js
+++ b/tests/tags.performance.js
@@ -5,20 +5,36 @@
* Use these tags to categorize and filter performance tests.
*
* Usage in tests:
- * import { PerformanceLogin, PerformanceSwaps } from '../../tags.js';
+ * import { Performance, System, PerformanceLogin, PerformanceSwaps } from '../../tags.performance.js';
*
- * test.describe(PerformanceLogin, () => {
- * test('My login test', async ({ device }) => { ... });
- * });
+ * // Both perf and system test (most common):
+ * test.describe(`${Performance} ${System} ${PerformanceLogin} ${PerformanceSwaps}`, () => { ... });
*
- * Or with multiple tags:
- * test.describe(`${PerformanceLogin} ${PerformanceSwaps}`, () => { ... });
+ * // System-only test (functional verification, no perf measurement):
+ * test.describe(`${System} ${PerformanceLogin}`, () => { ... });
+ *
+ * // Perf-only test (not run in system test suite):
+ * test.describe(`${Performance} ${PerformanceLaunch}`, () => { ... });
*
* Running tests with tags:
- * yarn playwright test --grep "@PerformanceLogin"
+ * yarn playwright test --grep "@Performance" # All performance tests
+ * yarn playwright test --grep "@System" # All system tests
+ * yarn playwright test --grep "@PerformanceLogin" # By area
* yarn playwright test --grep "@PerformanceSwaps|@PerformanceOnboarding"
*/
+// ---------- Test type tags ----------
+// Runner-agnostic tags that control which config/runner picks up a test.
+// Used in test.describe() names and filtered via --grep / config grep.
+
+/** Tag for performance tests (measured with TimerHelper, quality gates enforced). */
+export const Performance = '@Performance';
+
+/** Tag for system tests (functional verification, no quality gates or metrics). */
+export const System = '@System';
+
+// ---------- Area tags ----------
+
export const PerformanceAccountList = '@PerformanceAccountList';
export const PerformanceOnboarding = '@PerformanceOnboarding';
export const PerformanceLogin = '@PerformanceLogin';
diff --git a/yarn.lock b/yarn.lock
index 9d5d1b0345b..787872a8cd3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7882,28 +7882,28 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/assets-controller@npm:^6.1.0, @metamask/assets-controller@npm:^6.2.0, @metamask/assets-controller@npm:^6.2.1":
- version: 6.2.1
- resolution: "@metamask/assets-controller@npm:6.2.1"
+"@metamask/assets-controller@npm:^6.2.1, @metamask/assets-controller@npm:^6.3.0":
+ version: 6.3.0
+ resolution: "@metamask/assets-controller@npm:6.3.0"
dependencies:
"@ethereumjs/util": "npm:^9.1.0"
"@ethersproject/abi": "npm:^5.7.0"
"@ethersproject/providers": "npm:^5.7.0"
"@metamask/account-tree-controller": "npm:^7.1.0"
"@metamask/accounts-controller": "npm:^37.2.0"
- "@metamask/assets-controllers": "npm:^105.0.0"
+ "@metamask/assets-controllers": "npm:^105.1.0"
"@metamask/base-controller": "npm:^9.1.0"
"@metamask/client-controller": "npm:^1.0.1"
"@metamask/controller-utils": "npm:^11.20.0"
"@metamask/core-backend": "npm:^6.2.1"
- "@metamask/keyring-api": "npm:^23.0.1"
- "@metamask/keyring-controller": "npm:^25.2.0"
- "@metamask/keyring-internal-api": "npm:^10.1.1"
- "@metamask/keyring-snap-client": "npm:^9.0.1"
- "@metamask/messenger": "npm:^1.1.1"
- "@metamask/network-controller": "npm:^30.0.1"
+ "@metamask/keyring-api": "npm:^23.1.0"
+ "@metamask/keyring-controller": "npm:^25.3.0"
+ "@metamask/keyring-internal-api": "npm:^11.0.1"
+ "@metamask/keyring-snap-client": "npm:^9.0.2"
+ "@metamask/messenger": "npm:^1.2.0"
+ "@metamask/network-controller": "npm:^30.1.0"
"@metamask/network-enablement-controller": "npm:^5.0.2"
- "@metamask/permission-controller": "npm:^12.3.0"
+ "@metamask/permission-controller": "npm:^13.0.0"
"@metamask/phishing-controller": "npm:^17.1.1"
"@metamask/polling-controller": "npm:^16.0.4"
"@metamask/preferences-controller": "npm:^23.1.0"
@@ -7915,13 +7915,13 @@ __metadata:
bignumber.js: "npm:^9.1.2"
lodash: "npm:^4.17.21"
p-limit: "npm:^3.1.0"
- checksum: 10/32645ec9dc88199a93d72498cf8a00a0b8fc5a34c889229a80d7ec972acbb20eafbfe978a5ee98619f003a8a671de75ca96ed8e283cffb2208b2fc0fd893ee71
+ checksum: 10/a1c2511ec5f954b78778684f116d09b177fa1a38458f83c8ac2b155b925724917c038a9b1dbc57b1e4092cb64df2464af39314d897139474c398bab88cd755f7
languageName: node
linkType: hard
-"@metamask/assets-controllers@npm:^104.3.0":
- version: 104.3.0
- resolution: "@metamask/assets-controllers@npm:104.3.0"
+"@metamask/assets-controllers@npm:^105.0.0, @metamask/assets-controllers@npm:^105.1.0":
+ version: 105.1.0
+ resolution: "@metamask/assets-controllers@npm:105.1.0"
dependencies:
"@ethereumjs/util": "npm:^9.1.0"
"@ethersproject/abi": "npm:^5.7.0"
@@ -7938,70 +7938,14 @@ __metadata:
"@metamask/controller-utils": "npm:^11.20.0"
"@metamask/core-backend": "npm:^6.2.1"
"@metamask/eth-query": "npm:^4.0.0"
- "@metamask/keyring-api": "npm:^23.0.1"
- "@metamask/keyring-controller": "npm:^25.2.0"
- "@metamask/messenger": "npm:^1.1.1"
- "@metamask/metamask-eth-abis": "npm:^3.1.1"
- "@metamask/multichain-account-service": "npm:^8.0.1"
- "@metamask/network-controller": "npm:^30.0.1"
- "@metamask/network-enablement-controller": "npm:^5.0.2"
- "@metamask/permission-controller": "npm:^12.3.0"
- "@metamask/phishing-controller": "npm:^17.1.1"
- "@metamask/polling-controller": "npm:^16.0.4"
- "@metamask/preferences-controller": "npm:^23.1.0"
- "@metamask/profile-sync-controller": "npm:^28.0.2"
- "@metamask/rpc-errors": "npm:^7.0.2"
- "@metamask/snaps-controllers": "npm:^19.0.0"
- "@metamask/snaps-sdk": "npm:^11.0.0"
- "@metamask/snaps-utils": "npm:^12.1.2"
- "@metamask/storage-service": "npm:^1.0.1"
- "@metamask/transaction-controller": "npm:^64.3.0"
- "@metamask/utils": "npm:^11.9.0"
- "@types/bn.js": "npm:^5.1.5"
- "@types/uuid": "npm:^8.3.0"
- async-mutex: "npm:^0.5.0"
- bitcoin-address-validation: "npm:^2.2.3"
- bn.js: "npm:^5.2.1"
- immer: "npm:^9.0.6"
- lodash: "npm:^4.17.21"
- multiformats: "npm:^9.9.0"
- reselect: "npm:^5.1.1"
- single-call-balance-checker-abi: "npm:^1.0.0"
- uuid: "npm:^8.3.2"
- peerDependencies:
- "@metamask/providers": ^22.0.0
- webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0
- checksum: 10/9dbab56816788a66ca1dacf1aa476455115c3f1bb53ac2b809c096dee0b43e3dd641a15ea6ec192dc088cfbeb0d84c2bddd4d19d4f0b732aa7c3e7befe64a1ea
- languageName: node
- linkType: hard
-
-"@metamask/assets-controllers@npm:^105.0.0":
- version: 105.0.0
- resolution: "@metamask/assets-controllers@npm:105.0.0"
- dependencies:
- "@ethereumjs/util": "npm:^9.1.0"
- "@ethersproject/abi": "npm:^5.7.0"
- "@ethersproject/address": "npm:^5.7.0"
- "@ethersproject/bignumber": "npm:^5.7.0"
- "@ethersproject/contracts": "npm:^5.7.0"
- "@ethersproject/providers": "npm:^5.7.0"
- "@metamask/abi-utils": "npm:^2.0.3"
- "@metamask/account-tree-controller": "npm:^7.1.0"
- "@metamask/accounts-controller": "npm:^37.2.0"
- "@metamask/approval-controller": "npm:^9.0.1"
- "@metamask/base-controller": "npm:^9.1.0"
- "@metamask/contract-metadata": "npm:^2.4.0"
- "@metamask/controller-utils": "npm:^11.20.0"
- "@metamask/core-backend": "npm:^6.2.1"
- "@metamask/eth-query": "npm:^4.0.0"
- "@metamask/keyring-api": "npm:^23.0.1"
- "@metamask/keyring-controller": "npm:^25.2.0"
- "@metamask/messenger": "npm:^1.1.1"
+ "@metamask/keyring-api": "npm:^23.1.0"
+ "@metamask/keyring-controller": "npm:^25.3.0"
+ "@metamask/messenger": "npm:^1.2.0"
"@metamask/metamask-eth-abis": "npm:^3.1.1"
"@metamask/multichain-account-service": "npm:^8.0.1"
- "@metamask/network-controller": "npm:^30.0.1"
+ "@metamask/network-controller": "npm:^30.1.0"
"@metamask/network-enablement-controller": "npm:^5.0.2"
- "@metamask/permission-controller": "npm:^12.3.0"
+ "@metamask/permission-controller": "npm:^13.0.0"
"@metamask/phishing-controller": "npm:^17.1.1"
"@metamask/polling-controller": "npm:^16.0.4"
"@metamask/preferences-controller": "npm:^23.1.0"
@@ -8027,7 +7971,7 @@ __metadata:
peerDependencies:
"@metamask/providers": ^22.0.0
webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0
- checksum: 10/5b0d0b5e96f0e34bef7607574490db28cc5d844f5f57277c5b895626d13f842fceaa714c2a4b52400d0f4d333a1f1af95b5d4db34d81d2906a5d0c7bfa189727
+ checksum: 10/206289f0fe122f228c7669a3a11c54ffbc07739a359193b8f8a10e0223a6e58b49c3a435fae4e18ab78ad9d2a03a9cb1758ba5ec2c8b08c618ff28c4dafd5e02
languageName: node
linkType: hard
@@ -8126,39 +8070,6 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/bridge-controller@npm:^70.2.0":
- version: 70.2.0
- resolution: "@metamask/bridge-controller@npm:70.2.0"
- dependencies:
- "@ethersproject/address": "npm:^5.7.0"
- "@ethersproject/bignumber": "npm:^5.7.0"
- "@ethersproject/constants": "npm:^5.7.0"
- "@ethersproject/contracts": "npm:^5.7.0"
- "@ethersproject/providers": "npm:^5.7.0"
- "@metamask/accounts-controller": "npm:^37.2.0"
- "@metamask/assets-controller": "npm:^6.1.0"
- "@metamask/assets-controllers": "npm:^104.3.0"
- "@metamask/base-controller": "npm:^9.1.0"
- "@metamask/controller-utils": "npm:^11.20.0"
- "@metamask/gas-fee-controller": "npm:^26.1.1"
- "@metamask/keyring-api": "npm:^23.0.1"
- "@metamask/messenger": "npm:^1.1.1"
- "@metamask/metamask-eth-abis": "npm:^3.1.1"
- "@metamask/multichain-network-controller": "npm:^3.0.6"
- "@metamask/network-controller": "npm:^30.0.1"
- "@metamask/polling-controller": "npm:^16.0.4"
- "@metamask/profile-sync-controller": "npm:^28.0.2"
- "@metamask/remote-feature-flag-controller": "npm:^4.2.0"
- "@metamask/snaps-controllers": "npm:^19.0.0"
- "@metamask/transaction-controller": "npm:^64.3.0"
- "@metamask/utils": "npm:^11.9.0"
- bignumber.js: "npm:^9.1.2"
- reselect: "npm:^5.1.1"
- uuid: "npm:^8.3.2"
- checksum: 10/5e3ff900bbcdbe2bee0e143cfb06076fb0a27d1c582d88dff9eba57458207dc2c76a47b178403ae4c36ec8a48e5fb1ddbefc310f73a4baaf24b4528a488514b1
- languageName: node
- linkType: hard
-
"@metamask/bridge-controller@npm:^71.0.0":
version: 71.0.0
resolution: "@metamask/bridge-controller@npm:71.0.0"
@@ -8192,7 +8103,7 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/bridge-status-controller@npm:71.1.0":
+"@metamask/bridge-status-controller@npm:71.1.0, @metamask/bridge-status-controller@npm:^71.1.0":
version: 71.1.0
resolution: "@metamask/bridge-status-controller@npm:71.1.0"
dependencies:
@@ -8960,17 +8871,18 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/json-rpc-engine@npm:^10.0.0, @metamask/json-rpc-engine@npm:^10.0.2, @metamask/json-rpc-engine@npm:^10.0.3, @metamask/json-rpc-engine@npm:^10.1.0, @metamask/json-rpc-engine@npm:^10.1.1, @metamask/json-rpc-engine@npm:^10.2.3, @metamask/json-rpc-engine@npm:^10.2.4":
- version: 10.2.4
- resolution: "@metamask/json-rpc-engine@npm:10.2.4"
+"@metamask/json-rpc-engine@npm:^10.0.0, @metamask/json-rpc-engine@npm:^10.0.2, @metamask/json-rpc-engine@npm:^10.0.3, @metamask/json-rpc-engine@npm:^10.1.0, @metamask/json-rpc-engine@npm:^10.1.1, @metamask/json-rpc-engine@npm:^10.2.3, @metamask/json-rpc-engine@npm:^10.2.4, @metamask/json-rpc-engine@npm:^10.3.0":
+ version: 10.3.0
+ resolution: "@metamask/json-rpc-engine@npm:10.3.0"
dependencies:
+ "@metamask/messenger": "npm:^1.2.0"
"@metamask/rpc-errors": "npm:^7.0.2"
"@metamask/safe-event-emitter": "npm:^3.0.0"
"@metamask/utils": "npm:^11.9.0"
"@types/deep-freeze-strict": "npm:^1.1.0"
deep-freeze-strict: "npm:^1.1.1"
klona: "npm:^2.0.6"
- checksum: 10/b207dd2a9a44674c141c2e027c082974464a37beada98a27e80fe59c9bd44e2c2a992edf8a8d7e3ed461fa27ed372c95d4e27df18752b558c10bf540b7fe7bcd
+ checksum: 10/8d4da5d933e4be2a85783871b6f1282763cbb5bc559e3228da099c75517530e3ac42a040109f17a4d4ff768f1c8cbcc4358f5e06b820b893af29a13f95180bd6
languageName: node
linkType: hard
@@ -9024,7 +8936,7 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/keyring-controller@npm:^25.1.0, @metamask/keyring-controller@npm:^25.1.1, @metamask/keyring-controller@npm:^25.2.0, @metamask/keyring-controller@npm:^25.4.0":
+"@metamask/keyring-controller@npm:^25.1.0, @metamask/keyring-controller@npm:^25.1.1, @metamask/keyring-controller@npm:^25.2.0, @metamask/keyring-controller@npm:^25.3.0, @metamask/keyring-controller@npm:^25.4.0":
version: 25.4.0
resolution: "@metamask/keyring-controller@npm:25.4.0"
dependencies:
@@ -9118,7 +9030,7 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/keyring-snap-client@npm:^9.0.1, @metamask/keyring-snap-client@npm:^9.0.2":
+"@metamask/keyring-snap-client@npm:^9.0.2":
version: 9.0.2
resolution: "@metamask/keyring-snap-client@npm:9.0.2"
dependencies:
@@ -9533,6 +9445,25 @@ __metadata:
languageName: node
linkType: hard
+"@metamask/permission-controller@npm:^13.0.0":
+ version: 13.0.0
+ resolution: "@metamask/permission-controller@npm:13.0.0"
+ dependencies:
+ "@metamask/approval-controller": "npm:^9.0.1"
+ "@metamask/base-controller": "npm:^9.1.0"
+ "@metamask/controller-utils": "npm:^11.20.0"
+ "@metamask/json-rpc-engine": "npm:^10.3.0"
+ "@metamask/messenger": "npm:^1.2.0"
+ "@metamask/rpc-errors": "npm:^7.0.2"
+ "@metamask/utils": "npm:^11.9.0"
+ "@types/deep-freeze-strict": "npm:^1.1.0"
+ deep-freeze-strict: "npm:^1.1.1"
+ immer: "npm:^9.0.6"
+ nanoid: "npm:^3.3.8"
+ checksum: 10/e4062076f7dd7da7acf890f66ee7df1a0309bbb9d9adb221f28eefb203318c2675707754b884a0d4f49892608a8771443a97e743c2c68f7c75f123ec7fafbf49
+ languageName: node
+ linkType: hard
+
"@metamask/phishing-controller@npm:^17.1.1":
version: 17.1.1
resolution: "@metamask/phishing-controller@npm:17.1.1"
@@ -10329,7 +10260,7 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/transaction-controller@npm:^64.0.0, @metamask/transaction-controller@npm:^64.2.0, @metamask/transaction-controller@npm:^64.3.0":
+"@metamask/transaction-controller@npm:^64.0.0, @metamask/transaction-controller@npm:^64.2.0":
version: 64.4.0
resolution: "@metamask/transaction-controller@npm:64.4.0"
dependencies:
@@ -10405,23 +10336,23 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/transaction-pay-controller@npm:^20.0.0":
- version: 20.0.0
- resolution: "@metamask/transaction-pay-controller@npm:20.0.0"
+"@metamask/transaction-pay-controller@npm:^21.0.0":
+ version: 21.0.0
+ resolution: "@metamask/transaction-pay-controller@npm:21.0.0"
dependencies:
"@ethersproject/abi": "npm:^5.7.0"
"@ethersproject/contracts": "npm:^5.7.0"
"@ethersproject/providers": "npm:^5.7.0"
- "@metamask/assets-controller": "npm:^6.2.0"
- "@metamask/assets-controllers": "npm:^104.3.0"
+ "@metamask/assets-controller": "npm:^6.3.0"
+ "@metamask/assets-controllers": "npm:^105.1.0"
"@metamask/base-controller": "npm:^9.1.0"
- "@metamask/bridge-controller": "npm:^70.2.0"
- "@metamask/bridge-status-controller": "npm:^71.0.0"
+ "@metamask/bridge-controller": "npm:^71.0.0"
+ "@metamask/bridge-status-controller": "npm:^71.1.0"
"@metamask/controller-utils": "npm:^11.20.0"
"@metamask/gas-fee-controller": "npm:^26.1.1"
- "@metamask/messenger": "npm:^1.1.1"
+ "@metamask/messenger": "npm:^1.2.0"
"@metamask/metamask-eth-abis": "npm:^3.1.1"
- "@metamask/network-controller": "npm:^30.0.1"
+ "@metamask/network-controller": "npm:^30.1.0"
"@metamask/ramps-controller": "npm:^13.2.0"
"@metamask/remote-feature-flag-controller": "npm:^4.2.0"
"@metamask/transaction-controller": "npm:^65.0.0"
@@ -10430,7 +10361,7 @@ __metadata:
bn.js: "npm:^5.2.1"
immer: "npm:^9.0.6"
lodash: "npm:^4.17.21"
- checksum: 10/c67e9e911711dda45973f053ef1c4dd3c825f72749263d694a6a01279f06bfbe958c9b1f82ea2c798fc555adae3ca5936c66a63c141e355d588f692752f17807
+ checksum: 10/090dc5efad84ceb2f956b30cec3544125080dc6f9ff5b6030f6446ceca59e9ea0c085309a780f9ffbaa75d3263c067e89fdc7f50ea3354f031ccb465c630ec48
languageName: node
linkType: hard
@@ -35812,7 +35743,7 @@ __metadata:
"@metamask/test-dapp-multichain": "npm:^0.17.1"
"@metamask/test-dapp-solana": "npm:^0.3.0"
"@metamask/transaction-controller": "npm:^65.0.0"
- "@metamask/transaction-pay-controller": "npm:^20.0.0"
+ "@metamask/transaction-pay-controller": "npm:^21.0.0"
"@metamask/tron-wallet-snap": "npm:^1.25.3"
"@metamask/utils": "npm:^11.11.0"
"@myx-trade/sdk": "npm:^0.1.265"