From 15edc39daf4c59b69cd269295d718b3068c967b3 Mon Sep 17 00:00:00 2001
From: Matthew Grainger <46547583+Matt561@users.noreply.github.com>
Date: Thu, 12 Mar 2026 14:48:17 -0400
Subject: [PATCH 001/206] feat: MUSD-454 add quick convert event tracking
(#27305)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
### Changes:
- Adds Segment event tracking for the mUSD Quick Convert flow
- Enriches generic `Transaction*` events for `musdConversion`
transactions
- Adds `confirmation_source` to differentiate between the "Max" convert
bottom sheet and custom amount confirmations
- Adds `is_max` which is `true` when "Max" conversion flow is used or
when custom amount is used and user clicks "Max" button in percentage
button row
- Adds mUSD quote tracking data
### Events
| Event | Type | Location | Description |
|---|---|---|---|
| `mUSD Quick Convert Screen Viewed` | New standalone event |
`MusdQuickConvertView` (on mount) | Fires when the quick convert token
list screen is viewed |
| `mUSD Bonus Terms of Use Pressed` | New standalone event |
`MusdQuickConvertView` (`quick_convert_home_screen`),
`EarnMusdConversionEducationView` (`conversion_education_screen`),
`useMusdConversionNavbar` (`custom_amount_navbar`), `PercentageRow`
(`percentage_row`) | Fires when user presses the bonus terms of use
link; `location` property differentiates the source |
| `mUSD Quick Convert Token Row Button Clicked` | New standalone event |
`MusdQuickConvertView` | Fires on "Max" or "Edit" button tap; includes
`button_action`, `redirects_to`, asset details |
| `confirmation_source` | New property on `Transaction*` events |
`useMusdConversionConfirmationMetrics` |
`'quick_convert_max_bottom_sheet_confirmation_screen'` or
`'custom_amount_screen'` — only attached to `musdConversion`
transactions |
| `is_max` | New property on `Transaction*` events |
`useMusdConversionConfirmationMetrics` | Derived from
`TransactionPayController.isMaxAmount` — only attached to
`musdConversion` transactions |
| Quote tracking data | New properties on `Transaction*` events |
`useMusdConversionConfirmationMetrics` | Standardized quote/pay data via
`getMusdConversionQuoteTrackingData` — only attached to `musdConversion`
transactions |
## **Changelog**
CHANGELOG entry: Added Segment event tracking for mUSD Quick Convert
flow and enrich generic Transaction* events for mUSD conversion
transactions
## **Related issues**
Fixes: [MUSD-454: Add segment events for Quick Convert
flow](https://consensyssoftware.atlassian.net/browse/MUSD-454)
## **Manual testing steps**
```gherkin
Feature: mUSD Quick Convert Segment event tracking
Scenario: user views the quick convert screen
Given user navigates to the mUSD Quick Convert screen
When the screen mounts
Then "mUSD Quick Convert Screen Viewed" event fires with location "quick_convert_home_screen"
Scenario: user taps Max on a token row
Given user is on the mUSD Quick Convert screen with convertible tokens
When user taps "Max" on a token row
Then "mUSD Quick Convert Token Row Button Clicked" event fires with button_action "max" and redirects_to "quick_convert_max_bottom_sheet_confirmation_screen"
Scenario: user taps Edit on a token row
Given user is on the mUSD Quick Convert screen with convertible tokens
When user taps the edit icon on a token row
Then "mUSD Quick Convert Token Row Button Clicked" event fires with button_action "custom" and redirects_to "custom_amount_screen"
Scenario: user taps "Terms apply" link on the quick convert screen
Given user is on a screen displaying the mUSD bonus "Terms apply" link
When user taps "Terms apply"
Then "mUSD Bonus Terms of Use Pressed" event fires with the location of the current screen
Scenario: user confirms a max mUSD conversion
Given user is on the max convert bottom sheet confirmation
When user taps "Convert"
Then "Transaction Approved" event includes confirmation_source "quick_convert_max_bottom_sheet_confirmation_screen", "is_max: true", and quote tracking data
Scenario: user confirms a custom amount mUSD conversion
Given user is on the custom amount conversion screen
When user taps "Convert"
Then "Transaction Approved" event includes confirmation_source "custom_amount_screen", "is_max: false"
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> **Medium Risk**
> Adds new MetaMetrics events and confirmation-metric dispatches across
mUSD conversion/confirmation screens; while behavior is mostly
observational, it touches confirmations flow and transaction status
tracking and could affect analytics payloads or introduce unintended
side effects if hooks fire unexpectedly.
>
> **Overview**
> Adds **MetaMetrics tracking for the mUSD Quick Convert flow**,
including `MUSD_QUICK_CONVERT_SCREEN_VIEWED`,
`MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED` (Max/Edit), and
`MUSD_BONUS_TERMS_OF_USE_PRESSED` with location/context properties.
>
> Introduces a shared analytics utility
(`getMusdConversionQuoteTrackingData` + `deepSnakeCaseKeys`) and
refactors `useMusdConversionStatus` to use it when emitting
`MUSD_CONVERSION_STATUS_UPDATED`, standardizing quote-derived
properties.
>
> Enriches **confirmation metrics for `musdConversion`** by adding a new
`useMusdConversionConfirmationMetrics` hook (wired into
`MusdConversionInfoRoot`) that dispatches `confirmation_source`,
`is_max`, and select quote fields into `confirmationMetrics`. Tests are
updated/added accordingly, and `EVENT_LOCATIONS`/`MetaMetricsEvents` are
extended to support the new instrumentation.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
6e2e686d9ce46fbe55a1b10ec6cb0ecb1f9a15d3. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../index.test.tsx | 15 ++
.../EarnMusdConversionEducationView/index.tsx | 9 +
.../MusdQuickConvertView.test.tsx | 171 ++++++++++++
.../Earn/Views/MusdQuickConvertView/index.tsx | 77 +++++-
.../UI/Earn/constants/events/musdEvents.ts | 4 +
.../UI/Earn/hooks/useMusdConversion.ts | 6 -
.../hooks/useMusdConversionNavbar.test.tsx | 55 ++++
.../UI/Earn/hooks/useMusdConversionNavbar.tsx | 21 +-
.../UI/Earn/hooks/useMusdConversionStatus.ts | 150 +----------
.../UI/Earn/utils/analytics.test.ts | 242 +++++++++++++++++
app/components/UI/Earn/utils/analytics.ts | 106 +++++++-
.../UI/Earn/utils/analytics.types.ts | 18 ++
.../custom-amount-info.test.tsx | 8 +
.../musd-conversion-info-root.test.tsx | 17 ++
.../musd-conversion-info-root.tsx | 2 +
.../percentage-row/percentage-row.test.tsx | 51 ++++
.../rows/percentage-row/percentage-row.tsx | 19 +-
...eMusdConversionConfirmationMetrics.test.ts | 245 ++++++++++++++++++
.../useMusdConversionConfirmationMetrics.ts | 80 ++++++
app/core/Analytics/MetaMetrics.events.ts | 12 +
20 files changed, 1150 insertions(+), 158 deletions(-)
create mode 100644 app/components/UI/Earn/utils/analytics.test.ts
create mode 100644 app/components/UI/Earn/utils/analytics.types.ts
create mode 100644 app/components/Views/confirmations/hooks/metrics/useMusdConversionConfirmationMetrics.test.ts
create mode 100644 app/components/Views/confirmations/hooks/metrics/useMusdConversionConfirmationMetrics.ts
diff --git a/app/components/UI/Earn/Views/EarnMusdConversionEducationView/index.test.tsx b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/index.test.tsx
index ff8365edd91..6dbed37d9af 100644
--- a/app/components/UI/Earn/Views/EarnMusdConversionEducationView/index.test.tsx
+++ b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/index.test.tsx
@@ -659,10 +659,25 @@ describe('EarnMusdConversionEducationView', () => {
{ state: {} },
);
+ mockTrackEvent.mockClear();
+ mockCreateEventBuilder.mockClear();
+ mockAddProperties.mockClear();
+ mockBuild.mockClear();
+
fireEvent.press(
getByText(strings('earn.musd_conversion.education.terms_apply')),
);
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.MUSD_BONUS_TERMS_OF_USE_PRESSED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ location:
+ MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.CONVERSION_EDUCATION_SCREEN,
+ url: AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE,
+ });
+ expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' });
+
expect(openUrlSpy).toHaveBeenCalledTimes(1);
expect(openUrlSpy).toHaveBeenCalledWith(
AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE,
diff --git a/app/components/UI/Earn/Views/EarnMusdConversionEducationView/index.tsx b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/index.tsx
index 359229480a8..ada352c47f3 100644
--- a/app/components/UI/Earn/Views/EarnMusdConversionEducationView/index.tsx
+++ b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/index.tsx
@@ -316,6 +316,15 @@ const EarnMusdConversionEducationView = () => {
};
const handleTermsOfUsePressed = () => {
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.MUSD_BONUS_TERMS_OF_USE_PRESSED)
+ .addProperties({
+ location: EVENT_LOCATIONS.CONVERSION_EDUCATION_SCREEN,
+ url: AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE,
+ })
+ .build(),
+ );
+
Linking.openURL(AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE);
};
diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.test.tsx b/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.test.tsx
index b06f4efd5b9..cfe0b54a992 100644
--- a/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.test.tsx
+++ b/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.test.tsx
@@ -19,10 +19,25 @@ import { MUSD_CONVERSION_APY } from '../../constants/musd';
import AppConstants from '../../../../../core/AppConstants';
import { Linking } from 'react-native';
import { useNavigation, useFocusEffect } from '@react-navigation/native';
+import { MetaMetricsEvents } from '../../../../../core/Analytics';
+import { MUSD_EVENTS_CONSTANTS } from '../../constants/events';
import { strings } from '../../../../../../locales/i18n';
import { MUSD_CONVERSION_NAVIGATION_OVERRIDE } from '../../types/musd.types';
import { ConvertTokenRowTestIds } from '../../components/Musd/ConvertTokenRow';
import { useMusdBalance } from '../../hooks/useMusdBalance';
+import { IconName } from '@metamask/design-system-react-native';
+
+const mockTrackEvent = jest.fn();
+const mockCreateEventBuilder = jest.fn();
+const mockAddProperties = jest.fn();
+const mockBuild = jest.fn();
+
+jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ }),
+}));
jest.mock('@react-navigation/native', () => {
const actual = jest.requireActual('@react-navigation/native');
@@ -70,6 +85,9 @@ jest.mock('../../../../hooks/useStyles', () => ({
theme: { colors: {} },
})),
}));
+jest.mock('../../utils/network', () => ({
+ getNetworkName: jest.fn(() => 'Ethereum'),
+}));
jest.mock('react-native/Libraries/Linking/Linking', () => ({
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
@@ -133,6 +151,14 @@ describe('MusdQuickConvertView', () => {
beforeEach(() => {
jest.clearAllMocks();
+
+ mockBuild.mockReturnValue({ name: 'mock-built-event' });
+ mockAddProperties.mockImplementation(() => ({ build: mockBuild }));
+ mockCreateEventBuilder.mockImplementation(() => ({
+ addProperties: mockAddProperties,
+ build: mockBuild,
+ }));
+
mockUseNavigation.mockReturnValue({
navigate: jest.fn(),
goBack: jest.fn(),
@@ -518,4 +544,149 @@ describe('MusdQuickConvertView', () => {
);
});
});
+
+ describe('MetaMetrics', () => {
+ it('tracks MUSD_BONUS_TERMS_OF_USE_PRESSED event when terms apply text is pressed', () => {
+ const { getByText } = renderWithProvider(, {
+ state: initialRootState,
+ });
+
+ mockTrackEvent.mockClear();
+ mockCreateEventBuilder.mockClear();
+ mockAddProperties.mockClear();
+ mockBuild.mockClear();
+
+ const termsApplyText = getByText(
+ strings('earn.musd_conversion.education.terms_apply'),
+ );
+
+ act(() => {
+ fireEvent.press(termsApplyText);
+ });
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.MUSD_BONUS_TERMS_OF_USE_PRESSED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ location:
+ MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.QUICK_CONVERT_HOME_SCREEN,
+ url: AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE,
+ });
+ expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' });
+ });
+
+ it('tracks MUSD_QUICK_CONVERT_SCREEN_VIEWED event on mount', () => {
+ renderWithProvider(, {
+ state: initialRootState,
+ });
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.MUSD_QUICK_CONVERT_SCREEN_VIEWED,
+ );
+ expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' });
+ });
+
+ it('does not track MUSD_QUICK_CONVERT_SCREEN_VIEWED when feature flag is disabled', () => {
+ mockSelectMusdQuickConvertEnabledFlag.mockReturnValue(false);
+
+ renderWithProvider(, {
+ state: initialRootState,
+ });
+
+ expect(mockCreateEventBuilder).not.toHaveBeenCalledWith(
+ MetaMetricsEvents.MUSD_QUICK_CONVERT_SCREEN_VIEWED,
+ );
+ });
+
+ it('tracks MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED event when Max button is pressed', async () => {
+ const token = createMockToken();
+ mockUseMusdConversionTokens.mockReturnValue({
+ tokens: [token],
+ filterAllowedTokens: jest.fn(),
+ isConversionToken: jest.fn(),
+ isMusdSupportedOnChain: jest.fn(),
+ hasConvertibleTokensByChainId: jest.fn(),
+ });
+ mockInitiateMaxConversion.mockResolvedValue({
+ transactionId: 'tx-max-123',
+ });
+
+ const { getAllByTestId } = renderWithProvider(, {
+ state: initialRootState,
+ });
+
+ mockTrackEvent.mockClear();
+ mockCreateEventBuilder.mockClear();
+ mockAddProperties.mockClear();
+ mockBuild.mockClear();
+
+ const maxButton = getAllByTestId(ConvertTokenRowTestIds.MAX_BUTTON)[0];
+
+ await act(async () => {
+ fireEvent.press(maxButton);
+ });
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ location:
+ MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.QUICK_CONVERT_HOME_SCREEN,
+ button_type: 'text_button',
+ button_action: 'max',
+ button_text: strings('earn.musd_conversion.max'),
+ redirects_to:
+ MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS
+ .QUICK_CONVERT_MAX_BOTTOM_SHEET_CONFIRMATION_SCREEN,
+ asset_symbol: token.symbol,
+ network_chain_id: token.chainId,
+ network_name: 'Ethereum',
+ });
+ expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' });
+ });
+
+ it('tracks MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED event when Edit button is pressed', async () => {
+ const token = createMockToken();
+ mockUseMusdConversionTokens.mockReturnValue({
+ tokens: [token],
+ filterAllowedTokens: jest.fn(),
+ isConversionToken: jest.fn(),
+ isMusdSupportedOnChain: jest.fn(),
+ hasConvertibleTokensByChainId: jest.fn(),
+ });
+ mockInitiateCustomConversion.mockResolvedValue('tx-edit-789');
+
+ const { getAllByTestId } = renderWithProvider(, {
+ state: initialRootState,
+ });
+
+ mockTrackEvent.mockClear();
+ mockCreateEventBuilder.mockClear();
+ mockAddProperties.mockClear();
+ mockBuild.mockClear();
+
+ const editButton = getAllByTestId(ConvertTokenRowTestIds.EDIT_BUTTON)[0];
+
+ await act(async () => {
+ fireEvent.press(editButton);
+ });
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ location:
+ MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.QUICK_CONVERT_HOME_SCREEN,
+ button_type: 'icon_button',
+ icon: IconName.Edit,
+ button_action: 'custom',
+ redirects_to:
+ MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN,
+ asset_symbol: token.symbol,
+ network_chain_id: token.chainId,
+ network_name: 'Ethereum',
+ });
+ expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' });
+ });
+ });
});
diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx b/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx
index d13e718d22d..a6221510000 100644
--- a/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx
+++ b/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useMemo } from 'react';
+import React, { useCallback, useEffect, useMemo } from 'react';
import { View, SectionList, Linking } from 'react-native';
import { useSelector } from 'react-redux';
import { useNavigation, useFocusEffect } from '@react-navigation/native';
@@ -31,6 +31,13 @@ import MusdBalanceCard from './components/MusdBalanceCard';
import { MUSD_CONVERSION_NAVIGATION_OVERRIDE } from '../../types/musd.types';
import Logger from '../../../../../util/Logger';
import { useMusdBalance } from '../../hooks/useMusdBalance';
+import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
+import { MetaMetricsEvents } from '../../../../../core/Analytics';
+import { MUSD_EVENTS_CONSTANTS } from '../../constants/events';
+import { getNetworkName } from '../../utils/network';
+import { IconName } from '@metamask/design-system-react-native';
+
+const { EVENT_LOCATIONS } = MUSD_EVENTS_CONSTANTS;
export const MusdQuickConvertViewTestIds = {
CONTAINER: 'musd-quick-convert-view-container',
@@ -76,6 +83,8 @@ const MusdQuickConvertView = () => {
const { initiateCustomConversion, initiateMaxConversion } =
useMusdConversion();
+ const { trackEvent, createEventBuilder } = useAnalytics();
+
// Feature flags
const isQuickConvertEnabled = useSelector(selectMusdQuickConvertEnabledFlag);
@@ -104,13 +113,38 @@ const MusdQuickConvertView = () => {
}, [navigation, colors]),
);
+ useEffect(() => {
+ if (!isQuickConvertEnabled) return;
+
+ trackEvent(
+ createEventBuilder(
+ MetaMetricsEvents.MUSD_QUICK_CONVERT_SCREEN_VIEWED,
+ ).build(),
+ );
+ }, [createEventBuilder, isQuickConvertEnabled, trackEvent]);
+
// navigate to max conversion bottom sheet
const handleMaxPress = useCallback(
async (token: AssetType) => {
- if (!token.rawBalance) {
- // TODO: Handle error instead of returning silently.
- return;
- }
+ trackEvent(
+ createEventBuilder(
+ MetaMetricsEvents.MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED,
+ )
+ .addProperties({
+ location: EVENT_LOCATIONS.QUICK_CONVERT_HOME_SCREEN,
+ button_type: 'text_button',
+ button_action: 'max',
+ button_text: strings('earn.musd_conversion.max'),
+ redirects_to:
+ EVENT_LOCATIONS.QUICK_CONVERT_MAX_BOTTOM_SHEET_CONFIRMATION_SCREEN,
+ asset_symbol: token.symbol,
+ network_chain_id: token.chainId,
+ network_name: token.chainId
+ ? getNetworkName(token.chainId as Hex)
+ : 'unknown',
+ })
+ .build(),
+ );
try {
await initiateMaxConversion(token);
@@ -129,12 +163,31 @@ const MusdQuickConvertView = () => {
});
}
},
- [initiateMaxConversion],
+ [createEventBuilder, initiateMaxConversion, trackEvent],
);
// navigate to existing confirmation screen
const handleEditPress = useCallback(
async (token: AssetType) => {
+ trackEvent(
+ createEventBuilder(
+ MetaMetricsEvents.MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED,
+ )
+ .addProperties({
+ location: EVENT_LOCATIONS.QUICK_CONVERT_HOME_SCREEN,
+ button_type: 'icon_button',
+ icon: IconName.Edit,
+ button_action: 'custom',
+ redirects_to: EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN,
+ asset_symbol: token.symbol,
+ network_chain_id: token.chainId,
+ network_name: token.chainId
+ ? getNetworkName(token.chainId as Hex)
+ : 'unknown',
+ })
+ .build(),
+ );
+
try {
await initiateCustomConversion({
preferredPaymentToken: {
@@ -158,7 +211,7 @@ const MusdQuickConvertView = () => {
});
}
},
- [initiateCustomConversion],
+ [createEventBuilder, initiateCustomConversion, trackEvent],
);
const tokensWithBalance = useMemo(
@@ -246,8 +299,16 @@ const MusdQuickConvertView = () => {
);
const handleTermsOfUsePressed = useCallback(() => {
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.MUSD_BONUS_TERMS_OF_USE_PRESSED)
+ .addProperties({
+ location: EVENT_LOCATIONS.QUICK_CONVERT_HOME_SCREEN,
+ url: AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE,
+ })
+ .build(),
+ );
Linking.openURL(AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE);
- }, []);
+ }, [createEventBuilder, trackEvent]);
// If feature flags are not enabled, don't render
if (!isQuickConvertEnabled) {
diff --git a/app/components/UI/Earn/constants/events/musdEvents.ts b/app/components/UI/Earn/constants/events/musdEvents.ts
index ae5e370c332..0abf0ed12c0 100644
--- a/app/components/UI/Earn/constants/events/musdEvents.ts
+++ b/app/components/UI/Earn/constants/events/musdEvents.ts
@@ -12,6 +12,10 @@ const EVENT_LOCATIONS = {
CUSTOM_AMOUNT_SCREEN: 'custom_amount_screen', // Single convert screen.
BUY_SCREEN: 'buy_screen', // Buy mUSD screen.
QUICK_CONVERT_HOME_SCREEN: 'quick_convert_home_screen',
+ QUICK_CONVERT_MAX_BOTTOM_SHEET_CONFIRMATION_SCREEN:
+ 'quick_convert_max_bottom_sheet_confirmation_screen',
+ CUSTOM_AMOUNT_NAVBAR: 'custom_amount_navbar',
+ PERCENTAGE_ROW: 'percentage_row',
};
const MUSD_CTA_TYPES = {
diff --git a/app/components/UI/Earn/hooks/useMusdConversion.ts b/app/components/UI/Earn/hooks/useMusdConversion.ts
index 506dcb2cba5..b8d033a8553 100644
--- a/app/components/UI/Earn/hooks/useMusdConversion.ts
+++ b/app/components/UI/Earn/hooks/useMusdConversion.ts
@@ -280,12 +280,6 @@ export const useMusdConversion = () => {
} = Engine.context;
try {
- Logger.log('[mUSD Max Conversion] Setting payment token:', {
- transactionId,
- tokenAddress,
- chainId: tokenChainId,
- });
-
// Must be called BEFORE updatePaymentToken.
TransactionPayController.setTransactionConfig(
transactionId,
diff --git a/app/components/UI/Earn/hooks/useMusdConversionNavbar.test.tsx b/app/components/UI/Earn/hooks/useMusdConversionNavbar.test.tsx
index 0aedd62b9c5..1193e5abcf1 100644
--- a/app/components/UI/Earn/hooks/useMusdConversionNavbar.test.tsx
+++ b/app/components/UI/Earn/hooks/useMusdConversionNavbar.test.tsx
@@ -9,6 +9,20 @@ import { NavbarOverrides } from '../../../Views/confirmations/components/UI/navb
import useTooltipModal from '../../../hooks/useTooltipModal';
import { MUSD_CONVERSION_APY } from '../constants/musd';
import AppConstants from '../../../../core/AppConstants';
+import { MetaMetricsEvents } from '../../../../core/Analytics';
+import { MUSD_EVENTS_CONSTANTS } from '../constants/events';
+
+const mockTrackEvent = jest.fn();
+const mockCreateEventBuilder = jest.fn();
+const mockAddProperties = jest.fn();
+const mockBuild = jest.fn();
+
+jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ }),
+}));
jest.mock('../../../../../locales/i18n', () => ({
strings: jest.fn((key: string) => key),
@@ -29,6 +43,13 @@ describe('useMusdConversionNavbar', () => {
beforeEach(() => {
jest.clearAllMocks();
+
+ mockBuild.mockReturnValue({ name: 'mock-built-event' });
+ mockAddProperties.mockImplementation(() => ({ build: mockBuild }));
+ mockCreateEventBuilder.mockImplementation(() => ({
+ addProperties: mockAddProperties,
+ }));
+
mockUseTooltipModal.mockReturnValue({
openTooltipModal: mockOpenTooltipModal,
});
@@ -185,4 +206,38 @@ describe('useMusdConversionNavbar', () => {
AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE,
);
});
+
+ it('tracks MUSD_BONUS_TERMS_OF_USE_PRESSED event when "Terms apply" is pressed in tooltip content', () => {
+ let capturedOverrides: NavbarOverrides | undefined;
+ mockUseNavbar.mockImplementation((_title, _addBackButton, overrides) => {
+ capturedOverrides = overrides;
+ });
+
+ renderHook(() => useMusdConversionNavbar());
+
+ const HeaderRight = capturedOverrides?.headerRight as React.FC;
+ const { getByTestId } = render();
+
+ fireEvent.press(getByTestId('button-icon'));
+
+ const tooltipBody = mockOpenTooltipModal.mock
+ .calls[0][1] as React.ReactElement;
+ const { getByText } = render(tooltipBody);
+
+ mockTrackEvent.mockClear();
+ mockCreateEventBuilder.mockClear();
+ mockAddProperties.mockClear();
+ mockBuild.mockClear();
+
+ fireEvent.press(getByText('earn.musd_conversion.education.terms_apply'));
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.MUSD_BONUS_TERMS_OF_USE_PRESSED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ location: MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.CUSTOM_AMOUNT_NAVBAR,
+ url: AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE,
+ });
+ expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' });
+ });
});
diff --git a/app/components/UI/Earn/hooks/useMusdConversionNavbar.tsx b/app/components/UI/Earn/hooks/useMusdConversionNavbar.tsx
index a6594f5fb31..cd910f976ad 100644
--- a/app/components/UI/Earn/hooks/useMusdConversionNavbar.tsx
+++ b/app/components/UI/Earn/hooks/useMusdConversionNavbar.tsx
@@ -14,6 +14,11 @@ import {
import useNavbar from '../../../Views/confirmations/hooks/ui/useNavbar';
import useTooltipModal from '../../../hooks/useTooltipModal';
import AppConstants from '../../../../core/AppConstants';
+import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics';
+import { MetaMetricsEvents } from '../../../../core/Analytics';
+import { MUSD_EVENTS_CONSTANTS } from '../constants/events';
+
+const { EVENT_LOCATIONS } = MUSD_EVENTS_CONSTANTS;
const styles = StyleSheet.create({
headerTitle: {
@@ -39,6 +44,8 @@ const styles = StyleSheet.create({
export function useMusdConversionNavbar() {
const { openTooltipModal } = useTooltipModal();
+ const { trackEvent, createEventBuilder } = useAnalytics();
+
const renderHeaderTitle = useCallback(
() => (
@@ -66,9 +73,17 @@ export function useMusdConversionNavbar() {
[],
);
- const handleTermsOfUsePressed = () => {
+ const handleTermsOfUsePressed = useCallback(() => {
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.MUSD_BONUS_TERMS_OF_USE_PRESSED)
+ .addProperties({
+ location: EVENT_LOCATIONS.CUSTOM_AMOUNT_NAVBAR,
+ url: AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE,
+ })
+ .build(),
+ );
Linking.openURL(AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE);
- };
+ }, [createEventBuilder, trackEvent]);
const onInfoPress = useCallback(() => {
openTooltipModal(
@@ -88,7 +103,7 @@ export function useMusdConversionNavbar() {
strings('earn.musd_conversion.powered_by_relay'),
strings('earn.musd_conversion.ok'),
);
- }, [openTooltipModal]);
+ }, [handleTermsOfUsePressed, openTooltipModal]);
const renderHeaderRight = useCallback(
() => (
diff --git a/app/components/UI/Earn/hooks/useMusdConversionStatus.ts b/app/components/UI/Earn/hooks/useMusdConversionStatus.ts
index 3571b3d41ec..31b7bfcec3f 100644
--- a/app/components/UI/Earn/hooks/useMusdConversionStatus.ts
+++ b/app/components/UI/Earn/hooks/useMusdConversionStatus.ts
@@ -4,7 +4,6 @@ import {
TransactionType,
} from '@metamask/transaction-controller';
import { Hex } from '@metamask/utils';
-import type { TransactionPayQuote } from '@metamask/transaction-pay-controller';
import { useCallback, useEffect, useRef } from 'react';
import { useSelector } from 'react-redux';
import Engine from '../../../../core/Engine';
@@ -24,99 +23,11 @@ import {
import { store } from '../../../../store';
import { selectTransactionPayQuotesByTransactionId } from '../../../../selectors/transactionPayController';
import { getNetworkName } from '../utils/network';
+import { getMusdConversionQuoteTrackingData } from '../utils/analytics';
-type PayQuote = TransactionPayQuote;
-
-function chainIdsMatch(a?: Hex, b?: Hex): boolean | undefined {
- if (!a || !b) return undefined;
- return a.toLowerCase() === b.toLowerCase();
-}
-
-function getTransactionPayQuotes(transactionId: string): PayQuote[] {
+function getTransactionPayQuotes(transactionId: string) {
const state = store.getState();
- return (
- (selectTransactionPayQuotesByTransactionId(state, transactionId) as
- | PayQuote[]
- | undefined) ?? []
- );
-}
-
-function getMusdConversionQuoteTrackingData(transactionMeta: TransactionMeta): {
- quotePaymentChainId?: Hex;
- quoteOutputChainId?: Hex;
- quotePaymentTokenAddress?: Hex;
- quoteOutputTokenAddress?: Hex;
- quoteIsSameChain?: boolean;
- strategy: string;
- paymentAmountUsd?: string;
- outputAmountUsd?: string;
- selectedPaymentChainId?: Hex;
- selectedPaymentChainMatchesQuotePaymentChain?: boolean;
- txExecutionChainMatchesQuoteOutputChain?: boolean;
- paymentTokenAddress?: Hex;
- paymentTokenChainId?: Hex;
- outputTokenAddress?: Hex;
- outputTokenChainId?: Hex;
-} {
- const quote = getTransactionPayQuotes(transactionMeta.id)[0];
- const quoteRequest: PayQuote['request'] | undefined = quote?.request;
-
- const quotePaymentChainId = quoteRequest?.sourceChainId;
- const quoteOutputChainId = quoteRequest?.targetChainId;
- const quotePaymentTokenAddress = quoteRequest?.sourceTokenAddress;
- const quoteOutputTokenAddress = quoteRequest?.targetTokenAddress;
-
- const quoteIsSameChain = chainIdsMatch(
- quotePaymentChainId,
- quoteOutputChainId,
- );
-
- const strategy = quote?.strategy
- ? String(quote.strategy).toLowerCase()
- : 'unknown';
-
- const paymentAmountUsd = quote?.sourceAmount?.usd;
- const outputAmountUsd = quote?.targetAmount?.usd;
-
- const selectedPaymentChainId = transactionMeta.metamaskPay?.chainId;
- const selectedPaymentTokenAddress = transactionMeta.metamaskPay?.tokenAddress;
-
- const selectedPaymentChainMatchesQuotePaymentChain = chainIdsMatch(
- selectedPaymentChainId,
- quotePaymentChainId,
- );
-
- const txExecutionChainMatchesQuoteOutputChain = chainIdsMatch(
- transactionMeta?.chainId,
- quoteOutputChainId,
- );
-
- const paymentTokenAddress =
- selectedPaymentTokenAddress ?? quotePaymentTokenAddress;
- const paymentTokenChainId = selectedPaymentChainId ?? quotePaymentChainId;
-
- const outputTokenAddress =
- quoteOutputTokenAddress ??
- (transactionMeta?.txParams?.to as Hex | undefined);
- const outputTokenChainId = quoteOutputChainId ?? transactionMeta?.chainId;
-
- return {
- quotePaymentChainId,
- quoteOutputChainId,
- quotePaymentTokenAddress,
- quoteOutputTokenAddress,
- quoteIsSameChain,
- strategy,
- paymentAmountUsd,
- outputAmountUsd,
- selectedPaymentChainId,
- selectedPaymentChainMatchesQuotePaymentChain,
- txExecutionChainMatchesQuoteOutputChain,
- paymentTokenAddress,
- paymentTokenChainId,
- outputTokenAddress,
- outputTokenChainId,
- };
+ return selectTransactionPayQuotesByTransactionId(state, transactionId) ?? [];
}
/**
@@ -163,23 +74,12 @@ export const useMusdConversionStatus = () => {
// If txParams.data is malformed or missing, keep amounts empty.
}
- const {
- quotePaymentChainId,
- quoteOutputChainId,
- quotePaymentTokenAddress,
- quoteOutputTokenAddress,
- quoteIsSameChain,
- strategy,
- paymentAmountUsd,
- outputAmountUsd,
- selectedPaymentChainId,
- selectedPaymentChainMatchesQuotePaymentChain,
- txExecutionChainMatchesQuoteOutputChain,
- paymentTokenAddress,
- paymentTokenChainId,
- outputTokenAddress,
- outputTokenChainId,
- } = getMusdConversionQuoteTrackingData(transactionMeta);
+ const quotes = getTransactionPayQuotes(transactionMeta.id);
+
+ const quoteTrackingData = getMusdConversionQuoteTrackingData(
+ transactionMeta,
+ quotes,
+ );
trackEvent(
createEventBuilder(MetaMetricsEvents.MUSD_CONVERSION_STATUS_UPDATED)
@@ -192,30 +92,7 @@ export const useMusdConversionStatus = () => {
network_name: getNetworkName(transactionMeta?.chainId),
amount_decimal: amountDecimalString,
amount_hex: amountHexString,
-
- // Quote-derived (primary)
- quote_payment_chain_id: quotePaymentChainId,
- quote_output_chain_id: quoteOutputChainId,
- quote_is_same_chain: quoteIsSameChain,
- quote_payment_token_address: quotePaymentTokenAddress,
- quote_output_token_address: quoteOutputTokenAddress,
- payment_amount_usd: paymentAmountUsd,
- output_amount_usd: outputAmountUsd,
- pay_quote_strategy: strategy,
-
- // Secondary consistency checks.
- selected_payment_chain_id: selectedPaymentChainId,
- selected_payment_chain_matches_quote_payment_chain:
- selectedPaymentChainMatchesQuotePaymentChain,
- tx_execution_chain_matches_quote_output_chain:
- txExecutionChainMatchesQuoteOutputChain,
-
- // Explicit token identity (in/out).
- payment_token_address: paymentTokenAddress,
- payment_token_chain_id: paymentTokenChainId,
-
- output_token_address: outputTokenAddress,
- output_token_chain_id: outputTokenChainId,
+ ...quoteTrackingData,
})
.build(),
);
@@ -302,12 +179,7 @@ export const useMusdConversionStatus = () => {
);
shownToastsRef.current.add(toastKey);
- // Get quotes from state to include strategy in trace
- const state = store.getState();
- const quotes = selectTransactionPayQuotesByTransactionId(
- state,
- transactionId,
- );
+ const quotes = getTransactionPayQuotes(transactionId);
// Start confirmation trace (approved fires immediately after user confirms)
trace({
diff --git a/app/components/UI/Earn/utils/analytics.test.ts b/app/components/UI/Earn/utils/analytics.test.ts
new file mode 100644
index 00000000000..b48a5c60917
--- /dev/null
+++ b/app/components/UI/Earn/utils/analytics.test.ts
@@ -0,0 +1,242 @@
+import { TransactionMeta } from '@metamask/transaction-controller';
+import { TransactionPayQuote } from '@metamask/transaction-pay-controller';
+import { Hex, Json } from '@metamask/utils';
+import {
+ deepSnakeCaseKeys,
+ getMusdConversionQuoteTrackingData,
+} from './analytics';
+
+describe('deepSnakeCaseKeys', () => {
+ it('converts camelCase keys to snake_case', () => {
+ const input = { myKey: 'value', anotherKey: 42 };
+
+ expect(deepSnakeCaseKeys(input)).toStrictEqual({
+ my_key: 'value',
+ another_key: 42,
+ });
+ });
+
+ it('handles nested objects recursively', () => {
+ const input = {
+ outerKey: {
+ innerKey: 'value',
+ deeperLevel: {
+ deepKey: true,
+ },
+ },
+ };
+
+ expect(deepSnakeCaseKeys(input)).toStrictEqual({
+ outer_key: {
+ inner_key: 'value',
+ deeper_level: {
+ deep_key: true,
+ },
+ },
+ });
+ });
+
+ it('handles arrays by converting each element', () => {
+ const input = [{ itemKey: 1 }, { itemKey: 2 }];
+
+ expect(deepSnakeCaseKeys(input)).toStrictEqual([
+ { item_key: 1 },
+ { item_key: 2 },
+ ]);
+ });
+
+ it('handles arrays nested inside objects', () => {
+ const input = {
+ myList: [{ listItemKey: 'a' }, { listItemKey: 'b' }],
+ };
+
+ expect(deepSnakeCaseKeys(input)).toStrictEqual({
+ my_list: [{ list_item_key: 'a' }, { list_item_key: 'b' }],
+ });
+ });
+
+ it('returns primitive values unchanged', () => {
+ expect(deepSnakeCaseKeys('hello')).toBe('hello');
+ expect(deepSnakeCaseKeys(42)).toBe(42);
+ expect(deepSnakeCaseKeys(true)).toBe(true);
+ expect(deepSnakeCaseKeys(null)).toBeNull();
+ expect(deepSnakeCaseKeys(undefined)).toBeUndefined();
+ });
+
+ it('handles empty objects', () => {
+ expect(deepSnakeCaseKeys({})).toStrictEqual({});
+ });
+
+ it('handles empty arrays', () => {
+ expect(deepSnakeCaseKeys([])).toStrictEqual([]);
+ });
+
+ it('preserves keys already in snake_case', () => {
+ const input = { already_snake: 'value' };
+
+ expect(deepSnakeCaseKeys(input)).toStrictEqual({
+ already_snake: 'value',
+ });
+ });
+});
+
+describe('getMusdConversionQuoteTrackingData', () => {
+ const SOURCE_CHAIN_ID = '0x1' as Hex;
+ const TARGET_CHAIN_ID = '0xa' as Hex;
+ const SOURCE_TOKEN_ADDRESS = '0xabc' as Hex;
+ const TARGET_TOKEN_ADDRESS = '0xdef' as Hex;
+ const TX_TO_ADDRESS = '0x999' as Hex;
+
+ const buildQuote = (
+ overrides: Partial<{
+ sourceChainId: Hex;
+ targetChainId: Hex;
+ sourceTokenAddress: Hex;
+ targetTokenAddress: Hex;
+ strategy: string;
+ sourceAmountUsd: string;
+ targetAmountUsd: string;
+ }> = {},
+ ): TransactionPayQuote =>
+ ({
+ strategy: overrides.strategy ?? 'bridge',
+ sourceAmount: { usd: overrides.sourceAmountUsd ?? '100.00' },
+ targetAmount: { usd: overrides.targetAmountUsd ?? '99.50' },
+ request: {
+ sourceChainId: overrides.sourceChainId ?? SOURCE_CHAIN_ID,
+ targetChainId: overrides.targetChainId ?? TARGET_CHAIN_ID,
+ sourceTokenAddress:
+ overrides.sourceTokenAddress ?? SOURCE_TOKEN_ADDRESS,
+ targetTokenAddress:
+ overrides.targetTokenAddress ?? TARGET_TOKEN_ADDRESS,
+ },
+ }) as unknown as TransactionPayQuote;
+
+ const buildTxMeta = (
+ overrides: Partial<{
+ chainId: Hex;
+ to: Hex;
+ metamaskPayChainId: Hex;
+ metamaskPayTokenAddress: Hex;
+ }> = {},
+ ): TransactionMeta =>
+ ({
+ chainId: overrides.chainId ?? TARGET_CHAIN_ID,
+ txParams: {
+ to: overrides.to ?? TX_TO_ADDRESS,
+ },
+ metamaskPay: {
+ chainId: overrides.metamaskPayChainId ?? SOURCE_CHAIN_ID,
+ tokenAddress: overrides.metamaskPayTokenAddress ?? SOURCE_TOKEN_ADDRESS,
+ },
+ }) as unknown as TransactionMeta;
+
+ it('returns all snake_cased quote tracking properties', () => {
+ const txMeta = buildTxMeta();
+ const quotes = [buildQuote()];
+
+ const result = getMusdConversionQuoteTrackingData(txMeta, quotes);
+
+ expect(result).toStrictEqual({
+ quote_payment_chain_id: SOURCE_CHAIN_ID,
+ quote_output_chain_id: TARGET_CHAIN_ID,
+ quote_payment_token_address: SOURCE_TOKEN_ADDRESS,
+ quote_output_token_address: TARGET_TOKEN_ADDRESS,
+ quote_is_same_chain: false,
+ pay_quote_strategy: 'bridge',
+ payment_amount_usd: '100.00',
+ output_amount_usd: '99.50',
+ selected_payment_chain_id: SOURCE_CHAIN_ID,
+ selected_payment_chain_matches_quote_payment_chain: true,
+ tx_execution_chain_matches_quote_output_chain: true,
+ payment_token_address: SOURCE_TOKEN_ADDRESS,
+ payment_token_chain_id: SOURCE_CHAIN_ID,
+ output_token_address: TARGET_TOKEN_ADDRESS,
+ output_token_chain_id: TARGET_CHAIN_ID,
+ });
+ });
+
+ it('detects same-chain when source and target chain match', () => {
+ const txMeta = buildTxMeta({ chainId: SOURCE_CHAIN_ID });
+ const quotes = [
+ buildQuote({
+ sourceChainId: SOURCE_CHAIN_ID,
+ targetChainId: SOURCE_CHAIN_ID,
+ }),
+ ];
+
+ const result = getMusdConversionQuoteTrackingData(txMeta, quotes);
+
+ expect(result.quote_is_same_chain).toBe(true);
+ });
+
+ it('returns "unknown" strategy when quote has no strategy', () => {
+ const txMeta = buildTxMeta();
+ const quote = buildQuote();
+ (quote as unknown as Record).strategy = undefined;
+
+ const result = getMusdConversionQuoteTrackingData(txMeta, [quote]);
+
+ expect(result.pay_quote_strategy).toBe('unknown');
+ });
+
+ it('lowercases the strategy string', () => {
+ const txMeta = buildTxMeta();
+ const quotes = [buildQuote({ strategy: 'RELAY' })];
+
+ const result = getMusdConversionQuoteTrackingData(txMeta, quotes);
+
+ expect(result.pay_quote_strategy).toBe('relay');
+ });
+
+ it('falls back to quote token address when metamaskPay is absent', () => {
+ const txMeta = {
+ chainId: TARGET_CHAIN_ID,
+ txParams: { to: TX_TO_ADDRESS },
+ } as unknown as TransactionMeta;
+ const quotes = [buildQuote()];
+
+ const result = getMusdConversionQuoteTrackingData(txMeta, quotes);
+
+ expect(result.payment_token_address).toBe(SOURCE_TOKEN_ADDRESS);
+ expect(result.payment_token_chain_id).toBe(SOURCE_CHAIN_ID);
+ });
+
+ // TODO: We don't want this behaviour. If the quote has no target token address, we should not use the txParams.to.
+ it('falls back to txParams.to when quote has no target token address', () => {
+ const txMeta = buildTxMeta();
+ const quote = buildQuote();
+ (
+ quote as unknown as { request: Record }
+ ).request.targetTokenAddress = undefined;
+
+ const result = getMusdConversionQuoteTrackingData(txMeta, [quote]);
+
+ expect(result.output_token_address).toBe(TX_TO_ADDRESS);
+ });
+
+ it('falls back to transactionMeta.chainId when quote has no target chain', () => {
+ const txMeta = buildTxMeta();
+ const quote = buildQuote();
+ (
+ quote as unknown as { request: Record }
+ ).request.targetChainId = undefined;
+
+ const result = getMusdConversionQuoteTrackingData(txMeta, [quote]);
+
+ expect(result.output_token_chain_id).toBe(TARGET_CHAIN_ID);
+ });
+
+ it('uses the first quote when multiple quotes are provided', () => {
+ const txMeta = buildTxMeta();
+ const firstQuote = buildQuote({ strategy: 'bridge' });
+ const secondQuote = buildQuote({ strategy: 'relay' });
+
+ const result = getMusdConversionQuoteTrackingData(txMeta, [
+ firstQuote,
+ secondQuote,
+ ]);
+
+ expect(result.pay_quote_strategy).toBe('bridge');
+ });
+});
diff --git a/app/components/UI/Earn/utils/analytics.ts b/app/components/UI/Earn/utils/analytics.ts
index 2260df50e89..dfb9f0b4257 100644
--- a/app/components/UI/Earn/utils/analytics.ts
+++ b/app/components/UI/Earn/utils/analytics.ts
@@ -1,5 +1,9 @@
-import { toHex } from '@metamask/controller-utils';
+import { toHex, isEqualCaseInsensitive } from '@metamask/controller-utils';
import { isNonEvmChainId } from '../../../../core/Multichain/utils';
+import { Hex, Json } from '@metamask/utils';
+import { TransactionMeta } from '@metamask/transaction-controller';
+import { TransactionPayQuote } from '@metamask/transaction-pay-controller';
+import type { DeepSnakeCaseKeys } from './analytics.types';
/**
* Formats a chain ID for analytics tracking.
@@ -18,3 +22,103 @@ export const formatChainIdForAnalytics = (
const chainIdStr = String(chainId);
return isNonEvmChainId(chainIdStr) ? chainIdStr : toHex(chainId);
};
+
+const camelToSnakeCase = (str: string): string =>
+ str.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
+
+export const deepSnakeCaseKeys = (obj: T): DeepSnakeCaseKeys => {
+ if (Array.isArray(obj)) {
+ return obj.map(deepSnakeCaseKeys) as DeepSnakeCaseKeys;
+ }
+ if (obj !== null && typeof obj === 'object') {
+ return Object.entries(obj).reduce(
+ (acc, [key, value]) => {
+ acc[camelToSnakeCase(key)] = deepSnakeCaseKeys(value);
+ return acc;
+ },
+ {} as Record,
+ ) as DeepSnakeCaseKeys;
+ }
+ return obj as DeepSnakeCaseKeys;
+};
+
+export function getMusdConversionQuoteTrackingData(
+ transactionMeta: TransactionMeta,
+ quotes: TransactionPayQuote[],
+): {
+ quote_payment_chain_id?: Hex;
+ quote_output_chain_id?: Hex;
+ quote_is_same_chain?: boolean;
+ quote_payment_token_address?: Hex;
+ quote_output_token_address?: Hex;
+ payment_amount_usd?: string;
+ output_amount_usd?: string;
+ pay_quote_strategy: string;
+ selected_payment_chain_id?: Hex;
+ selected_payment_chain_matches_quote_payment_chain?: boolean;
+ tx_execution_chain_matches_quote_output_chain?: boolean;
+ payment_token_address?: Hex;
+ payment_token_chain_id?: Hex;
+ output_token_address?: Hex;
+ output_token_chain_id?: Hex;
+} {
+ const quote = quotes?.[0];
+ const quoteRequest = quote?.request;
+
+ const quotePaymentChainId = quoteRequest?.sourceChainId;
+ const quoteOutputChainId = quoteRequest?.targetChainId;
+ const quotePaymentTokenAddress = quoteRequest?.sourceTokenAddress;
+ const quoteOutputTokenAddress = quoteRequest?.targetTokenAddress;
+
+ const quoteIsSameChain =
+ quotePaymentChainId && quoteOutputChainId
+ ? isEqualCaseInsensitive(quotePaymentChainId, quoteOutputChainId)
+ : undefined;
+
+ const payQuoteStrategy = quote?.strategy
+ ? String(quote.strategy).toLowerCase()
+ : 'unknown';
+
+ const paymentAmountUsd = quote?.sourceAmount?.usd;
+ const outputAmountUsd = quote?.targetAmount?.usd;
+
+ const selectedPaymentChainId = transactionMeta.metamaskPay?.chainId;
+ const selectedPaymentTokenAddress = transactionMeta.metamaskPay?.tokenAddress;
+
+ const selectedPaymentChainMatchesQuotePaymentChain =
+ selectedPaymentChainId && quotePaymentChainId
+ ? isEqualCaseInsensitive(selectedPaymentChainId, quotePaymentChainId)
+ : undefined;
+
+ const txExecutionChainMatchesQuoteOutputChain =
+ transactionMeta?.chainId && quoteOutputChainId
+ ? isEqualCaseInsensitive(transactionMeta.chainId, quoteOutputChainId)
+ : undefined;
+
+ const paymentTokenAddress =
+ selectedPaymentTokenAddress ?? quotePaymentTokenAddress;
+ const paymentTokenChainId = selectedPaymentChainId ?? quotePaymentChainId;
+
+ const outputTokenAddress =
+ quoteOutputTokenAddress ??
+ (transactionMeta?.txParams?.to as Hex | undefined);
+ const outputTokenChainId = quoteOutputChainId ?? transactionMeta?.chainId;
+
+ return deepSnakeCaseKeys({
+ quotePaymentChainId,
+ quoteOutputChainId,
+ quotePaymentTokenAddress,
+ quoteOutputTokenAddress,
+ quoteIsSameChain,
+ payQuoteStrategy,
+ paymentAmountUsd,
+ outputAmountUsd,
+ selectedPaymentChainId,
+ selectedPaymentChainMatchesQuotePaymentChain,
+ txExecutionChainMatchesQuoteOutputChain,
+ paymentTokenAddress,
+ paymentTokenChainId,
+ outputTokenAddress,
+ outputTokenChainId,
+ });
+}
diff --git a/app/components/UI/Earn/utils/analytics.types.ts b/app/components/UI/Earn/utils/analytics.types.ts
new file mode 100644
index 00000000000..cb114284e23
--- /dev/null
+++ b/app/components/UI/Earn/utils/analytics.types.ts
@@ -0,0 +1,18 @@
+/**
+ * Converts a camelCase string to snake_case at the type level.
+ *
+ * Inserts an underscore before each uppercase letter, then lowercases.
+ */
+type CamelToSnakeCase = S extends `${infer T}${infer U}`
+ ? U extends Uncapitalize
+ ? `${Lowercase}${CamelToSnakeCase}`
+ : `${Lowercase}_${CamelToSnakeCase}`
+ : S;
+
+export type DeepSnakeCaseKeys = T extends readonly (infer U)[]
+ ? DeepSnakeCaseKeys[]
+ : T extends object
+ ? {
+ [K in keyof T as CamelToSnakeCase]: DeepSnakeCaseKeys;
+ }
+ : T;
diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx
index 3245da47117..7878a6d99e7 100644
--- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx
+++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx
@@ -56,6 +56,14 @@ jest.mock('../../../hooks/metrics/useConfirmationMetricEvents', () => ({
setConfirmationMetric: jest.fn(),
}),
}));
+jest.mock('../../../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: jest.fn(),
+ createEventBuilder: jest.fn(() => ({
+ addProperties: jest.fn(() => ({ build: jest.fn() })),
+ })),
+ }),
+}));
const mockGoToBuy = jest.fn();
diff --git a/app/components/Views/confirmations/components/info/musd-conversion-info-root/musd-conversion-info-root.test.tsx b/app/components/Views/confirmations/components/info/musd-conversion-info-root/musd-conversion-info-root.test.tsx
index a9d74e1bf85..9302e8b8de9 100644
--- a/app/components/Views/confirmations/components/info/musd-conversion-info-root/musd-conversion-info-root.test.tsx
+++ b/app/components/Views/confirmations/components/info/musd-conversion-info-root/musd-conversion-info-root.test.tsx
@@ -6,6 +6,15 @@ import { MusdConversionInfoRoot } from './musd-conversion-info-root';
const MUSD_CONVERSION_INFO_TEST_ID = 'musd-conversion-info';
const MUSD_MAX_CONVERSION_INFO_TEST_ID = 'musd-max-conversion-info';
+const mockUseMusdConversionConfirmationMetrics = jest.fn();
+jest.mock(
+ '../../../hooks/metrics/useMusdConversionConfirmationMetrics',
+ () => ({
+ useMusdConversionConfirmationMetrics: (...args: unknown[]) =>
+ mockUseMusdConversionConfirmationMetrics(...args),
+ }),
+);
+
jest.mock('../musd-conversion-info', () => {
const { View } =
jest.requireActual('react-native');
@@ -69,4 +78,12 @@ describe('MusdConversionInfoRoot', () => {
expect(getByTestId(MUSD_CONVERSION_INFO_TEST_ID)).toBeOnTheScreen();
expect(queryByTestId(MUSD_MAX_CONVERSION_INFO_TEST_ID)).toBeNull();
});
+
+ it('calls useMusdConversionConfirmationMetrics on render', () => {
+ mockUseParams.mockReturnValue({ forceBottomSheet: false });
+
+ renderWithProvider();
+
+ expect(mockUseMusdConversionConfirmationMetrics).toHaveBeenCalled();
+ });
});
diff --git a/app/components/Views/confirmations/components/info/musd-conversion-info-root/musd-conversion-info-root.tsx b/app/components/Views/confirmations/components/info/musd-conversion-info-root/musd-conversion-info-root.tsx
index c6b249ab882..9a0d30c222f 100644
--- a/app/components/Views/confirmations/components/info/musd-conversion-info-root/musd-conversion-info-root.tsx
+++ b/app/components/Views/confirmations/components/info/musd-conversion-info-root/musd-conversion-info-root.tsx
@@ -2,9 +2,11 @@ import React from 'react';
import { MusdConversionInfo } from '../musd-conversion-info';
import { MusdMaxConversionInfo } from '../musd-max-conversion-info';
import { useParams } from '../../../../../../util/navigation/navUtils';
+import { useMusdConversionConfirmationMetrics } from '../../../hooks/metrics/useMusdConversionConfirmationMetrics';
export const MusdConversionInfoRoot = () => {
const { forceBottomSheet } = useParams<{ forceBottomSheet?: boolean }>();
+ useMusdConversionConfirmationMetrics();
return forceBottomSheet ? : ;
};
diff --git a/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.test.tsx b/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.test.tsx
index f425bc42b99..d23b1b25f4f 100644
--- a/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.test.tsx
+++ b/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.test.tsx
@@ -1,4 +1,6 @@
import React from 'react';
+import { fireEvent } from '@testing-library/react-native';
+import { Linking } from 'react-native';
import renderWithProvider from '../../../../../../util/test/renderWithProvider';
import { PercentageRow } from './percentage-row';
import { useIsTransactionPayLoading } from '../../../hooks/pay/useTransactionPayData';
@@ -6,6 +8,21 @@ import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTr
import { strings } from '../../../../../../../locales/i18n';
import { MUSD_CONVERSION_APY } from '../../../../../UI/Earn/constants/musd';
import { TransactionType } from '@metamask/transaction-controller';
+import { MetaMetricsEvents } from '../../../../../../core/Analytics';
+import { MUSD_EVENTS_CONSTANTS } from '../../../../../UI/Earn/constants/events';
+import AppConstants from '../../../../../../core/AppConstants';
+
+const mockTrackEvent = jest.fn();
+const mockCreateEventBuilder = jest.fn();
+const mockAddProperties = jest.fn();
+const mockBuild = jest.fn();
+
+jest.mock('../../../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ }),
+}));
jest.mock('../../../hooks/pay/useTransactionPayData');
jest.mock('../../../hooks/transactions/useTransactionMetadataRequest');
@@ -25,6 +42,12 @@ describe('PercentageRow', () => {
beforeEach(() => {
jest.resetAllMocks();
+ mockBuild.mockReturnValue({ name: 'mock-built-event' });
+ mockAddProperties.mockImplementation(() => ({ build: mockBuild }));
+ mockCreateEventBuilder.mockImplementation(() => ({
+ addProperties: mockAddProperties,
+ }));
+
useIsTransactionPayLoadingMock.mockReturnValue(false);
useTransactionMetadataRequestMock.mockReturnValue({
type: TransactionType.musdConversion,
@@ -47,6 +70,34 @@ describe('PercentageRow', () => {
expect(getByTestId('percentage-row-skeleton')).toBeOnTheScreen();
});
+ it('tracks event and opens URL when terms apply tooltip link is pressed', () => {
+ const openUrlSpy = jest
+ .spyOn(Linking, 'openURL')
+ .mockResolvedValueOnce(undefined);
+
+ const { getByTestId, getByText } = render();
+
+ fireEvent.press(getByTestId('info-row-tooltip-open-btn'));
+
+ fireEvent.press(
+ getByText(strings('earn.musd_conversion.education.terms_apply')),
+ );
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.MUSD_BONUS_TERMS_OF_USE_PRESSED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ location: MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.PERCENTAGE_ROW,
+ url: AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE,
+ });
+ expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' });
+
+ expect(openUrlSpy).toHaveBeenCalledTimes(1);
+ expect(openUrlSpy).toHaveBeenCalledWith(
+ AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE,
+ );
+ });
+
it('renders nothing for non-musdConversion transactions', () => {
useTransactionMetadataRequestMock.mockReturnValue({
type: TransactionType.simpleSend,
diff --git a/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.tsx b/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.tsx
index 83f89ad2751..59a922ed3be 100644
--- a/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.tsx
+++ b/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.tsx
@@ -14,6 +14,11 @@ import AppConstants from '../../../../../../core/AppConstants';
import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest';
import { TransactionType } from '@metamask/transaction-controller';
import { hasTransactionType } from '../../../utils/transaction';
+import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics';
+import { MetaMetricsEvents } from '../../../../../../core/Analytics';
+import { MUSD_EVENTS_CONSTANTS } from '../../../../../UI/Earn/constants/events';
+
+const { EVENT_LOCATIONS } = MUSD_EVENTS_CONSTANTS;
const styles = StyleSheet.create({
termsText: {
@@ -26,14 +31,26 @@ export function PercentageRow() {
const transactionMetadata = useTransactionMetadataRequest();
+ const { trackEvent, createEventBuilder } = useAnalytics();
+
if (
!hasTransactionType(transactionMetadata, [TransactionType.musdConversion])
) {
return null;
}
- const redirectToBonusFaq = () =>
+ const redirectToBonusFaq = () => {
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.MUSD_BONUS_TERMS_OF_USE_PRESSED)
+ .addProperties({
+ location: EVENT_LOCATIONS.PERCENTAGE_ROW,
+ url: AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE,
+ })
+ .build(),
+ );
+
Linking.openURL(AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE);
+ };
if (isLoading) {
return ;
diff --git a/app/components/Views/confirmations/hooks/metrics/useMusdConversionConfirmationMetrics.test.ts b/app/components/Views/confirmations/hooks/metrics/useMusdConversionConfirmationMetrics.test.ts
new file mode 100644
index 00000000000..d832e098931
--- /dev/null
+++ b/app/components/Views/confirmations/hooks/metrics/useMusdConversionConfirmationMetrics.test.ts
@@ -0,0 +1,245 @@
+import { merge } from 'lodash';
+import { updateConfirmationMetric } from '../../../../../core/redux/slices/confirmationMetrics';
+import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider';
+import { otherControllersMock } from '../../__mocks__/controllers/other-controllers-mock';
+import { useMusdConversionConfirmationMetrics } from './useMusdConversionConfirmationMetrics';
+import {
+ simpleSendTransactionControllerMock,
+ transactionIdMock,
+} from '../../__mocks__/controllers/transaction-controller-mock';
+import { transactionApprovalControllerMock } from '../../__mocks__/controllers/approval-controller-mock';
+import {
+ useTransactionPayIsMaxAmount,
+ useTransactionPayQuotes,
+} from '../pay/useTransactionPayData';
+import { getMusdConversionQuoteTrackingData } from '../../../../UI/Earn/utils/analytics';
+import { useParams } from '../../../../../util/navigation/navUtils';
+import { MUSD_EVENTS_CONSTANTS } from '../../../../UI/Earn/constants/events';
+import { Json } from '@metamask/utils';
+import { TransactionPayQuote } from '@metamask/transaction-pay-controller';
+
+const { EVENT_LOCATIONS } = MUSD_EVENTS_CONSTANTS;
+
+jest.mock('../../../../../core/redux/slices/confirmationMetrics', () => ({
+ ...(jest.requireActual(
+ '../../../../../core/redux/slices/confirmationMetrics',
+ ) as object),
+ updateConfirmationMetric: jest.fn(),
+}));
+
+jest.mock('../pay/useTransactionPayData', () => ({
+ ...(jest.requireActual('../pay/useTransactionPayData') as object),
+ useTransactionPayQuotes: jest.fn(),
+ useTransactionPayIsMaxAmount: jest.fn(),
+}));
+
+jest.mock('../../../../UI/Earn/utils/analytics', () => ({
+ ...(jest.requireActual('../../../../UI/Earn/utils/analytics') as object),
+ getMusdConversionQuoteTrackingData: jest.fn(),
+}));
+
+jest.mock('../../../../../util/navigation/navUtils', () => ({
+ ...(jest.requireActual('../../../../../util/navigation/navUtils') as object),
+ useParams: jest.fn(),
+}));
+
+const QUOTE_TRACKING_DATA_MOCK = {
+ quote_is_same_chain: true,
+ payment_amount_usd: '5',
+ output_amount_usd: '5',
+ tx_execution_chain_matches_quote_output_chain: true,
+ pay_quote_strategy: 'relay',
+};
+
+function runHook() {
+ return renderHookWithProvider(useMusdConversionConfirmationMetrics, {
+ state: merge(
+ {},
+ simpleSendTransactionControllerMock,
+ transactionApprovalControllerMock,
+ otherControllersMock,
+ ),
+ });
+}
+
+function runHookWithoutTransaction() {
+ return renderHookWithProvider(useMusdConversionConfirmationMetrics, {
+ state: merge(
+ {},
+ simpleSendTransactionControllerMock,
+ otherControllersMock,
+ {
+ engine: {
+ backgroundState: {
+ ApprovalController: {
+ pendingApprovals: {},
+ pendingApprovalCount: 0,
+ approvalFlows: [],
+ },
+ },
+ },
+ },
+ ),
+ });
+}
+
+describe('useMusdConversionConfirmationMetrics', () => {
+ const updateConfirmationMetricMock = jest.mocked(updateConfirmationMetric);
+ const useTransactionPayQuotesMock = jest.mocked(useTransactionPayQuotes);
+ const useTransactionPayIsMaxAmountMock = jest.mocked(
+ useTransactionPayIsMaxAmount,
+ );
+ const getMusdConversionQuoteTrackingDataMock = jest.mocked(
+ getMusdConversionQuoteTrackingData,
+ );
+ const useParamsMock = jest.mocked(useParams);
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+
+ updateConfirmationMetricMock.mockReturnValue({
+ type: 'mockedAction',
+ } as never);
+
+ useTransactionPayQuotesMock.mockReturnValue([]);
+ useTransactionPayIsMaxAmountMock.mockReturnValue(false);
+ getMusdConversionQuoteTrackingDataMock.mockReturnValue(
+ QUOTE_TRACKING_DATA_MOCK,
+ );
+ useParamsMock.mockReturnValue({ forceBottomSheet: false });
+ });
+
+ it('dispatches custom_amount_screen source when forceBottomSheet is false', () => {
+ useParamsMock.mockReturnValue({ forceBottomSheet: false });
+
+ runHook();
+
+ expect(updateConfirmationMetricMock).toHaveBeenCalledWith({
+ id: transactionIdMock,
+ params: {
+ properties: {
+ confirmation_source: EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN,
+ is_max: false,
+ },
+ sensitiveProperties: {},
+ },
+ });
+ });
+
+ it('dispatches max_convert_bottom_sheet source when forceBottomSheet is true', () => {
+ useParamsMock.mockReturnValue({ forceBottomSheet: true });
+
+ runHook();
+
+ expect(updateConfirmationMetricMock).toHaveBeenCalledWith({
+ id: transactionIdMock,
+ params: {
+ properties: {
+ confirmation_source:
+ EVENT_LOCATIONS.QUICK_CONVERT_MAX_BOTTOM_SHEET_CONFIRMATION_SCREEN,
+ is_max: false,
+ },
+ sensitiveProperties: {},
+ },
+ });
+ });
+
+ it('dispatches is_max as true when transaction pay config has isMaxAmount', () => {
+ useTransactionPayIsMaxAmountMock.mockReturnValue(true);
+
+ runHook();
+
+ expect(updateConfirmationMetricMock).toHaveBeenCalledWith({
+ id: transactionIdMock,
+ params: {
+ properties: {
+ confirmation_source: EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN,
+ is_max: true,
+ },
+ sensitiveProperties: {},
+ },
+ });
+ });
+
+ it('dispatches is_max as false when isMaxAmount is false', () => {
+ useTransactionPayIsMaxAmountMock.mockReturnValue(false);
+
+ runHook();
+
+ expect(updateConfirmationMetricMock).toHaveBeenCalledWith({
+ id: transactionIdMock,
+ params: {
+ properties: {
+ confirmation_source: EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN,
+ is_max: false,
+ },
+ sensitiveProperties: {},
+ },
+ });
+ });
+
+ it('includes quote tracking data when quotes are available', () => {
+ useTransactionPayQuotesMock.mockReturnValue([
+ { strategy: 'relay' } as unknown as TransactionPayQuote,
+ ]);
+
+ runHook();
+
+ expect(getMusdConversionQuoteTrackingDataMock).toHaveBeenCalled();
+ expect(updateConfirmationMetricMock).toHaveBeenCalledWith({
+ id: transactionIdMock,
+ params: {
+ properties: {
+ confirmation_source: EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN,
+ is_max: false,
+ quote_is_same_chain: true,
+ payment_amount_usd: '5',
+ output_amount_usd: '5',
+ tx_execution_chain_matches_quote_output_chain: true,
+ },
+ sensitiveProperties: {},
+ },
+ });
+ });
+
+ it('dispatches only base properties when no quotes exist', () => {
+ useTransactionPayQuotesMock.mockReturnValue([]);
+
+ runHook();
+
+ expect(getMusdConversionQuoteTrackingDataMock).not.toHaveBeenCalled();
+ expect(updateConfirmationMetricMock).toHaveBeenCalledWith({
+ id: transactionIdMock,
+ params: {
+ properties: {
+ confirmation_source: EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN,
+ is_max: false,
+ },
+ sensitiveProperties: {},
+ },
+ });
+ });
+
+ it('dispatches custom_amount_screen source when forceBottomSheet is undefined', () => {
+ useParamsMock.mockReturnValue({});
+
+ runHook();
+
+ expect(updateConfirmationMetricMock).toHaveBeenCalledWith({
+ id: transactionIdMock,
+ params: {
+ properties: {
+ confirmation_source: EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN,
+ is_max: false,
+ },
+ sensitiveProperties: {},
+ },
+ });
+ });
+
+ it('does not dispatch updateConfirmationMetric when txMeta is undefined', () => {
+ runHookWithoutTransaction();
+
+ expect(updateConfirmationMetricMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/components/Views/confirmations/hooks/metrics/useMusdConversionConfirmationMetrics.ts b/app/components/Views/confirmations/hooks/metrics/useMusdConversionConfirmationMetrics.ts
new file mode 100644
index 00000000000..c2b7569cf81
--- /dev/null
+++ b/app/components/Views/confirmations/hooks/metrics/useMusdConversionConfirmationMetrics.ts
@@ -0,0 +1,80 @@
+import { useEffect, useMemo } from 'react';
+import { useDispatch } from 'react-redux';
+import { useParams } from '../../../../../util/navigation/navUtils';
+import { updateConfirmationMetric } from '../../../../../core/redux/slices/confirmationMetrics';
+import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest';
+import {
+ useTransactionPayIsMaxAmount,
+ useTransactionPayQuotes,
+} from '../pay/useTransactionPayData';
+import { getMusdConversionQuoteTrackingData } from '../../../../UI/Earn/utils/analytics';
+import { ConfirmationParams } from '../../components/confirm/confirm-component';
+import { MUSD_EVENTS_CONSTANTS } from '../../../../UI/Earn/constants/events';
+
+const { EVENT_LOCATIONS } = MUSD_EVENTS_CONSTANTS;
+
+/**
+ * Enriches mUSD conversion confirmation metrics with quote tracking data.
+ *
+ * Dispatches {@link updateConfirmationMetric} to attach confirmation source,
+ * max-amount flag, and quote-level properties (selected quote, exchange rate, etc.)
+ * to the transaction's confirmation metric entry. Re-dispatches whenever the
+ * underlying transaction metadata, quotes, or max-amount state change.
+ */
+export function useMusdConversionConfirmationMetrics() {
+ const dispatch = useDispatch();
+ const { forceBottomSheet } = useParams();
+ const txMeta = useTransactionMetadataRequest();
+ const quotes = useTransactionPayQuotes();
+ const isMaxAmount = useTransactionPayIsMaxAmount();
+ const transactionId = txMeta?.id ?? '';
+
+ const confirmationSource = forceBottomSheet
+ ? EVENT_LOCATIONS.QUICK_CONVERT_MAX_BOTTOM_SHEET_CONFIRMATION_SCREEN
+ : EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN;
+
+ const quoteTrackingData = useMemo(() => {
+ if (!txMeta || !quotes?.length) {
+ return {};
+ }
+ const {
+ quote_is_same_chain,
+ payment_amount_usd,
+ output_amount_usd,
+ tx_execution_chain_matches_quote_output_chain,
+ } = getMusdConversionQuoteTrackingData(txMeta, quotes);
+
+ return {
+ quote_is_same_chain,
+ payment_amount_usd,
+ output_amount_usd,
+ tx_execution_chain_matches_quote_output_chain,
+ };
+ }, [txMeta, quotes]);
+
+ useEffect(() => {
+ if (!transactionId) {
+ return;
+ }
+
+ dispatch(
+ updateConfirmationMetric({
+ id: transactionId,
+ params: {
+ properties: {
+ confirmation_source: confirmationSource,
+ is_max: isMaxAmount,
+ ...quoteTrackingData,
+ },
+ sensitiveProperties: {},
+ },
+ }),
+ );
+ }, [
+ dispatch,
+ transactionId,
+ confirmationSource,
+ isMaxAmount,
+ quoteTrackingData,
+ ]);
+}
diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts
index 53387cc656f..c76a106bea7 100644
--- a/app/core/Analytics/MetaMetrics.events.ts
+++ b/app/core/Analytics/MetaMetrics.events.ts
@@ -642,6 +642,9 @@ enum EVENT_NAME {
MUSD_CONVERSION_STATUS_UPDATED = 'mUSD Conversion Status Updated',
MUSD_CLAIM_BONUS_BUTTON_CLICKED = 'mUSD Claim Bonus Button Clicked',
MUSD_CLAIM_BONUS_STATUS_UPDATED = 'mUSD Claim Bonus Status Updated',
+ MUSD_QUICK_CONVERT_SCREEN_VIEWED = 'mUSD Quick Convert Screen Viewed',
+ MUSD_BONUS_TERMS_OF_USE_PRESSED = 'mUSD Bonus Terms of Use Pressed',
+ MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED = 'mUSD Quick Convert Token Row Button Clicked',
// Assets
ASSETS_FIRST_INIT_FETCH_COMPLETED = 'Assets First Init Fetch Completed',
@@ -1670,6 +1673,15 @@ const events = {
MUSD_CLAIM_BONUS_STATUS_UPDATED: generateOpt(
EVENT_NAME.MUSD_CLAIM_BONUS_STATUS_UPDATED,
),
+ MUSD_QUICK_CONVERT_SCREEN_VIEWED: generateOpt(
+ EVENT_NAME.MUSD_QUICK_CONVERT_SCREEN_VIEWED,
+ ),
+ MUSD_BONUS_TERMS_OF_USE_PRESSED: generateOpt(
+ EVENT_NAME.MUSD_BONUS_TERMS_OF_USE_PRESSED,
+ ),
+ MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED: generateOpt(
+ EVENT_NAME.MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED,
+ ),
};
/**
From 1ea19b61ead6103d8eb79adbebaa66825500ebb8 Mon Sep 17 00:00:00 2001
From: Hassan Malik <41640681+hmalik88@users.noreply.github.com>
Date: Thu, 12 Mar 2026 15:20:33 -0400
Subject: [PATCH 002/206] chore: bump core dependencies (#26849)
## **Description**
Updated the following dependencies (with required integration work, if
any):
1. `@metamask/accounts-controller` - Update
`getInternalAccountByAddress` selector.
2. `@metamask/preferences-controller` - Update `PreferencesController`
state type.
3. `@metamask/account-tree-controller`
4. `@metamask/delegation-controller`
5. `@metamask/multichain-network-controller`
6. `@metamask/multichain-transactions-controller`
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes: N/A
## **Manual testing steps**
1. Build from branch.
2. Create an account
3. Verify nothing breaks
## **Screenshots/Recordings**
N/A
## **Pre-merge author checklist**
- [ ] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I've included tests if applicable
- [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> **Medium Risk**
> Bumps multiple core controller dependencies and changes account lookup
to rely on the new `AccountsController.state.accountIdByAddress` map,
which could affect any code paths that resolve accounts by address if
the map is missing or stale.
>
> **Overview**
> Updates several MetaMask core dependencies (notably
`@metamask/accounts-controller` to `37.0.0` and
`@metamask/account-tree-controller` to `5.x`).
>
> Adopts the new `AccountsController` state field `accountIdByAddress`
by adding it to default/fixture state and updating
`getInternalAccountByAddress` to resolve accounts via this map (with
lowercase fallback) instead of scanning all accounts.
>
> Adjusts tests, fixtures, and snapshots to include
`accountIdByAddress`, and updates migration test typings to reflect that
this field is non-persisted.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
bc2a1ab48ffb793668945499ffbba40028004cb6. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../AccountConnectMultiSelector.test.tsx | 4 ++
.../AccountPermissions.test.tsx | 4 ++
.../accounts-controller/constants.ts | 1 +
...ofile-metrics-controller-messenger.test.ts | 17 +----
app/core/Engine/utils/test/logger.test.ts | 2 +
app/selectors/accountsController.test.ts | 9 +++
app/store/migrations/066.test.ts | 3 +-
app/store/migrations/067.test.ts | 3 +-
app/util/address/index.ts | 11 +--
.../logs/__snapshots__/index.test.ts.snap | 2 +
app/util/test/accountsControllerTestUtils.ts | 22 ++++++
app/util/test/initial-background-state.json | 7 +-
package.json | 12 ++--
tests/framework/fixtures/FixtureBuilder.ts | 4 ++
.../framework/fixtures/fixture-validation.ts | 1 +
.../fixtures/json/default-fixture.json | 3 +
yarn.lock | 68 ++++++-------------
17 files changed, 98 insertions(+), 75 deletions(-)
diff --git a/app/components/Views/AccountConnect/AccountConnectMultiSelector/AccountConnectMultiSelector.test.tsx b/app/components/Views/AccountConnect/AccountConnectMultiSelector/AccountConnectMultiSelector.test.tsx
index 87f4a8681d2..4462cbd6ab0 100644
--- a/app/components/Views/AccountConnect/AccountConnectMultiSelector/AccountConnectMultiSelector.test.tsx
+++ b/app/components/Views/AccountConnect/AccountConnectMultiSelector/AccountConnectMultiSelector.test.tsx
@@ -56,6 +56,10 @@ jest.mock('../../../../core/Engine', () => ({
},
},
},
+ accountIdByAddress: {
+ '0x1234': '0x1234',
+ '0x5678': '0x5678',
+ },
},
},
},
diff --git a/app/components/Views/AccountPermissions/AccountPermissions.test.tsx b/app/components/Views/AccountPermissions/AccountPermissions.test.tsx
index 70aa9ad9250..c14b10700c2 100644
--- a/app/components/Views/AccountPermissions/AccountPermissions.test.tsx
+++ b/app/components/Views/AccountPermissions/AccountPermissions.test.tsx
@@ -185,6 +185,10 @@ jest.mock('../../../core/Engine', () => ({
},
selectedAccount: 'mock-id-1',
},
+ accountIdByAddress: {
+ '0xc4955c0d639d99699bfd7ec54d9fafee40e4d272': 'mock-id-1',
+ '0xd018538c87232ff95acbce4870629b75640a78e7': 'mock-id-2',
+ },
},
},
AccountTrackerController: {
diff --git a/app/core/Engine/controllers/accounts-controller/constants.ts b/app/core/Engine/controllers/accounts-controller/constants.ts
index a54b1ad8c87..9af99929c2c 100644
--- a/app/core/Engine/controllers/accounts-controller/constants.ts
+++ b/app/core/Engine/controllers/accounts-controller/constants.ts
@@ -6,4 +6,5 @@ export const defaultAccountsControllerState: AccountsControllerState = {
accounts: {},
selectedAccount: '',
},
+ accountIdByAddress: {},
};
diff --git a/app/core/Engine/messengers/profile-metrics-controller-messenger.test.ts b/app/core/Engine/messengers/profile-metrics-controller-messenger.test.ts
index e94dab2f47f..8857354b260 100644
--- a/app/core/Engine/messengers/profile-metrics-controller-messenger.test.ts
+++ b/app/core/Engine/messengers/profile-metrics-controller-messenger.test.ts
@@ -1,20 +1,7 @@
-import {
- MOCK_ANY_NAMESPACE,
- Messenger,
- MessengerActions,
- MessengerEvents,
- MockAnyNamespace,
-} from '@metamask/messenger';
+import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger';
import { getProfileMetricsControllerMessenger } from './profile-metrics-controller-messenger';
-import { ProfileMetricsControllerMessenger } from '@metamask/profile-metrics-controller';
-type RootMessenger = Messenger<
- MockAnyNamespace,
- MessengerActions,
- MessengerEvents
->;
-
-const getRootMessenger = (): RootMessenger =>
+const getRootMessenger = () =>
new Messenger({
namespace: MOCK_ANY_NAMESPACE,
});
diff --git a/app/core/Engine/utils/test/logger.test.ts b/app/core/Engine/utils/test/logger.test.ts
index 855646ebc5e..26260353778 100644
--- a/app/core/Engine/utils/test/logger.test.ts
+++ b/app/core/Engine/utils/test/logger.test.ts
@@ -44,6 +44,7 @@ describe('logEngineCreation', () => {
accounts: {},
selectedAccount: '',
},
+ accountIdByAddress: {},
},
};
@@ -87,6 +88,7 @@ describe('logEngineCreation', () => {
accounts: {},
selectedAccount: '',
},
+ accountIdByAddress: {},
},
KeyringController: {
vault: 'test-vault',
diff --git a/app/selectors/accountsController.test.ts b/app/selectors/accountsController.test.ts
index 8ff014325d9..9544fc31000 100644
--- a/app/selectors/accountsController.test.ts
+++ b/app/selectors/accountsController.test.ts
@@ -72,11 +72,17 @@ const MOCK_GENERATED_ACCOUNTS_CONTROLLER_REVERSED =
},
{} as Record,
);
+ const accountIdByAddress: Record = {};
+ Object.values(accountsForInternalAccounts).forEach((account) => {
+ accountIdByAddress[account.address] = account.id;
+ });
+
return {
internalAccounts: {
accounts: accountsForInternalAccounts,
selectedAccount: Object.values(accountsForInternalAccounts)[0].id,
},
+ accountIdByAddress,
};
};
@@ -110,6 +116,9 @@ describe('Accounts Controller Selectors', () => {
},
selectedAccount: 'non-existent-id',
},
+ accountIdByAddress: {
+ [internalAccount1.address]: internalAccount1.id,
+ },
};
const errorMessage =
'selectSelectedInternalAccount: Account with ID non-existent-id not found.';
diff --git a/app/store/migrations/066.test.ts b/app/store/migrations/066.test.ts
index 91930983476..0b214cc0e0a 100644
--- a/app/store/migrations/066.test.ts
+++ b/app/store/migrations/066.test.ts
@@ -19,7 +19,8 @@ const mockedCaptureException = jest.mocked(captureException);
interface StateType {
engine: {
backgroundState: {
- AccountsController: AccountsControllerState;
+ // accountIdByAddress is non-persisted state, so it is not present in migration fixtures
+ AccountsController: Omit;
};
};
}
diff --git a/app/store/migrations/067.test.ts b/app/store/migrations/067.test.ts
index 277919eabf6..2ab4ecd1ff4 100644
--- a/app/store/migrations/067.test.ts
+++ b/app/store/migrations/067.test.ts
@@ -19,7 +19,8 @@ const mockedCaptureException = jest.mocked(captureException);
interface StateType {
engine: {
backgroundState: {
- AccountsController: AccountsControllerState;
+ // accountIdByAddress is non-persisted state, so it is not present in migration fixtures
+ AccountsController: Omit;
};
};
}
diff --git a/app/util/address/index.ts b/app/util/address/index.ts
index cc6b977ac49..503abc2fcb9 100644
--- a/app/util/address/index.ts
+++ b/app/util/address/index.ts
@@ -361,10 +361,13 @@ export function isAddressCompatibleWithChainId(
export function getInternalAccountByAddress(
address: string,
): InternalAccount | undefined {
- const { accounts } = Engine.context.AccountsController.state.internalAccounts;
- return Object.values(accounts).find((a: InternalAccount) =>
- areAddressesEqual(a.address, address),
- );
+ const {
+ internalAccounts: { accounts },
+ accountIdByAddress,
+ } = Engine.context.AccountsController.state;
+ const id =
+ accountIdByAddress[address] ?? accountIdByAddress[address?.toLowerCase()];
+ return id ? accounts[id] : undefined;
}
/**
diff --git a/app/util/logs/__snapshots__/index.test.ts.snap b/app/util/logs/__snapshots__/index.test.ts.snap
index 94c5d558d76..7254f9cc576 100644
--- a/app/util/logs/__snapshots__/index.test.ts.snap
+++ b/app/util/logs/__snapshots__/index.test.ts.snap
@@ -20,6 +20,7 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State
"isAccountTreeSyncingInProgress": false,
},
"AccountsController": {
+ "accountIdByAddress": {},
"internalAccounts": {
"accounts": {},
"selectedAccount": "",
@@ -885,6 +886,7 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = `
"isAccountTreeSyncingInProgress": false,
},
"AccountsController": {
+ "accountIdByAddress": {},
"internalAccounts": {
"accounts": {},
"selectedAccount": "",
diff --git a/app/util/test/accountsControllerTestUtils.ts b/app/util/test/accountsControllerTestUtils.ts
index 7bc1c4b28b4..bd1870306e0 100644
--- a/app/util/test/accountsControllerTestUtils.ts
+++ b/app/util/test/accountsControllerTestUtils.ts
@@ -321,6 +321,10 @@ export const MOCK_ACCOUNTS_CONTROLLER_STATE: AccountsControllerState = {
},
selectedAccount: internalAccount2.id,
},
+ accountIdByAddress: {
+ [internalAccount1.address]: internalAccount1.id,
+ [internalAccount2.address]: internalAccount2.id,
+ },
};
export const MOCK_ACCOUNTS_CONTROLLER_STATE_WITH_SOLANA: AccountsControllerState =
@@ -333,6 +337,10 @@ export const MOCK_ACCOUNTS_CONTROLLER_STATE_WITH_SOLANA: AccountsControllerState
[internalSolanaAccount1.id]: internalSolanaAccount1,
},
},
+ accountIdByAddress: {
+ ...MOCK_ACCOUNTS_CONTROLLER_STATE.accountIdByAddress,
+ [internalSolanaAccount1.address]: internalSolanaAccount1.id,
+ },
};
export const MOCK_KEYRING_CONTROLLER_STATE: KeyringControllerState = {
@@ -423,6 +431,14 @@ export const MOCK_ACCOUNTS_CONTROLLER_STATE_WITH_KEYRING_TYPES: AccountsControll
[expectedSecondHDKeyringUuid]: mockSecondHDKeyringInternalAccount,
},
},
+ accountIdByAddress: {
+ ...MOCK_ACCOUNTS_CONTROLLER_STATE_WITH_SOLANA.accountIdByAddress,
+ [mockQRHardwareInternalAccount.address]: mockQRHardwareAccountId,
+ [mockSimpleKeyringInternalAccount.address]: mockSimpleKeyringAccountId,
+ [mockSnapAccount1InternalAccount.address]: mockSnapAccount1Id,
+ [mockSnapAccount2InternalAccount.address]: mockSnapAccount2Id,
+ [mockSecondHDKeyringInternalAccount.address]: expectedSecondHDKeyringUuid,
+ },
};
export function createMockAccountsControllerState(
@@ -447,11 +463,17 @@ export function createMockAccountsControllerState(
? createMockUuidFromAddress(selectedAddress.toLowerCase())
: createMockUuidFromAddress(addresses[0].toLowerCase());
+ const accountIdByAddress: Record = {};
+ Object.values(accounts).forEach((account) => {
+ accountIdByAddress[account.address] = account.id;
+ });
+
return {
internalAccounts: {
accounts,
selectedAccount,
},
+ accountIdByAddress,
};
}
diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json
index ceb1d182835..5d6a838a52c 100644
--- a/app/util/test/initial-background-state.json
+++ b/app/util/test/initial-background-state.json
@@ -13,7 +13,8 @@
"internalAccounts": {
"accounts": {},
"selectedAccount": ""
- }
+ },
+ "accountIdByAddress": {}
},
"AccountTreeController": {
"accountTree": {
@@ -768,7 +769,9 @@
"rewardsEnvUrl": null
},
"PredictController": {
- "eligibility": { "eligible": false },
+ "eligibility": {
+ "eligible": false
+ },
"lastError": null,
"lastUpdateTimestamp": 0,
"balances": {},
diff --git a/package.json b/package.json
index 954481736e4..b4a123665ef 100644
--- a/package.json
+++ b/package.json
@@ -182,7 +182,7 @@
"viem": "2.31.3",
"@metamask/bridge-controller@npm:^66.2.0": "patch:@metamask/bridge-controller@npm%3A66.2.0#~/.yarn/patches/@metamask-bridge-controller-npm-66.2.0-8ed148964f.patch",
"@metamask/bridge-status-controller@npm:^66.0.2": "patch:@metamask/bridge-status-controller@npm%3A66.1.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-66.1.0-a2a478ef94.patch",
- "@metamask/accounts-controller": "^36.0.0",
+ "@metamask/accounts-controller": "37.0.0",
"@metamask/core-backend": "^5.0.0",
"bn.js@npm:4.11.6": "4.12.3",
"bn.js@npm:5.2.1": "5.2.3",
@@ -205,8 +205,8 @@
"@ledgerhq/react-native-hw-transport-ble": "^6.37.0",
"@metamask/abi-utils": "^3.0.0",
"@metamask/account-api": "^1.0.0",
- "@metamask/account-tree-controller": "^4.1.1",
- "@metamask/accounts-controller": "^36.0.0",
+ "@metamask/account-tree-controller": "^5.0.0",
+ "@metamask/accounts-controller": "^37.0.0",
"@metamask/address-book-controller": "^7.0.1",
"@metamask/ai-controllers": "^0.2.0",
"@metamask/analytics-controller": "^1.0.0",
@@ -222,7 +222,7 @@
"@metamask/connectivity-controller": "^0.1.0",
"@metamask/controller-utils": "^11.18.0",
"@metamask/core-backend": "^5.0.0",
- "@metamask/delegation-controller": "^2.0.1",
+ "@metamask/delegation-controller": "^2.0.2",
"@metamask/delegation-deployments": "^0.15.0",
"@metamask/design-system-react-native": "^0.10.0",
"@metamask/design-system-twrnc-preset": "^0.3.0",
@@ -265,8 +265,8 @@
"@metamask/multichain-account-service": "^7.0.0",
"@metamask/multichain-api-client": "^0.10.1",
"@metamask/multichain-api-middleware": "1.2.5",
- "@metamask/multichain-network-controller": "^3.0.3",
- "@metamask/multichain-transactions-controller": "^7.0.1",
+ "@metamask/multichain-network-controller": "^3.0.5",
+ "@metamask/multichain-transactions-controller": "^7.0.2",
"@metamask/native-utils": "^0.8.0",
"@metamask/network-controller": "^30.0.0",
"@metamask/network-enablement-controller": "^4.2.0",
diff --git a/tests/framework/fixtures/FixtureBuilder.ts b/tests/framework/fixtures/FixtureBuilder.ts
index ef1995e3f61..3b12f1a54bf 100644
--- a/tests/framework/fixtures/FixtureBuilder.ts
+++ b/tests/framework/fixtures/FixtureBuilder.ts
@@ -1368,6 +1368,10 @@ class FixtureBuilder {
},
selectedAccount: '4d7a5e0b-b261-4aed-8126-43972b0fa0a1', // Default to Ethereum account
},
+ accountIdByAddress: {
+ '0xbacec2e26c5c794de6e82a1a7e21b9c329fa8cf6':
+ '4d7a5e0b-b261-4aed-8126-43972b0fa0a1',
+ },
};
// Configure for Ethereum mainnet only
diff --git a/tests/framework/fixtures/fixture-validation.ts b/tests/framework/fixtures/fixture-validation.ts
index f8bea0b5d12..ff64f1383a8 100644
--- a/tests/framework/fixtures/fixture-validation.ts
+++ b/tests/framework/fixtures/fixture-validation.ts
@@ -208,6 +208,7 @@ export function getMobileFixtureIgnoredKeys(): string[] {
// ── Per-wallet secrets and dynamic IDs (change every onboarding) ──
'engine.backgroundState.AccountsController.internalAccounts.selectedAccount',
'engine.backgroundState.AccountsController.internalAccounts.accounts',
+ 'engine.backgroundState.AccountsController.accountIdByAddress',
'engine.backgroundState.AccountTrackerController.accountsByChainId',
'engine.backgroundState.KeyringController.keyrings',
'engine.backgroundState.KeyringController.vault',
diff --git a/tests/framework/fixtures/json/default-fixture.json b/tests/framework/fixtures/json/default-fixture.json
index d317cd8d8bb..ee5e110fd05 100644
--- a/tests/framework/fixtures/json/default-fixture.json
+++ b/tests/framework/fixtures/json/default-fixture.json
@@ -101,6 +101,9 @@
"isAccountTreeSyncingInProgress": false
},
"AccountsController": {
+ "accountIdByAddress": {
+ "0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3": "4d7a5e0b-b261-4aed-8126-43972b0fa0a1"
+ },
"internalAccounts": {
"accounts": {
"4d7a5e0b-b261-4aed-8126-43972b0fa0a1": {
diff --git a/yarn.lock b/yarn.lock
index d32027d4819..ee03b84bfdd 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7556,30 +7556,6 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/account-tree-controller@npm:^4.1.1":
- version: 4.1.1
- resolution: "@metamask/account-tree-controller@npm:4.1.1"
- dependencies:
- "@metamask/accounts-controller": "npm:^36.0.0"
- "@metamask/base-controller": "npm:^9.0.0"
- "@metamask/keyring-controller": "npm:^25.1.0"
- "@metamask/messenger": "npm:^0.3.0"
- "@metamask/multichain-account-service": "npm:^7.0.0"
- "@metamask/profile-sync-controller": "npm:^27.1.0"
- "@metamask/snaps-controllers": "npm:^17.2.0"
- "@metamask/snaps-sdk": "npm:^10.3.0"
- "@metamask/snaps-utils": "npm:^11.7.0"
- "@metamask/superstruct": "npm:^3.1.0"
- "@metamask/utils": "npm:^11.9.0"
- fast-deep-equal: "npm:^3.1.3"
- lodash: "npm:^4.17.21"
- peerDependencies:
- "@metamask/providers": ^22.0.0
- webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0
- checksum: 10/2fca1352cfe9fe89950a88af546c12258c922cd16aa40e8d1c0b98b0a6457e8bf4c5e78edccbc80e744fe7c0ffa45ddec1abdb5e0d5037a06c268042350d33a5
- languageName: node
- linkType: hard
-
"@metamask/account-tree-controller@npm:^5.0.0, @metamask/account-tree-controller@npm:^5.0.1":
version: 5.0.1
resolution: "@metamask/account-tree-controller@npm:5.0.1"
@@ -7604,9 +7580,9 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/accounts-controller@npm:^36.0.0":
- version: 36.0.1
- resolution: "@metamask/accounts-controller@npm:36.0.1"
+"@metamask/accounts-controller@npm:37.0.0":
+ version: 37.0.0
+ resolution: "@metamask/accounts-controller@npm:37.0.0"
dependencies:
"@ethereumjs/util": "npm:^9.1.0"
"@metamask/base-controller": "npm:^9.0.0"
@@ -7630,7 +7606,7 @@ __metadata:
peerDependencies:
"@metamask/providers": ^22.0.0
webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0
- checksum: 10/b60d45d06d85d481c2f6b397a1a2845866f8cfa18dd6d02ca0ab81809a1e543667c5d2bba82abc06ef53600bcd4e6c233268a530a81dce2779c3721d81946465
+ checksum: 10/4ea9a310d707160b05a314090a7a1e7eee9bcf68e0cc82e1aa471fc2932560fde856176748328932ed4d10a16a6ff8cb9288d10be8821a4460ee76290bf8d747
languageName: node
linkType: hard
@@ -8169,16 +8145,16 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/delegation-controller@npm:^2.0.1":
- version: 2.0.1
- resolution: "@metamask/delegation-controller@npm:2.0.1"
+"@metamask/delegation-controller@npm:^2.0.2":
+ version: 2.0.2
+ resolution: "@metamask/delegation-controller@npm:2.0.2"
dependencies:
- "@metamask/accounts-controller": "npm:^36.0.0"
+ "@metamask/accounts-controller": "npm:^37.0.0"
"@metamask/base-controller": "npm:^9.0.0"
"@metamask/keyring-controller": "npm:^25.1.0"
"@metamask/messenger": "npm:^0.3.0"
"@metamask/utils": "npm:^11.9.0"
- checksum: 10/0d9edc3177234844cba8e48684647398df676044e6bd9ca244c0c2d891fcaf088a8a8210913116d10b333403488257a7387901d8e4ead86de9d823b9b99d65cd
+ checksum: 10/a5bfece3aadbfd2a4b079dc5672b75ef7acbe71e8af92e1c533884d59eadaf4d56cfa89641212cff0776f91964eb2e71d55d45fa0f813dfd9610fd6c0fdff05d
languageName: node
linkType: hard
@@ -9058,7 +9034,7 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/multichain-network-controller@npm:^3.0.3, @metamask/multichain-network-controller@npm:^3.0.4, @metamask/multichain-network-controller@npm:^3.0.5":
+"@metamask/multichain-network-controller@npm:^3.0.4, @metamask/multichain-network-controller@npm:^3.0.5":
version: 3.0.5
resolution: "@metamask/multichain-network-controller@npm:3.0.5"
dependencies:
@@ -9077,17 +9053,17 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/multichain-transactions-controller@npm:^7.0.1":
- version: 7.0.1
- resolution: "@metamask/multichain-transactions-controller@npm:7.0.1"
+"@metamask/multichain-transactions-controller@npm:^7.0.2":
+ version: 7.0.2
+ resolution: "@metamask/multichain-transactions-controller@npm:7.0.2"
dependencies:
- "@metamask/accounts-controller": "npm:^36.0.0"
+ "@metamask/accounts-controller": "npm:^37.0.0"
"@metamask/base-controller": "npm:^9.0.0"
"@metamask/keyring-api": "npm:^21.5.0"
"@metamask/keyring-internal-api": "npm:^10.0.0"
"@metamask/keyring-snap-client": "npm:^8.2.0"
"@metamask/messenger": "npm:^0.3.0"
- "@metamask/polling-controller": "npm:^16.0.2"
+ "@metamask/polling-controller": "npm:^16.0.3"
"@metamask/snaps-controllers": "npm:^17.2.0"
"@metamask/snaps-sdk": "npm:^10.3.0"
"@metamask/snaps-utils": "npm:^11.7.0"
@@ -9095,7 +9071,7 @@ __metadata:
"@types/uuid": "npm:^8.3.0"
immer: "npm:^9.0.6"
uuid: "npm:^8.3.2"
- checksum: 10/c46227c9ecc59a114338348be0b209670bd0cded5f51afc17ad406817bb56d19480634ec57727bfbe74f7e24fec27237c1580ed33102dda050f60ee811c96401
+ checksum: 10/6111a63600c74d7db80437fafb42eb71016210a0a5d1d860cee09f35266c07ba111a89523a62688c7cb4a1c28fdcc8033a50bd44fd88232a04b9e81cecda0e74
languageName: node
linkType: hard
@@ -9326,7 +9302,7 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/polling-controller@npm:^16.0.0, @metamask/polling-controller@npm:^16.0.2, @metamask/polling-controller@npm:^16.0.3":
+"@metamask/polling-controller@npm:^16.0.0, @metamask/polling-controller@npm:^16.0.3":
version: 16.0.3
resolution: "@metamask/polling-controller@npm:16.0.3"
dependencies:
@@ -35430,8 +35406,8 @@ __metadata:
"@ledgerhq/react-native-hw-transport-ble": "npm:^6.37.0"
"@metamask/abi-utils": "npm:^3.0.0"
"@metamask/account-api": "npm:^1.0.0"
- "@metamask/account-tree-controller": "npm:^4.1.1"
- "@metamask/accounts-controller": "npm:^36.0.0"
+ "@metamask/account-tree-controller": "npm:^5.0.0"
+ "@metamask/accounts-controller": "npm:^37.0.0"
"@metamask/address-book-controller": "npm:^7.0.1"
"@metamask/ai-controllers": "npm:^0.2.0"
"@metamask/analytics-controller": "npm:^1.0.0"
@@ -35451,7 +35427,7 @@ __metadata:
"@metamask/connectivity-controller": "npm:^0.1.0"
"@metamask/controller-utils": "npm:^11.18.0"
"@metamask/core-backend": "npm:^5.0.0"
- "@metamask/delegation-controller": "npm:^2.0.1"
+ "@metamask/delegation-controller": "npm:^2.0.2"
"@metamask/delegation-deployments": "npm:^0.15.0"
"@metamask/design-system-react-native": "npm:^0.10.0"
"@metamask/design-system-twrnc-preset": "npm:^0.3.0"
@@ -35498,8 +35474,8 @@ __metadata:
"@metamask/multichain-account-service": "npm:^7.0.0"
"@metamask/multichain-api-client": "npm:^0.10.1"
"@metamask/multichain-api-middleware": "npm:1.2.5"
- "@metamask/multichain-network-controller": "npm:^3.0.3"
- "@metamask/multichain-transactions-controller": "npm:^7.0.1"
+ "@metamask/multichain-network-controller": "npm:^3.0.5"
+ "@metamask/multichain-transactions-controller": "npm:^7.0.2"
"@metamask/native-utils": "npm:^0.8.0"
"@metamask/network-controller": "npm:^30.0.0"
"@metamask/network-enablement-controller": "npm:^4.2.0"
From 4e42befa2a0f9cc60b2c9aa14f30e5f90c4094c4 Mon Sep 17 00:00:00 2001
From: Bruno Nascimento
Date: Thu, 12 Mar 2026 16:23:11 -0300
Subject: [PATCH 003/206] feat(card): embed Metal Card checkout flow into
onboarding flow (#27420)
## **Description**
This PR embed the metal card checkout flow into the Card
onboarding/sign-up flow.
## **Changelog**
CHANGELOG entry: embed the metal card checkout flow into the Card
onboarding/sign-up flow.
## **Related issues**
Fixes:
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
## **Screenshots/Recordings**
### **Before**
### **After**
https://github.com/user-attachments/assets/776a82dc-b814-4dd1-96da-b902932c31cc
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> **Medium Risk**
> Changes card onboarding navigation (including new `home` flow params
and StackActions replacement) and adds new UI/animation behavior, which
could affect user progression through card setup if eligibility logic or
routing params are wrong.
>
> **Overview**
> Eligible US users who press **Enable Card** from `CardHome` are now
redirected to `Routes.CARD.CHOOSE_YOUR_CARD` (new `flow: 'home'`)
instead of always starting delegation/spending-limit setup; the route
passes through `shippingAddress` plus token/delegation/external wallet
context.
>
> `ChooseYourCard` is updated to support the new `home` flow, adds a
swipe/peek affordance and an *upgrade-to-metal* link, refreshes
metal/virtual copy, and routes virtual-card selection into
`SPENDING_LIMIT` with `flow: 'manage'` and the forwarded params.
>
> Post-order behavior changes so `OrderCompleted` uses
`StackActions.replace` to take onboarding users directly to
`SPENDING_LIMIT` (while upgrade users still go back to `CARD.HOME`), and
`SpendingLimit` now blocks back navigation during loading using a ref to
avoid stale listener state. Tests and English strings are updated
accordingly.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
c52fcb51e2561068414b00a630d524b0854ed814. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../UI/Card/Views/CardHome/CardHome.test.tsx | 275 +++++++++++++++++
.../UI/Card/Views/CardHome/CardHome.tsx | 60 +++-
.../ChooseYourCard/ChooseYourCard.test.tsx | 172 +++++++++--
.../ChooseYourCard/ChooseYourCard.testIds.ts | 1 +
.../Views/ChooseYourCard/ChooseYourCard.tsx | 281 ++++++++++++++++--
.../OrderCompleted/OrderCompleted.test.tsx | 35 ++-
.../Views/OrderCompleted/OrderCompleted.tsx | 16 +-
.../Views/SpendingLimit/SpendingLimit.tsx | 12 +-
app/components/UI/Card/routes/index.tsx | 6 +-
locales/languages/en.json | 11 +-
10 files changed, 809 insertions(+), 60 deletions(-)
diff --git a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx
index 27d1479bc0c..7280faff6f3 100644
--- a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx
+++ b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx
@@ -668,6 +668,7 @@ function setupLoadCardDataMock(
externalWalletDetailsData: {
mappedWalletDetails?: Record[];
} | null;
+ delegationSettings: Record | null;
}>,
) {
const defaults = {
@@ -682,6 +683,7 @@ function setupLoadCardDataMock(
isCardholder: true,
kycStatus: { verificationState: 'VERIFIED' as const, userId: 'user-123' },
externalWalletDetailsData: null,
+ delegationSettings: null,
};
const config = { ...defaults, ...overrides };
@@ -5776,4 +5778,277 @@ describe('CardHome Component', () => {
expect(mockTrackEvent).toHaveBeenCalled();
});
});
+
+ describe('Enable Card - ChooseYourCard Redirect', () => {
+ it('navigates to ChooseYourCard when eligible US user presses Enable Card', async () => {
+ // Given: Verified, authenticated US user with shipping address, metal card enabled, no card
+ const priorityTokenForNav = { ...mockPriorityToken };
+ const allTokensForNav = [mockPriorityToken];
+ const delegationSettingsForNav = { networks: [] };
+ const externalWalletDetailsForNav = { mappedWalletDetails: [] };
+
+ setupMockSelectors({
+ isAuthenticated: true,
+ userLocation: 'us',
+ isMetalCardCheckoutEnabled: true,
+ });
+
+ (useLoadCardData as jest.Mock).mockReturnValueOnce({
+ priorityToken: priorityTokenForNav,
+ allTokens: allTokensForNav,
+ cardDetails: null,
+ isLoading: false,
+ error: null,
+ warning: CardStateWarning.NoCard,
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ isCardholder: false,
+ kycStatus: {
+ verificationState: 'VERIFIED',
+ userId: 'user-123',
+ userDetails: {
+ id: 'user-123',
+ addressLine1: '123 Main St',
+ city: 'New York',
+ zip: '10001',
+ usState: 'NY',
+ },
+ },
+ externalWalletDetailsData: externalWalletDetailsForNav,
+ delegationSettings: delegationSettingsForNav,
+ fetchAllData: mockFetchAllData,
+ refetchAllData: mockRefetchAllData,
+ fetchCardDetails: mockFetchCardDetails,
+ });
+
+ // When: user presses Enable Card button
+ render();
+ const enableButton = screen.getByTestId(
+ CardHomeSelectors.ENABLE_CARD_BUTTON,
+ );
+ fireEvent.press(enableButton);
+
+ // Then: navigates to ChooseYourCard with home flow and card data params
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith(
+ Routes.CARD.CHOOSE_YOUR_CARD,
+ expect.objectContaining({
+ flow: 'home',
+ shippingAddress: expect.objectContaining({
+ line1: '123 Main St',
+ city: 'New York',
+ zip: '10001',
+ }),
+ priorityToken: priorityTokenForNav,
+ allTokens: allTokensForNav,
+ delegationSettings: delegationSettingsForNav,
+ externalWalletDetailsData: externalWalletDetailsForNav,
+ }),
+ );
+ });
+ });
+
+ it('navigates to delegation when warning is NeedDelegation even with metal card enabled', async () => {
+ // Given: US user with shipping address and metal card enabled, but warning is NeedDelegation (not NoCard)
+ setupMockSelectors({
+ isAuthenticated: true,
+ userLocation: 'us',
+ isMetalCardCheckoutEnabled: true,
+ });
+
+ (useLoadCardData as jest.Mock).mockReturnValueOnce({
+ priorityToken: null,
+ allTokens: [],
+ cardDetails: null,
+ isLoading: false,
+ error: null,
+ warning: CardStateWarning.NeedDelegation,
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ isCardholder: true,
+ kycStatus: {
+ verificationState: 'VERIFIED',
+ userId: 'user-123',
+ userDetails: {
+ id: 'user-123',
+ addressLine1: '123 Main St',
+ city: 'New York',
+ zip: '10001',
+ usState: 'NY',
+ },
+ },
+ externalWalletDetailsData: null,
+ delegationSettings: null,
+ fetchAllData: mockFetchAllData,
+ refetchAllData: mockRefetchAllData,
+ fetchCardDetails: mockFetchCardDetails,
+ });
+
+ // When: user presses Enable Card button
+ render();
+ const enableButton = screen.getByTestId(
+ CardHomeSelectors.ENABLE_ASSETS_BUTTON,
+ );
+ fireEvent.press(enableButton);
+
+ // Then: navigates to SpendingLimit (delegation) instead of ChooseYourCard
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith(
+ 'CardSpendingLimit',
+ expect.objectContaining({
+ flow: 'manage',
+ }),
+ );
+ });
+ });
+
+ it('navigates to delegation when metal card checkout is disabled', async () => {
+ // Given: Verified US user but metal card checkout is disabled
+ setupMockSelectors({
+ isAuthenticated: true,
+ userLocation: 'us',
+ isMetalCardCheckoutEnabled: false,
+ });
+
+ (useLoadCardData as jest.Mock).mockReturnValueOnce({
+ priorityToken: null,
+ allTokens: [],
+ cardDetails: null,
+ isLoading: false,
+ error: null,
+ warning: CardStateWarning.NeedDelegation,
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ isCardholder: true,
+ kycStatus: {
+ verificationState: 'VERIFIED',
+ userId: 'user-123',
+ userDetails: {
+ id: 'user-123',
+ addressLine1: '123 Main St',
+ city: 'New York',
+ zip: '10001',
+ usState: 'NY',
+ },
+ },
+ fetchAllData: mockFetchAllData,
+ refetchAllData: mockRefetchAllData,
+ fetchCardDetails: mockFetchCardDetails,
+ });
+
+ // When: user presses Enable Card button
+ render();
+ const enableButton = screen.getByTestId(
+ CardHomeSelectors.ENABLE_ASSETS_BUTTON,
+ );
+ fireEvent.press(enableButton);
+
+ // Then: navigates to SpendingLimit (delegation) instead of ChooseYourCard
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith(
+ 'CardSpendingLimit',
+ expect.objectContaining({
+ flow: 'manage',
+ }),
+ );
+ });
+ });
+
+ it('navigates to delegation for international user even with metal card enabled', async () => {
+ // Given: Verified international user with metal card checkout enabled
+ setupMockSelectors({
+ isAuthenticated: true,
+ userLocation: 'international',
+ isMetalCardCheckoutEnabled: true,
+ });
+
+ (useLoadCardData as jest.Mock).mockReturnValueOnce({
+ priorityToken: null,
+ allTokens: [],
+ cardDetails: null,
+ isLoading: false,
+ error: null,
+ warning: CardStateWarning.NeedDelegation,
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ isCardholder: true,
+ kycStatus: {
+ verificationState: 'VERIFIED',
+ userId: 'user-123',
+ userDetails: {
+ id: 'user-123',
+ addressLine1: '123 Main St',
+ city: 'London',
+ zip: 'SW1A 1AA',
+ },
+ },
+ fetchAllData: mockFetchAllData,
+ refetchAllData: mockRefetchAllData,
+ fetchCardDetails: mockFetchCardDetails,
+ });
+
+ // When: user presses Enable Card button
+ render();
+ const enableButton = screen.getByTestId(
+ CardHomeSelectors.ENABLE_ASSETS_BUTTON,
+ );
+ fireEvent.press(enableButton);
+
+ // Then: navigates to SpendingLimit (delegation), not ChooseYourCard
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith(
+ 'CardSpendingLimit',
+ expect.objectContaining({
+ flow: 'manage',
+ }),
+ );
+ });
+ });
+
+ it('navigates to delegation when US user has no shipping address', async () => {
+ // Given: Verified US user with metal card enabled but no address data
+ setupMockSelectors({
+ isAuthenticated: true,
+ userLocation: 'us',
+ isMetalCardCheckoutEnabled: true,
+ });
+
+ (useLoadCardData as jest.Mock).mockReturnValueOnce({
+ priorityToken: null,
+ allTokens: [],
+ cardDetails: null,
+ isLoading: false,
+ error: null,
+ warning: CardStateWarning.NeedDelegation,
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ isCardholder: true,
+ kycStatus: {
+ verificationState: 'VERIFIED',
+ userId: 'user-123',
+ userDetails: null,
+ },
+ fetchAllData: mockFetchAllData,
+ refetchAllData: mockRefetchAllData,
+ fetchCardDetails: mockFetchCardDetails,
+ });
+
+ // When: user presses Enable Card button
+ render();
+ const enableButton = screen.getByTestId(
+ CardHomeSelectors.ENABLE_ASSETS_BUTTON,
+ );
+ fireEvent.press(enableButton);
+
+ // Then: navigates to delegation since no shipping address is available
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith(
+ 'CardSpendingLimit',
+ expect.objectContaining({
+ flow: 'manage',
+ }),
+ );
+ });
+ });
+ });
});
diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx
index 9268ccee8ac..23e8e504812 100644
--- a/app/components/UI/Card/Views/CardHome/CardHome.tsx
+++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx
@@ -950,6 +950,58 @@ const CardHome = () => {
[isAuthenticated, kycStatus, warning, externalWalletDetailsData],
);
+ const shouldRedirectToChooseCard = useMemo(
+ () =>
+ !isLoading &&
+ !cardSetupState.isKYCPending &&
+ !isCardProvisioning &&
+ isMetalCardCheckoutEnabled &&
+ isBaanxLoginEnabled &&
+ isAuthenticated &&
+ warning === CardStateWarning.NoCard &&
+ userLocation === 'us' &&
+ !!userShippingAddress,
+ [
+ isLoading,
+ cardSetupState.isKYCPending,
+ isCardProvisioning,
+ isMetalCardCheckoutEnabled,
+ isBaanxLoginEnabled,
+ isAuthenticated,
+ warning,
+ userLocation,
+ userShippingAddress,
+ ],
+ );
+
+ const navigateToChooseYourCard = useCallback(() => {
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED)
+ .addProperties({
+ action: CardActions.ORDER_METAL_CARD_BUTTON,
+ })
+ .build(),
+ );
+
+ navigation.navigate(Routes.CARD.CHOOSE_YOUR_CARD, {
+ flow: 'home',
+ shippingAddress: userShippingAddress,
+ priorityToken,
+ allTokens,
+ delegationSettings,
+ externalWalletDetailsData,
+ });
+ }, [
+ navigation,
+ trackEvent,
+ createEventBuilder,
+ userShippingAddress,
+ priorityToken,
+ allTokens,
+ delegationSettings,
+ externalWalletDetailsData,
+ ]);
+
const ButtonsSection = useMemo(() => {
if (isLoading) {
return (
@@ -989,7 +1041,11 @@ const CardHome = () => {
variant={ButtonVariants.Primary}
label={strings('card.card_home.enable_card_button_label')}
size={ButtonSize.Lg}
- onPress={openOnboardingDelegationAction}
+ onPress={
+ shouldRedirectToChooseCard
+ ? navigateToChooseYourCard
+ : openOnboardingDelegationAction
+ }
width={ButtonWidthTypes.Full}
testID={cardSetupState.setupTestId}
/>
@@ -1032,6 +1088,8 @@ const CardHome = () => {
tw,
openOnboardingDelegationAction,
isCardProvisioning,
+ shouldRedirectToChooseCard,
+ navigateToChooseYourCard,
]);
const isUserEligibleForMetalCard = useMemo(
diff --git a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx
index 9037fc36450..84e7b8be547 100644
--- a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx
+++ b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx
@@ -1,11 +1,11 @@
import React from 'react';
-import { render, fireEvent } from '@testing-library/react-native';
+import { render, fireEvent, waitFor } from '@testing-library/react-native';
import ChooseYourCard from './ChooseYourCard';
import { ChooseYourCardSelectors } from './ChooseYourCard.testIds';
import { strings } from '../../../../../../locales/i18n';
import Routes from '../../../../../constants/navigation/Routes';
import { MetaMetricsEvents } from '../../../../../core/Analytics';
-import { CardType } from '../../types';
+import { AllowanceState, CardType } from '../../types';
import { CardActions, CardScreens } from '../../util/metrics';
const mockNavigate = jest.fn();
@@ -26,11 +26,13 @@ jest.mock('@react-navigation/native', () => {
};
});
+const mockUseParams = jest.fn(() => ({
+ flow: 'onboarding',
+ shippingAddress: undefined,
+}));
+
jest.mock('../../../../../util/navigation/navUtils', () => ({
- useParams: () => ({
- flow: 'onboarding',
- shippingAddress: undefined,
- }),
+ useParams: () => mockUseParams(),
}));
jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({
@@ -44,8 +46,9 @@ jest.mock('../../../../../../locales/i18n', () => ({
strings: (key: string) => {
const map: Record = {
'card.choose_your_card.title': 'Choose your card',
+ 'card.choose_your_card.upgrade_title': 'Upgrade to Metal',
'card.choose_your_card.continue_button': 'Continue',
- 'card.choose_your_card.virtual_card.name': 'Orange Virtual Card',
+ 'card.choose_your_card.virtual_card.name': 'Virtual Card',
'card.choose_your_card.virtual_card.price': 'Free',
'card.choose_your_card.virtual_card.feature_1':
'Virtual card for Apple Pay and Google Pay',
@@ -55,17 +58,37 @@ jest.mock('../../../../../../locales/i18n', () => ({
'1% USDC cashback on every purchase',
'card.choose_your_card.metal_card.name': 'Metal Card',
'card.choose_your_card.metal_card.price': '$199/year',
+ 'card.choose_your_card.metal_card.everything_in_virtual':
+ 'Everything in virtual, plus:',
'card.choose_your_card.metal_card.feature_1':
- 'Engraved metal card and virtual card for Apple Pay and Google Pay',
+ 'Premium engraved metal card',
'card.choose_your_card.metal_card.feature_2':
- '3% cashback on the first $10,000 spent each year, then 1% after that',
+ '3% cashback on first $10,000/year',
'card.choose_your_card.metal_card.feature_3':
'No foreign transaction fees',
+ 'card.choose_your_card.earn_up_to_badge':
+ 'Earn up to $300 in cashback annually',
+ 'card.choose_your_card.upgrade_to_metal_label':
+ 'Or upgrade to Metal for 3x rewards',
};
return map[key] || key;
},
}));
+jest.mock('react-native-linear-gradient', () => {
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ const React = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: ({
+ children,
+ ...props
+ }: React.PropsWithChildren>) =>
+ React.createElement(View, props, children),
+ };
+});
+
// Mock CardImage component
jest.mock('../../components/CardImage/CardImage', () => {
// eslint-disable-next-line @typescript-eslint/no-shadow
@@ -156,12 +179,16 @@ describe('ChooseYourCard', () => {
it('renders all required UI elements', () => {
const { getByTestId } = render();
- expect(getByTestId(ChooseYourCardSelectors.CONTAINER)).toBeTruthy();
- expect(getByTestId(ChooseYourCardSelectors.TITLE)).toBeTruthy();
- expect(getByTestId(ChooseYourCardSelectors.CARD_CAROUSEL)).toBeTruthy();
- expect(getByTestId(ChooseYourCardSelectors.CARD_NAME)).toBeTruthy();
- expect(getByTestId(ChooseYourCardSelectors.CARD_PRICE)).toBeTruthy();
- expect(getByTestId(ChooseYourCardSelectors.CONTINUE_BUTTON)).toBeTruthy();
+ expect(getByTestId(ChooseYourCardSelectors.CONTAINER)).toBeOnTheScreen();
+ expect(getByTestId(ChooseYourCardSelectors.TITLE)).toBeOnTheScreen();
+ expect(
+ getByTestId(ChooseYourCardSelectors.CARD_CAROUSEL),
+ ).toBeOnTheScreen();
+ expect(getByTestId(ChooseYourCardSelectors.CARD_NAME)).toBeOnTheScreen();
+ expect(getByTestId(ChooseYourCardSelectors.CARD_PRICE)).toBeOnTheScreen();
+ expect(
+ getByTestId(ChooseYourCardSelectors.CONTINUE_BUTTON),
+ ).toBeOnTheScreen();
});
it('displays correct title text', () => {
@@ -190,10 +217,10 @@ describe('ChooseYourCard', () => {
getByTestId(
`${ChooseYourCardSelectors.CARD_IMAGE}-${CardType.VIRTUAL}`,
),
- ).toBeTruthy();
+ ).toBeOnTheScreen();
expect(
getByTestId(`${ChooseYourCardSelectors.CARD_IMAGE}-${CardType.METAL}`),
- ).toBeTruthy();
+ ).toBeOnTheScreen();
});
it('displays virtual card features by default', () => {
@@ -201,13 +228,13 @@ describe('ChooseYourCard', () => {
expect(
getByText(strings('card.choose_your_card.virtual_card.feature_1')),
- ).toBeTruthy();
+ ).toBeOnTheScreen();
expect(
getByText(strings('card.choose_your_card.virtual_card.feature_2')),
- ).toBeTruthy();
+ ).toBeOnTheScreen();
expect(
getByText(strings('card.choose_your_card.virtual_card.feature_3')),
- ).toBeTruthy();
+ ).toBeOnTheScreen();
});
});
@@ -251,5 +278,110 @@ describe('ChooseYourCard', () => {
flow: 'onboarding',
});
});
+
+ it('navigates to spending limit with manage flow params when flow is home and virtual card selected', () => {
+ const priorityToken = {
+ caipChainId: 'eip155:1',
+ symbol: 'USDC',
+ name: 'USD Coin',
+ address: '0x123',
+ decimals: 6,
+ allowanceState: AllowanceState.Enabled,
+ allowance: '1000',
+ };
+ const allTokens = [priorityToken];
+ const delegationSettings = { networks: [] };
+ const externalWalletDetailsData = {
+ walletDetails: {},
+ mappedWalletDetails: [priorityToken],
+ priorityWalletDetail: priorityToken,
+ };
+
+ mockUseParams.mockImplementationOnce(() => ({
+ flow: 'home',
+ shippingAddress: undefined,
+ priorityToken,
+ allTokens,
+ delegationSettings,
+ externalWalletDetailsData,
+ }));
+
+ const { getByTestId } = render();
+
+ fireEvent.press(getByTestId(ChooseYourCardSelectors.CONTINUE_BUTTON));
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.SPENDING_LIMIT, {
+ flow: 'manage',
+ priorityToken,
+ allTokens,
+ delegationSettings,
+ externalWalletDetailsData,
+ });
+ });
+ });
+
+ describe('Button Variant', () => {
+ it('renders Secondary variant when virtual card is selected', () => {
+ const { getByTestId } = render();
+
+ const continueButton = getByTestId(
+ ChooseYourCardSelectors.CONTINUE_BUTTON,
+ );
+ expect(continueButton.props.children).toBeDefined();
+ });
+
+ it('renders continue button for default virtual selection', () => {
+ const { getByTestId } = render();
+
+ expect(
+ getByTestId(ChooseYourCardSelectors.CONTINUE_BUTTON),
+ ).toBeOnTheScreen();
+ });
+ });
+
+ describe('Upgrade to Metal Link', () => {
+ it('shows upgrade link when virtual card is selected in onboarding flow', () => {
+ const { getByTestId } = render();
+
+ expect(
+ getByTestId(ChooseYourCardSelectors.UPGRADE_TO_METAL_BUTTON),
+ ).toBeOnTheScreen();
+ });
+
+ it('displays correct upgrade link label', () => {
+ const { getByText } = render();
+
+ expect(
+ getByText(strings('card.choose_your_card.upgrade_to_metal_label')),
+ ).toBeOnTheScreen();
+ });
+
+ it('scrolls to metal card when upgrade link is pressed', async () => {
+ const { getByTestId } = render();
+
+ fireEvent.press(
+ getByTestId(ChooseYourCardSelectors.UPGRADE_TO_METAL_BUTTON),
+ );
+
+ await waitFor(() => {
+ expect(
+ getByTestId(ChooseYourCardSelectors.CARD_NAME),
+ ).toHaveTextContent(strings('card.choose_your_card.metal_card.name'));
+ });
+ });
+
+ it('hides upgrade link after scrolling to metal card', async () => {
+ const { getByTestId, queryByTestId } = render();
+
+ fireEvent.press(
+ getByTestId(ChooseYourCardSelectors.UPGRADE_TO_METAL_BUTTON),
+ );
+
+ await waitFor(() => {
+ expect(
+ queryByTestId(ChooseYourCardSelectors.UPGRADE_TO_METAL_BUTTON),
+ ).not.toBeOnTheScreen();
+ });
+ });
});
});
diff --git a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.testIds.ts b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.testIds.ts
index a386ac74435..1c89b86863f 100644
--- a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.testIds.ts
+++ b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.testIds.ts
@@ -6,4 +6,5 @@ export const ChooseYourCardSelectors = {
CARD_NAME: 'choose-your-card-name',
CARD_PRICE: 'choose-your-card-price',
CONTINUE_BUTTON: 'choose-your-card-continue-button',
+ UPGRADE_TO_METAL_BUTTON: 'choose-your-card-upgrade-to-metal-button',
};
diff --git a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx
index 290173210e4..96e51c63162 100644
--- a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx
+++ b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx
@@ -13,7 +13,10 @@ import {
FlatList,
ListRenderItem,
View,
+ TouchableOpacity,
+ Animated,
} from 'react-native';
+import LinearGradient from 'react-native-linear-gradient';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import {
@@ -38,23 +41,44 @@ import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
import { MetaMetricsEvents } from '../../../../../core/Analytics';
import { CardActions, CardScreens } from '../../util/metrics';
import { ChooseYourCardSelectors } from './ChooseYourCard.testIds';
-import { CardType, CardStatus } from '../../types';
+import {
+ CardType,
+ CardStatus,
+ DelegationSettingsResponse,
+ CardExternalWalletDetailsResponse,
+ CardTokenAllowance,
+} from '../../types';
import CardImage from '../../components/CardImage/CardImage';
import { useParams } from '../../../../../util/navigation/navUtils';
import type { ShippingAddress } from '../ReviewOrder';
-export type ChooseYourCardFlow = 'onboarding' | 'upgrade';
+export type ChooseYourCardFlow = 'onboarding' | 'upgrade' | 'home';
export interface ChooseYourCardParams {
flow?: ChooseYourCardFlow;
shippingAddress?: ShippingAddress;
+ priorityToken?: CardTokenAllowance | null;
+ allTokens?: CardTokenAllowance[];
+ delegationSettings?: DelegationSettingsResponse | null;
+ externalWalletDetailsData?:
+ | {
+ walletDetails: never[];
+ mappedWalletDetails: never[];
+ priorityWalletDetail: null;
+ }
+ | {
+ walletDetails: CardExternalWalletDetailsResponse;
+ mappedWalletDetails: CardTokenAllowance[];
+ priorityWalletDetail: CardTokenAllowance | undefined;
+ }
+ | null;
}
interface CardOption {
id: CardType;
name: string;
price: string;
- features: string[];
+ features: { label: string; isHighlighted: boolean }[];
}
const ItemSeparator = ({ width }: { width: number }) => (
@@ -68,11 +92,42 @@ const ChooseYourCard = () => {
const { width: screenWidth } = useWindowDimensions();
const flatListRef = useRef(null);
const [activeIndex, setActiveIndex] = useState(0);
+ const [hasUserSwiped, setHasUserSwiped] = useState(false);
+ const arrowAnimValue = useRef(new Animated.Value(0)).current;
- const { flow = 'onboarding', shippingAddress } =
- useParams();
+ const {
+ flow = 'onboarding',
+ shippingAddress,
+ priorityToken,
+ allTokens,
+ delegationSettings,
+ externalWalletDetailsData,
+ } = useParams();
const isUpgradeFlow = flow === 'upgrade';
+ // Arrow bounce animation for swipe indicator
+ useEffect(() => {
+ if (activeIndex !== 0 || isUpgradeFlow || hasUserSwiped) return;
+
+ const animation = Animated.loop(
+ Animated.sequence([
+ Animated.timing(arrowAnimValue, {
+ toValue: 8,
+ duration: 600,
+ useNativeDriver: true,
+ }),
+ Animated.timing(arrowAnimValue, {
+ toValue: 0,
+ duration: 600,
+ useNativeDriver: true,
+ }),
+ ]),
+ );
+ animation.start();
+
+ return () => animation.stop();
+ }, [activeIndex, isUpgradeFlow, arrowAnimValue, hasUserSwiped]);
+
const CARD_WIDTH = screenWidth - 64;
const CARD_SPACING = 16;
@@ -83,9 +138,18 @@ const ChooseYourCard = () => {
name: strings('card.choose_your_card.virtual_card.name'),
price: strings('card.choose_your_card.virtual_card.price'),
features: [
- strings('card.choose_your_card.virtual_card.feature_1'),
- strings('card.choose_your_card.virtual_card.feature_2'),
- strings('card.choose_your_card.virtual_card.feature_3'),
+ {
+ label: strings('card.choose_your_card.virtual_card.feature_1'),
+ isHighlighted: false,
+ },
+ {
+ label: strings('card.choose_your_card.virtual_card.feature_2'),
+ isHighlighted: false,
+ },
+ {
+ label: strings('card.choose_your_card.virtual_card.feature_3'),
+ isHighlighted: false,
+ },
],
},
{
@@ -93,9 +157,24 @@ const ChooseYourCard = () => {
name: strings('card.choose_your_card.metal_card.name'),
price: strings('card.choose_your_card.metal_card.price'),
features: [
- strings('card.choose_your_card.metal_card.feature_1'),
- strings('card.choose_your_card.metal_card.feature_2'),
- strings('card.choose_your_card.metal_card.feature_3'),
+ {
+ label: strings(
+ 'card.choose_your_card.metal_card.everything_in_virtual',
+ ),
+ isHighlighted: false,
+ },
+ {
+ label: strings('card.choose_your_card.metal_card.feature_1'),
+ isHighlighted: true,
+ },
+ {
+ label: strings('card.choose_your_card.metal_card.feature_2'),
+ isHighlighted: true,
+ },
+ {
+ label: strings('card.choose_your_card.metal_card.feature_3'),
+ isHighlighted: true,
+ },
],
},
],
@@ -121,7 +200,65 @@ const ChooseYourCard = () => {
);
}, [trackEvent, createEventBuilder, flow]);
+ const peekTimersRef = useRef[]>([]);
+ const peekStoppedRef = useRef(false);
+
+ const stopPeekAnimation = useCallback(() => {
+ peekStoppedRef.current = true;
+ peekTimersRef.current.forEach(clearTimeout);
+ peekTimersRef.current = [];
+ }, []);
+
+ useEffect(() => {
+ if (isUpgradeFlow || cardOptions.length <= 1) return;
+
+ const peekDistance = (CARD_WIDTH + CARD_SPACING) * 0.15;
+ const BOUNCE_HOLD = 600;
+ const PAUSE_BETWEEN_BOUNCES = 3000;
+ const cycleDuration = BOUNCE_HOLD + PAUSE_BETWEEN_BOUNCES;
+
+ const scheduleBounce = (delay: number) => {
+ peekTimersRef.current.push(
+ setTimeout(() => {
+ if (peekStoppedRef.current) return;
+ flatListRef.current?.scrollToOffset({
+ offset: peekDistance,
+ animated: true,
+ });
+ }, delay),
+ );
+
+ peekTimersRef.current.push(
+ setTimeout(() => {
+ if (peekStoppedRef.current) return;
+ flatListRef.current?.scrollToOffset({
+ offset: 0,
+ animated: true,
+ });
+ }, delay + BOUNCE_HOLD),
+ );
+
+ peekTimersRef.current.push(
+ setTimeout(() => {
+ if (peekStoppedRef.current) return;
+ scheduleBounce(0);
+ }, delay + cycleDuration),
+ );
+ };
+
+ scheduleBounce(800);
+
+ return stopPeekAnimation;
+ }, [
+ isUpgradeFlow,
+ cardOptions.length,
+ CARD_WIDTH,
+ CARD_SPACING,
+ stopPeekAnimation,
+ ]);
+
const handleContinue = useCallback(() => {
+ stopPeekAnimation();
const selectedCard = cardOptions[activeIndex];
trackEvent(
@@ -135,7 +272,18 @@ const ChooseYourCard = () => {
);
if (selectedCard.id === CardType.VIRTUAL) {
- navigate(Routes.CARD.SPENDING_LIMIT, { flow: 'onboarding' });
+ navigate(
+ Routes.CARD.SPENDING_LIMIT,
+ flow === 'onboarding'
+ ? { flow: 'onboarding' }
+ : {
+ flow: 'manage',
+ priorityToken,
+ allTokens,
+ delegationSettings,
+ externalWalletDetailsData,
+ },
+ );
} else {
navigate(Routes.CARD.REVIEW_ORDER, {
shippingAddress,
@@ -151,17 +299,37 @@ const ChooseYourCard = () => {
flow,
shippingAddress,
isUpgradeFlow,
+ stopPeekAnimation,
+ priorityToken,
+ allTokens,
+ delegationSettings,
+ externalWalletDetailsData,
]);
+ const handleScrollToMetal = useCallback(() => {
+ stopPeekAnimation();
+ setHasUserSwiped(true);
+ flatListRef.current?.scrollToIndex({ index: 1, animated: true });
+ setTimeout(() => setActiveIndex(1), 300);
+ }, [stopPeekAnimation]);
+
const handleScroll = useCallback(
(event: NativeSyntheticEvent) => {
const contentOffsetX = event.nativeEvent.contentOffset.x;
const index = Math.round(contentOffsetX / (CARD_WIDTH + CARD_SPACING));
if (index !== activeIndex && index >= 0 && index < cardOptions.length) {
+ stopPeekAnimation();
+ setHasUserSwiped(true);
setActiveIndex(index);
}
},
- [activeIndex, cardOptions.length, CARD_WIDTH, CARD_SPACING],
+ [
+ activeIndex,
+ cardOptions.length,
+ CARD_WIDTH,
+ CARD_SPACING,
+ stopPeekAnimation,
+ ],
);
const renderCardItem: ListRenderItem = useCallback(
@@ -178,17 +346,17 @@ const ChooseYourCard = () => {
);
const renderFeatureItem = useCallback(
- (feature: string, index: number) => (
+ (feature: string, index: number, isHighlighted: boolean) => (
{feature}
@@ -235,7 +403,6 @@ const ChooseYourCard = () => {
);
const selectedCard = cardOptions[activeIndex];
-
const showPagination = cardOptions.length > 1;
return (
@@ -257,12 +424,14 @@ const ChooseYourCard = () => {
-
+
item.id}
horizontal
showsHorizontalScrollIndicator={false}
@@ -276,6 +445,36 @@ const ChooseYourCard = () => {
getItemLayout={getItemLayout}
testID={ChooseYourCardSelectors.CARD_CAROUSEL}
/>
+ {activeIndex === 0 &&
+ !isUpgradeFlow &&
+ !hasUserSwiped &&
+ cardOptions.length > 1 && (
+
+
+
+
+
+
+ )}
{showPagination && (
@@ -302,21 +501,59 @@ const ChooseYourCard = () => {
-
+ {selectedCard.id === CardType.METAL && (
+
+
+
+
+ {strings('card.choose_your_card.earn_up_to_badge')}
+
+
+
+ )}
+
+
{selectedCard.features.map((feature, index) =>
- renderFeatureItem(feature, index),
+ renderFeatureItem(feature.label, index, feature.isHighlighted),
)}
-
+
+ {activeIndex === 0 && !isUpgradeFlow && (
+
+
+ {strings('card.choose_your_card.upgrade_to_metal_label')}
+
+
+ )}
diff --git a/app/components/UI/Card/Views/OrderCompleted/OrderCompleted.test.tsx b/app/components/UI/Card/Views/OrderCompleted/OrderCompleted.test.tsx
index e9c1379c17e..11aa02bc7f8 100644
--- a/app/components/UI/Card/Views/OrderCompleted/OrderCompleted.test.tsx
+++ b/app/components/UI/Card/Views/OrderCompleted/OrderCompleted.test.tsx
@@ -8,6 +8,7 @@ import { MetaMetricsEvents } from '../../../../../core/Analytics';
import { CardActions, CardScreens } from '../../util/metrics';
const mockNavigate = jest.fn();
+const mockDispatch = jest.fn();
const mockTrackEvent = jest.fn();
const mockBuild = jest.fn();
const mockAddProperties = jest.fn(() => ({ build: mockBuild }));
@@ -21,13 +22,22 @@ jest.mock('@react-navigation/native', () => {
...actual,
useNavigation: () => ({
navigate: mockNavigate,
+ dispatch: mockDispatch,
}),
+ StackActions: {
+ replace: jest.fn((routeName, params) => ({
+ type: 'REPLACE',
+ payload: { name: routeName, params },
+ })),
+ },
};
});
+let mockFromUpgrade = false;
+
jest.mock('../../../../../util/navigation/navUtils', () => ({
useParams: () => ({
- fromUpgrade: false,
+ fromUpgrade: mockFromUpgrade,
}),
}));
@@ -46,6 +56,7 @@ jest.mock('../../../../../../locales/i18n', () => ({
'card.order_completed.description':
'Set up your virtual card and add it to your digital wallet to start earning cashback.',
'card.order_completed.set_up_card_button': 'Set up card',
+ 'card.order_completed.back_to_card_button': 'Back to card',
};
return map[key] || key;
},
@@ -104,6 +115,7 @@ jest.mock('@metamask/design-system-react-native', () => {
describe('OrderCompleted', () => {
beforeEach(() => {
jest.clearAllMocks();
+ mockFromUpgrade = false;
});
describe('Render', () => {
@@ -165,12 +177,31 @@ describe('OrderCompleted', () => {
});
describe('Navigation', () => {
- it('navigates to Card Home when set up card button is pressed', () => {
+ it('navigates to SpendingLimit via StackActions.replace during onboarding flow', () => {
+ mockFromUpgrade = false;
+ const { StackActions } = jest.requireMock('@react-navigation/native');
+ const { getByTestId } = render();
+
+ fireEvent.press(getByTestId(OrderCompletedSelectors.SET_UP_CARD_BUTTON));
+
+ expect(StackActions.replace).toHaveBeenCalledWith(
+ Routes.CARD.SPENDING_LIMIT,
+ { flow: 'onboarding' },
+ );
+ expect(mockDispatch).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'REPLACE' }),
+ );
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('navigates to Card Home when fromUpgrade is true', () => {
+ mockFromUpgrade = true;
const { getByTestId } = render();
fireEvent.press(getByTestId(OrderCompletedSelectors.SET_UP_CARD_BUTTON));
expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.HOME);
+ expect(mockDispatch).not.toHaveBeenCalled();
});
});
});
diff --git a/app/components/UI/Card/Views/OrderCompleted/OrderCompleted.tsx b/app/components/UI/Card/Views/OrderCompleted/OrderCompleted.tsx
index 639d8900b2d..ecfa2f0de66 100644
--- a/app/components/UI/Card/Views/OrderCompleted/OrderCompleted.tsx
+++ b/app/components/UI/Card/Views/OrderCompleted/OrderCompleted.tsx
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect } from 'react';
import { Image } from 'react-native';
-import { useNavigation } from '@react-navigation/native';
+import { useNavigation, StackActions } from '@react-navigation/native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import {
@@ -31,7 +31,7 @@ export interface OrderCompletedParams {
const OrderCompleted: React.FC = () => {
const { trackEvent, createEventBuilder } = useAnalytics();
- const { navigate } = useNavigation();
+ const navigation = useNavigation();
const tw = useTailwind();
const { fromUpgrade } = useParams();
@@ -56,8 +56,16 @@ const OrderCompleted: React.FC = () => {
.build(),
);
- navigate(Routes.CARD.HOME);
- }, [navigate, trackEvent, createEventBuilder, fromUpgrade]);
+ if (fromUpgrade) {
+ navigation.navigate(Routes.CARD.HOME);
+ } else {
+ navigation.dispatch(
+ StackActions.replace(Routes.CARD.SPENDING_LIMIT, {
+ flow: 'onboarding',
+ }),
+ );
+ }
+ }, [navigation, trackEvent, createEventBuilder, fromUpgrade]);
const buttonLabel = fromUpgrade
? strings('card.order_completed.back_to_card_button')
diff --git a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx
index 31f74429256..f40e781579b 100644
--- a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx
+++ b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useCallback, useMemo } from 'react';
+import React, { useEffect, useCallback, useMemo, useRef } from 'react';
import { ActivityIndicator } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { SafeAreaView } from 'react-native-safe-area-context';
@@ -130,14 +130,18 @@ const SpendingLimit: React.FC = ({ route }) => {
routeParams: route?.params as Record | undefined,
});
- // Prevent navigation while loading
+ const isLoadingRef = useRef(isLoading);
+ useEffect(() => {
+ isLoadingRef.current = isLoading;
+ }, [isLoading]);
+
useEffect(() => {
const unsubscribe = navigation.addListener('beforeRemove', (e) => {
- if (!isLoading) return;
+ if (!isLoadingRef.current) return;
e.preventDefault();
});
return unsubscribe;
- }, [navigation, isLoading]);
+ }, [navigation]);
// Check if a quick-select token is selected
const isQuickSelectTokenSelected = useCallback(
diff --git a/app/components/UI/Card/routes/index.tsx b/app/components/UI/Card/routes/index.tsx
index f76776460ee..92949262e5f 100644
--- a/app/components/UI/Card/routes/index.tsx
+++ b/app/components/UI/Card/routes/index.tsx
@@ -116,14 +116,14 @@ export const cardChooseYourCardNavigationOptions = ({
route,
}: {
navigation: NavigationProp;
- route: { params?: { flow?: 'onboarding' | 'upgrade' } };
+ route: { params?: { flow?: 'onboarding' | 'upgrade' | 'home' } };
}): StackNavigationOptions => {
const flow = route.params?.flow || 'onboarding';
- const isUpgradeFlow = flow === 'upgrade';
+ const showBackButton = flow === 'upgrade' || flow === 'home';
return {
headerLeft: () =>
- isUpgradeFlow ? (
+ showBackButton ? (
Date: Thu, 12 Mar 2026 20:51:43 +0100
Subject: [PATCH 004/206] feat: migrate Skeleton (swaps scope) (#27363)
## **Description**
Removed unused mocks.
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/DSYS-274
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [x] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [x] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> **Low Risk**
> Test-only change that removes redundant Jest mocks; low risk aside
from potentially reintroducing minor animation/timing differences in
affected tests.
>
> **Overview**
> Removes the custom Jest mock for the `Skeleton` component from
`BridgeView.test.tsx` and `SwapsConfirmButton.test.tsx`, relying on the
real implementation during tests.
>
> This cleans up unused test scaffolding in the Bridge/Swaps test suite
without changing production behavior.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
e94e5dfebd00ad3c38ff3790e2c27f03c1e656ee. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../UI/Bridge/Views/BridgeView/BridgeView.test.tsx | 5 -----
.../SwapsConfirmButton/SwapsConfirmButton.test.tsx | 5 -----
2 files changed, 10 deletions(-)
diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx b/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx
index c413f537475..0effde01eaf 100644
--- a/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx
+++ b/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx
@@ -262,11 +262,6 @@ jest.mock('../../hooks/useLatestBalance', () => ({
}),
}));
-// Mock Skeleton component to prevent animation
-jest.mock('../../../../../component-library/components/Skeleton', () => ({
- Skeleton: () => null,
-}));
-
jest.mock('../../hooks/useBridgeQuoteData', () => ({
useBridgeQuoteData: jest
.fn()
diff --git a/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx b/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx
index 39cc23710d2..10f3774e5f1 100644
--- a/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx
+++ b/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx
@@ -169,11 +169,6 @@ jest.mock('../../../../../util/address', () => ({
isHardwareAccount: jest.fn(),
}));
-// Mock Skeleton component to prevent animation
-jest.mock('../../../../../component-library/components/Skeleton', () => ({
- Skeleton: () => null,
-}));
-
jest.mock('react-native-fade-in-image', () => {
const ReactModule = jest.requireActual('react');
const { View } = jest.requireActual('react-native');
From 581facd7e3124e7bd78ccb177ace8f5398d75151 Mon Sep 17 00:00:00 2001
From: MetaMask Bot <37885440+metamaskbot@users.noreply.github.com>
Date: Thu, 12 Mar 2026 18:06:30 -0230
Subject: [PATCH 005/206] chore(release): Bump main version to 7.71.0 (#27453)
## Version Bump After Release
This PR bumps the main branch version from 7.70.0 to 7.71.0 after
cutting the release branch.
### Why this is needed:
- **Nightly builds**: Each nightly build needs to be one minor version
ahead of the current release candidate
- **Version conflicts**: Prevents conflicts between nightlies and
release candidates
- **Platform alignment**: Maintains version alignment between MetaMask
mobile and extension
- **Update systems**: Ensures nightlies are accepted by app stores and
browser update systems
### What changed:
- Version bumped from `7.70.0` to `7.71.0`
- Platform: `mobile`
- Files updated by `set-semvar-version.sh` script
### Next steps:
This PR should be **manually reviewed and merged by the release
manager** to maintain proper version flow.
### Related:
- Release version: 7.70.0
- Release branch: release/7.70.0
- Platform: mobile
- Test mode: false
---
*This PR was automatically created by the
`create-platform-release-pr.sh` script.*
Co-authored-by: metamaskbot
---
android/app/build.gradle | 2 +-
bitrise.yml | 4 ++--
ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------
package.json | 2 +-
4 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 35041984aeb..97165e04944 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -187,7 +187,7 @@ android {
applicationId "io.metamask"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionName "7.70.0"
+ versionName "7.71.0"
versionCode 3607
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
diff --git a/bitrise.yml b/bitrise.yml
index 18d3908bec5..c8f848cf5bd 100644
--- a/bitrise.yml
+++ b/bitrise.yml
@@ -3531,13 +3531,13 @@ app:
PROJECT_LOCATION_IOS: ios
- opts:
is_expand: false
- VERSION_NAME: 7.70.0
+ VERSION_NAME: 7.71.0
- opts:
is_expand: false
VERSION_NUMBER: 3911
- opts:
is_expand: false
- FLASK_VERSION_NAME: 7.70.0
+ FLASK_VERSION_NAME: 7.71.0
- opts:
is_expand: false
FLASK_VERSION_NUMBER: 3911
diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj
index 6eb84c16eb7..6726013ffa4 100644
--- a/ios/MetaMask.xcodeproj/project.pbxproj
+++ b/ios/MetaMask.xcodeproj/project.pbxproj
@@ -1319,7 +1319,7 @@
"${inherited}",
);
LLVM_LTO = YES;
- MARKETING_VERSION = 7.70.0;
+ MARKETING_VERSION = 7.71.0;
ONLY_ACTIVE_ARCH = YES;
OTHER_CFLAGS = "$(inherited)";
OTHER_LDFLAGS = (
@@ -1385,7 +1385,7 @@
"${inherited}",
);
LLVM_LTO = YES;
- MARKETING_VERSION = 7.70.0;
+ MARKETING_VERSION = 7.71.0;
ONLY_ACTIVE_ARCH = NO;
OTHER_CFLAGS = "$(inherited)";
OTHER_LDFLAGS = (
@@ -1454,7 +1454,7 @@
"\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"",
);
LLVM_LTO = YES;
- MARKETING_VERSION = 7.70.0;
+ MARKETING_VERSION = 7.71.0;
ONLY_ACTIVE_ARCH = YES;
OTHER_CFLAGS = "$(inherited)";
OTHER_LDFLAGS = (
@@ -1518,7 +1518,7 @@
"\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"",
);
LLVM_LTO = YES;
- MARKETING_VERSION = 7.70.0;
+ MARKETING_VERSION = 7.71.0;
ONLY_ACTIVE_ARCH = NO;
OTHER_CFLAGS = "$(inherited)";
OTHER_LDFLAGS = (
@@ -1684,7 +1684,7 @@
"\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"",
);
LLVM_LTO = YES;
- MARKETING_VERSION = 7.70.0;
+ MARKETING_VERSION = 7.71.0;
ONLY_ACTIVE_ARCH = YES;
OTHER_CFLAGS = (
"$(inherited)",
@@ -1751,7 +1751,7 @@
"\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"",
);
LLVM_LTO = YES;
- MARKETING_VERSION = 7.70.0;
+ MARKETING_VERSION = 7.71.0;
ONLY_ACTIVE_ARCH = NO;
OTHER_CFLAGS = (
"$(inherited)",
diff --git a/package.json b/package.json
index b4a123665ef..6a1f5c76207 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "metamask",
- "version": "7.70.0",
+ "version": "7.71.0",
"private": true,
"scripts": {
"install:foundryup": "yarn mm-foundryup",
From e0d223c2908d1ee1eab68442b64c9229a870c2e4 Mon Sep 17 00:00:00 2001
From: Brian August Nguyen
Date: Thu, 12 Mar 2026 14:29:35 -0700
Subject: [PATCH 006/206] refactor: Updated perps market details header
(#27316)
## **Description**
Refactors the Perps Market Details screen to use the shared
`HeaderStandardAnimated` component instead of the custom
`PerpsMarketHeader` and navbar options, aligning with patterns used
elsewhere (e.g. Perps home, Token Details).
1. **Reason for the change:** Market Details used
`getPerpsMarketDetailsNavbar` (navigation.setOptions) and a custom
`PerpsMarketHeader` component; this was inconsistent with other screens
that use component-library header primitives and scroll-linked header
animation.
2. **Improvement:** Replaced `PerpsMarketHeader` with
`HeaderStandardAnimated`. Title is
`${getPerpsDisplaySymbol(market.symbol)}-USD`; subtitle is
`LivePriceHeader` (symbol, current price, throttle). Back button,
fullscreen (expand) and watchlist (star) actions are passed as
`endButtonIconProps`. Removed the navbar `useEffect`. Scroll content is
now an `Animated.ScrollView` with scroll passed to the header; a
`TitleSubpage` block at the top (token logo, title, leverage badge, and
`LivePriceHeader` as bottom accessory) drives the title section height
via `onLayout` and `useHeaderStandardAnimated`. `LivePriceHeader` text
variants were updated to `BodySM` and price color to `Alternative` for
the compact header context.
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes:
https://consensyssoftware.atlassian.net/issues?jql=issueKey%20in%20(DSYS-555%2CDSYS-556%2CDSYS-557%2CDSYS-558)&selectedIssue=DSYS-558
## **Manual testing steps**
```gherkin
Feature: Perps Market Details header
Scenario: Market Details shows animated header and title section
Given the app is open and user has access to Perps
When user opens a market (e.g. ETH-USD) from the Perps list
Then the screen shows HeaderStandardAnimated with market title (e.g. "ETH-USD") and live price as subtitle
And the header shows back button, fullscreen (expand) and watchlist (star) actions
And the scroll area shows TitleSubpage with token logo, title, leverage badge, and live price
When user scrolls the page
Then the header animates (collapses/expands) in sync with scroll
And chart, stats, and rest of content remain usable
```
## **Screenshots/Recordings**
### **Before**
### **After**
https://github.com/user-attachments/assets/eaf0e7ee-766a-4c84-8e48-7b7d9a897621
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> **Medium Risk**
> Refactors the Perps Market Details header/navigation and scroll
handling, which could introduce UI regressions (collapsed header
behavior, button actions, layout) on a high-traffic trading screen. No
changes to trading/business logic or data handling.
>
> **Overview**
> Refactors the Perps Market Details screen to use the shared
`HeaderStandardAnimated` pattern instead of the bespoke
`PerpsMarketHeader` and `navigation.setOptions` navbar configuration.
>
> The header is now driven by `useHeaderStandardAnimated` and an
`Animated.ScrollView`, with a new top-of-page `TitleSubpage` section
(token logo, title, leverage badge, and live price) providing the
measured height for the collapse/expand animation; fullscreen and
watchlist actions are wired through header icon props.
>
> Updates `HeaderStandardAnimated` to accept non-string
`title`/`subtitle` nodes, adjusts `LivePriceHeader` typography for the
compact header, removes the unused `getPerpsMarketDetailsNavbar`, and
extends Perps test IDs/tests to account for duplicated title rendering
(header + title section) and new price/change selectors.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
0f37d8420cdd86588aa48575b89005bc84b4b37d. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../HeaderStandardAnimated.tsx | 35 ++++--
app/components/UI/Navbar/index.js | 32 -----
app/components/UI/Perps/Perps.testIds.ts | 3 +
.../PerpsMarketDetailsView.test.tsx | 12 +-
.../PerpsMarketDetailsView.tsx | 119 ++++++++++++++----
.../PerpsMarketDetailsView.view.test.tsx | 83 ++++++++++++
.../LivePriceDisplay/LivePriceHeader.tsx | 6 +-
7 files changed, 209 insertions(+), 81 deletions(-)
diff --git a/app/component-library/components-temp/HeaderStandardAnimated/HeaderStandardAnimated.tsx b/app/component-library/components-temp/HeaderStandardAnimated/HeaderStandardAnimated.tsx
index 6cb8f3c55eb..a0178b3cdd8 100644
--- a/app/component-library/components-temp/HeaderStandardAnimated/HeaderStandardAnimated.tsx
+++ b/app/component-library/components-temp/HeaderStandardAnimated/HeaderStandardAnimated.tsx
@@ -47,22 +47,31 @@ const HeaderStandardAnimated: React.FC = ({
const content = title ? (
-
- {title}
-
- {subtitle && (
+ {typeof title === 'string' ? (
- {subtitle}
+ {title}
+ ) : (
+ title
+ )}
+ {subtitle && (
+
+ {typeof subtitle === 'string' ? (
+
+ {subtitle}
+
+ ) : (
+ subtitle
+ )}
+
)}
) : null;
diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js
index 097cbdf9fe2..2adc239715c 100644
--- a/app/components/UI/Navbar/index.js
+++ b/app/components/UI/Navbar/index.js
@@ -1355,38 +1355,6 @@ export function getPerpsTransactionsDetailsNavbar(navigation, title) {
};
}
-export function getPerpsMarketDetailsNavbar(navigation, title) {
- const innerStyles = StyleSheet.create({
- perpsMarketDetailsTitle: {
- fontWeight: '700',
- textAlign: 'center',
- flex: 1,
- },
- });
- // Always navigate back to markets page for consistent navigation
- const leftAction = () => navigation.navigate(Routes.PERPS.PERPS_HOME);
-
- return {
- headerTitle: () => (
-
- ),
- headerLeft: () => (
-
- ),
- };
-}
-
/**
* Function that returns navigation options for deposit flow screens
*
diff --git a/app/components/UI/Perps/Perps.testIds.ts b/app/components/UI/Perps/Perps.testIds.ts
index 551e40f89eb..5fe6aa9fe42 100644
--- a/app/components/UI/Perps/Perps.testIds.ts
+++ b/app/components/UI/Perps/Perps.testIds.ts
@@ -357,6 +357,7 @@ export const PerpsMarketDetailsViewSelectorsIDs = {
MARKET_HOURS_BOTTOM_SHEET_TOOLTIP:
'perps-market-details-market-hours-bottom-sheet-tooltip',
STOP_LOSS_PROMPT_BANNER: 'perps-market-details-stop-loss-prompt-banner',
+ TITLE_SECTION_WRAPPER: 'perps-market-details-title-section-wrapper',
};
// ========================================
@@ -370,6 +371,8 @@ export const PerpsMarketHeaderSelectorsIDs = {
ASSET_NAME: 'perps-market-header-asset-name',
PRICE: 'perps-market-header-price',
PRICE_CHANGE: 'perps-market-header-price-change',
+ PRICE_TITLE_SECTION: 'perps-market-header-price-title-section',
+ PRICE_CHANGE_TITLE_SECTION: 'perps-market-header-price-change-title-section',
MORE_BUTTON: 'perps-market-header-more-button',
};
diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx
index 34c15b6f61c..5a189ea488c 100644
--- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx
+++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx
@@ -3172,7 +3172,7 @@ describe('PerpsMarketDetailsView', () => {
isRefreshing: false,
});
- const { getByText } = renderWithProvider(
+ const { getByText, getAllByText } = renderWithProvider(
,
@@ -3183,7 +3183,7 @@ describe('PerpsMarketDetailsView', () => {
// Should show the route market's leverage badge
expect(getByText('25x')).toBeOnTheScreen();
- expect(getByText('ETH-USD')).toBeOnTheScreen();
+ expect(getAllByText('ETH-USD').length).toBeGreaterThanOrEqual(1);
});
it('enriches market data from usePerpsMarkets when route has minimal data', async () => {
@@ -3213,7 +3213,7 @@ describe('PerpsMarketDetailsView', () => {
isRefreshing: false,
}));
- const { getByText, getByTestId } = renderWithProvider(
+ const { getByText, getByTestId, getAllByText } = renderWithProvider(
,
@@ -3224,7 +3224,7 @@ describe('PerpsMarketDetailsView', () => {
// Verify the header renders with correct market symbol
expect(getByTestId('perps-market-header')).toBeOnTheScreen();
- expect(getByText('BTC-USD')).toBeOnTheScreen();
+ expect(getAllByText('BTC-USD').length).toBeGreaterThanOrEqual(1);
// Should show the enriched market's leverage badge from usePerpsMarkets
await waitFor(() => {
@@ -3248,7 +3248,7 @@ describe('PerpsMarketDetailsView', () => {
isRefreshing: false,
});
- const { getByText, queryByText } = renderWithProvider(
+ const { getAllByText, queryByText } = renderWithProvider(
,
@@ -3258,7 +3258,7 @@ describe('PerpsMarketDetailsView', () => {
);
// Should show the asset name but no leverage badge (since no maxLeverage available)
- expect(getByText('UNKNOWN-USD')).toBeOnTheScreen();
+ expect(getAllByText('UNKNOWN-USD').length).toBeGreaterThanOrEqual(1);
// No leverage badge should be shown
expect(queryByText('40x')).toBeNull();
expect(queryByText('25x')).toBeNull();
diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx
index 55f164cf007..d94f56ac5ca 100644
--- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx
+++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx
@@ -1,4 +1,8 @@
-import { ButtonSize as ButtonSizeRNDesignSystem } from '@metamask/design-system-react-native';
+import {
+ Box,
+ ButtonSize as ButtonSizeRNDesignSystem,
+ IconName,
+} from '@metamask/design-system-react-native';
import {
useNavigation,
useRoute,
@@ -11,13 +15,15 @@ import React, {
useRef,
useState,
} from 'react';
-import { Linking, RefreshControl, ScrollView, View } from 'react-native';
+import { Linking, RefreshControl, View } from 'react-native';
+import Animated from 'react-native-reanimated';
import {
CandlePeriod,
TimeDuration,
PERPS_EVENT_PROPERTY,
PERPS_EVENT_VALUE,
PERPS_CONSTANTS,
+ getPerpsDisplaySymbol,
type Position,
type PerpsMarketData,
type TPSLTrackingData,
@@ -47,7 +53,6 @@ import { isNotificationsFeatureEnabled } from '../../../../../util/notifications
import { TraceName } from '../../../../../util/trace';
import { MetaMetricsEvents } from '../../../../../core/Analytics';
import ComponentErrorBoundary from '../../../ComponentErrorBoundary';
-import { getPerpsMarketDetailsNavbar } from '../../../Navbar';
import PerpsBottomSheetTooltip from '../../components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip';
import type { PerpsTooltipContentKey } from '../../components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types';
import PerpsCandlePeriodBottomSheet from '../../components/PerpsCandlePeriodBottomSheet';
@@ -57,11 +62,17 @@ import PerpsCompactOrderRow from '../../components/PerpsCompactOrderRow';
import PerpsFlipPositionConfirmSheet from '../../components/PerpsFlipPositionConfirmSheet';
import {
PerpsMarketDetailsViewSelectorsIDs,
+ PerpsMarketHeaderSelectorsIDs,
PerpsOrderViewSelectorsIDs,
PerpsTutorialSelectorsIDs,
} from '../../Perps.testIds';
-import PerpsMarketHeader from '../../components/PerpsMarketHeader';
+import HeaderStandardAnimated from '../../../../../component-library/components-temp/HeaderStandardAnimated';
+import useHeaderStandardAnimated from '../../../../../component-library/components-temp/HeaderStandardAnimated/useHeaderStandardAnimated';
+import TitleSubpage from '../../../../../component-library/components-temp/TitleSubpage';
+import LivePriceHeader from '../../components/LivePriceDisplay/LivePriceHeader';
+import PerpsLeverage from '../../components/PerpsLeverage/PerpsLeverage';
import PerpsMarketHoursBanner from '../../components/PerpsMarketHoursBanner';
+import PerpsTokenLogo from '../../components/PerpsTokenLogo';
import PerpsMarketStatisticsCard from '../../components/PerpsMarketStatisticsCard';
import PerpsMarketTradesList from '../../components/PerpsMarketTradesList';
import PerpsNavigationCard, {
@@ -244,14 +255,12 @@ const PerpsMarketDetailsView: React.FC = () => {
}
}, [isWatchlistFromRedux, optimisticWatchlist]);
- // Set navigation header with proper back button
- useEffect(() => {
- if (market) {
- navigation.setOptions(
- getPerpsMarketDetailsNavbar(navigation, market.symbol),
- );
- }
- }, [navigation, market]);
+ const {
+ scrollY: scrollYShared,
+ onScroll,
+ setTitleSectionHeight,
+ titleSectionHeightSv,
+ } = useHeaderStandardAnimated();
// Get persisted candle period preference from Redux store
const selectedCandlePeriod = useSelector(
@@ -1085,35 +1094,91 @@ const PerpsMarketDetailsView: React.FC = () => {
const shouldShowLongShortButtonsOnly =
shouldShowNewPositionActions && !showAddFundsCTA;
+ const displayTitle = `${getPerpsDisplaySymbol(market.symbol)}-USD`;
+
return (
- {/* Fixed Header Section */}
-
-
-
+
+ }
+ onBack={handleBackPress}
+ backButtonProps={{
+ onPress: handleBackPress,
+ testID: PerpsMarketHeaderSelectorsIDs.BACK_BUTTON,
+ }}
+ endButtonIconProps={[
+ {
+ iconName: IconName.Expand,
+ onPress: handleFullscreenChartOpen,
+ testID: `${PerpsMarketDetailsViewSelectorsIDs.HEADER}-fullscreen-button`,
+ },
+ {
+ iconName: isWatchlist ? IconName.StarFilled : IconName.Star,
+ onPress: handleWatchlistPress,
+ testID: PerpsMarketHeaderSelectorsIDs.MORE_BUTTON,
+ },
+ ]}
+ testID={PerpsMarketDetailsViewSelectorsIDs.HEADER}
+ />
- {/* Scrollable Content Container */}
-
}
>
+ setTitleSectionHeight(e.nativeEvent.layout.height)}
+ >
+
+ }
+ title={displayTitle}
+ titleAccessory={
+ market.maxLeverage ? (
+
+
+
+ ) : undefined
+ }
+ bottomAccessory={
+
+ }
+ twClassName="px-4 pt-1 pb-3"
+ />
+
+
{/* TradingView Chart Section */}
= () => {
-
+
{/* Fixed Actions Footer */}
diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.view.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.view.test.tsx
index 8462b746e9d..df5aa041207 100644
--- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.view.test.tsx
+++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.view.test.tsx
@@ -275,6 +275,89 @@ describe('PerpsMarketDetailsView', () => {
).toBeOnTheScreen();
});
+ it('renders header and title section with market title', async () => {
+ renderPerpsMarketDetailsView({
+ streamOverrides: { positions: [] },
+ overrides: {
+ engine: {
+ backgroundState: {
+ PerpsController: { isEligible: true },
+ },
+ },
+ },
+ });
+
+ expect(
+ await screen.findByTestId(PerpsMarketDetailsViewSelectorsIDs.HEADER),
+ ).toBeOnTheScreen();
+ expect(screen.getAllByText('ETH-USD').length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('renders title section with price when market has no maxLeverage', async () => {
+ renderPerpsMarketDetailsView({
+ initialParams: {
+ market: {
+ symbol: 'ETH',
+ name: 'Ethereum',
+ price: '$2,000',
+ change24h: '$0',
+ change24hPercent: '0%',
+ volume: '$1M',
+ },
+ },
+ streamOverrides: {
+ positions: [],
+ marketData: [{ symbol: 'BTC', name: 'Bitcoin', maxLeverage: '50x' }],
+ },
+ overrides: {
+ engine: {
+ backgroundState: {
+ PerpsController: { isEligible: true },
+ },
+ },
+ },
+ });
+
+ expect(
+ await screen.findByTestId(PerpsMarketDetailsViewSelectorsIDs.HEADER),
+ ).toBeOnTheScreen();
+ expect(
+ await screen.findByTestId(
+ PerpsMarketHeaderSelectorsIDs.PRICE_TITLE_SECTION,
+ ),
+ ).toBeOnTheScreen();
+ expect(
+ await screen.findByTestId(
+ PerpsMarketHeaderSelectorsIDs.PRICE_CHANGE_TITLE_SECTION,
+ ),
+ ).toBeOnTheScreen();
+ });
+
+ it('title section onLayout sets header height for scroll animation', async () => {
+ renderPerpsMarketDetailsView({
+ streamOverrides: { positions: [] },
+ overrides: {
+ engine: {
+ backgroundState: {
+ PerpsController: { isEligible: true },
+ },
+ },
+ },
+ });
+
+ const titleSectionWrapper = await screen.findByTestId(
+ PerpsMarketDetailsViewSelectorsIDs.TITLE_SECTION_WRAPPER,
+ );
+ fireEvent(titleSectionWrapper, 'layout', {
+ nativeEvent: { layout: { x: 0, y: 0, width: 100, height: 80 } },
+ });
+
+ expect(titleSectionWrapper).toBeOnTheScreen();
+ expect(
+ screen.getByTestId(PerpsMarketDetailsViewSelectorsIDs.HEADER),
+ ).toBeOnTheScreen();
+ });
+
it('opens fullscreen chart modal and close button is pressable', async () => {
renderPerpsMarketDetailsView({
streamOverrides: { positions: [] },
diff --git a/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx
index 5e3cdff5541..0686b839efd 100644
--- a/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx
+++ b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx
@@ -105,14 +105,14 @@ const LivePriceHeader: React.FC = ({
return (
{formattedPrice}
From afdab7ba089fe67dc7a76e8e84a779c0bbca43ad Mon Sep 17 00:00:00 2001
From: Curtis David
Date: Thu, 12 Mar 2026 18:15:52 -0400
Subject: [PATCH 007/206] test: Migrate page objects to the new framework
(Login) (#27434)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Refactors multiple e2e page objects and flows to use the new
framework-agnostic element/gesture layer (EncapsulatedElementType,
UnifiedGestures, encapsulatedAction) so the same objects work under both
Detox and Appium/Playwright.
Adds/adjusts selector plumbing to support this migration, including new
localized selector text in
NetworkEducationModal/WalletActionsBottomSheet test IDs, swipe option
passthrough (speed, percentage) in UnifiedGestureOptions, and new
swap/bridge performance-test helpers in QuoteView (token testId
derivation, quote-visible assertion text, and network→chainId mapping).
## **Changelog**
CHANGELOG entry:
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/MMQA-1563
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [ ] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I've included tests if applicable
- [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> **Medium Risk**
> Refactors a wide set of e2e page objects and selectors to a new
cross-framework abstraction, which can cause broad test failures if any
locator mapping is wrong. App runtime behavior is largely unchanged
aside from adding a few `testID`s used by automation.
>
> **Overview**
> Updates e2e automation to use the framework-agnostic
`EncapsulatedElementType`/`UnifiedGestures`/`encapsulatedAction` APIs
across login, wallet, perps, networks, and swap/bridge page objects,
including replacing direct Detox elements with dual
Detox+Appium/Playwright locators.
>
> Extends selector plumbing to support the migration: adds new localized
selector text constants, passes through swipe options (`speed`,
`percentage`) in unified gesture options, and adds swap/bridge helpers
(fee-disclaimer `testID`, token testId derivation, network→chainId
mapping, and quote-visibility assertions) to stabilize performance and
deeplink tests.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
e474ab6e134511787f149d3391e27b64ee3c5de1. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../UI/AssetOverview/TokenOverview.testIds.ts | 1 +
.../Views/BridgeView/BridgeView.testIds.ts | 1 +
.../UI/Bridge/Views/BridgeView/index.tsx | 6 +-
.../NetworkEducationModal.testIds.ts | 1 +
.../WalletActionsBottomSheet.testIds.ts | 7 +
tests/flows/wallet.flow.ts | 14 +-
tests/framework/GestureStrategy.ts | 6 +
.../Network/NetworkEducationModal.ts | 77 ++++--
tests/page-objects/Perps/PerpsDepositView.ts | 32 +++
.../Perps/PerpsMarketDetailsView.ts | 117 ++++++++-
.../page-objects/Perps/PerpsMarketListView.ts | 15 +-
tests/page-objects/Perps/PerpsOnboarding.ts | 61 ++++-
tests/page-objects/Perps/PerpsOrderView.ts | 38 ++-
tests/page-objects/swaps/QuoteView.ts | 238 +++++++++++++++---
.../wallet/AccountListBottomSheet.ts | 50 +++-
.../wallet/AddAccountBottomSheet.ts | 26 +-
tests/page-objects/wallet/LoginView.ts | 6 +-
tests/page-objects/wallet/TabBarComponent.ts | 136 ++++++++--
tests/page-objects/wallet/TokenOverview.ts | 60 ++++-
.../wallet/WalletActionsBottomSheet.ts | 58 ++++-
tests/page-objects/wallet/WalletView.ts | 63 ++++-
.../networks/add-custom-rpc.spec.ts | 3 +-
tests/selectors/Bridge/QuoteView.selectors.ts | 26 ++
tests/smoke/swap/swap-deeplink-smoke.spec.ts | 3 +-
24 files changed, 890 insertions(+), 155 deletions(-)
diff --git a/app/components/UI/AssetOverview/TokenOverview.testIds.ts b/app/components/UI/AssetOverview/TokenOverview.testIds.ts
index 8feaf17005d..9faea5f10f1 100644
--- a/app/components/UI/AssetOverview/TokenOverview.testIds.ts
+++ b/app/components/UI/AssetOverview/TokenOverview.testIds.ts
@@ -20,6 +20,7 @@ export const TokenOverviewSelectorsIDs = {
export const TokenOverviewSelectorsText = {
STAKED_BALANCE: enContent.stake.staked_balance,
+ TODAYS_CHANGE_SUFFIX: '%) Today',
NO_CHART_DATA: enContent.asset_overview.no_chart_data.title,
'1d': enContent.asset_overview.chart_time_period_navigation['1d'],
'1w': enContent.asset_overview.chart_time_period_navigation['1w'],
diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts b/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts
index 3a0ea8689b5..4e63125a80c 100644
--- a/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts
+++ b/app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts
@@ -6,6 +6,7 @@ export const BridgeViewSelectorsIDs = {
CONFIRM_BUTTON: 'bridge-confirm-button',
CONFIRM_BUTTON_KEYPAD: 'bridge-confirm-button-keypad',
BRIDGE_VIEW_SCROLL: 'bridge-view-scroll',
+ FEE_DISCLAIMER: 'bridge-fee-disclaimer',
QUOTE_DETAILS_SKELETON: 'bridge-quote-details-skeleton',
} as const;
diff --git a/app/components/UI/Bridge/Views/BridgeView/index.tsx b/app/components/UI/Bridge/Views/BridgeView/index.tsx
index a4c641bb749..e9093492abd 100644
--- a/app/components/UI/Bridge/Views/BridgeView/index.tsx
+++ b/app/components/UI/Bridge/Views/BridgeView/index.tsx
@@ -426,7 +426,11 @@ const BridgeView = () => {
location={location}
latestSourceBalance={latestSourceBalance}
/>
-
+
{hasFee
? strings('bridge.fee_disclaimer', {
diff --git a/app/components/UI/NetworkInfo/NetworkEducationModal.testIds.ts b/app/components/UI/NetworkInfo/NetworkEducationModal.testIds.ts
index d73167babfa..3b25e6d39a6 100644
--- a/app/components/UI/NetworkInfo/NetworkEducationModal.testIds.ts
+++ b/app/components/UI/NetworkInfo/NetworkEducationModal.testIds.ts
@@ -2,6 +2,7 @@ import enContent from '../../../../locales/languages/en.json';
export const NetworkEducationModalSelectorsText = {
ADD_TOKEN: enContent.network_information.add_token,
+ GOT_IT: enContent.network_information.got_it,
};
export const NetworkEducationModalSelectorsIDs = {
diff --git a/app/components/Views/WalletActions/WalletActionsBottomSheet.testIds.ts b/app/components/Views/WalletActions/WalletActionsBottomSheet.testIds.ts
index 8ef99908a3b..3e58b47ddfd 100644
--- a/app/components/Views/WalletActions/WalletActionsBottomSheet.testIds.ts
+++ b/app/components/Views/WalletActions/WalletActionsBottomSheet.testIds.ts
@@ -1,3 +1,5 @@
+import enContent from '../../../../locales/languages/en.json';
+
export const WalletActionsBottomSheetSelectorsIDs = {
SEND_BUTTON: 'wallet-send-button',
RECEIVE_BUTTON: 'wallet-receive-action',
@@ -11,3 +13,8 @@ export const WalletActionsBottomSheetSelectorsIDs = {
PERPS_BUTTON: 'wallet-perps-action',
PREDICT_BUTTON: 'wallet-predict-action',
};
+
+export const WalletActionsBottomSheetSelectorsText = {
+ PERPS_DESCRIPTION: enContent.asset_overview.perps_description,
+ PREDICT_DESCRIPTION: enContent.asset_overview.predict_description,
+} as const;
diff --git a/tests/flows/wallet.flow.ts b/tests/flows/wallet.flow.ts
index 8d4a902ba01..db24d36fe10 100644
--- a/tests/flows/wallet.flow.ts
+++ b/tests/flows/wallet.flow.ts
@@ -8,6 +8,7 @@ import {
Utilities,
} from '../framework';
import Assertions from '../framework/Assertions';
+import { asDetoxElement } from '../framework/EncapsulatedElement';
import NetworkEducationModal from '../page-objects/Network/NetworkEducationModal';
import {
getAnvilPortForFixture,
@@ -99,7 +100,7 @@ export const addLocalhostNetwork = async (): Promise => {
description: 'Network Education Modal should be visible',
});
await Assertions.expectElementToHaveText(
- NetworkEducationModal.networkName,
+ asDetoxElement(NetworkEducationModal.networkName),
'Localhost',
{
description: 'Network Name should be Localhost',
@@ -360,7 +361,7 @@ export const switchToSepoliaNetwork = async (): Promise => {
);
await Assertions.expectElementToBeVisible(NetworkEducationModal.container);
await Assertions.expectElementToHaveText(
- NetworkEducationModal.networkName,
+ asDetoxElement(NetworkEducationModal.networkName),
CustomNetworks.Sepolia.providerConfig.nickname,
);
await NetworkEducationModal.tapGotItButton();
@@ -394,9 +395,12 @@ export const loginToApp = async (password?: string): Promise => {
await Assertions.expectElementToBeVisible(LoginView.container, {
description: 'Login View container should be visible',
});
- await Assertions.expectElementToBeVisible(LoginView.passwordInput, {
- description: 'Login View password input should be visible',
- });
+ await Assertions.expectElementToBeVisible(
+ asDetoxElement(LoginView.passwordInput),
+ {
+ description: 'Login View password input should be visible',
+ },
+ );
await LoginView.enterPassword(PASSWORD);
diff --git a/tests/framework/GestureStrategy.ts b/tests/framework/GestureStrategy.ts
index 3e4c5c78c50..8a4f062ebcb 100644
--- a/tests/framework/GestureStrategy.ts
+++ b/tests/framework/GestureStrategy.ts
@@ -18,6 +18,10 @@ export interface UnifiedGestureOptions {
timeout?: number;
/** Human-readable description for logging and error messages */
description?: string;
+ /** Swipe speed — Detox only; Appium ignores this */
+ speed?: 'fast' | 'slow';
+ /** Swipe percentage (0–1) — Detox only; Appium ignores this */
+ percentage?: number;
}
/**
@@ -184,6 +188,8 @@ export class DetoxGestureStrategy implements GestureStrategy {
await Gestures.swipe(asDetoxElement(elem), direction, {
timeout: opts?.timeout,
elemDescription: opts?.description,
+ speed: opts?.speed,
+ percentage: opts?.percentage,
});
}
diff --git a/tests/page-objects/Network/NetworkEducationModal.ts b/tests/page-objects/Network/NetworkEducationModal.ts
index 2284cd034a6..aa976501872 100644
--- a/tests/page-objects/Network/NetworkEducationModal.ts
+++ b/tests/page-objects/Network/NetworkEducationModal.ts
@@ -3,19 +3,55 @@ import {
NetworkEducationModalSelectorsText,
} from '../../../app/components/UI/NetworkInfo/NetworkEducationModal.testIds';
import Matchers from '../../framework/Matchers';
-import Gestures from '../../framework/Gestures';
+import UnifiedGestures from '../../framework/UnifiedGestures';
+import {
+ encapsulated,
+ EncapsulatedElementType,
+} from '../../framework/EncapsulatedElement';
+import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
class NetworkEducationModal {
- get container(): DetoxElement {
- return Matchers.getElementByID(NetworkEducationModalSelectorsIDs.CONTAINER);
+ get container(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(NetworkEducationModalSelectorsIDs.CONTAINER),
+ appium: {
+ android: () =>
+ PlaywrightMatchers.getElementById(
+ NetworkEducationModalSelectorsIDs.CONTAINER,
+ { exact: true },
+ ),
+ ios: () =>
+ PlaywrightMatchers.getElementByAccessibilityId(
+ NetworkEducationModalSelectorsIDs.CONTAINER,
+ ),
+ },
+ });
}
- get closeButton(): DetoxElement {
- return device.getPlatform() === 'ios'
- ? Matchers.getElementByID(NetworkEducationModalSelectorsIDs.CLOSE_BUTTON)
- : Matchers.getElementByLabel(
- NetworkEducationModalSelectorsIDs.CLOSE_BUTTON,
- );
+ get closeButton(): EncapsulatedElementType {
+ return encapsulated({
+ // Android Detox: testID may not be exposed for StyledButton; use label. iOS: use testID.
+ detox: () =>
+ device.getPlatform() === 'android'
+ ? Matchers.getElementByLabel(
+ NetworkEducationModalSelectorsText.GOT_IT,
+ )
+ : Matchers.getElementByID(
+ NetworkEducationModalSelectorsIDs.CLOSE_BUTTON,
+ ),
+ appium: {
+ android: () =>
+ PlaywrightMatchers.getElementById(
+ NetworkEducationModalSelectorsIDs.CLOSE_BUTTON,
+ { exact: true },
+ ),
+ ios: () =>
+ PlaywrightMatchers.getElementByAccessibilityId(
+ NetworkEducationModalSelectorsIDs.CLOSE_BUTTON,
+ ),
+ },
+ });
}
get addToken(): DetoxElement {
@@ -24,20 +60,29 @@ class NetworkEducationModal {
);
}
- get networkName(): DetoxElement {
- return Matchers.getElementByID(
- NetworkEducationModalSelectorsIDs.NETWORK_NAME,
- );
+ /** Network name - wdio uses networkEducationNetworkName */
+ get networkName(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(NetworkEducationModalSelectorsIDs.NETWORK_NAME),
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ NetworkEducationModalSelectorsIDs.NETWORK_NAME,
+ { exact: true },
+ ),
+ });
}
async tapGotItButton(): Promise {
- await Gestures.waitAndTap(this.closeButton, {
- elemDescription: 'Got it button',
+ await UnifiedGestures.waitAndTap(this.closeButton, {
+ description: 'Got it button',
});
}
async tapNetworkName(): Promise {
- await Gestures.waitAndTap(this.networkName);
+ await UnifiedGestures.waitAndTap(this.networkName, {
+ description: 'Network name',
+ });
}
}
diff --git a/tests/page-objects/Perps/PerpsDepositView.ts b/tests/page-objects/Perps/PerpsDepositView.ts
index ba2793d9fad..66af73863db 100644
--- a/tests/page-objects/Perps/PerpsDepositView.ts
+++ b/tests/page-objects/Perps/PerpsDepositView.ts
@@ -1,6 +1,11 @@
import Matchers from '../../framework/Matchers';
import Gestures from '../../framework/Gestures';
import Assertions from '../../framework/Assertions';
+import {
+ encapsulated,
+ EncapsulatedElementType,
+} from '../../framework/EncapsulatedElement';
+import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
class PerpsDepositView {
// Custom deposit keypad container
@@ -8,6 +13,33 @@ class PerpsDepositView {
return Matchers.getElementByID('deposit-keyboard');
}
+ /** Amount input - wdio PerpsDepositScreen uses 'custom-amount-input' for isAmountInputVisible */
+ get amountInput(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () => Matchers.getElementByID('custom-amount-input'),
+ appium: () =>
+ PlaywrightMatchers.getElementById('custom-amount-input', {
+ exact: true,
+ }),
+ });
+ }
+
+ /** Add funds button - wdio uses getElementByText('Add funds') for isAddFundsVisible */
+ get addFundsButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () => Matchers.getElementByText('Add funds'),
+ appium: () => PlaywrightMatchers.getElementByText('Add funds'),
+ });
+ }
+
+ /** Total text - wdio uses getElementByText('Total') for isTotalVisible */
+ get totalText(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () => Matchers.getElementByText('Total'),
+ appium: () => PlaywrightMatchers.getElementByText('Total'),
+ });
+ }
+
// Continue button (toolbar text)
get continueButtonByText(): DetoxElement {
return Matchers.getElementByText('Continue');
diff --git a/tests/page-objects/Perps/PerpsMarketDetailsView.ts b/tests/page-objects/Perps/PerpsMarketDetailsView.ts
index c415430a698..af60730f57c 100644
--- a/tests/page-objects/Perps/PerpsMarketDetailsView.ts
+++ b/tests/page-objects/Perps/PerpsMarketDetailsView.ts
@@ -3,11 +3,21 @@ import {
PerpsMarketHeaderSelectorsIDs,
PerpsCandlestickChartSelectorsIDs,
PerpsOpenOrderCardSelectorsIDs,
+ PerpsClosePositionViewSelectorsIDs,
} from '../../../app/components/UI/Perps/Perps.testIds';
import Gestures from '../../framework/Gestures';
import Matchers from '../../framework/Matchers';
import Utilities from '../../framework/Utilities';
import Assertions from '../../framework/Assertions';
+import {
+ encapsulated,
+ EncapsulatedElementType,
+ asPlaywrightElement,
+ asDetoxElement,
+} from '../../framework/EncapsulatedElement';
+import { encapsulatedAction } from '../../framework/encapsulatedAction';
+import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
+import UnifiedGestures from '../../framework/UnifiedGestures';
class PerpsMarketDetailsView {
// Container elements
@@ -25,9 +35,19 @@ class PerpsMarketDetailsView {
return Matchers.getElementByID(PerpsMarketDetailsViewSelectorsIDs.ERROR);
}
- // Header elements
- get header() {
- return Matchers.getElementByID(PerpsMarketDetailsViewSelectorsIDs.HEADER);
+ /** Header - wdio PerpsPositionDetailsView uses 'perps-market-header' for isContainerDisplayed */
+ get header(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(PerpsMarketDetailsViewSelectorsIDs.HEADER),
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ PerpsMarketDetailsViewSelectorsIDs.HEADER,
+ {
+ exact: true,
+ },
+ ),
+ });
}
get backButton() {
@@ -117,6 +137,34 @@ class PerpsMarketDetailsView {
);
}
+ get closeButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(
+ PerpsMarketDetailsViewSelectorsIDs.CLOSE_BUTTON,
+ ) as DetoxElement,
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ PerpsMarketDetailsViewSelectorsIDs.CLOSE_BUTTON,
+ { exact: true },
+ ),
+ });
+ }
+
+ get confirmCloseButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(
+ PerpsClosePositionViewSelectorsIDs.CLOSE_POSITION_CONFIRM_BUTTON,
+ ) as DetoxElement,
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ PerpsClosePositionViewSelectorsIDs.CLOSE_POSITION_CONFIRM_BUTTON,
+ { exact: true },
+ ),
+ });
+ }
+
// Trading action buttons
get longButton() {
return Matchers.getElementByID(
@@ -264,6 +312,69 @@ class PerpsMarketDetailsView {
timeout: 5000,
});
}
+
+ async isContainerDisplayed(): Promise {
+ await encapsulatedAction({
+ detox: async () => {
+ const headerEl = asDetoxElement(this.header);
+ await Assertions.expectElementToBeVisible(headerEl, {
+ description: 'Perps market details header visible',
+ timeout: 20000,
+ });
+ },
+ appium: async () => {
+ const headerEl = await asPlaywrightElement(this.header);
+ await headerEl.waitForDisplayed({ timeout: 20000 });
+ },
+ });
+ }
+
+ async isPositionOpen(timeout = 5000): Promise {
+ await encapsulatedAction({
+ detox: async () => {
+ const el = asDetoxElement(this.closeButton);
+ await Assertions.expectElementToBeVisible(el, {
+ timeout,
+ description: 'Close position button',
+ });
+ },
+ appium: async () => {
+ const closeEl = await asPlaywrightElement(this.closeButton);
+ await closeEl.waitForDisplayed({ timeout });
+ },
+ });
+ }
+
+ async tapClosePositionButton(): Promise {
+ await UnifiedGestures.waitAndTap(this.closeButton, {
+ description: 'Close position button',
+ });
+ await UnifiedGestures.waitAndTap(this.confirmCloseButton, {
+ description: 'Confirm close position button',
+ });
+ }
+
+ async closePositionWithRetry(): Promise {
+ await encapsulatedAction({
+ detox: async () => {
+ await this.isPositionOpen();
+ await this.tapClosePositionButton();
+ await Assertions.expectElementToNotBeVisible(
+ asDetoxElement(this.closeButton),
+ {
+ timeout: 5000,
+ description: 'Close button disappears after confirm',
+ },
+ );
+ },
+ appium: async () => {
+ await this.isPositionOpen();
+ await this.tapClosePositionButton();
+ const closeEl = await asPlaywrightElement(this.closeButton);
+ await closeEl.waitForDisplayed({ reverse: true, timeout: 5000 });
+ },
+ });
+ }
}
export default new PerpsMarketDetailsView();
diff --git a/tests/page-objects/Perps/PerpsMarketListView.ts b/tests/page-objects/Perps/PerpsMarketListView.ts
index 129b1e75226..366252d0019 100644
--- a/tests/page-objects/Perps/PerpsMarketListView.ts
+++ b/tests/page-objects/Perps/PerpsMarketListView.ts
@@ -5,6 +5,11 @@ import {
} from '../../../app/components/UI/Perps/Perps.testIds';
import Gestures from '../../framework/Gestures';
import Matchers from '../../framework/Matchers';
+import {
+ encapsulated,
+ EncapsulatedElementType,
+} from '../../framework/EncapsulatedElement';
+import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
class PerpsMarketListView {
// Main container
@@ -32,8 +37,14 @@ class PerpsMarketListView {
);
}
- get listHeader() {
- return Matchers.getElementByID(PerpsMarketListViewSelectorsIDs.LIST_HEADER);
+ /** List header - wdio PerpsMarketListView uses 'perps-home' for isHeaderVisible */
+ get listHeader(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(PerpsMarketListViewSelectorsIDs.LIST_HEADER),
+ appium: () =>
+ PlaywrightMatchers.getElementById('perps-home', { exact: true }),
+ });
}
get marketRowItemBTC() {
diff --git a/tests/page-objects/Perps/PerpsOnboarding.ts b/tests/page-objects/Perps/PerpsOnboarding.ts
index 33683efe942..f03a41aba76 100644
--- a/tests/page-objects/Perps/PerpsOnboarding.ts
+++ b/tests/page-objects/Perps/PerpsOnboarding.ts
@@ -1,25 +1,68 @@
-import Gestures from '../../framework/Gestures';
import Matchers from '../../framework/Matchers';
+import UnifiedGestures from '../../framework/UnifiedGestures';
+import {
+ encapsulated,
+ EncapsulatedElementType,
+} from '../../framework/EncapsulatedElement';
+import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
import { PerpsTutorialSelectorsIDs } from '../../../app/components/UI/Perps/Perps.testIds';
class PerpsOnboarding {
- get continueButton(): DetoxElement {
- return Matchers.getElementByID(PerpsTutorialSelectorsIDs.CONTINUE_BUTTON);
+ get continueButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(PerpsTutorialSelectorsIDs.CONTINUE_BUTTON),
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ PerpsTutorialSelectorsIDs.CONTINUE_BUTTON,
+ { exact: true },
+ ),
+ });
+ }
+
+ get skipButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(PerpsTutorialSelectorsIDs.SKIP_BUTTON),
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ PerpsTutorialSelectorsIDs.SKIP_BUTTON,
+ { exact: true },
+ ),
+ });
}
- get skipButton(): DetoxElement {
- return Matchers.getElementByID(PerpsTutorialSelectorsIDs.SKIP_BUTTON);
+ /** Add funds button - wdio uses getElementByCatchAll('Add funds') */
+ get addFundsButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () => Matchers.getElementByText('Add funds'),
+ appium: () => PlaywrightMatchers.getElementByText('Add funds'),
+ });
+ }
+
+ /** Tutorial title for isContainerDisplayed - wdio uses getElementByCatchAll('What are perps?') */
+ get tutorialTitle(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () => Matchers.getElementByText('What are perps?'),
+ appium: () => PlaywrightMatchers.getElementByText('What are perps?'),
+ });
}
async tapContinueButton(): Promise {
- await Gestures.waitAndTap(this.continueButton, {
- elemDescription: 'Perps Tutorial Continue Button',
+ await UnifiedGestures.waitAndTap(this.continueButton, {
+ description: 'Perps Tutorial Continue Button',
});
}
async tapSkipButton(): Promise {
- await Gestures.waitAndTap(this.skipButton, {
- elemDescription: 'Perps Tutorial Skip Button',
+ await UnifiedGestures.waitAndTap(this.skipButton, {
+ description: 'Perps Tutorial Skip Button',
+ });
+ }
+
+ async tapAddFunds(): Promise {
+ await UnifiedGestures.waitAndTap(this.addFundsButton, {
+ description: 'Add funds button',
});
}
}
diff --git a/tests/page-objects/Perps/PerpsOrderView.ts b/tests/page-objects/Perps/PerpsOrderView.ts
index 3af65bac5c9..d82e2959728 100644
--- a/tests/page-objects/Perps/PerpsOrderView.ts
+++ b/tests/page-objects/Perps/PerpsOrderView.ts
@@ -1,5 +1,6 @@
import Gestures from '../../framework/Gestures';
import Matchers from '../../framework/Matchers';
+import UnifiedGestures from '../../framework/UnifiedGestures';
import Assertions from '../../framework/Assertions';
import Utilities from '../../framework/Utilities';
import {
@@ -7,13 +8,25 @@ import {
PerpsOrderViewSelectorsIDs,
PerpsAmountDisplaySelectorsIDs,
} from '../../../app/components/UI/Perps/Perps.testIds';
+import {
+ encapsulated,
+ EncapsulatedElementType,
+} from '../../framework/EncapsulatedElement';
+import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
import { element as detoxElement, by as detoxBy } from 'detox';
class PerpsOrderView {
- get placeOrderButton() {
- return Matchers.getElementByID(
- PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON,
- );
+ /** Place order button - wdio uses 'perps-order-view-place-order-button' */
+ get placeOrderButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON),
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON,
+ { exact: true },
+ ),
+ });
}
get takeProfitButton() {
@@ -33,9 +46,12 @@ class PerpsOrderView {
return Matchers.getElementByText(`${leverageX}x`, index);
}
- // Row label to open the leverage modal (uses visible text "Leverage")
- get leverageRowLabel(): DetoxElement {
- return Matchers.getElementByText('Leverage');
+ /** Row label to open the leverage modal - wdio uses getElementByText('Leverage') */
+ get leverageRowLabel(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () => Matchers.getElementByText('Leverage'),
+ appium: () => PlaywrightMatchers.getElementByText('Leverage'),
+ });
}
// Modal title to ensure the leverage bottom sheet is visible
@@ -44,7 +60,9 @@ class PerpsOrderView {
}
async tapPlaceOrderButton() {
- await Gestures.waitAndTap(this.placeOrderButton);
+ await UnifiedGestures.waitAndTap(this.placeOrderButton, {
+ description: 'Place Order button',
+ });
}
async tapTakeProfitButton() {
@@ -59,8 +77,8 @@ class PerpsOrderView {
async selectLeverage(leverageX: number) {
// Open leverage modal
- await Gestures.waitAndTap(this.leverageRowLabel, {
- elemDescription: 'Open leverage modal',
+ await UnifiedGestures.waitAndTap(this.leverageRowLabel, {
+ description: 'Open leverage modal',
});
// Wait for the modal to be visible
diff --git a/tests/page-objects/swaps/QuoteView.ts b/tests/page-objects/swaps/QuoteView.ts
index be1b01f6526..918199b5ca0 100644
--- a/tests/page-objects/swaps/QuoteView.ts
+++ b/tests/page-objects/swaps/QuoteView.ts
@@ -1,13 +1,31 @@
import { waitFor } from 'detox';
-import Matchers from '../../framework/Matchers';
-import Gestures from '../../framework/Gestures';
-import Assertions from '../../framework/Assertions';
+import {
+ Assertions,
+ Gestures,
+ Matchers,
+ PlaywrightMatchers,
+ UnifiedGestures,
+ encapsulated,
+ encapsulatedAction,
+ asDetoxElement,
+ asPlaywrightElement,
+ type EncapsulatedElementType,
+} from '../../framework';
+import { getAssetTestId } from '../../../wdio/screen-objects/testIDs/Screens/WalletView.testIds';
import {
QuoteViewSelectorIDs,
QuoteViewSelectorText,
+ getChainIdForNetwork,
} from '../../selectors/Bridge/QuoteView.selectors';
-const TOKEN_LIST_MATCHER = by.id(QuoteViewSelectorIDs.TOKEN_LIST);
+const TIMEOUT = {
+ SWAP_SCREEN_VISIBLE: 10000,
+ TOKEN_EXISTS_BEFORE_SCROLL: 15000,
+ QUOTE_DISPLAYED: 30000,
+ NETWORK_SELECT: 10000,
+ TOKEN_SELECT: 30000,
+ KEYPAD_DIGIT: 10000,
+} as const;
class QuoteView {
get selectAmountLabel(): DetoxElement {
@@ -26,12 +44,28 @@ class QuoteView {
return Matchers.getElementByID(QuoteViewSelectorIDs.SOURCE_TOKEN_AREA);
}
- get amountInput(): DetoxElement {
- return Matchers.getElementByID(QuoteViewSelectorIDs.SOURCE_TOKEN_INPUT);
+ get amountInput(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(QuoteViewSelectorIDs.SOURCE_TOKEN_INPUT),
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ QuoteViewSelectorIDs.SOURCE_TOKEN_INPUT,
+ { exact: true },
+ ),
+ });
}
- get destinationTokenArea(): DetoxElement {
- return Matchers.getElementByID(QuoteViewSelectorIDs.DESTINATION_TOKEN_AREA);
+ get destinationTokenArea(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(QuoteViewSelectorIDs.DESTINATION_TOKEN_AREA),
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ QuoteViewSelectorIDs.DESTINATION_TOKEN_AREA,
+ { exact: true },
+ ),
+ });
}
get searchToken(): Promise {
@@ -52,8 +86,27 @@ class QuoteView {
return Matchers.getElementByText(QuoteViewSelectorText.NETWORK_FEE);
}
- get bridgeViewScroll(): DetoxElement {
- return Matchers.getElementByID(QuoteViewSelectorIDs.BRIDGE_VIEW_SCROLL);
+ get bridgeViewScroll(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(QuoteViewSelectorIDs.BRIDGE_VIEW_SCROLL),
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ QuoteViewSelectorIDs.BRIDGE_VIEW_SCROLL,
+ { exact: true },
+ ),
+ });
+ }
+
+ /** Fee disclaimer (e.g. "Includes 0.875% MetaMask fee") - used for isQuoteDisplayed. */
+ get feeDisclaimerLabel(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () => Matchers.getElementByID(QuoteViewSelectorIDs.FEE_DISCLAIMER),
+ appium: () =>
+ PlaywrightMatchers.getElementById(QuoteViewSelectorIDs.FEE_DISCLAIMER, {
+ exact: true,
+ }),
+ });
}
get keypadDeleteButton(): DetoxElement {
@@ -72,9 +125,13 @@ class QuoteView {
return Matchers.getElementByText(QuoteViewSelectorText.RATE);
}
+ /** Token selector testID - matches TokenSelectorItem's getAssetTestId(chainId-symbol). */
+ getTokenElementId(chainId: string, symbol: string): string {
+ return getAssetTestId(`${chainId}-${symbol}`);
+ }
+
token(chainId: string, symbol: string): Detox.NativeElement {
- const elementId = `asset-${chainId}-${symbol}`;
- return element(by.id(elementId)).atIndex(0);
+ return element(by.id(this.getTokenElementId(chainId, symbol))).atIndex(0);
}
async enterAmount(amount: string): Promise {
@@ -93,26 +150,38 @@ class QuoteView {
}
async tapToken(chainId: string, symbol: string): Promise {
- const tokenElement = this.token(chainId, symbol);
- // Wait for the token element to exist first (network change may still be in progress)
- await waitFor(tokenElement).toExist().withTimeout(15000);
- // Scroll to the token element since it may be below the visible viewport
- await Gestures.scrollToElement(
- tokenElement as unknown as DetoxElement,
- Promise.resolve(TOKEN_LIST_MATCHER),
- {
- direction: 'down',
- scrollAmount: 350,
- elemDescription: `Scroll to token symbol ${symbol}`,
+ await encapsulatedAction({
+ detox: async () => {
+ const tokenElement = this.token(chainId, symbol);
+ await waitFor(tokenElement)
+ .toExist()
+ .withTimeout(TIMEOUT.TOKEN_EXISTS_BEFORE_SCROLL);
+ await Gestures.scrollToElement(
+ tokenElement as unknown as DetoxElement,
+ Matchers.getIdentifier(QuoteViewSelectorIDs.TOKEN_LIST),
+ {
+ direction: 'down',
+ scrollAmount: 350,
+ elemDescription: `Scroll to token symbol ${symbol}`,
+ },
+ );
+ await Gestures.waitAndTap(tokenElement as unknown as DetoxElement, {
+ delay: 1000,
+ elemDescription: `Select token symbol ${symbol}`,
+ });
+ },
+ appium: async () => {
+ const tokenElement = await PlaywrightMatchers.getElementById(
+ this.getTokenElementId(chainId, symbol),
+ { exact: false },
+ );
+ await tokenElement.waitForDisplayed({ timeout: TIMEOUT.TOKEN_SELECT });
+ await tokenElement.click();
},
- );
- await Gestures.waitAndTap(tokenElement as unknown as DetoxElement, {
- delay: 1000,
- elemDescription: `Select token symbol ${symbol}`,
});
}
- async typeSearchToken(symbol: string) {
+ async typeSearchToken(symbol: string): Promise {
await Gestures.typeText(this.searchToken, symbol, {
elemDescription: `Search Token with symbol ${symbol}`,
});
@@ -136,8 +205,8 @@ class QuoteView {
* Use before enterAmount() when the keypad may be closed (e.g. after returning from token/network selection).
*/
async tapSourceAmountInput(): Promise {
- await Gestures.waitAndTap(this.amountInput, {
- elemDescription: 'Tap source amount input to open keypad',
+ await UnifiedGestures.waitAndTap(this.amountInput, {
+ description: 'Tap source amount input to open keypad',
});
}
@@ -148,8 +217,8 @@ class QuoteView {
}
async tapDestinationToken(): Promise {
- await Gestures.waitAndTap(this.destinationTokenArea, {
- elemDescription: 'Tap destination asset picker',
+ await UnifiedGestures.waitAndTap(this.destinationTokenArea, {
+ description: 'Tap destination asset picker',
});
}
@@ -165,10 +234,22 @@ class QuoteView {
}
async selectNetwork(network: string): Promise {
- const networkElement = Matchers.getElementByText(network);
- await Gestures.waitAndTap(networkElement, {
- delay: 1000,
- elemDescription: `Select network ${network}`,
+ await encapsulatedAction({
+ detox: async () => {
+ const networkElement = Matchers.getElementByText(network);
+ await Gestures.waitAndTap(networkElement, {
+ delay: 1000,
+ elemDescription: `Select network ${network}`,
+ });
+ },
+ appium: async () => {
+ const networkElement =
+ await PlaywrightMatchers.getElementByCatchAll(network);
+ await networkElement.waitForDisplayed({
+ timeout: TIMEOUT.NETWORK_SELECT,
+ });
+ await networkElement.click();
+ },
});
}
@@ -185,7 +266,7 @@ class QuoteView {
});
}
- async tapOnBackButton() {
+ async tapOnBackButton(): Promise {
await Gestures.waitAndTap(this.backButton, {
elemDescription: 'Back button on Quote View',
});
@@ -197,9 +278,88 @@ class QuoteView {
});
}
+ /**
+ * Asserts the swap/bridge view is visible (BridgeScreen.isVisible equivalent).
+ * Used by performance tests.
+ */
+ async isVisible(): Promise {
+ await encapsulatedAction({
+ detox: async () => {
+ await Assertions.expectElementToBeVisible(
+ asDetoxElement(this.amountInput),
+ {
+ timeout: TIMEOUT.SWAP_SCREEN_VISIBLE,
+ description: 'Swap screen source token input should be visible',
+ },
+ );
+ },
+ appium: async () => {
+ const el = await asPlaywrightElement(this.amountInput);
+ await el.waitForDisplayed({ timeout: TIMEOUT.SWAP_SCREEN_VISIBLE });
+ },
+ });
+ }
+
+ /**
+ * Asserts the quote is displayed (fee disclaimer visible).
+ * BridgeScreen.isQuoteDisplayed equivalent.
+ */
+ async isQuoteDisplayed(): Promise {
+ await encapsulatedAction({
+ detox: async () => {
+ await Assertions.expectElementToBeVisible(
+ asDetoxElement(this.feeDisclaimerLabel),
+ {
+ timeout: TIMEOUT.QUOTE_DISPLAYED,
+ description: 'Fee disclaimer (quote) should be visible',
+ },
+ );
+ },
+ appium: async () => {
+ const el = await asPlaywrightElement(this.feeDisclaimerLabel);
+ await el.waitForDisplayed({ timeout: TIMEOUT.QUOTE_DISPLAYED });
+ },
+ });
+ }
+
+ /**
+ * Selects destination network and token (BridgeScreen.selectNetworkAndTokenTo equivalent).
+ * Orchestrates tapDestinationToken, selectNetwork, tapToken. Supports Ethereum, Polygon, Solana.
+ */
+ async selectNetworkAndTokenTo(network: string, token: string): Promise {
+ await this.tapDestinationToken();
+ if (network !== 'Ethereum') {
+ await this.selectNetwork(network);
+ }
+ const chainId = getChainIdForNetwork(network);
+ await this.tapToken(chainId, token);
+ }
+
+ /**
+ * Enters source token amount via keypad (BridgeScreen.enterSourceTokenAmount equivalent).
+ */
+ async enterSourceTokenAmount(amount: string): Promise {
+ await encapsulatedAction({
+ detox: async () => {
+ await this.tapSourceAmountInput();
+ await this.enterAmount(amount);
+ },
+ appium: async () => {
+ await UnifiedGestures.waitAndTap(this.amountInput, {
+ description: 'Tap source amount input',
+ });
+ for (const digit of amount) {
+ const digitEl = await PlaywrightMatchers.getElementByText(digit);
+ await digitEl.waitForDisplayed({ timeout: TIMEOUT.KEYPAD_DIGIT });
+ await digitEl.click();
+ }
+ },
+ });
+ }
+
/**
* Gets the slippage display text element (e.g., "2.5%")
- * @param value - The slippage value to match (e.g., "2.5" for "2.5%")
+ * @param value - The slippage value to match (e.g., "2.5" for 2.5%)
*/
slippageDisplayText(value: string): DetoxElement {
return Matchers.getElementByText(`${value}%`);
@@ -211,7 +371,7 @@ class QuoteView {
*/
async verifySlippageDisplayed(value: string): Promise {
await Assertions.expectElementToBeVisible(this.slippageDisplayText(value), {
- timeout: 10000,
+ timeout: TIMEOUT.SWAP_SCREEN_VISIBLE,
description: `Slippage should display ${value}%`,
});
}
diff --git a/tests/page-objects/wallet/AccountListBottomSheet.ts b/tests/page-objects/wallet/AccountListBottomSheet.ts
index 6011165e648..c23494763e1 100644
--- a/tests/page-objects/wallet/AccountListBottomSheet.ts
+++ b/tests/page-objects/wallet/AccountListBottomSheet.ts
@@ -8,12 +8,26 @@ import { ConnectAccountBottomSheetSelectorsIDs } from '../../../app/components/V
import { AccountCellIds } from '../../../app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.testIds';
import Matchers from '../../framework/Matchers';
import Gestures from '../../framework/Gestures';
+import UnifiedGestures from '../../framework/UnifiedGestures';
+import {
+ encapsulated,
+ EncapsulatedElementType,
+} from '../../framework/EncapsulatedElement';
+import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
class AccountListBottomSheet {
- get accountList(): DetoxElement {
- return Matchers.getElementByID(
- AccountListBottomSheetSelectorsIDs.ACCOUNT_LIST_ID,
- );
+ /** Account list container - wdio uses getElementByText('Accounts') for Appium */
+ get accountList(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(
+ AccountListBottomSheetSelectorsIDs.ACCOUNT_LIST_ID,
+ ),
+ appium: () =>
+ PlaywrightMatchers.getElementByText(
+ AccountListBottomSheetSelectorsText.ACCOUNTS_LIST_TITLE,
+ ),
+ });
}
get accountTypeLabel(): DetoxElement {
@@ -32,10 +46,19 @@ class AccountListBottomSheet {
);
}
- get addAccountButton(): DetoxElement {
- return Matchers.getElementByID(
- AccountListBottomSheetSelectorsIDs.ACCOUNT_LIST_ADD_BUTTON_ID,
- );
+ /** Add wallet/account button - wdio tapOnAddWalletButton uses 'account-list-add-account-button' */
+ get addAccountButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(
+ AccountListBottomSheetSelectorsIDs.ACCOUNT_LIST_ADD_BUTTON_ID,
+ ),
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ AccountListBottomSheetSelectorsIDs.ACCOUNT_LIST_ADD_BUTTON_ID,
+ { exact: true },
+ ),
+ });
}
get addEthereumAccountButton(): DetoxElement {
@@ -129,8 +152,8 @@ class AccountListBottomSheet {
}
async tapAddAccountButton(): Promise {
- await Gestures.waitAndTap(this.addAccountButton, {
- elemDescription: 'Add Account button',
+ await UnifiedGestures.waitAndTap(this.addAccountButton, {
+ description: 'Add Account button',
});
}
@@ -194,8 +217,8 @@ class AccountListBottomSheet {
}
async tapAccountByNameV2(accountName: string): Promise {
- const element = this.getAccountElementByAccountNameV2(accountName);
- await Gestures.waitAndTap(element, {
+ const accountEl = this.getAccountElementByAccountNameV2(accountName);
+ await Gestures.waitAndTap(accountEl, {
elemDescription: `Tap on account with name: ${accountName}`,
});
}
@@ -210,8 +233,9 @@ class AccountListBottomSheet {
}
async scrollToBottomOfAccountList(): Promise {
- await Gestures.swipe(this.accountList, 'up', {
+ await UnifiedGestures.swipe(this.accountList, 'up', {
speed: 'fast',
+ description: 'Scroll to bottom of account list',
});
}
diff --git a/tests/page-objects/wallet/AddAccountBottomSheet.ts b/tests/page-objects/wallet/AddAccountBottomSheet.ts
index 3b59d235e47..12fca277d59 100644
--- a/tests/page-objects/wallet/AddAccountBottomSheet.ts
+++ b/tests/page-objects/wallet/AddAccountBottomSheet.ts
@@ -1,6 +1,12 @@
import { AddAccountBottomSheetSelectorsIDs } from '../../../app/components/Views/AddAccountActions/AddAccountBottomSheet.testIds';
import Matchers from '../../framework/Matchers';
import Gestures from '../../framework/Gestures';
+import UnifiedGestures from '../../framework/UnifiedGestures';
+import {
+ encapsulated,
+ EncapsulatedElementType,
+} from '../../framework/EncapsulatedElement';
+import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
class AddAccountBottomSheet {
get importAccountButton(): DetoxElement {
@@ -21,10 +27,18 @@ class AddAccountBottomSheet {
);
}
- get importSrpButton(): DetoxElement {
- return Matchers.getElementByID(
- AddAccountBottomSheetSelectorsIDs.IMPORT_SRP_BUTTON,
- );
+ get importSrpButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(
+ AddAccountBottomSheetSelectorsIDs.IMPORT_SRP_BUTTON,
+ ),
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ AddAccountBottomSheetSelectorsIDs.IMPORT_SRP_BUTTON,
+ { exact: true },
+ ),
+ });
}
async tapImportAccount(): Promise {
@@ -40,8 +54,8 @@ class AddAccountBottomSheet {
}
async tapImportSrp(): Promise {
- await Gestures.waitAndTap(this.importSrpButton, {
- elemDescription: 'Import SRP button',
+ await UnifiedGestures.waitAndTap(this.importSrpButton, {
+ description: 'Import SRP button',
});
}
diff --git a/tests/page-objects/wallet/LoginView.ts b/tests/page-objects/wallet/LoginView.ts
index 00c874cf61c..3047819ecda 100644
--- a/tests/page-objects/wallet/LoginView.ts
+++ b/tests/page-objects/wallet/LoginView.ts
@@ -54,7 +54,7 @@ class LoginView {
return encapsulated({
detox: () => Matchers.getElementByID(LoginViewSelectors.TITLE_ID),
appium: () =>
- PlaywrightMatchers.getElementById(LoginViewSelectors.LOGIN_BUTTON_ID),
+ PlaywrightMatchers.getElementById(LoginViewSelectors.TITLE_ID),
});
}
@@ -85,8 +85,8 @@ class LoginView {
async waitForScreenToDisplay(): Promise {
await encapsulatedAction({
appium: async () => {
- const element = await asPlaywrightElement(this.title);
- await element.waitForDisplayed({ timeout: 15000 });
+ const titleEl = await asPlaywrightElement(this.title);
+ await titleEl.waitForDisplayed({ timeout: 15000 });
},
});
}
diff --git a/tests/page-objects/wallet/TabBarComponent.ts b/tests/page-objects/wallet/TabBarComponent.ts
index 34165f7067d..5de83f198fb 100644
--- a/tests/page-objects/wallet/TabBarComponent.ts
+++ b/tests/page-objects/wallet/TabBarComponent.ts
@@ -1,39 +1,129 @@
import Matchers from '../../framework/Matchers';
import Gestures from '../../framework/Gestures';
+import UnifiedGestures from '../../framework/UnifiedGestures';
import { TabBarSelectorIDs } from '../../../app/components/Nav/Main/TabBar.testIds';
import { Assertions, Utilities } from '../../framework';
+import {
+ encapsulated,
+ EncapsulatedElementType,
+} from '../../framework/EncapsulatedElement';
+import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
import ActivitiesView from '../Transactions/ActivitiesView';
import SettingsView from '../Settings/SettingsView';
import WalletView from './WalletView';
import TrendingView from '../Trending/TrendingView';
class TabBarComponent {
- get tabBarExploreButton(): DetoxElement {
- return Matchers.getElementByID(TabBarSelectorIDs.EXPLORE);
+ get tabBarExploreButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () => Matchers.getElementByID(TabBarSelectorIDs.EXPLORE),
+ appium: {
+ android: () =>
+ PlaywrightMatchers.getElementById(TabBarSelectorIDs.EXPLORE, {
+ exact: true,
+ }),
+ ios: () =>
+ PlaywrightMatchers.getElementByAccessibilityId(
+ TabBarSelectorIDs.EXPLORE,
+ ),
+ },
+ });
}
- get tabBarWalletButton(): DetoxElement {
- return Matchers.getElementByID(TabBarSelectorIDs.WALLET);
+ get tabBarWalletButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () => Matchers.getElementByID(TabBarSelectorIDs.WALLET),
+ appium: {
+ android: () =>
+ PlaywrightMatchers.getElementById(TabBarSelectorIDs.WALLET, {
+ exact: true,
+ }),
+ ios: () =>
+ PlaywrightMatchers.getElementByAccessibilityId(
+ TabBarSelectorIDs.WALLET,
+ ),
+ },
+ });
}
- get tabBarActionButton(): DetoxElement {
- return Matchers.getElementByID(TabBarSelectorIDs.TRADE);
+ get tabBarActionButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () => Matchers.getElementByID(TabBarSelectorIDs.TRADE),
+ appium: {
+ android: () =>
+ PlaywrightMatchers.getElementById(TabBarSelectorIDs.ACTIONS, {
+ exact: true,
+ }),
+ ios: () =>
+ PlaywrightMatchers.getElementByAccessibilityId(
+ TabBarSelectorIDs.ACTIONS,
+ ),
+ },
+ });
}
- get tabBarTradeButton(): DetoxElement {
- return Matchers.getElementByID(TabBarSelectorIDs.TRADE);
+ get tabBarTradeButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () => Matchers.getElementByID(TabBarSelectorIDs.TRADE),
+ appium: {
+ android: () =>
+ PlaywrightMatchers.getElementById(TabBarSelectorIDs.TRADE, {
+ exact: true,
+ }),
+ ios: () =>
+ PlaywrightMatchers.getElementByAccessibilityId(
+ TabBarSelectorIDs.TRADE,
+ ),
+ },
+ });
}
- get tabBarSettingButton(): DetoxElement {
- return Matchers.getElementByID(TabBarSelectorIDs.SETTING);
+ get tabBarSettingButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () => Matchers.getElementByID(TabBarSelectorIDs.SETTING),
+ appium: {
+ android: () =>
+ PlaywrightMatchers.getElementById(TabBarSelectorIDs.SETTING, {
+ exact: true,
+ }),
+ ios: () =>
+ PlaywrightMatchers.getElementByAccessibilityId(
+ TabBarSelectorIDs.SETTING,
+ ),
+ },
+ });
}
- get tabBarActivityButton(): DetoxElement {
- return Matchers.getElementByID(TabBarSelectorIDs.ACTIVITY);
+ get tabBarActivityButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () => Matchers.getElementByID(TabBarSelectorIDs.ACTIVITY),
+ appium: {
+ android: () =>
+ PlaywrightMatchers.getElementById(TabBarSelectorIDs.ACTIVITY, {
+ exact: true,
+ }),
+ ios: () =>
+ PlaywrightMatchers.getElementByAccessibilityId(
+ TabBarSelectorIDs.ACTIVITY,
+ ),
+ },
+ });
}
- get tabBarRewardsButton(): DetoxElement {
- return Matchers.getElementByID(TabBarSelectorIDs.REWARDS);
+ get tabBarRewardsButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () => Matchers.getElementByID(TabBarSelectorIDs.REWARDS),
+ appium: {
+ android: () =>
+ PlaywrightMatchers.getElementById(TabBarSelectorIDs.REWARDS, {
+ exact: true,
+ }),
+ ios: () =>
+ PlaywrightMatchers.getElementByAccessibilityId(
+ TabBarSelectorIDs.REWARDS,
+ ),
+ },
+ });
}
async tapHome(): Promise {
@@ -44,7 +134,7 @@ class TabBarComponent {
async tapWallet(): Promise {
await Utilities.executeWithRetry(
async () => {
- await Gestures.waitAndTap(this.tabBarWalletButton, {
+ await UnifiedGestures.waitAndTap(this.tabBarWalletButton, {
timeout: 2000,
});
await Assertions.expectElementToBeVisible(WalletView.container, {
@@ -61,14 +151,14 @@ class TabBarComponent {
}
async tapActions(): Promise {
- await Gestures.waitAndTap(this.tabBarActionButton, {
- elemDescription: 'Tab Bar - Trade Button',
+ await UnifiedGestures.waitAndTap(this.tabBarActionButton, {
+ description: 'Tab Bar - Trade Button',
});
}
async tapTrade(): Promise {
- await Gestures.waitAndTap(this.tabBarTradeButton, {
- elemDescription: 'Tab Bar - Trade Button',
+ await UnifiedGestures.waitAndTap(this.tabBarTradeButton, {
+ description: 'Tab Bar - Trade Button',
});
}
@@ -76,7 +166,7 @@ class TabBarComponent {
await Utilities.executeWithRetry(
async () => {
// Navigate to Wallet first (where the hamburger menu lives)
- await Gestures.waitAndTap(this.tabBarWalletButton);
+ await UnifiedGestures.waitAndTap(this.tabBarWalletButton);
await Assertions.expectElementToBeVisible(WalletView.container);
await Gestures.waitAndTap(WalletView.hamburgerMenuButton);
await Assertions.expectElementToBeVisible(SettingsView.title);
@@ -90,7 +180,7 @@ class TabBarComponent {
async tapExploreButton(): Promise {
await Utilities.executeWithRetry(
async () => {
- await Gestures.waitAndTap(this.tabBarExploreButton, {
+ await UnifiedGestures.waitAndTap(this.tabBarExploreButton, {
timeout: 2000,
});
await Assertions.expectElementToBeVisible(TrendingView.searchButton, {
@@ -110,7 +200,7 @@ class TabBarComponent {
async tapActivity(): Promise {
await Utilities.executeWithRetry(
async () => {
- await Gestures.waitAndTap(this.tabBarActivityButton, {
+ await UnifiedGestures.waitAndTap(this.tabBarActivityButton, {
timeout: 2000,
});
await Assertions.expectElementToBeVisible(ActivitiesView.title, {
@@ -130,7 +220,7 @@ class TabBarComponent {
async tapRewards(): Promise {
await Utilities.executeWithRetry(
async () => {
- await Gestures.waitAndTap(this.tabBarRewardsButton, {
+ await UnifiedGestures.waitAndTap(this.tabBarRewardsButton, {
timeout: 2000,
});
},
diff --git a/tests/page-objects/wallet/TokenOverview.ts b/tests/page-objects/wallet/TokenOverview.ts
index 7a5fcda1bc0..42f598c8056 100644
--- a/tests/page-objects/wallet/TokenOverview.ts
+++ b/tests/page-objects/wallet/TokenOverview.ts
@@ -1,5 +1,6 @@
import Matchers from '../../framework/Matchers';
import Gestures from '../../framework/Gestures';
+import UnifiedGestures from '../../framework/UnifiedGestures';
import {
TokenOverviewSelectorsIDs,
TokenOverviewSelectorsText,
@@ -7,18 +8,65 @@ import {
import { WalletActionsBottomSheetSelectorsIDs } from '../../../app/components/Views/WalletActions/WalletActionsBottomSheet.testIds';
import { WalletViewSelectorsIDs } from '../../../app/components/Views/Wallet/WalletView.testIds';
import { CommonSelectorsIDs } from '../../../app/util/Common.testIds';
+import {
+ encapsulated,
+ EncapsulatedElementType,
+} from '../../framework/EncapsulatedElement';
+import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
class TokenOverview {
- get container(): DetoxElement {
- return Matchers.getElementByID(TokenOverviewSelectorsIDs.TOKEN_PRICE);
+ get container(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(TokenOverviewSelectorsIDs.TOKEN_PRICE),
+ appium: {
+ android: () =>
+ PlaywrightMatchers.getElementById(
+ TokenOverviewSelectorsIDs.CONTAINER,
+ { exact: true },
+ ),
+ ios: () =>
+ PlaywrightMatchers.getElementByAccessibilityId(
+ TokenOverviewSelectorsIDs.CONTAINER,
+ ),
+ },
+ });
}
get tokenPrice(): DetoxElement {
return Matchers.getElementByID(TokenOverviewSelectorsIDs.TOKEN_PRICE);
}
- get sendButton(): DetoxElement {
- return Matchers.getElementByID(TokenOverviewSelectorsIDs.SEND_BUTTON);
+ get sendButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(TokenOverviewSelectorsIDs.SEND_BUTTON),
+ appium: {
+ android: () =>
+ PlaywrightMatchers.getElementById(
+ TokenOverviewSelectorsIDs.SEND_BUTTON,
+ { exact: true },
+ ),
+ ios: () =>
+ PlaywrightMatchers.getElementByAccessibilityId(
+ TokenOverviewSelectorsIDs.SEND_BUTTON,
+ ),
+ },
+ });
+ }
+
+ /** Today's change display (e.g. "+2.5% Today") - used by performance tests */
+ get todaysChange(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByText(
+ TokenOverviewSelectorsText.TODAYS_CHANGE_SUFFIX,
+ ),
+ appium: () =>
+ PlaywrightMatchers.getElementByText(
+ TokenOverviewSelectorsText.TODAYS_CHANGE_SUFFIX,
+ ),
+ });
}
get unstakeButton(): DetoxElement {
@@ -92,7 +140,9 @@ class TokenOverview {
}
async tapSendButton(): Promise {
- await Gestures.waitAndTap(this.sendButton);
+ await UnifiedGestures.waitAndTap(this.sendButton, {
+ description: 'Send Button',
+ });
}
async tapActionSheetSendButton(): Promise {
diff --git a/tests/page-objects/wallet/WalletActionsBottomSheet.ts b/tests/page-objects/wallet/WalletActionsBottomSheet.ts
index 3623a3db651..ff891f1e770 100644
--- a/tests/page-objects/wallet/WalletActionsBottomSheet.ts
+++ b/tests/page-objects/wallet/WalletActionsBottomSheet.ts
@@ -1,6 +1,14 @@
import { WalletActionsBottomSheetSelectorsIDs } from '../../../app/components/Views/WalletActions/WalletActionsBottomSheet.testIds';
import Matchers from '../../framework/Matchers';
import Gestures from '../../framework/Gestures';
+import UnifiedGestures from '../../framework/UnifiedGestures';
+import {
+ encapsulated,
+ EncapsulatedElementType,
+ asPlaywrightElement,
+} from '../../framework/EncapsulatedElement';
+import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
+import { encapsulatedAction } from '../../framework/encapsulatedAction';
class WalletActionsBottomSheet {
get sendButton(): DetoxElement {
@@ -39,15 +47,32 @@ class WalletActionsBottomSheet {
);
}
- get perpsButton(): DetoxElement {
- return Matchers.getElementByID(
- WalletActionsBottomSheetSelectorsIDs.PERPS_BUTTON,
- );
+ get perpsButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(
+ WalletActionsBottomSheetSelectorsIDs.PERPS_BUTTON,
+ ),
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ WalletActionsBottomSheetSelectorsIDs.PERPS_BUTTON,
+ { exact: true },
+ ),
+ });
}
- get predictButton(): DetoxElement {
- return Matchers.getElementByID(
- WalletActionsBottomSheetSelectorsIDs.PREDICT_BUTTON,
- );
+
+ get predictButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(
+ WalletActionsBottomSheetSelectorsIDs.PREDICT_BUTTON,
+ ),
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ WalletActionsBottomSheetSelectorsIDs.PREDICT_BUTTON,
+ { exact: true },
+ ),
+ });
}
async tapSendButton(): Promise {
@@ -79,11 +104,24 @@ class WalletActionsBottomSheet {
}
async tapPerpsButton(): Promise {
- await Gestures.waitAndTap(this.perpsButton);
+ await UnifiedGestures.waitAndTap(this.perpsButton, {
+ description: 'Perps Button',
+ });
}
async tapPredictButton(): Promise {
- await Gestures.waitAndTap(this.predictButton);
+ await UnifiedGestures.waitAndTap(this.predictButton, {
+ description: 'Predict Button',
+ });
+ }
+ // We would need to update this as assertions should not live in page objects
+ async checkModalVisibility(): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const resolved = await asPlaywrightElement(this.perpsButton);
+ await resolved.waitForDisplayed({ timeout: 5000 });
+ },
+ });
}
async swipeDownActionsBottomSheet(): Promise {
diff --git a/tests/page-objects/wallet/WalletView.ts b/tests/page-objects/wallet/WalletView.ts
index e4197b3a90d..19ab9a8383d 100644
--- a/tests/page-objects/wallet/WalletView.ts
+++ b/tests/page-objects/wallet/WalletView.ts
@@ -11,6 +11,7 @@ import {
PredictClaimConfirmationSelectorsIDs,
} from '../../../app/components/UI/Predict/Predict.testIds';
import Gestures from '../../framework/Gestures';
+import UnifiedGestures from '../../framework/UnifiedGestures';
import Matchers from '../../framework/Matchers';
import TestHelpers from '../../helpers.js';
import Assertions from '../../framework/Assertions';
@@ -98,10 +99,16 @@ class WalletView {
return Matchers.getElementByID(WalletViewSelectorsIDs.NAVBAR_NETWORK_TEXT);
}
- get navbarNetworkButton(): DetoxElement {
- return Matchers.getElementByID(
- WalletViewSelectorsIDs.NAVBAR_NETWORK_BUTTON,
- );
+ get navbarNetworkButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(WalletViewSelectorsIDs.NAVBAR_NETWORK_BUTTON),
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ WalletViewSelectorsIDs.TOKEN_NETWORK_FILTER,
+ { exact: true },
+ ),
+ });
}
get navbarNetworkPicker(): DetoxElement {
@@ -568,6 +575,17 @@ class WalletView {
return Matchers.getElementByText(WalletViewSelectorsText.TOKENS_SECTION);
}
+ get tokensSection(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByText(WalletViewSelectorsText.TOKENS_SECTION),
+ appium: () =>
+ PlaywrightMatchers.getElementByText(
+ WalletViewSelectorsText.TOKENS_SECTION,
+ ),
+ });
+ }
+
/** NFTs section header on the homepage. */
get nftsSectionHeader(): DetoxElement {
return Matchers.getElementByText(WalletViewSelectorsText.NFTS_SECTION);
@@ -580,6 +598,21 @@ class WalletView {
});
}
+ async tapOnTokensSection(): Promise {
+ await encapsulatedAction({
+ detox: async () => {
+ await Gestures.waitAndTap(this.tokensSectionHeader, {
+ checkStability: true,
+ elemDescription: 'Tokens Section',
+ });
+ },
+ appium: async () => {
+ const el = await asPlaywrightElement(this.tokensSection);
+ await el.click();
+ },
+ });
+ }
+
async tapOnDeFiTab(): Promise {
await Gestures.waitAndTap(this.defiTab, {
elemDescription: 'DeFi Tab',
@@ -866,8 +899,22 @@ class WalletView {
return Matchers.getElementByID(WalletViewSelectorsIDs.WALLET_BUY_BUTTON);
}
- get walletSwapButton(): DetoxElement {
- return Matchers.getElementByID(WalletViewSelectorsIDs.WALLET_SWAP_BUTTON);
+ get walletSwapButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(WalletViewSelectorsIDs.WALLET_SWAP_BUTTON),
+ appium: {
+ android: () =>
+ PlaywrightMatchers.getElementById(
+ WalletViewSelectorsIDs.WALLET_SWAP_BUTTON,
+ { exact: true },
+ ),
+ ios: () =>
+ PlaywrightMatchers.getElementByAccessibilityId(
+ WalletViewSelectorsIDs.WALLET_SWAP_BUTTON,
+ ),
+ },
+ });
}
get walletBridgeButton(): DetoxElement {
@@ -929,8 +976,8 @@ class WalletView {
}
async tapWalletSwapButton(): Promise {
- await Gestures.waitAndTap(this.walletSwapButton, {
- elemDescription: 'Wallet Swap Button',
+ await UnifiedGestures.waitAndTap(this.walletSwapButton, {
+ description: 'Wallet Swap Button',
});
}
diff --git a/tests/regression/networks/add-custom-rpc.spec.ts b/tests/regression/networks/add-custom-rpc.spec.ts
index 85f8406a855..f120bd8dcc4 100644
--- a/tests/regression/networks/add-custom-rpc.spec.ts
+++ b/tests/regression/networks/add-custom-rpc.spec.ts
@@ -8,6 +8,7 @@ import { loginToApp } from '../../flows/wallet.flow';
import FixtureBuilder from '../../framework/fixtures/FixtureBuilder';
import { withFixtures } from '../../framework/fixtures/FixtureHelper';
import Assertions from '../../framework/Assertions';
+import { asDetoxElement } from '../../framework/EncapsulatedElement';
import { CustomNetworks } from '../../resources/networks.e2e';
import TestHelpers from '../../helpers';
@@ -74,7 +75,7 @@ describe.skip(RegressionAssets('Custom RPC Tests'), () => {
NetworkEducationModal.container,
);
await Assertions.expectElementToHaveText(
- NetworkEducationModal.networkName,
+ asDetoxElement(NetworkEducationModal.networkName),
CustomNetworks.Gnosis.providerConfig.nickname,
);
await NetworkEducationModal.tapGotItButton();
diff --git a/tests/selectors/Bridge/QuoteView.selectors.ts b/tests/selectors/Bridge/QuoteView.selectors.ts
index 6c806225e4e..4852120a9ae 100644
--- a/tests/selectors/Bridge/QuoteView.selectors.ts
+++ b/tests/selectors/Bridge/QuoteView.selectors.ts
@@ -1,6 +1,19 @@
import { toSentenceCase } from '../../../app/util/string';
import enContent from '../../../locales/languages/en.json';
+/** Default fee percentage (0.875%) used when quote has MetaMask fee. */
+const DEFAULT_FEE_PERCENTAGE = '0.875';
+
+/**
+ * Rendered text when quote includes MetaMask fee (used for isQuoteDisplayed assertion).
+ * Derived from en.json fee_disclaimer template - stays in sync with app copy.
+ */
+export const FEE_DISCLAIMER_QUOTE_VISIBLE =
+ enContent.bridge.fee_disclaimer.replace(
+ '{{feePercentage}}',
+ DEFAULT_FEE_PERCENTAGE,
+ );
+
export const QuoteViewSelectorText = {
NETWORK_FEE: toSentenceCase(enContent.bridge.network_fee),
CONFIRM_BRIDGE: enContent.bridge.confirm_bridge,
@@ -8,11 +21,23 @@ export const QuoteViewSelectorText = {
SELECT_AMOUNT: enContent.bridge.select_amount,
SELECT_ALL: enContent.bridge.see_all,
FEE_DISCLAIMER: enContent.bridge.fee_disclaimer,
+ FEE_DISCLAIMER_QUOTE_VISIBLE,
MAX: enContent.bridge.max,
INCLUDED: enContent.bridge.included,
RATE: enContent.bridge.rate,
};
+// Performance tests only: Maps network name to chain ID for token selection.
+export const NETWORK_TO_CHAIN_ID: Record = {
+ Ethereum: '0x1',
+ Polygon: '0x89',
+ Solana: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
+};
+
+export function getChainIdForNetwork(network: string): string {
+ return NETWORK_TO_CHAIN_ID[network] ?? '0x1';
+}
+
export const QuoteViewSelectorIDs = {
TOKEN_SEARCH_INPUT: 'bridge-token-search-input',
TOKEN_LIST: 'bridge-token-list',
@@ -24,6 +49,7 @@ export const QuoteViewSelectorIDs = {
SOURCE_TOKEN_SELECTOR: 'select-source-token-selector',
CONFIRM_BUTTON: 'bridge-confirm-button',
BRIDGE_VIEW_SCROLL: 'bridge-view-scroll',
+ FEE_DISCLAIMER: 'bridge-fee-disclaimer',
KEYPAD_DELETE_BUTTON: 'keypad-delete-button',
BACK_BUTTON: 'button-icon',
};
diff --git a/tests/smoke/swap/swap-deeplink-smoke.spec.ts b/tests/smoke/swap/swap-deeplink-smoke.spec.ts
index 266e82c85be..5cfbc0c4d1c 100644
--- a/tests/smoke/swap/swap-deeplink-smoke.spec.ts
+++ b/tests/smoke/swap/swap-deeplink-smoke.spec.ts
@@ -8,6 +8,7 @@ import { AnvilManager } from '../../seeder/anvil-manager';
import { AnvilPort } from '../../framework/fixtures/FixtureUtils';
import { SmokeTrade } from '../../tags';
import Assertions from '../../framework/Assertions';
+import { asDetoxElement } from '../../framework';
import QuoteView from '../../page-objects/swaps/QuoteView';
import { testSpecificMock } from '../../helpers/swap/swap-mocks';
import WalletView from '../../page-objects/wallet/WalletView';
@@ -77,7 +78,7 @@ describe(
await Assertions.expectTextDisplayed('USDC');
await Assertions.expectTextDisplayed('USDT');
await Assertions.expectElementToHaveText(
- QuoteView.amountInput,
+ asDetoxElement(QuoteView.amountInput),
'1.0',
);
await Assertions.expectElementToBeVisible(QuoteView.confirmSwap);
From 3251d01da89dca6e1791ccb5f5dbcc7ec62cee44 Mon Sep 17 00:00:00 2001
From: Christopher Ferreira
<104831203+christopherferreira9@users.noreply.github.com>
Date: Thu, 12 Mar 2026 22:42:36 +0000
Subject: [PATCH 008/206] test: migrates page objects for MM-Connect along with
required functions (#27436)
## **Description**
This PR migrates the page objects used by `mm-connect` in the
performance tests to the new unified framework.
## **Changelog**
CHANGELOG entry:
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/MMQA-1564
## **Manual testing steps**
N/A
## **Screenshots/Recordings**
### **Before**
N/A
### **After**
N/A
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> **Medium Risk**
> Moderate risk because it refactors shared E2E/performance test
infrastructure (driver utilities, visibility/wait options, and context
switching), which can cause widespread test flakiness if selector
semantics or WebView switching behavior differs.
>
> **Overview**
> Migrates MM-Connect performance test coverage off the legacy
`wdio/screen-objects` page objects into new TypeScript page objects
under `tests/page-objects/MMConnect`, built on the unified
`EncapsulatedElement` + `UnifiedGestures` APIs.
>
> Refactors Playwright/WebdriverIO test utilities by extracting
`boxedStep`/`getDriver` into `PlaywrightUtilities`, expanding
`PlaywrightElement.isVisible`/`waitForDisplayed` to accept WebdriverIO
`isDisplayed` options, and adding a custom `expect.toBeVisible` matcher
in the performance fixture.
>
> Adds `PlaywrightContextHelpers` to make WebView context switching more
robust (URL matching with a LavaMoat-scuttling fallback) and introduces
shared `MMConnectDappTestIds` plus updated Snap footer selector IDs;
`@wdio/protocols` is added to support typed context APIs.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
804fc1eb7cb28e30432c98448df69dfc6cea5529. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
package.json | 1 +
tests/framework/PlaywrightAdapter.ts | 41 +-
tests/framework/PlaywrightContextHelpers.ts | 142 +++++
tests/framework/PlaywrightGestures.ts | 2 +-
tests/framework/PlaywrightMatchers.ts | 4 +-
tests/framework/PlaywrightUtilities.ts | 99 ++++
tests/framework/Utilities.ts | 82 ---
.../performance/performance-fixture.ts | 23 +
tests/framework/index.ts | 9 +-
tests/page-objects/MMConnect/AddChainModal.ts | 43 ++
.../MMConnect/AndroidScreenHelpers.ts | 23 +
.../MMConnect/BrowserPlaygroundDapp.ts | 548 ++++++++++++++++++
.../MMConnect/DappConnectionModal.ts | 136 +++++
.../MMConnect/RNPlaygroundDapp.ts | 402 +++++++++++++
tests/page-objects/MMConnect/SignModal.ts | 59 ++
tests/page-objects/MMConnect/SnapSignModal.ts | 59 ++
.../MMConnect/SwitchChainModal.ts | 46 ++
.../selectors/Browser/TestSnaps.selectors.ts | 1 +
.../MMConnect/MMConnectDapp.testIds.ts | 55 ++
wdio/screen-objects/BrowserPlaygroundDapp.js | 1 +
wdio/screen-objects/Modals/AddChainModal.js | 1 +
.../Modals/DappConnectionModal.js | 1 +
wdio/screen-objects/Modals/SignModal.js | 1 +
wdio/screen-objects/Modals/SnapSignModal.js | 1 +
.../screen-objects/Modals/SwitchChainModal.js | 1 +
wdio/screen-objects/Native/Android.js | 1 +
wdio/screen-objects/RNPlaygroundDapp.js | 1 +
yarn.lock | 8 +
28 files changed, 1696 insertions(+), 95 deletions(-)
create mode 100644 tests/framework/PlaywrightContextHelpers.ts
create mode 100644 tests/framework/PlaywrightUtilities.ts
create mode 100644 tests/page-objects/MMConnect/AddChainModal.ts
create mode 100644 tests/page-objects/MMConnect/AndroidScreenHelpers.ts
create mode 100644 tests/page-objects/MMConnect/BrowserPlaygroundDapp.ts
create mode 100644 tests/page-objects/MMConnect/DappConnectionModal.ts
create mode 100644 tests/page-objects/MMConnect/RNPlaygroundDapp.ts
create mode 100644 tests/page-objects/MMConnect/SignModal.ts
create mode 100644 tests/page-objects/MMConnect/SnapSignModal.ts
create mode 100644 tests/page-objects/MMConnect/SwitchChainModal.ts
create mode 100644 tests/selectors/MMConnect/MMConnectDapp.testIds.ts
diff --git a/package.json b/package.json
index 6a1f5c76207..90899b6424b 100644
--- a/package.json
+++ b/package.json
@@ -577,6 +577,7 @@
"@typescript-eslint/eslint-plugin": "^7.10.0",
"@typescript-eslint/parser": "^7.10.0",
"@walletconnect/types": "^2.23.0",
+ "@wdio/protocols": "^9.24.0",
"@welldone-software/why-did-you-render": "^8.0.1",
"appium": "^2.12.1",
"appium-adb": "^9.11.4",
diff --git a/tests/framework/PlaywrightAdapter.ts b/tests/framework/PlaywrightAdapter.ts
index a007122fa20..17ea61ba29a 100644
--- a/tests/framework/PlaywrightAdapter.ts
+++ b/tests/framework/PlaywrightAdapter.ts
@@ -1,5 +1,28 @@
import { ChainablePromiseElement } from 'webdriverio';
-import { boxedStep } from './Utilities.ts';
+import { boxedStep } from './PlaywrightUtilities.ts';
+
+export interface IsDisplayedParams {
+ /**
+ * `true` to check if the element is within the viewport. false by default.
+ */
+ withinViewport?: boolean;
+ /**
+ * `true` to check if the element content-visibility property has (or inherits) the value auto,
+ * and it is currently skipping its rendering. `true` by default.
+ * @default true
+ */
+ contentVisibilityAuto?: boolean;
+ /**
+ * `true` to check if the element opacity property has (or inherits) a value of 0. `true` by default.
+ * @default true
+ */
+ opacityProperty?: boolean;
+ /**
+ * `true` to check if the element is invisible due to the value of its visibility property. `true` by default.
+ * @default true
+ */
+ visibilityProperty?: boolean;
+}
/**
* PlaywrightAdapter - Provides Playwright-like API on top of WebdriverIO elems
@@ -50,8 +73,8 @@ export class PlaywrightElement {
* Maps to WebdriverIO's isDisplayed()
*/
@boxedStep
- async isVisible(): Promise {
- return await this.elem.isDisplayed();
+ async isVisible(options?: IsDisplayedParams): Promise {
+ return await this.elem.isDisplayed(options);
}
/**
@@ -94,10 +117,20 @@ export class PlaywrightElement {
/**
* Wait for elem to be displayed
+ * Note: This will throw if the element is not displayed within the timeout.
+ * @param options - The options to pass to the waitForDisplayed method.
+ * @param options.timeout - The timeout in milliseconds.
+ * @returns The result of the waitForDisplayed method.
*/
@boxedStep
async waitForDisplayed(options?: {
timeout?: number;
+ interval?: number;
+ timeoutMsg?: string;
+ withinViewport?: boolean;
+ contentVisibilityAuto?: boolean;
+ opacityProperty?: boolean;
+ visibilityProperty?: boolean;
reverse?: boolean;
}): Promise {
await this.elem.waitForDisplayed(options);
@@ -110,6 +143,8 @@ export class PlaywrightElement {
async waitForEnabled(options?: {
timeout?: number;
reverse?: boolean;
+ interval?: number;
+ timeoutMsg?: string;
}): Promise {
await this.elem.waitForEnabled(options);
}
diff --git a/tests/framework/PlaywrightContextHelpers.ts b/tests/framework/PlaywrightContextHelpers.ts
new file mode 100644
index 00000000000..aa8533e8724
--- /dev/null
+++ b/tests/framework/PlaywrightContextHelpers.ts
@@ -0,0 +1,142 @@
+import type { Context } from '@wdio/protocols';
+import type {
+ AndroidDetailedContext,
+ IosDetailedContext,
+} from 'webdriverio/build/types';
+import { APP_PACKAGE_IDS } from './Constants';
+import { PlatformDetector } from './PlatformLocator';
+import { getDriver } from './PlaywrightUtilities';
+
+type DetailedContext = IosDetailedContext | AndroidDetailedContext;
+
+const NATIVE_APP = 'NATIVE_APP';
+const LAVAMOAT_PATTERN = /LavaMoat|ShadowRoot|scuttling/i;
+
+export default class PlaywrightContextHelpers {
+ private static readonly WEBVIEW_TIMEOUT_MS = 30_000;
+ private static readonly POLL_INTERVAL_MS = 1_000;
+
+ static async switchToNativeContext(): Promise {
+ await getDriver().switchContext(NATIVE_APP);
+ }
+
+ static async switchToWebViewContext(dappUrl: string): Promise {
+ // Strategy B: Try WebdriverIO's built-in URL matching first.
+ // Falls back to manual polling only on LavaMoat scuttling errors.
+ try {
+ await getDriver().switchContext({
+ url: new RegExp(dappUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
+ androidWebviewConnectTimeout: this.WEBVIEW_TIMEOUT_MS,
+ });
+ return;
+ } catch (err) {
+ if (!LAVAMOAT_PATTERN.test(this.getErrorMessage(err))) {
+ throw err;
+ }
+ console.log(
+ 'WebdriverIO switchContext hit LavaMoat scuttling, falling back to manual polling',
+ );
+ }
+
+ await this.switchToWebViewWithRetry(dappUrl);
+ }
+
+ private static async switchToWebViewWithRetry(
+ dappUrl: string,
+ ): Promise {
+ const deadline = Date.now() + this.WEBVIEW_TIMEOUT_MS;
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+ while (Date.now() < deadline) {
+ const webviews = await this.getDetailedWebviews();
+ const selected = await this.selectBestWebview(webviews, dappUrl);
+
+ if (selected?.id) {
+ const switched = await this.attemptContextSwitch(selected.id);
+ if (switched) return;
+ }
+
+ await sleep(this.POLL_INTERVAL_MS);
+ }
+
+ throw new Error(
+ `No suitable webview context found within ${this.WEBVIEW_TIMEOUT_MS}ms for URL: ${dappUrl}`,
+ );
+ }
+
+ private static async getDetailedWebviews(): Promise {
+ const contexts: (Context | DetailedContext)[] =
+ await getDriver().getContexts({ returnDetailedContexts: true });
+
+ return contexts.filter((ctx): ctx is DetailedContext => {
+ if (typeof ctx === 'string') return false;
+ return ctx.id !== NATIVE_APP;
+ });
+ }
+
+ private static async selectBestWebview(
+ webviews: DetailedContext[],
+ dappUrl?: string,
+ ): Promise {
+ if (dappUrl) {
+ const urlMatch = webviews.find(
+ (ctx) => ctx.url?.includes(dappUrl) && !/localhost/i.test(ctx.url),
+ );
+ if (urlMatch) return urlMatch;
+ }
+
+ const filtered = webviews.filter((ctx) => {
+ const shouldAvoid =
+ /chrome|devtools/i.test(ctx.id) ||
+ (ctx.url && /chrome|devtools|localhost/i.test(ctx.url));
+ return !shouldAvoid;
+ });
+
+ const packageId = (await PlatformDetector.isAndroid())
+ ? APP_PACKAGE_IDS.ANDROID
+ : APP_PACKAGE_IDS.IOS;
+
+ return (
+ filtered.find((ctx) => ctx.id.includes(packageId)) ??
+ filtered[filtered.length - 1]
+ );
+ }
+
+ private static async attemptContextSwitch(
+ contextId: string,
+ ): Promise {
+ try {
+ await getDriver().switchContext(contextId);
+ return true;
+ } catch (err) {
+ const message = this.getErrorMessage(err);
+
+ if (LAVAMOAT_PATTERN.test(message)) {
+ console.log('Encountered LavaMoat scuttling, retrying...');
+ return false;
+ }
+
+ console.log('Error switching to webview context:', message);
+ return false;
+ }
+ }
+
+ private static getErrorMessage(err: unknown): string {
+ if (err instanceof Error) return err.message;
+ if (typeof err === 'string') return err;
+ return JSON.stringify(err);
+ }
+
+ static async withWebAction(
+ actionFn: () => Promise,
+ dappUrl: string,
+ ): Promise {
+ await this.switchToWebViewContext(dappUrl);
+ await actionFn();
+ }
+
+ static async withNativeAction(actionFn: () => Promise): Promise {
+ await this.switchToNativeContext();
+ await actionFn();
+ }
+}
diff --git a/tests/framework/PlaywrightGestures.ts b/tests/framework/PlaywrightGestures.ts
index 6c4596e4a51..c9d788fdee8 100644
--- a/tests/framework/PlaywrightGestures.ts
+++ b/tests/framework/PlaywrightGestures.ts
@@ -1,6 +1,6 @@
import { PlatformDetector } from './PlatformLocator';
import { PlaywrightElement } from './PlaywrightAdapter';
-import { boxedStep, getDriver } from './Utilities';
+import { boxedStep, getDriver } from './PlaywrightUtilities';
/**
* PlaywrightGestures - Gesture helpers for WebdriverIO/Playwright
diff --git a/tests/framework/PlaywrightMatchers.ts b/tests/framework/PlaywrightMatchers.ts
index e6baa16e1b9..9bba5828ffb 100644
--- a/tests/framework/PlaywrightMatchers.ts
+++ b/tests/framework/PlaywrightMatchers.ts
@@ -1,7 +1,7 @@
import { PlatformDetector } from './PlatformLocator';
import { PlaywrightElement, wrapElement } from './PlaywrightAdapter';
-import { MatcherOptions } from './types.ts';
-import { getDriver } from './Utilities.ts';
+import { MatcherOptions } from './types';
+import { getDriver } from './PlaywrightUtilities';
import { ChainablePromiseElement } from 'webdriverio';
/**
diff --git a/tests/framework/PlaywrightUtilities.ts b/tests/framework/PlaywrightUtilities.ts
new file mode 100644
index 00000000000..a675492f818
--- /dev/null
+++ b/tests/framework/PlaywrightUtilities.ts
@@ -0,0 +1,99 @@
+import test from '@playwright/test';
+
+/**
+ * Get the driver instance.
+ * @returns The driver instance.
+ */
+export function getDriver(): WebdriverIO.Browser {
+ const drv = globalThis.driver;
+ if (!drv) throw new Error('driver is not available');
+ return drv;
+}
+
+/**
+ * boxedStep - Wraps a function in a Playwright step - Used for the Test Report
+ * Used for the Test Report of the Appium framework.
+ * @param target - The function to wrap
+ * @param context - The context of the function
+ * @returns - The wrapped function
+ */
+export function boxedStep(
+ target: (this: This, ...args: Args) => Return,
+ context: ClassMethodDecoratorContext,
+): (this: This, ...args: Args) => Return {
+ const replacementMethod = function (this: This, ...args: Args): Return {
+ const self = this as This & {
+ name?: string; // For static methods, `this` is the class constructor which has a `name` property
+ constructor: {
+ name: string;
+ };
+ elem?: WebdriverIO.Element | { selector: string }; // WebdriverIO element with selector
+ };
+ const methodName = context.name as string;
+
+ // For static methods, `this` is the class constructor itself, so use `this.name`
+ // For instance methods, `this` is the instance, so use `this.constructor.name`
+ const className = context.static ? self.name : self.constructor.name;
+ let stepName = className + '.' + methodName;
+
+ if (self.elem?.selector) {
+ stepName += ` [${self.elem.selector}]`;
+ }
+
+ // Add args info for certain methods
+ if (args.length > 0 && ['fill', 'type', 'setValue'].includes(methodName)) {
+ const argPreview =
+ String(args[0]).length > 50
+ ? String(args[0]).substring(0, 50) + '...'
+ : String(args[0]);
+ stepName += ` ("${argPreview}")`;
+ }
+
+ return test.step(
+ stepName,
+ async () => {
+ try {
+ const result = await target.call(this, ...args);
+ return result;
+ } catch (error) {
+ // Take screenshot on error for better debugging
+ try {
+ const driver = getDriver();
+ const screenshot = await driver.takeScreenshot();
+ await test.info().attach(`${methodName}-error-screenshot`, {
+ body: Buffer.from(screenshot, 'base64'),
+ contentType: 'image/png',
+ });
+ } catch (screenshotError) {
+ // Don't fail if screenshot fails
+ console.warn(
+ 'Failed to capture error screenshot:',
+ screenshotError,
+ );
+ }
+ throw error;
+ }
+ },
+ { box: true },
+ ) as Return;
+ };
+
+ return replacementMethod;
+}
+
+class PlaywrightUtilities {
+ /**
+ * Get the device screen size.
+ * @returns The device screen size.
+ */
+ static async getDeviceScreenSize(): Promise<{
+ width: number;
+ height: number;
+ }> {
+ const screenSize = await getDriver().getWindowSize();
+ return { width: screenSize.width, height: screenSize.height };
+ }
+}
+
+// Change this once we use functions for the PlaywrightAdapter Utils
+export default PlaywrightUtilities;
diff --git a/tests/framework/Utilities.ts b/tests/framework/Utilities.ts
index 19bc8171261..cf2ff6bad6f 100644
--- a/tests/framework/Utilities.ts
+++ b/tests/framework/Utilities.ts
@@ -2,7 +2,6 @@ import { waitFor } from 'detox';
import { blacklistURLs } from '../resources/blacklistURLs.json';
import { RetryOptions, StabilityOptions } from './types.ts';
import { createLogger } from './logger.ts';
-import test from '@playwright/test';
// eslint-disable-next-line import/no-nodejs-modules
import { setTimeout as asyncSetTimeout } from 'node:timers/promises';
@@ -19,87 +18,6 @@ const logger = createLogger({ name: 'Utilities' });
export const sleep = (ms: number): Promise =>
new Promise((resolve) => setTimeout(resolve, ms));
-/**
- * Get the driver instance.
- * @returns The driver instance.
- */
-export function getDriver(): WebdriverIO.Browser {
- const drv = globalThis.driver;
- if (!drv) throw new Error('driver is not available');
- return drv;
-}
-
-/**
- * boxedStep - Wraps a function in a Playwright step - Used for the Test Report
- * Used for the Test Report of the Appium framework.
- * @param target - The function to wrap
- * @param context - The context of the function
- * @returns - The wrapped function
- */
-export function boxedStep(
- target: (this: This, ...args: Args) => Return,
- context: ClassMethodDecoratorContext,
-): (this: This, ...args: Args) => Return {
- const replacementMethod = function (this: This, ...args: Args): Return {
- const self = this as This & {
- name?: string; // For static methods, `this` is the class constructor which has a `name` property
- constructor: {
- name: string;
- };
- elem?: WebdriverIO.Element | { selector: string }; // WebdriverIO element with selector
- };
- const methodName = context.name as string;
-
- // For static methods, `this` is the class constructor itself, so use `this.name`
- // For instance methods, `this` is the instance, so use `this.constructor.name`
- const className = context.static ? self.name : self.constructor.name;
- let stepName = className + '.' + methodName;
-
- if (self.elem?.selector) {
- stepName += ` [${self.elem.selector}]`;
- }
-
- // Add args info for certain methods
- if (args.length > 0 && ['fill', 'type', 'setValue'].includes(methodName)) {
- const argPreview =
- String(args[0]).length > 50
- ? String(args[0]).substring(0, 50) + '...'
- : String(args[0]);
- stepName += ` ("${argPreview}")`;
- }
-
- return test.step(
- stepName,
- async () => {
- try {
- const result = await target.call(this, ...args);
- return result;
- } catch (error) {
- // Take screenshot on error for better debugging
- try {
- const driver = getDriver();
- const screenshot = await driver.takeScreenshot();
- await test.info().attach(`${methodName}-error-screenshot`, {
- body: Buffer.from(screenshot, 'base64'),
- contentType: 'image/png',
- });
- } catch (screenshotError) {
- // Don't fail if screenshot fails
- console.warn(
- 'Failed to capture error screenshot:',
- screenshotError,
- );
- }
- throw error;
- }
- },
- { box: true },
- ) as Return;
- };
-
- return replacementMethod;
-}
-
/**
* Enhanced Utilities class with retry mechanisms and stability checking
*/
diff --git a/tests/framework/fixtures/performance/performance-fixture.ts b/tests/framework/fixtures/performance/performance-fixture.ts
index 657e18a4951..a8ab7f0ea09 100644
--- a/tests/framework/fixtures/performance/performance-fixture.ts
+++ b/tests/framework/fixtures/performance/performance-fixture.ts
@@ -11,6 +11,7 @@ import {
getTestId,
} from '../../quality-gates';
import { getTeamInfoFromTags } from '../../utils/teams';
+import { IsDisplayedParams, PlaywrightElement } from '../../PlaywrightAdapter';
interface PerformanceFixtures {
performanceTracker: PerformanceTracker;
@@ -158,3 +159,25 @@ export const test = base.extend({
}
},
});
+
+/**
+ * Extend the test expect with a toBeVisible matcher.
+ * @param locator - The locator to check.
+ * @param options - The options to pass to the isVisible method.
+ * @returns The result of the isVisible method.
+ */
+export const expect = test.expect.extend({
+ toBeVisible: async (elem: PlaywrightElement, options?: IsDisplayedParams) => {
+ const isVisible = await elem.isVisible(options);
+ return {
+ message: () =>
+ isVisible
+ ? `Expected element NOT to be visible, but it was found on the screen`
+ : `Element was not found on the screen`,
+ pass: isVisible,
+ name: 'toBeVisible',
+ expected: true,
+ actual: isVisible,
+ };
+ },
+});
diff --git a/tests/framework/index.ts b/tests/framework/index.ts
index 3ca2bfe3d24..68cd0e478a4 100644
--- a/tests/framework/index.ts
+++ b/tests/framework/index.ts
@@ -2,16 +2,11 @@
export { default as Assertions } from './Assertions.ts';
export { default as Gestures } from './Gestures.ts';
export { default as Matchers } from './Matchers.ts';
-export {
- default as Utilities,
- BASE_DEFAULTS,
- sleep,
- boxedStep,
- getDriver,
-} from './Utilities.ts';
+export { default as Utilities, BASE_DEFAULTS, sleep } from './Utilities.ts';
export { Logger, createLogger, LogLevel, logger } from './logger.ts';
export { default as PortManager, ResourceType } from './PortManager.ts';
export * from './types.ts';
+export { boxedStep, getDriver } from './PlaywrightUtilities.ts';
// Mock server utilities
export { safeGetBodyText } from '../api-mocking/MockServerE2E.ts';
diff --git a/tests/page-objects/MMConnect/AddChainModal.ts b/tests/page-objects/MMConnect/AddChainModal.ts
new file mode 100644
index 00000000000..f7c015331dc
--- /dev/null
+++ b/tests/page-objects/MMConnect/AddChainModal.ts
@@ -0,0 +1,43 @@
+import {
+ encapsulated,
+ EncapsulatedElementType,
+ asPlaywrightElement,
+} from '../../framework/EncapsulatedElement';
+import { encapsulatedAction } from '../../framework/encapsulatedAction';
+import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
+import UnifiedGestures from '../../framework/UnifiedGestures';
+
+class AddChainModal {
+ get confirmButton(): EncapsulatedElementType {
+ return encapsulated({
+ appium: () =>
+ PlaywrightMatchers.getElementById('approve-network-approve-button'),
+ });
+ }
+
+ getText(value: string): EncapsulatedElementType {
+ return encapsulated({
+ appium: () =>
+ PlaywrightMatchers.getElementByXPath(
+ `//android.widget.TextView[@text="${value}"]`,
+ ),
+ });
+ }
+
+ async tapConfirmButton(): Promise {
+ await UnifiedGestures.tap(this.confirmButton);
+ }
+
+ async assertText(value: string): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(this.getText(value));
+ await element.waitForDisplayed({
+ timeoutMsg: `AddChainModal: text "${value}" not visible`,
+ });
+ },
+ });
+ }
+}
+
+export default new AddChainModal();
diff --git a/tests/page-objects/MMConnect/AndroidScreenHelpers.ts b/tests/page-objects/MMConnect/AndroidScreenHelpers.ts
new file mode 100644
index 00000000000..e9b79fdd4e5
--- /dev/null
+++ b/tests/page-objects/MMConnect/AndroidScreenHelpers.ts
@@ -0,0 +1,23 @@
+import {
+ encapsulated,
+ EncapsulatedElementType,
+} from '../../framework/EncapsulatedElement';
+import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
+import UnifiedGestures from '../../framework/UnifiedGestures';
+
+class AndroidScreenHelpers {
+ get openDeeplinkWithMetaMask(): EncapsulatedElementType {
+ return encapsulated({
+ appium: () =>
+ PlaywrightMatchers.getElementByXPath(
+ '//android.widget.TextView[@text="MetaMask"]',
+ ),
+ });
+ }
+
+ async tapOpenDeeplinkWithMetaMask(): Promise {
+ await UnifiedGestures.tap(this.openDeeplinkWithMetaMask);
+ }
+}
+
+export default new AndroidScreenHelpers();
diff --git a/tests/page-objects/MMConnect/BrowserPlaygroundDapp.ts b/tests/page-objects/MMConnect/BrowserPlaygroundDapp.ts
new file mode 100644
index 00000000000..15f5d377daa
--- /dev/null
+++ b/tests/page-objects/MMConnect/BrowserPlaygroundDapp.ts
@@ -0,0 +1,548 @@
+import {
+ encapsulated,
+ EncapsulatedElementType,
+ asPlaywrightElement,
+} from '../../framework/EncapsulatedElement';
+import { encapsulatedAction } from '../../framework/encapsulatedAction';
+import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
+import UnifiedGestures from '../../framework/UnifiedGestures';
+import { expect } from '@playwright/test';
+import { MMConnectDappTestIds } from '../../selectors/MMConnect/MMConnectDapp.testIds';
+
+class BrowserPlaygroundDapp {
+ private getByDataTestId(testId: string): EncapsulatedElementType {
+ return encapsulated({
+ appium: () =>
+ PlaywrightMatchers.getElementByXPath(`//*[@data-testid="${testId}"]`),
+ });
+ }
+
+ // App-level selectors
+ get connectLegacyButton(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.CONNECT_BUTTON_LEGACY);
+ }
+
+ get disconnectButton(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.DISCONNECT_BUTTON);
+ }
+
+ get errorSection(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.ERROR_SECTION);
+ }
+
+ get connectButton(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.CONNECT_BUTTON);
+ }
+
+ get connectedScopesSection(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.SCOPES_SECTION);
+ }
+
+ // Legacy EVM selectors
+ get legacyEvmCard(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.LEGACY_EVM_CARD);
+ }
+
+ get chainIdValue(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.LEGACY_EVM_CHAIN_ID_VALUE);
+ }
+
+ get accountsValue(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.LEGACY_EVM_ACCOUNTS_VALUE);
+ }
+
+ get activeAccount(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.LEGACY_EVM_ACTIVE_ACCOUNT);
+ }
+
+ get responseText(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.LEGACY_EVM_RESPONSE_TEXT);
+ }
+
+ get personalSignButton(): EncapsulatedElementType {
+ return this.getByDataTestId(
+ MMConnectDappTestIds.LEGACY_EVM_BTN_PERSONAL_SIGN,
+ );
+ }
+
+ get signTypedDataV4Button(): EncapsulatedElementType {
+ return this.getByDataTestId(
+ MMConnectDappTestIds.LEGACY_EVM_BTN_SIGN_TYPED_DATA_V4,
+ );
+ }
+
+ get sendTransactionButton(): EncapsulatedElementType {
+ return this.getByDataTestId(
+ MMConnectDappTestIds.LEGACY_EVM_BTN_SEND_TRANSACTION,
+ );
+ }
+
+ get switchToMainnetButton(): EncapsulatedElementType {
+ return this.getByDataTestId(
+ MMConnectDappTestIds.LEGACY_EVM_BTN_SWITCH_MAINNET,
+ );
+ }
+
+ get switchToPolygonButton(): EncapsulatedElementType {
+ return this.getByDataTestId(
+ MMConnectDappTestIds.LEGACY_EVM_BTN_SWITCH_POLYGON,
+ );
+ }
+
+ get switchToGoerliButton(): EncapsulatedElementType {
+ return this.getByDataTestId(
+ MMConnectDappTestIds.LEGACY_EVM_BTN_SWITCH_GOERLI,
+ );
+ }
+
+ get getBalanceButton(): EncapsulatedElementType {
+ return this.getByDataTestId(
+ MMConnectDappTestIds.LEGACY_EVM_BTN_GET_BALANCE,
+ );
+ }
+
+ get blockNumberButton(): EncapsulatedElementType {
+ return this.getByDataTestId(
+ MMConnectDappTestIds.LEGACY_EVM_BTN_BLOCK_NUMBER,
+ );
+ }
+
+ get gasPriceButton(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.LEGACY_EVM_BTN_GAS_PRICE);
+ }
+
+ // Wagmi selectors
+ get connectWagmiButton(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.WAGMI_CONNECT_BUTTON);
+ }
+
+ get wagmiDisconnectButton(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.WAGMI_DISCONNECT_BUTTON);
+ }
+
+ get wagmiCard(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.WAGMI_CARD);
+ }
+
+ get wagmiChainIdValue(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.WAGMI_CHAIN_ID_VALUE);
+ }
+
+ get wagmiAccountValue(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.WAGMI_ACCOUNT_VALUE);
+ }
+
+ get wagmiActiveAccount(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.WAGMI_ACTIVE_ACCOUNT);
+ }
+
+ get wagmiBalanceValue(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.WAGMI_BALANCE_VALUE);
+ }
+
+ get wagmiSignMessageInput(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.WAGMI_INPUT_MESSAGE);
+ }
+
+ get wagmiSignMessageButton(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.WAGMI_BUTTON_SIGN_MESSAGE);
+ }
+
+ get wagmiSignatureResult(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.WAGMI_SIGNATURE_RESULT);
+ }
+
+ get wagmiSendTxToAddressInput(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.WAGMI_INPUT_TO_ADDRESS);
+ }
+
+ get wagmiSendTxAmountInput(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.WAGMI_INPUT_AMOUNT);
+ }
+
+ get wagmiSendTransactionButton(): EncapsulatedElementType {
+ return this.getByDataTestId(
+ MMConnectDappTestIds.WAGMI_BUTTON_SEND_TRANSACTION,
+ );
+ }
+
+ get wagmiTxHashResult(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.WAGMI_TX_HASH_RESULT);
+ }
+
+ getWagmiSwitchChainButton(chainId: number): EncapsulatedElementType {
+ return this.getByDataTestId(
+ `${MMConnectDappTestIds.WAGMI_BTN_SWITCH_CHAIN}-${chainId}`,
+ );
+ }
+
+ // Solana selectors
+ get solanaCard(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.SOLANA_CARD);
+ }
+
+ get solanaConnectButton(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.SOLANA_CONNECT_BUTTON);
+ }
+
+ get solanaDisconnectButton(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.SOLANA_DISCONNECT_BUTTON);
+ }
+
+ get solanaAddressContainer(): EncapsulatedElementType {
+ return this.getByDataTestId(MMConnectDappTestIds.SOLANA_ADDRESS_CONTAINER);
+ }
+
+ get solanaSignMessageButton(): EncapsulatedElementType {
+ return this.getByDataTestId(
+ MMConnectDappTestIds.SOLANA_SIGN_MESSAGE_BUTTON,
+ );
+ }
+
+ get solanaSignedMessageResult(): EncapsulatedElementType {
+ return this.getByDataTestId(
+ MMConnectDappTestIds.SOLANA_SIGNED_MESSAGE_RESULT,
+ );
+ }
+
+ getScopeCard(scope: string): EncapsulatedElementType {
+ const escapedScope = scope.toLowerCase().replace(/:/g, '-');
+ return this.getByDataTestId(
+ `${MMConnectDappTestIds.SCOPE_CARD}-${escapedScope}`,
+ );
+ }
+
+ // Tap actions
+ async tapConnectLegacy(): Promise {
+ await UnifiedGestures.tap(this.connectLegacyButton);
+ }
+
+ async tapDisconnect(): Promise {
+ await UnifiedGestures.tap(this.disconnectButton);
+ }
+
+ async tapPersonalSign(): Promise {
+ await UnifiedGestures.tap(this.personalSignButton);
+ }
+
+ async tapSignTypedDataV4(): Promise {
+ await UnifiedGestures.tap(this.signTypedDataV4Button);
+ }
+
+ async tapSendTransaction(): Promise {
+ await UnifiedGestures.tap(this.sendTransactionButton);
+ }
+
+ async tapSwitchToMainnet(): Promise {
+ await UnifiedGestures.tap(this.switchToMainnetButton);
+ }
+
+ async tapSwitchToPolygon(): Promise {
+ await UnifiedGestures.tap(this.switchToPolygonButton);
+ }
+
+ async tapSwitchToGoerli(): Promise {
+ await UnifiedGestures.tap(this.switchToGoerliButton);
+ }
+
+ async tapGetBalance(): Promise {
+ await UnifiedGestures.tap(this.getBalanceButton);
+ }
+
+ async tapConnectWagmi(): Promise {
+ await UnifiedGestures.tap(this.connectWagmiButton);
+ }
+
+ async tapWagmiDisconnect(): Promise {
+ await UnifiedGestures.tap(this.wagmiDisconnectButton);
+ }
+
+ async tapWagmiSignMessage(): Promise {
+ await UnifiedGestures.tap(this.wagmiSignMessageButton);
+ }
+
+ async tapWagmiSendTransaction(): Promise {
+ await UnifiedGestures.tap(this.wagmiSendTransactionButton);
+ }
+
+ async tapWagmiSwitchChain(chainId: number): Promise {
+ await UnifiedGestures.tap(this.getWagmiSwitchChainButton(chainId));
+ }
+
+ async typeWagmiSignMessage(message: string): Promise {
+ await UnifiedGestures.typeText(this.wagmiSignMessageInput, message);
+ }
+
+ async tapSolanaConnect(): Promise {
+ await UnifiedGestures.tap(this.solanaConnectButton);
+ }
+
+ async tapSolanaDisconnect(): Promise {
+ await UnifiedGestures.tap(this.solanaDisconnectButton);
+ }
+
+ async tapSolanaSignMessage(): Promise {
+ await UnifiedGestures.tap(this.solanaSignMessageButton);
+ }
+
+ async tapConnect(): Promise {
+ await UnifiedGestures.tap(this.connectButton);
+ }
+
+ async waitForConnectButtonVisible(timeoutMs = 15000): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(this.connectButton);
+ await element.waitForDisplayed({
+ timeout: timeoutMs,
+ timeoutMsg: 'BrowserPlaygroundDapp: connect button not visible',
+ });
+ },
+ });
+ }
+
+ // Assertions
+ async assertConnected(isConnected = true): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ if (isConnected) {
+ const element = await asPlaywrightElement(this.activeAccount);
+ await element.waitForDisplayed({
+ timeout: 10000,
+ timeoutMsg:
+ 'BrowserPlaygroundDapp: active account not visible (expected connected)',
+ });
+ } else {
+ const element = await asPlaywrightElement(this.connectLegacyButton);
+ await element.waitForDisplayed({
+ timeout: 10000,
+ timeoutMsg:
+ 'BrowserPlaygroundDapp: connect legacy button not visible (expected disconnected)',
+ });
+ }
+ },
+ });
+ }
+
+ async assertChainIdValue(expectedChainId: string): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(this.chainIdValue);
+ const text = await element.textContent();
+ expect(text).toContain(expectedChainId);
+ },
+ });
+ }
+
+ async assertResponseValue(expectedValue: string): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(this.responseText);
+ const text = await element.textContent();
+ expect(text).toContain(expectedValue);
+ },
+ });
+ }
+
+ async assertActiveAccount(expectedAccount: string): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(this.activeAccount);
+ const text = await element.textContent();
+ expect(text?.toLowerCase()).toContain(expectedAccount.toLowerCase());
+ },
+ });
+ }
+
+ async assertAccountsCount(expectedCount: number): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(this.accountsValue);
+ const text = await element.textContent();
+ expect(text).toContain(`${expectedCount} available`);
+ },
+ });
+ }
+
+ async isConnected(): Promise {
+ try {
+ const element = await asPlaywrightElement(this.activeAccount);
+ await element.waitForDisplayed({
+ timeout: 5000,
+ timeoutMsg:
+ 'BrowserPlaygroundDapp: active account not visible (isConnected check)',
+ });
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ async assertWagmiConnected(isConnected = true): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ if (isConnected) {
+ const element = await asPlaywrightElement(this.wagmiActiveAccount);
+ await element.waitForDisplayed({
+ timeout: 10000,
+ timeoutMsg:
+ 'BrowserPlaygroundDapp: wagmi active account not visible (expected connected)',
+ });
+ } else {
+ const element = await asPlaywrightElement(this.connectWagmiButton);
+ await element.waitForDisplayed({
+ timeout: 10000,
+ timeoutMsg:
+ 'BrowserPlaygroundDapp: wagmi connect button not visible (expected disconnected)',
+ });
+ }
+ },
+ });
+ }
+
+ async assertWagmiChainIdValue(
+ expectedChainId: string | number,
+ ): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(this.wagmiChainIdValue);
+ const text = await element.textContent();
+ expect(text).toContain(String(expectedChainId));
+ },
+ });
+ }
+
+ async assertWagmiActiveAccount(expectedAccount: string): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(this.wagmiActiveAccount);
+ const text = await element.textContent();
+ expect(text?.toLowerCase()).toContain(expectedAccount.toLowerCase());
+ },
+ });
+ }
+
+ async assertWagmiSignatureResult(expectedValue: string): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(this.wagmiSignatureResult);
+ const text = await element.textContent();
+ expect(text).toContain(expectedValue);
+ },
+ });
+ }
+
+ async isWagmiConnected(): Promise {
+ try {
+ const element = await asPlaywrightElement(this.wagmiActiveAccount);
+ await element.waitForDisplayed({
+ timeout: 5000,
+ timeoutMsg:
+ 'BrowserPlaygroundDapp: wagmi active account not visible (isWagmiConnected check)',
+ });
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ async assertSolanaConnected(isConnected = true): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ if (isConnected) {
+ const element = await asPlaywrightElement(this.solanaCard);
+ await element.waitForDisplayed({
+ timeout: 10000,
+ timeoutMsg:
+ 'BrowserPlaygroundDapp: solana card not visible (expected connected)',
+ });
+ } else {
+ const element = await asPlaywrightElement(this.solanaConnectButton);
+ await element.waitForDisplayed({
+ timeout: 10000,
+ timeoutMsg:
+ 'BrowserPlaygroundDapp: solana connect button not visible (expected disconnected)',
+ });
+ }
+ },
+ });
+ }
+
+ async assertSolanaActiveAccount(expectedAddress: string): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(this.solanaAddressContainer);
+ const text = await element.textContent();
+ expect(text?.toLowerCase()).toContain(expectedAddress.toLowerCase());
+ },
+ });
+ }
+
+ async assertSolanaSignedMessageResult(expectedValue: string): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(
+ this.solanaSignedMessageResult,
+ );
+ await element.waitForDisplayed({
+ timeout: 10000,
+ timeoutMsg:
+ 'BrowserPlaygroundDapp: solana signed message result not visible',
+ });
+ const text = await element.textContent();
+ expect(text).toContain(expectedValue);
+ },
+ });
+ }
+
+ async assertMultichainConnected(isConnected = true): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ if (isConnected) {
+ const element = await asPlaywrightElement(
+ this.connectedScopesSection,
+ );
+ await element.waitForDisplayed({
+ timeout: 10000,
+ timeoutMsg:
+ 'BrowserPlaygroundDapp: scopes section not visible (expected multichain connected)',
+ });
+ } else {
+ const element = await asPlaywrightElement(this.connectButton);
+ await element.waitForDisplayed({
+ timeout: 10000,
+ timeoutMsg:
+ 'BrowserPlaygroundDapp: connect button not visible (expected multichain disconnected)',
+ });
+ }
+ },
+ });
+ }
+
+ async assertScopeCardVisible(scope: string): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(this.getScopeCard(scope));
+ await element.waitForDisplayed({
+ timeout: 10000,
+ timeoutMsg: `BrowserPlaygroundDapp: scope card "${scope}" not visible`,
+ });
+ },
+ });
+ }
+
+ async assertScopeCardNotVisible(scope: string): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(this.getScopeCard(scope));
+ await element.waitForDisplayed({
+ timeout: 10000,
+ reverse: true,
+ timeoutMsg: `BrowserPlaygroundDapp: scope card "${scope}" is visible (expected not displayed)`,
+ });
+ },
+ });
+ }
+}
+
+export default new BrowserPlaygroundDapp();
diff --git a/tests/page-objects/MMConnect/DappConnectionModal.ts b/tests/page-objects/MMConnect/DappConnectionModal.ts
new file mode 100644
index 00000000000..e555f6b0200
--- /dev/null
+++ b/tests/page-objects/MMConnect/DappConnectionModal.ts
@@ -0,0 +1,136 @@
+import {
+ encapsulated,
+ EncapsulatedElementType,
+ asPlaywrightElement,
+} from '../../framework/EncapsulatedElement';
+import { encapsulatedAction } from '../../framework/encapsulatedAction';
+import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
+import UnifiedGestures from '../../framework/UnifiedGestures';
+import { getDriver } from '../../framework/PlaywrightUtilities';
+import { ConnectAccountBottomSheetSelectorsIDs } from '../../../app/components/Views/AccountConnect/ConnectAccountBottomSheet.testIds';
+import { AccountCellIds } from '../../../app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.testIds';
+import { CellComponentSelectorsIDs } from '../../../app/component-library/components/Cells/Cell/CellComponent.testIds';
+
+class DappConnectionModal {
+ get connectButton(): EncapsulatedElementType {
+ return encapsulated({
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ ConnectAccountBottomSheetSelectorsIDs.CONNECT_BUTTON,
+ ),
+ });
+ }
+
+ get updateAccountsButton(): EncapsulatedElementType {
+ return encapsulated({
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ ConnectAccountBottomSheetSelectorsIDs.SELECT_MULTI_BUTTON,
+ ),
+ });
+ }
+
+ get editAccountsButton(): EncapsulatedElementType {
+ return encapsulated({
+ appium: () =>
+ PlaywrightMatchers.getElementByXPath(
+ '//android.view.ViewGroup[@content-desc="Edit accounts"]',
+ ),
+ });
+ }
+
+ get permissionsTabButton(): EncapsulatedElementType {
+ return encapsulated({
+ appium: () =>
+ PlaywrightMatchers.getElementByXPath(
+ '//android.view.ViewGroup[@content-desc="Permissions"]',
+ ),
+ });
+ }
+
+ get editNetworksButton(): EncapsulatedElementType {
+ return encapsulated({
+ appium: () =>
+ PlaywrightMatchers.getElementByXPath(
+ '(//android.widget.TextView[@text="Edit"])[2]',
+ ),
+ });
+ }
+
+ get updateNetworksButton(): EncapsulatedElementType {
+ return encapsulated({
+ appium: () =>
+ PlaywrightMatchers.getElementByXPath(
+ '//android.widget.Button[@content-desc="Update"]',
+ ),
+ });
+ }
+
+ getAccountButton(accountName: string): EncapsulatedElementType {
+ return encapsulated({
+ appium: () =>
+ PlaywrightMatchers.getElementByXPath(
+ `//android.widget.TextView[@resource-id="${AccountCellIds.ADDRESS}" and @text="${accountName}"]`,
+ ),
+ });
+ }
+
+ getNetworkButton(networkName: string): EncapsulatedElementType {
+ return encapsulated({
+ appium: () =>
+ PlaywrightMatchers.getElementByXPath(
+ `//android.widget.TextView[@resource-id="${CellComponentSelectorsIDs.BASE_TITLE}" and @text="${networkName}"]`,
+ ),
+ });
+ }
+
+ async tapConnectButton(): Promise {
+ await UnifiedGestures.tap(this.connectButton);
+ }
+
+ async tapEditAccountsButton(): Promise {
+ await UnifiedGestures.tap(this.editAccountsButton);
+ }
+
+ async tapAccountButton(accountName: string): Promise {
+ await UnifiedGestures.tap(this.getAccountButton(accountName));
+ }
+
+ async tapUpdateAccountsButton(): Promise {
+ await UnifiedGestures.tap(this.updateAccountsButton);
+ }
+
+ async tapPermissionsTabButton(): Promise {
+ await UnifiedGestures.tap(this.permissionsTabButton);
+ }
+
+ async tapEditNetworksButton(): Promise {
+ await UnifiedGestures.tap(this.editNetworksButton);
+ }
+
+ async tapNetworkButton(networkName: string): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const drv = getDriver();
+ await drv.execute('mobile: scrollGesture', {
+ left: 0,
+ top: 0,
+ width: 1000,
+ height: 1000,
+ direction: 'down',
+ percent: 1.0,
+ });
+ const element = await asPlaywrightElement(
+ this.getNetworkButton(networkName),
+ );
+ await element.click();
+ },
+ });
+ }
+
+ async tapUpdateNetworksButton(): Promise {
+ await UnifiedGestures.tap(this.updateNetworksButton);
+ }
+}
+
+export default new DappConnectionModal();
diff --git a/tests/page-objects/MMConnect/RNPlaygroundDapp.ts b/tests/page-objects/MMConnect/RNPlaygroundDapp.ts
new file mode 100644
index 00000000000..f2c481a3785
--- /dev/null
+++ b/tests/page-objects/MMConnect/RNPlaygroundDapp.ts
@@ -0,0 +1,402 @@
+import {
+ encapsulated,
+ EncapsulatedElementType,
+ asPlaywrightElement,
+} from '../../framework/EncapsulatedElement';
+import { encapsulatedAction } from '../../framework/encapsulatedAction';
+import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
+import UnifiedGestures from '../../framework/UnifiedGestures';
+import { sleep } from '../../framework/Utilities';
+import { PLAYGROUND_PACKAGE_ID } from '../../framework/Constants';
+import { getDriver } from '../../framework/PlaywrightUtilities';
+import { PlaywrightGestures } from '../../framework';
+import { expect } from '@playwright/test';
+import { MMConnectDappTestIds } from '../../selectors/MMConnect/MMConnectDapp.testIds';
+
+function escapeTestId(value: string): string {
+ return value
+ .toLowerCase()
+ .replace(/:/g, '-')
+ .replace(/\s+/g, '-')
+ .replace(/_/g, '-')
+ .replace(/[^a-z0-9-]/g, '');
+}
+
+class RNPlaygroundDapp {
+ private getByTestId(testId: string): EncapsulatedElementType {
+ return encapsulated({
+ appium: () => PlaywrightMatchers.getElementById(testId),
+ });
+ }
+
+ // App-level selectors
+ get appContainer(): EncapsulatedElementType {
+ return this.getByTestId(MMConnectDappTestIds.RM_APP_CONTAINER);
+ }
+
+ get appTitle(): EncapsulatedElementType {
+ return this.getByTestId(MMConnectDappTestIds.RM_APP_TITLE);
+ }
+
+ get connectButton(): EncapsulatedElementType {
+ return this.getByTestId(MMConnectDappTestIds.CONNECT_BUTTON);
+ }
+
+ get disconnectButton(): EncapsulatedElementType {
+ return this.getByTestId(MMConnectDappTestIds.DISCONNECT_BUTTON);
+ }
+
+ get scopesSection(): EncapsulatedElementType {
+ return this.getByTestId(MMConnectDappTestIds.SCOPES_SECTION);
+ }
+
+ get errorSection(): EncapsulatedElementType {
+ return this.getByTestId(MMConnectDappTestIds.ERROR_SECTION);
+ }
+
+ get connectLegacyButton(): EncapsulatedElementType {
+ return this.getByTestId(MMConnectDappTestIds.CONNECT_BUTTON_LEGACY);
+ }
+
+ // Legacy EVM selectors
+ get legacyEvmCard(): EncapsulatedElementType {
+ return this.getByTestId(MMConnectDappTestIds.LEGACY_EVM_CARD);
+ }
+
+ get legacyEvmChainIdValue(): EncapsulatedElementType {
+ return this.getByTestId(MMConnectDappTestIds.LEGACY_EVM_CHAIN_ID_VALUE);
+ }
+
+ get legacyEvmAccountsValue(): EncapsulatedElementType {
+ return this.getByTestId(MMConnectDappTestIds.LEGACY_EVM_ACCOUNTS_VALUE);
+ }
+
+ get legacyEvmActiveAccount(): EncapsulatedElementType {
+ return this.getByTestId(MMConnectDappTestIds.LEGACY_EVM_ACTIVE_ACCOUNT);
+ }
+
+ get legacyEvmResponseText(): EncapsulatedElementType {
+ return this.getByTestId(MMConnectDappTestIds.LEGACY_EVM_RESPONSE_TEXT);
+ }
+
+ get legacyEvmBtnPersonalSign(): EncapsulatedElementType {
+ return this.getByTestId(MMConnectDappTestIds.LEGACY_EVM_BTN_PERSONAL_SIGN);
+ }
+
+ get legacyEvmBtnSendTransaction(): EncapsulatedElementType {
+ return this.getByTestId(
+ MMConnectDappTestIds.LEGACY_EVM_BTN_SEND_TRANSACTION,
+ );
+ }
+
+ get legacyEvmBtnSwitchPolygon(): EncapsulatedElementType {
+ return this.getByTestId(MMConnectDappTestIds.LEGACY_EVM_BTN_SWITCH_POLYGON);
+ }
+
+ // Dynamic selectors
+ getNetworkCheckbox(caipChainId: string): EncapsulatedElementType {
+ return this.getByTestId(
+ `dynamic-inputs-checkbox-${escapeTestId(caipChainId)}`,
+ );
+ }
+
+ getScopeCard(scope: string): EncapsulatedElementType {
+ return this.getByTestId(
+ `${MMConnectDappTestIds.SCOPE_CARD}-${escapeTestId(scope)}`,
+ );
+ }
+
+ getScopeNetworkName(scope: string): EncapsulatedElementType {
+ return this.getByTestId(
+ `${MMConnectDappTestIds.SCOPE_CARD_NETWORK_NAME}-${escapeTestId(scope)}`,
+ );
+ }
+
+ getMethodSelect(scope: string): EncapsulatedElementType {
+ return this.getByTestId(
+ `${MMConnectDappTestIds.SCOPE_CARD_METHOD_SELECT}-${escapeTestId(scope)}`,
+ );
+ }
+
+ getInvokeButton(scope: string): EncapsulatedElementType {
+ return this.getByTestId(
+ `${MMConnectDappTestIds.SCOPE_CARD_INVOKE_BTN}-${escapeTestId(scope)}`,
+ );
+ }
+
+ getResultCode(
+ scope: string,
+ method: string,
+ index = 0,
+ ): EncapsulatedElementType {
+ const escapedScope = escapeTestId(scope);
+ const escapedMethod = escapeTestId(method);
+ return this.getByTestId(
+ `${MMConnectDappTestIds.SCOPE_CARD_RESULT_CODE}-${escapedScope}-${escapedMethod}-${index}`,
+ );
+ }
+
+ getResultStatus(
+ scope: string,
+ method: string,
+ index = 0,
+ ): EncapsulatedElementType {
+ const escapedScope = escapeTestId(scope);
+ const escapedMethod = escapeTestId(method);
+ return this.getByTestId(
+ `${MMConnectDappTestIds.SCOPE_CARD_RESULT_STATUS}-${escapedScope}-${escapedMethod}-${index}`,
+ );
+ }
+
+ // App lifecycle
+ async switchToPlayground(): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const drv = getDriver();
+ await drv.execute('mobile: activateApp', {
+ appId: PLAYGROUND_PACKAGE_ID,
+ });
+ await sleep(1000);
+ },
+ });
+ }
+
+ async waitForPlaygroundReady(timeoutMs = 15000): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(this.appContainer);
+ await element.waitForDisplayed({
+ timeout: timeoutMs,
+ timeoutMsg:
+ 'RNPlaygroundDapp: app container not visible (playground not ready)',
+ });
+ },
+ });
+ }
+
+ async ensureInPlayground(): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ try {
+ const element = await asPlaywrightElement(this.appContainer);
+ await element.waitForDisplayed({
+ timeout: 3000,
+ timeoutMsg:
+ 'RNPlaygroundDapp: app container not visible (will switch to playground)',
+ });
+ } catch {
+ await this.switchToPlayground();
+ await this.waitForPlaygroundReady();
+ }
+ },
+ });
+ }
+
+ // Simple actions
+ async tapNetworkCheckbox(caipChainId: string): Promise {
+ await UnifiedGestures.tap(this.getNetworkCheckbox(caipChainId));
+ }
+
+ async tapConnect(): Promise {
+ await UnifiedGestures.tap(this.connectButton);
+ }
+
+ async tapConnectLegacy(): Promise {
+ await UnifiedGestures.tap(this.connectLegacyButton);
+ }
+
+ async tapDisconnect(): Promise {
+ await UnifiedGestures.tap(this.disconnectButton);
+ }
+
+ async tapInvoke(scope: string): Promise {
+ await UnifiedGestures.tap(this.getInvokeButton(scope));
+ }
+
+ async tapLegacyEvmButton(
+ buttonGetter: EncapsulatedElementType,
+ ): Promise {
+ await UnifiedGestures.tap(buttonGetter);
+ }
+
+ // Complex actions
+ async selectMethod(
+ scope: string,
+ methodName: string,
+ maxScrollAttempts = 10,
+ ): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ await UnifiedGestures.tap(this.getMethodSelect(scope));
+ await sleep(500);
+
+ const drv = getDriver();
+
+ for (let attempt = 0; attempt < maxScrollAttempts; attempt++) {
+ try {
+ const option =
+ await PlaywrightMatchers.getElementByText(methodName);
+ const isVisible = await option.isVisible();
+ if (isVisible) {
+ await option.click();
+ await sleep(500);
+ return;
+ }
+ } catch {
+ // Option not found or not visible yet
+ }
+
+ await drv.execute('mobile: swipeGesture', {
+ left: 100,
+ top: 400,
+ width: 600,
+ height: 600,
+ direction: 'down',
+ percent: 0.3,
+ });
+ await sleep(300);
+ }
+
+ throw new Error(
+ `Method "${methodName}" not found in picker after ${maxScrollAttempts} scroll attempts`,
+ );
+ },
+ });
+ }
+
+ async scrollToElement(elemGetter: EncapsulatedElementType): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const elem = await asPlaywrightElement(elemGetter);
+ await PlaywrightGestures.scrollIntoView(elem);
+ },
+ });
+ }
+
+ // Assertions
+ async assertConnected(): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(this.scopesSection);
+ await element.waitForDisplayed({
+ timeout: 15000,
+ timeoutMsg:
+ 'RNPlaygroundDapp: scopes section not visible (expected connected)',
+ });
+ },
+ });
+ }
+
+ async assertDisconnected(): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(this.connectButton);
+ await element.waitForDisplayed({
+ timeout: 15000,
+ timeoutMsg:
+ 'RNPlaygroundDapp: connect button not visible (expected disconnected)',
+ });
+ },
+ });
+ }
+
+ async assertScopeCardVisible(scope: string): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(this.getScopeCard(scope));
+ await element.waitForDisplayed({
+ timeout: 15000,
+ timeoutMsg: `RNPlaygroundDapp: scope card "${scope}" not visible`,
+ });
+ },
+ });
+ }
+
+ async waitForResult(scope: string, method: string, index = 0): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(
+ this.getResultCode(scope, method, index),
+ );
+ await element.waitForDisplayed({
+ timeout: 15000,
+ timeoutMsg: `RNPlaygroundDapp: result code for ${scope}/${method}[${index}] not visible`,
+ });
+ },
+ });
+ }
+
+ async assertLegacyEvmConnected(): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(this.legacyEvmCard);
+ await element.waitForDisplayed({
+ timeout: 15000,
+ timeoutMsg: 'Legacy EVM card not found',
+ });
+ },
+ });
+ }
+
+ async assertLegacyEvmHasAccounts(timeoutMs = 10000): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(this.legacyEvmAccountsValue);
+ await element.waitForDisplayed({
+ timeout: timeoutMs,
+ timeoutMsg: 'RNPlaygroundDapp: legacy EVM accounts value not visible',
+ });
+ },
+ });
+ }
+
+ async assertLegacyEvmActiveAccount(timeoutMs = 10000): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(this.legacyEvmActiveAccount);
+ await element.waitForDisplayed({
+ timeout: timeoutMs,
+ timeoutMsg: 'RNPlaygroundDapp: legacy EVM active account not visible',
+ });
+ },
+ });
+ }
+
+ async getLegacyEvmChainId(): Promise {
+ const element = await asPlaywrightElement(this.legacyEvmChainIdValue);
+ await element.waitForDisplayed({
+ timeout: 10000,
+ timeoutMsg: 'RNPlaygroundDapp: legacy EVM chain ID value not visible',
+ });
+ return (await element.textContent()) ?? '';
+ }
+
+ async getLegacyEvmResponseText(): Promise {
+ const element = await asPlaywrightElement(this.legacyEvmResponseText);
+ return (await element.textContent()) ?? '';
+ }
+
+ async assertResultCodeContains(
+ scope: string,
+ method: string,
+ expectedText: string,
+ index = 0,
+ timeoutMs = 15000,
+ ): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(
+ this.getResultCode(scope, method, index),
+ );
+ await element.waitForDisplayed({
+ timeout: timeoutMs,
+ timeoutMsg: `RNPlaygroundDapp: result code for ${scope}/${method}[${index}] not visible`,
+ });
+ const text = await element.textContent();
+ expect(text).toContain(expectedText);
+ },
+ });
+ }
+}
+
+export default new RNPlaygroundDapp();
diff --git a/tests/page-objects/MMConnect/SignModal.ts b/tests/page-objects/MMConnect/SignModal.ts
new file mode 100644
index 00000000000..c8ea80109cf
--- /dev/null
+++ b/tests/page-objects/MMConnect/SignModal.ts
@@ -0,0 +1,59 @@
+import {
+ encapsulated,
+ EncapsulatedElementType,
+ asPlaywrightElement,
+} from '../../framework/EncapsulatedElement';
+import { encapsulatedAction } from '../../framework/encapsulatedAction';
+import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
+import UnifiedGestures from '../../framework/UnifiedGestures';
+import { ConfirmationFooterSelectorIDs } from '../../../app/components/Views/confirmations/ConfirmationView.testIds';
+
+class SignModal {
+ get confirmButton(): EncapsulatedElementType {
+ return encapsulated({
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ ConfirmationFooterSelectorIDs.CONFIRM_BUTTON,
+ ),
+ });
+ }
+
+ get cancelButton(): EncapsulatedElementType {
+ return encapsulated({
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ ConfirmationFooterSelectorIDs.CANCEL_BUTTON,
+ ),
+ });
+ }
+
+ getNetworkText(network: string): EncapsulatedElementType {
+ return encapsulated({
+ appium: () =>
+ PlaywrightMatchers.getElementByXPath(
+ `(//android.widget.TextView[@text="${network}"])[1]`,
+ ),
+ });
+ }
+
+ async tapConfirmButton(): Promise {
+ await UnifiedGestures.tap(this.confirmButton);
+ }
+
+ async tapCancelButton(): Promise {
+ await UnifiedGestures.tap(this.cancelButton);
+ }
+
+ async assertNetworkText(network: string): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(this.getNetworkText(network));
+ await element.waitForDisplayed({
+ timeoutMsg: `SignModal: network text "${network}" not visible`,
+ });
+ },
+ });
+ }
+}
+
+export default new SignModal();
diff --git a/tests/page-objects/MMConnect/SnapSignModal.ts b/tests/page-objects/MMConnect/SnapSignModal.ts
new file mode 100644
index 00000000000..35654a9d6b5
--- /dev/null
+++ b/tests/page-objects/MMConnect/SnapSignModal.ts
@@ -0,0 +1,59 @@
+import {
+ encapsulated,
+ EncapsulatedElementType,
+ asPlaywrightElement,
+} from '../../framework/EncapsulatedElement';
+import { encapsulatedAction } from '../../framework/encapsulatedAction';
+import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
+import { TestSnapBottomSheetSelectorWebIDS } from '../../selectors/Browser/TestSnaps.selectors';
+
+class SnapSignModal {
+ get confirmButton(): EncapsulatedElementType {
+ return encapsulated({
+ appium: () =>
+ PlaywrightMatchers.getElementByXPath(
+ `//*[contains(@resource-id,"${TestSnapBottomSheetSelectorWebIDS.SNAP_FOOTER_BUTTON_ID}") ` +
+ 'and not(contains(@resource-id,"cancel")) ' +
+ `and not(contains(@resource-id,"${TestSnapBottomSheetSelectorWebIDS.DEFAULT_FOOTER_BUTTON_ID}"))]`,
+ ),
+ });
+ }
+
+ get cancelButton(): EncapsulatedElementType {
+ return encapsulated({
+ appium: () =>
+ PlaywrightMatchers.getElementByXPath(
+ `//*[contains(@resource-id,"cancel") ` +
+ `and contains(@resource-id,"${TestSnapBottomSheetSelectorWebIDS.SNAP_FOOTER_BUTTON_ID}")]`,
+ ),
+ });
+ }
+
+ async tapConfirmButton({ timeout = 5000 } = {}): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(this.confirmButton);
+ await element.waitForDisplayed({
+ timeout,
+ timeoutMsg: 'SnapSignModal: confirm button not visible',
+ });
+ await element.click();
+ },
+ });
+ }
+
+ async tapCancelButton({ timeout = 5000 } = {}): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(this.cancelButton);
+ await element.waitForDisplayed({
+ timeout,
+ timeoutMsg: 'SnapSignModal: cancel button not visible',
+ });
+ await element.click();
+ },
+ });
+ }
+}
+
+export default new SnapSignModal();
diff --git a/tests/page-objects/MMConnect/SwitchChainModal.ts b/tests/page-objects/MMConnect/SwitchChainModal.ts
new file mode 100644
index 00000000000..c62e3500700
--- /dev/null
+++ b/tests/page-objects/MMConnect/SwitchChainModal.ts
@@ -0,0 +1,46 @@
+import {
+ encapsulated,
+ EncapsulatedElementType,
+ asPlaywrightElement,
+} from '../../framework/EncapsulatedElement';
+import { encapsulatedAction } from '../../framework/encapsulatedAction';
+import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
+import UnifiedGestures from '../../framework/UnifiedGestures';
+import { ConfirmationFooterSelectorIDs } from '../../../app/components/Views/confirmations/ConfirmationView.testIds';
+
+class SwitchChainModal {
+ get connectButton(): EncapsulatedElementType {
+ return encapsulated({
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ ConfirmationFooterSelectorIDs.CONFIRM_BUTTON,
+ ),
+ });
+ }
+
+ getNetworkText(network: string): EncapsulatedElementType {
+ return encapsulated({
+ appium: () =>
+ PlaywrightMatchers.getElementByXPath(
+ `//android.widget.TextView[@text="Requesting for ${network}"]`,
+ ),
+ });
+ }
+
+ async tapConnectButton(): Promise {
+ await UnifiedGestures.tap(this.connectButton);
+ }
+
+ async assertNetworkText(network: string): Promise {
+ await encapsulatedAction({
+ appium: async () => {
+ const element = await asPlaywrightElement(this.getNetworkText(network));
+ await element.waitForDisplayed({
+ timeoutMsg: `SwitchChainModal: network text "${network}" not visible`,
+ });
+ },
+ });
+ }
+}
+
+export default new SwitchChainModal();
diff --git a/tests/selectors/Browser/TestSnaps.selectors.ts b/tests/selectors/Browser/TestSnaps.selectors.ts
index ab786a90d3c..ffcee52b937 100644
--- a/tests/selectors/Browser/TestSnaps.selectors.ts
+++ b/tests/selectors/Browser/TestSnaps.selectors.ts
@@ -162,4 +162,5 @@ export const TestSnapResultSelectorWebIDS = {
export const TestSnapBottomSheetSelectorWebIDS = {
BOTTOMSHEET_FOOTER_BUTTON_ID: 'bottomsheetfooter-button-subsequent',
DEFAULT_FOOTER_BUTTON_ID: 'default-snap-footer-button',
+ SNAP_FOOTER_BUTTON_ID: 'snap-footer-button',
};
diff --git a/tests/selectors/MMConnect/MMConnectDapp.testIds.ts b/tests/selectors/MMConnect/MMConnectDapp.testIds.ts
new file mode 100644
index 00000000000..28917f055e8
--- /dev/null
+++ b/tests/selectors/MMConnect/MMConnectDapp.testIds.ts
@@ -0,0 +1,55 @@
+export const MMConnectDappTestIds = {
+ CONNECT_BUTTON_LEGACY: 'app-btn-connect-legacy',
+ DISCONNECT_BUTTON: 'app-btn-disconnect',
+ ERROR_SECTION: 'app-section-error',
+ CONNECT_BUTTON: 'app-btn-connect',
+ SCOPES_SECTION: 'app-section-scopes',
+ LEGACY_EVM_CARD: 'legacy-evm-card',
+ LEGACY_EVM_CHAIN_ID_VALUE: 'legacy-evm-chain-id-value',
+ LEGACY_EVM_ACCOUNTS_VALUE: 'legacy-evm-accounts-value',
+ LEGACY_EVM_ACTIVE_ACCOUNT: 'legacy-evm-active-account',
+ LEGACY_EVM_RESPONSE_TEXT: 'legacy-evm-response-text',
+ LEGACY_EVM_BTN_PERSONAL_SIGN: 'legacy-evm-btn-personal-sign',
+ LEGACY_EVM_BTN_SIGN_TYPED_DATA_V4: 'legacy-evm-btn-sign-typed-data-v4',
+ LEGACY_EVM_BTN_SEND_TRANSACTION: 'legacy-evm-btn-send-transaction',
+ LEGACY_EVM_BTN_SWITCH_MAINNET: 'legacy-evm-btn-switch-mainnet',
+ LEGACY_EVM_BTN_SWITCH_POLYGON: 'legacy-evm-btn-switch-polygon',
+ LEGACY_EVM_BTN_SWITCH_GOERLI: 'legacy-evm-btn-switch-goerli',
+ LEGACY_EVM_BTN_GET_BALANCE: 'legacy-evm-btn-get-balance',
+ LEGACY_EVM_BTN_BLOCK_NUMBER: 'legacy-evm-btn-block-number',
+ LEGACY_EVM_BTN_GAS_PRICE: 'legacy-evm-btn-gas-price',
+ WAGMI_CARD: 'wagmi-card',
+ WAGMI_CHAIN_ID_VALUE: 'wagmi-chain-id-value',
+ WAGMI_ACCOUNT_VALUE: 'wagmi-account-value',
+ WAGMI_ACTIVE_ACCOUNT: 'wagmi-active-account',
+ WAGMI_BALANCE_VALUE: 'wagmi-balance-value',
+ WAGMI_SIGN_MESSAGE_INPUT: 'wagmi-sign-message-input',
+ WAGMI_SIGN_MESSAGE_BUTTON: 'wagmi-sign-message-button',
+ WAGMI_SIGNATURE_RESULT: 'wagmi-signature-result',
+ WAGMI_SEND_TX_TO_ADDRESS_INPUT: 'wagmi-send-tx-to-address-input',
+ WAGMI_SEND_TX_AMOUNT_INPUT: 'wagmi-send-tx-amount-input',
+ WAGMI_SEND_TRANSACTION_BUTTON: 'wagmi-send-transaction-button',
+ WAGMI_CONNECT_BUTTON: 'app-btn-connect-wagmi',
+ WAGMI_DISCONNECT_BUTTON: 'wagmi-btn-disconnect',
+ WAGMI_INPUT_MESSAGE: 'wagmi-input-message',
+ WAGMI_BUTTON_SIGN_MESSAGE: 'wagmi-btn-sign-message',
+ WAGMI_INPUT_TO_ADDRESS: 'wagmi-input-to-address',
+ WAGMI_INPUT_AMOUNT: 'wagmi-input-amount',
+ WAGMI_BUTTON_SEND_TRANSACTION: 'wagmi-btn-send-transaction',
+ WAGMI_TX_HASH_RESULT: 'wagmi-tx-hash-result',
+ WAGMI_BTN_SWITCH_CHAIN: 'wagmi-btn-switch-chain',
+ SOLANA_CARD: 'solana-card',
+ SOLANA_CONNECT_BUTTON: 'app-btn-connect-solana',
+ SOLANA_DISCONNECT_BUTTON: 'solana-btn-disconnect',
+ SOLANA_ADDRESS_CONTAINER: 'solana-address-container',
+ SOLANA_SIGN_MESSAGE_BUTTON: 'solana-btn-sign-message',
+ SOLANA_SIGNED_MESSAGE_RESULT: 'solana-signed-message-result',
+ SCOPE_CARD: 'scope-card',
+ SCOPE_CARD_NETWORK_NAME: 'scope-card-network-name',
+ SCOPE_CARD_METHOD_SELECT: 'scope-card-method-select',
+ SCOPE_CARD_INVOKE_BTN: 'scope-card-invoke-btn',
+ SCOPE_CARD_RESULT_CODE: 'scope-card-result-code',
+ SCOPE_CARD_RESULT_STATUS: 'scope-card-result-status',
+ RM_APP_CONTAINER: 'app-container',
+ RM_APP_TITLE: 'app-title',
+} as const;
diff --git a/wdio/screen-objects/BrowserPlaygroundDapp.js b/wdio/screen-objects/BrowserPlaygroundDapp.js
index 13a9113f8b6..c87251697bf 100644
--- a/wdio/screen-objects/BrowserPlaygroundDapp.js
+++ b/wdio/screen-objects/BrowserPlaygroundDapp.js
@@ -1,3 +1,4 @@
+// Migrated to tests/page-objects/MMConnect/BrowserPlaygroundDapp.ts
import AppwrightSelectors from '../../tests/framework/AppwrightSelectors';
import AppwrightGestures from '../../tests/framework/AppwrightGestures';
import { expect } from 'appwright';
diff --git a/wdio/screen-objects/Modals/AddChainModal.js b/wdio/screen-objects/Modals/AddChainModal.js
index 266530034cb..3fc49fc1150 100644
--- a/wdio/screen-objects/Modals/AddChainModal.js
+++ b/wdio/screen-objects/Modals/AddChainModal.js
@@ -1,3 +1,4 @@
+// Migrated to tests/page-objects/MMConnect/AddChainModal.ts
import AppwrightSelectors from '../../../tests/framework/AppwrightSelectors';
import AppwrightGestures from '../../../tests/framework/AppwrightGestures';
import { expect } from 'appwright';
diff --git a/wdio/screen-objects/Modals/DappConnectionModal.js b/wdio/screen-objects/Modals/DappConnectionModal.js
index 105a58ece73..bacf79fa577 100644
--- a/wdio/screen-objects/Modals/DappConnectionModal.js
+++ b/wdio/screen-objects/Modals/DappConnectionModal.js
@@ -1,3 +1,4 @@
+// Migrated to tests/page-objects/MMConnect/DappConnectionModal.ts
import AppwrightSelectors from '../../../tests/framework/AppwrightSelectors';
import AppwrightGestures from '../../../tests/framework/AppwrightGestures';
diff --git a/wdio/screen-objects/Modals/SignModal.js b/wdio/screen-objects/Modals/SignModal.js
index 483d98ea14f..ae6a1b24e29 100644
--- a/wdio/screen-objects/Modals/SignModal.js
+++ b/wdio/screen-objects/Modals/SignModal.js
@@ -1,3 +1,4 @@
+// Migrated to tests/page-objects/MMConnect/SignModal.ts
import AppwrightSelectors from '../../../tests/framework/AppwrightSelectors';
import AppwrightGestures from '../../../tests/framework/AppwrightGestures';
import { expect } from 'appwright';
diff --git a/wdio/screen-objects/Modals/SnapSignModal.js b/wdio/screen-objects/Modals/SnapSignModal.js
index 175ebd89bc2..f64de778656 100644
--- a/wdio/screen-objects/Modals/SnapSignModal.js
+++ b/wdio/screen-objects/Modals/SnapSignModal.js
@@ -1,3 +1,4 @@
+// Migrated to tests/page-objects/MMConnect/SnapSignModal.ts
import AppwrightSelectors from '../../../tests/framework/AppwrightSelectors';
import AppwrightGestures from '../../../tests/framework/AppwrightGestures';
import { expect } from 'appwright';
diff --git a/wdio/screen-objects/Modals/SwitchChainModal.js b/wdio/screen-objects/Modals/SwitchChainModal.js
index cebc1793176..0273bcbe87a 100644
--- a/wdio/screen-objects/Modals/SwitchChainModal.js
+++ b/wdio/screen-objects/Modals/SwitchChainModal.js
@@ -1,3 +1,4 @@
+// Migrated to tests/page-objects/MMConnect/SwitchChainModal.ts
import AppwrightSelectors from '../../../tests/framework/AppwrightSelectors';
import AppwrightGestures from '../../../tests/framework/AppwrightGestures';
import { expect } from 'appwright';
diff --git a/wdio/screen-objects/Native/Android.js b/wdio/screen-objects/Native/Android.js
index 073a6b3c0dd..6f47b2da261 100644
--- a/wdio/screen-objects/Native/Android.js
+++ b/wdio/screen-objects/Native/Android.js
@@ -1,3 +1,4 @@
+// Migrated to tests/page-objects/MMConnect/AndroidScreenHelpers.ts
import AppwrightSelectors from '../../../tests/framework/AppwrightSelectors';
import AppwrightGestures from '../../../tests/framework/AppwrightGestures';
import { AppwrightLocator, Device } from 'appwright';
diff --git a/wdio/screen-objects/RNPlaygroundDapp.js b/wdio/screen-objects/RNPlaygroundDapp.js
index 73471a1d7c4..998da73ccdd 100644
--- a/wdio/screen-objects/RNPlaygroundDapp.js
+++ b/wdio/screen-objects/RNPlaygroundDapp.js
@@ -1,3 +1,4 @@
+// Migrated to tests/page-objects/MMConnect/RNPlaygroundDapp.ts
import AppwrightSelectors from '../../tests/framework/AppwrightSelectors';
import AppwrightGestures from '../../tests/framework/AppwrightGestures';
import { PLAYGROUND_PACKAGE_ID } from '../../tests/framework/Constants.ts';
diff --git a/yarn.lock b/yarn.lock
index ee03b84bfdd..ff910d2345c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -20367,6 +20367,13 @@ __metadata:
languageName: node
linkType: hard
+"@wdio/protocols@npm:^9.24.0":
+ version: 9.24.0
+ resolution: "@wdio/protocols@npm:9.24.0"
+ checksum: 10/b5478e70dbd0f5294115334c833455032433eff57b62c7355ec6ca01d15f56fb16bc0c5d62acb3c6f66b8360d33af71b8b66d7a9e2e76d684ec45dd0bb318f1b
+ languageName: node
+ linkType: hard
+
"@wdio/repl@npm:9.16.2":
version: 9.16.2
resolution: "@wdio/repl@npm:9.16.2"
@@ -35616,6 +35623,7 @@ __metadata:
"@walletconnect/react-native-compat": "npm:^2.23.0"
"@walletconnect/types": "npm:^2.23.0"
"@walletconnect/utils": "npm:^2.23.0"
+ "@wdio/protocols": "npm:^9.24.0"
"@welldone-software/why-did-you-render": "npm:^8.0.1"
"@xmldom/xmldom": "npm:^0.8.10"
appium: "npm:^2.12.1"
From 4d0ffbeb81fdfff38774b4ba2334657a3758d5c2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Caain=C3=A3=20Jeronimo?=
Date: Thu, 12 Mar 2026 21:01:11 -0300
Subject: [PATCH 009/206] feat(predict): add buy with any token flow (#27369)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add PredictBuyWithAnyToken view, components, and hooks for the
pay-with-any-token order flow. Includes PredictController, payment token
selection, fee summary, order tracking, navigation, active order
management, feature flag selectors, and full test coverage.
## **Description**
Adds the "Buy With Any Token" flow to prediction markets, enabling users
to place bets using any token in their wallet (not just their predict
balance). When a user doesn't have sufficient USDC.e balance on Polygon,
this flow handles the deposit + order as a single transaction batch
through the confirmations framework.
This is **PR 1 of 2** — contains only `@MetaMask/predict`-owned code. A
follow-up PR will add the confirmations integration (registering the new
`predictDepositAndOrder` transaction type, pay-with-modal changes, and
navigation support).
### What's included
**New view — `PredictBuyWithAnyToken`**
Full buy preview screen with amount input, fee summary, action button,
and payment token selection. Renders both standalone (predict routes)
and within confirmations (as info-root content).
**PredictController state extensions**
- `activeOrder` — tracks order lifecycle (preview → confirming →
success/error)
- `selectedPaymentToken` — stores the user's chosen payment token
**New hooks (13)**
| Hook | Purpose |
|------|---------|
| `usePredictActiveOrder` | Manages active order state in controller |
| `usePredictNavigation` | Navigation helpers with replace support |
| `usePredictPayWithAnyToken` | Triggers deposit+order confirmation flow
|
| `usePredictPaymentToken` | Handles payment token selection changes |
| `usePredictBalanceTokenFilter` | Filters available tokens for predict
context |
| `usePredictBuyPreviewActions` | Orchestrates confirm/back/error
handlers |
| `usePredictBuyConditions` | Derives UI state (canPlaceBet,
isBelowMinimum) |
| `usePredictBuyInfo` | Computes fees, total, toWin from preview |
| `usePredictBuyInputState` | Manages amount input + focus state |
| `usePredictBuyAvailableBalance` | Calculates available balance |
| `usePredictBuyBackSwipe` | Back gesture handler |
| `usePredictOrderTracking` | Tracks order result (success/error) |
| `usePredictPayWithAnyTokenTracking` | Tracks deposit confirmation
lifecycle |
**New components (8)**: PredictBuyActionButton, PredictBuyAmountSection,
PredictBuyBottomContent, PredictBuyMinimumError,
PredictBuyPreviewHeader, PredictFeeSummary, PredictPayWithRow,
PredictPayWithAnyTokenInfo
**Feature flag selectors**: `selectPredictFakOrdersEnabledFlag`,
`selectPredictWithAnyTokenEnabledFlag` — gated behind remote
`predictWithAnyToken` flag.
**Other changes**:
- Routes updated with new navigation stack entries
- Analytics helpers for parsing order event properties
- Transaction constants (chain ID, placeholder address, minimum bet)
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes:
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [ ] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I've included tests if applicable
- [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> **High Risk**
> Adds a new deposit+order transaction-batching path
(`payWithAnyTokenConfirmation`) and new controller state for active
orders/payment-token selection, which affects confirmation routing and
transaction submission. Although gated by a remote flag, mistakes could
break Predict order placement/deposit flows or create incorrect
confirmation behavior.
>
> **Overview**
> Introduces a new *Buy With Any Token* Predict buy-preview flow, gated
by the remote `predictWithAnyToken` flag, and wires Predict routing to
swap `BUY_PREVIEW` between the legacy screen and the new
`PredictBuyWithAnyToken` view.
>
> Extends `PredictController` with ephemeral `activeOrder` and
`selectedPaymentToken` state plus setters, and adds
`payWithAnyTokenConfirmation()` to submit a deposit transaction batch
that re-tags `predictDeposit` txs as `predictDepositAndOrder` to drive a
new confirmation/info experience.
>
> Adds supporting hooks/utilities for navigation
(`usePredictNavigation`), active order and token selection
(`usePredictActiveOrder`, `usePredictPaymentToken`,
`usePredictBalanceTokenFilter`), order preview initialization
(`initialPreview`), and shared error/toast + analytics helpers; updates
buy UI components to reflect the new fee summary/deposit-fee row and
improved deposit-in-progress handling. Test coverage is expanded broadly
across the new hooks, selectors, navigation, and controller behavior.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
7f6706f95d68b64dd69ef6dac92b203483b237f8. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../PredictFeeBreakdownSheet.test.tsx | 22 +
.../PredictFeeBreakdownSheet.tsx | 28 +
.../PredictKeypad/PredictKeypad.test.tsx | 59 --
.../PredictKeypad/PredictKeypad.tsx | 71 +-
.../PredictMarketMultiple.test.tsx | 9 +
.../PredictMarketMultiple.tsx | 10 +-
.../PredictMarketOutcome.test.tsx | 9 +
.../PredictMarketOutcome.tsx | 5 +-
.../PredictMarketSingle.test.tsx | 9 +
.../PredictMarketSingle.tsx | 10 +-
.../PredictSportCardFooter.test.tsx | 9 +
.../PredictSportCardFooter.tsx | 29 +-
.../UI/Predict/constants/transactions.ts | 12 +
.../controllers/PredictController.test.ts | 139 ++++
.../Predict/controllers/PredictController.ts | 197 ++++++
.../hooks/usePredictActiveOrder.test.ts | 370 ++++++++++
.../UI/Predict/hooks/usePredictActiveOrder.ts | 121 ++++
.../usePredictBalanceTokenFilter.test.ts | 179 +++++
.../hooks/usePredictBalanceTokenFilter.ts | 75 +++
.../UI/Predict/hooks/usePredictClaim.test.ts | 1 +
.../UI/Predict/hooks/usePredictDeposit.ts | 56 +-
.../hooks/usePredictNavigation.test.ts | 214 ++++++
.../UI/Predict/hooks/usePredictNavigation.ts | 46 ++
.../hooks/usePredictOrderPreview.test.ts | 72 ++
.../Predict/hooks/usePredictOrderPreview.ts | 11 +-
.../hooks/usePredictPayWithAnyToken.test.ts | 121 ++++
.../hooks/usePredictPayWithAnyToken.ts | 102 +++
.../hooks/usePredictPaymentToken.test.ts | 425 ++++++++++++
.../Predict/hooks/usePredictPaymentToken.ts | 140 ++++
.../hooks/usePredictPlaceOrder.test.ts | 84 ++-
.../UI/Predict/hooks/usePredictPlaceOrder.ts | 53 +-
.../Predict/hooks/usePredictTrading.test.ts | 42 ++
.../UI/Predict/hooks/usePredictTrading.ts | 6 +
app/components/UI/Predict/routes/index.tsx | 263 +++++---
.../selectors/featureFlags/index.test.ts | 135 ++++
.../Predict/selectors/featureFlags/index.ts | 18 +
.../selectors/predictController/index.test.ts | 99 +++
.../selectors/predictController/index.ts | 12 +
app/components/UI/Predict/types/index.ts | 20 +-
app/components/UI/Predict/types/navigation.ts | 6 +
.../UI/Predict/utils/analytics.test.ts | 426 ++++++++++++
app/components/UI/Predict/utils/analytics.ts | 33 +
.../Predict/utils/predictErrorHandler.test.ts | 170 +++++
.../UI/Predict/utils/predictErrorHandler.ts | 32 +
.../PredictBuyPreview/PredictBuyPreview.tsx | 177 +----
.../PredictBuyWithAnyToken.tsx | 333 +++++++++
.../PredictBuyActionButton.test.tsx | 288 ++++++++
.../PredictBuyActionButton.tsx | 85 +++
.../PredictBuyActionButton/index.ts | 1 +
.../PredictBuyAmountSection.test.tsx | 346 ++++++++++
.../PredictBuyAmountSection.tsx | 124 ++++
.../PredictBuyAmountSection/index.ts | 1 +
.../PredictBuyBottomContent.test.tsx | 250 +++++++
.../PredictBuyBottomContent.tsx | 66 ++
.../PredictBuyBottomContent/index.ts | 1 +
.../PredictBuyMinimumError.test.tsx | 108 +++
.../PredictBuyMinimumError.tsx | 48 ++
.../PredictBuyMinimumError/index.ts | 1 +
.../PredictBuyPreviewHeader.test.tsx | 419 ++++++++++++
.../PredictBuyPreviewHeader.tsx | 157 +++++
.../PredictBuyPreviewHeader/index.ts | 6 +
.../PredictFeeSummary.test.tsx | 252 +++++++
.../PredictFeeSummary/PredictFeeSummary.tsx | 198 ++++++
.../PredictPayWithAnyTokenInfo.test.tsx | 189 ++++++
.../PredictPayWithAnyTokenInfo.tsx | 70 ++
.../PredictPayWithAnyTokenInfo/index.ts | 1 +
.../PredictPayWithRow.test.tsx | 196 ++++++
.../PredictPayWithRow/PredictPayWithRow.tsx | 87 +++
.../components/PredictPayWithRow/index.ts | 1 +
.../usePredictBuyAvailableBalance.test.ts | 147 ++++
.../hooks/usePredictBuyAvailableBalance.ts | 28 +
.../hooks/usePredictBuyBackSwipe.test.ts | 164 +++++
.../hooks/usePredictBuyBackSwipe.ts | 35 +
.../hooks/usePredictBuyConditions.test.ts | 465 +++++++++++++
.../hooks/usePredictBuyConditions.ts | 169 +++++
.../hooks/usePredictBuyInfo.test.ts | 334 +++++++++
.../hooks/usePredictBuyInfo.ts | 90 +++
.../hooks/usePredictBuyInputState.test.ts | 191 ++++++
.../hooks/usePredictBuyInputState.ts | 77 +++
.../hooks/usePredictBuyPreviewActions.test.ts | 632 ++++++++++++++++++
.../hooks/usePredictBuyPreviewActions.ts | 249 +++++++
.../hooks/usePredictOrderTracking.test.ts | 234 +++++++
.../hooks/usePredictOrderTracking.ts | 26 +
.../usePredictPayWithAnyTokenTracking.test.ts | 343 ++++++++++
.../usePredictPayWithAnyTokenTracking.ts | 116 ++++
.../views/PredictBuyWithAnyToken/index.ts | 1 +
.../PredictMarketDetails.test.tsx | 9 +
.../PredictMarketDetails.tsx | 9 +-
.../predict-controller/index.test.ts | 1 +
app/util/test/initial-background-state.json | 2 +
locales/languages/en.json | 5 +-
tests/feature-flags/feature-flag-registry.ts | 11 +
92 files changed, 10243 insertions(+), 450 deletions(-)
create mode 100644 app/components/UI/Predict/constants/transactions.ts
create mode 100644 app/components/UI/Predict/hooks/usePredictActiveOrder.test.ts
create mode 100644 app/components/UI/Predict/hooks/usePredictActiveOrder.ts
create mode 100644 app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts
create mode 100644 app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts
create mode 100644 app/components/UI/Predict/hooks/usePredictNavigation.test.ts
create mode 100644 app/components/UI/Predict/hooks/usePredictNavigation.ts
create mode 100644 app/components/UI/Predict/hooks/usePredictPayWithAnyToken.test.ts
create mode 100644 app/components/UI/Predict/hooks/usePredictPayWithAnyToken.ts
create mode 100644 app/components/UI/Predict/hooks/usePredictPaymentToken.test.ts
create mode 100644 app/components/UI/Predict/hooks/usePredictPaymentToken.ts
create mode 100644 app/components/UI/Predict/utils/analytics.test.ts
create mode 100644 app/components/UI/Predict/utils/analytics.ts
create mode 100644 app/components/UI/Predict/utils/predictErrorHandler.test.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyActionButton/PredictBuyActionButton.test.tsx
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyActionButton/PredictBuyActionButton.tsx
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyActionButton/index.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.test.tsx
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/PredictBuyAmountSection.tsx
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyAmountSection/index.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.test.tsx
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/PredictBuyBottomContent.tsx
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyBottomContent/index.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/PredictBuyMinimumError.test.tsx
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/PredictBuyMinimumError.tsx
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyMinimumError/index.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyPreviewHeader/PredictBuyPreviewHeader.test.tsx
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyPreviewHeader/PredictBuyPreviewHeader.tsx
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictBuyPreviewHeader/index.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictFeeSummary/PredictFeeSummary.test.tsx
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictFeeSummary/PredictFeeSummary.tsx
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.test.tsx
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/PredictPayWithAnyTokenInfo.tsx
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithAnyTokenInfo/index.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.test.tsx
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/PredictPayWithRow.tsx
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/components/PredictPayWithRow/index.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyAvailableBalance.test.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyAvailableBalance.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyBackSwipe.test.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyBackSwipe.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyConditions.test.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyConditions.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInfo.test.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInfo.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.test.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyInputState.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyPreviewActions.test.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictBuyPreviewActions.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictOrderTracking.test.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictOrderTracking.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictPayWithAnyTokenTracking.test.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/hooks/usePredictPayWithAnyTokenTracking.ts
create mode 100644 app/components/UI/Predict/views/PredictBuyWithAnyToken/index.ts
diff --git a/app/components/UI/Predict/components/PredictFeeBreakdownSheet/PredictFeeBreakdownSheet.test.tsx b/app/components/UI/Predict/components/PredictFeeBreakdownSheet/PredictFeeBreakdownSheet.test.tsx
index bee8f2136d3..7e164bad917 100644
--- a/app/components/UI/Predict/components/PredictFeeBreakdownSheet/PredictFeeBreakdownSheet.test.tsx
+++ b/app/components/UI/Predict/components/PredictFeeBreakdownSheet/PredictFeeBreakdownSheet.test.tsx
@@ -81,6 +81,12 @@ jest.mock('../../../../../../locales/i18n', () => ({
if (key === 'predict.fee_summary.exchange_fee_description') {
return 'Fee paid to the exchange or market';
}
+ if (key === 'predict.fee_summary.deposit_fee') {
+ return 'Deposit fee';
+ }
+ if (key === 'predict.fee_summary.deposit_fee_description') {
+ return 'Fee paid for token deposit';
+ }
if (key === 'predict.fee_summary.total') {
return 'Total';
}
@@ -241,6 +247,22 @@ describe('PredictFeeBreakdownSheet', () => {
const zeroAmounts = getAllByText('$0.00');
expect(zeroAmounts.length).toBeGreaterThanOrEqual(2);
});
+
+ it('does not render standalone 0 text when depositFee is 0', () => {
+ const props = {
+ ...defaultProps,
+ depositFee: 0,
+ };
+ const TestComponent = () => {
+ const ref = useRef(null);
+ return ;
+ };
+
+ const { queryByText, queryAllByText } = render();
+
+ expect(queryByText(/^0$/)).not.toBeOnTheScreen();
+ expect(queryAllByText('Deposit fee')).toHaveLength(0);
+ });
});
describe('Total display', () => {
diff --git a/app/components/UI/Predict/components/PredictFeeBreakdownSheet/PredictFeeBreakdownSheet.tsx b/app/components/UI/Predict/components/PredictFeeBreakdownSheet/PredictFeeBreakdownSheet.tsx
index f2b57f844c1..4e16008ccda 100644
--- a/app/components/UI/Predict/components/PredictFeeBreakdownSheet/PredictFeeBreakdownSheet.tsx
+++ b/app/components/UI/Predict/components/PredictFeeBreakdownSheet/PredictFeeBreakdownSheet.tsx
@@ -42,6 +42,7 @@ const FeeRow = ({ title, description, amount }: FeeRowProps) => (
interface PredictFeeBreakdownSheetProps {
providerFee: number;
metamaskFee: number;
+ depositFee?: number;
sharePrice: number;
contractCount: number;
betAmount: number;
@@ -59,6 +60,7 @@ const PredictFeeBreakdownSheet = forwardRef<
providerFee,
metamaskFee,
sharePrice,
+ depositFee,
contractCount,
betAmount,
total,
@@ -95,6 +97,32 @@ const PredictFeeBreakdownSheet = forwardRef<
amount={formatPrice(providerFee, { maximumDecimals: 2 })}
/>
+ {depositFee && (
+ <>
+
+
+
+ {strings('predict.fee_summary.deposit_fee')}
+
+
+ {strings('predict.fee_summary.deposit_fee_description')}
+
+
+
+ {formatPrice(depositFee, { maximumDecimals: 2 })}
+
+
+
+
+ >
+ )}
+
{
});
});
- describe('Add Funds Button', () => {
- it('renders Add Funds button when hasInsufficientFunds is true and onAddFunds is provided', () => {
- const onAddFundsMock = jest.fn();
- const props = {
- ...defaultProps,
- hasInsufficientFunds: true,
- onAddFunds: onAddFundsMock,
- };
-
- const { getByText, queryByText } = render();
-
- expect(getByText('Add funds')).toBeOnTheScreen();
- expect(queryByText('$20')).toBeNull();
- expect(queryByText('$50')).toBeNull();
- expect(queryByText('$100')).toBeNull();
- });
-
- it('calls onAddFunds when Add Funds button is pressed', () => {
- const onAddFundsMock = jest.fn();
- const props = {
- ...defaultProps,
- hasInsufficientFunds: true,
- onAddFunds: onAddFundsMock,
- };
-
- const { getByText } = render();
- fireEvent.press(getByText('Add funds'));
-
- expect(onAddFundsMock).toHaveBeenCalledTimes(1);
- });
-
- it('renders quick amount buttons when hasInsufficientFunds is false', () => {
- const props = {
- ...defaultProps,
- hasInsufficientFunds: false,
- };
-
- const { getByText, queryByText } = render();
-
- expect(getByText('$20')).toBeOnTheScreen();
- expect(getByText('$50')).toBeOnTheScreen();
- expect(getByText('$100')).toBeOnTheScreen();
- expect(queryByText('predict.deposit.add_funds')).toBeNull();
- });
-
- it('renders quick amount buttons when onAddFunds is not provided', () => {
- const props = {
- ...defaultProps,
- hasInsufficientFunds: true,
- onAddFunds: undefined,
- };
-
- const { getByText, queryByText } = render();
-
- expect(getByText('$20')).toBeOnTheScreen();
- expect(queryByText('predict.deposit.add_funds')).toBeNull();
- });
- });
-
describe('handleDonePress', () => {
it('removes trailing decimal point when Done is pressed', () => {
const props = {
diff --git a/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.tsx b/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.tsx
index 232d0627089..a6a1381ace9 100644
--- a/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.tsx
+++ b/app/components/UI/Predict/components/PredictKeypad/PredictKeypad.tsx
@@ -1,12 +1,11 @@
import { useTailwind } from '@metamask/design-system-twrnc-preset';
-import React, { useCallback, forwardRef, useImperativeHandle } from 'react';
+import React, { forwardRef, useCallback, useImperativeHandle } from 'react';
import { View } from 'react-native';
import Button, {
ButtonSize,
ButtonVariants,
} from '../../../../../component-library/components/Buttons/Button';
import Keypad from '../../../../Base/Keypad';
-import { strings } from '../../../../../../locales/i18n';
interface PredictKeypadProps {
isInputFocused: boolean;
@@ -15,8 +14,6 @@ interface PredictKeypadProps {
setCurrentValue: (value: number) => void;
setCurrentValueUSDString: (value: string) => void;
setIsInputFocused: (focused: boolean) => void;
- hasInsufficientFunds?: boolean;
- onAddFunds?: () => void;
}
export interface PredictKeypadHandles {
@@ -34,8 +31,6 @@ const PredictKeypad = forwardRef(
setCurrentValue,
setCurrentValueUSDString,
setIsInputFocused,
- hasInsufficientFunds = false,
- onAddFunds,
},
ref,
) => {
@@ -144,46 +139,36 @@ const PredictKeypad = forwardRef(
return (
- {hasInsufficientFunds && onAddFunds ? (
+
+
{
};
});
+jest.mock('../../hooks/usePredictActiveOrder', () => ({
+ usePredictActiveOrder: () => ({
+ activeOrder: null,
+ updateActiveOrder: jest.fn(),
+ initializeActiveOrder: jest.fn(),
+ clearActiveOrder: jest.fn(),
+ }),
+}));
+
// Mock usePredictEligibility hook
const mockUsePredictEligibility = jest.fn();
jest.mock('../../hooks/usePredictEligibility', () => ({
diff --git a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx
index 91c3b4ff5d3..1624b8300d1 100644
--- a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx
+++ b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx
@@ -26,6 +26,7 @@ import Icon, {
} from '../../../../../component-library/components/Icons/Icon';
import { useStyles } from '../../../../../component-library/hooks';
import { usePredictActionGuard } from '../../hooks/usePredictActionGuard';
+import { usePredictNavigation } from '../../hooks/usePredictNavigation';
import Routes from '../../../../../constants/navigation/Routes';
import { PREDICT_CONSTANTS } from '../../constants/errors';
import { ensureError } from '../../utils/predictErrorHandler';
@@ -71,6 +72,7 @@ const PredictMarketMultiple: React.FC = ({
const navigation =
useNavigation>();
+ const { navigateToBuyPreview } = usePredictNavigation();
const { styles } = useStyles(styleSheet, { isCarousel });
const tw = useTailwind();
@@ -138,15 +140,15 @@ const PredictMarketMultiple: React.FC = ({
) => {
executeGuardedAction(
() => {
- navigation.navigate(Routes.PREDICT.ROOT, {
- screen: Routes.PREDICT.MODALS.BUY_PREVIEW,
- params: {
+ navigateToBuyPreview(
+ {
market,
outcome,
outcomeToken,
entryPoint: resolvedEntryPoint,
},
- });
+ { throughRoot: true },
+ );
},
{
attemptedAction: PredictEventValues.ATTEMPTED_ACTION.PREDICT,
diff --git a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.test.tsx b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.test.tsx
index e0d648c5c04..30bbcea0a3b 100644
--- a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.test.tsx
+++ b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.test.tsx
@@ -27,6 +27,15 @@ jest.mock('@react-navigation/native', () => {
};
});
+jest.mock('../../hooks/usePredictActiveOrder', () => ({
+ usePredictActiveOrder: () => ({
+ initializeActiveOrder: jest.fn(),
+ activeOrder: null,
+ updateActiveOrder: jest.fn(),
+ clearActiveOrder: jest.fn(),
+ }),
+}));
+
// Mock usePredictBalance hook
const mockUsePredictBalance = jest.fn();
jest.mock('../../hooks/usePredictBalance', () => ({
diff --git a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx
index 69704fc7078..e105d086bfb 100644
--- a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx
+++ b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx
@@ -21,7 +21,6 @@ import Icon, {
IconSize,
} from '../../../../../component-library/components/Icons/Icon';
import { useStyles } from '../../../../../component-library/hooks';
-import Routes from '../../../../../constants/navigation/Routes';
import {
PredictMarket,
PredictOutcomeToken,
@@ -39,6 +38,7 @@ import {
} from '../../utils/format';
import styleSheet from './PredictMarketOutcome.styles';
import { usePredictActionGuard } from '../../hooks/usePredictActionGuard';
+import { usePredictNavigation } from '../../hooks/usePredictNavigation';
interface PredictMarketOutcomeProps {
market: PredictMarket;
outcome: PredictOutcomeType;
@@ -64,6 +64,7 @@ const PredictMarketOutcome: React.FC = ({
const { executeGuardedAction } = usePredictActionGuard({
navigation,
});
+ const { navigateToBuyPreview } = usePredictNavigation();
const getOutcomePrices = (): number[] =>
outcome.tokens.map((token) => token.price);
@@ -94,7 +95,7 @@ const PredictMarketOutcome: React.FC = ({
const handleBuy = (token: PredictOutcomeToken) => {
executeGuardedAction(
() => {
- navigation.navigate(Routes.PREDICT.MODALS.BUY_PREVIEW, {
+ navigateToBuyPreview({
market,
outcome,
outcomeToken: token,
diff --git a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx
index 4bc499d1019..a2105c1d29d 100644
--- a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx
+++ b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.test.tsx
@@ -33,6 +33,15 @@ jest.mock('@react-navigation/native', () => ({
}),
}));
+jest.mock('../../hooks/usePredictActiveOrder', () => ({
+ usePredictActiveOrder: () => ({
+ initializeActiveOrder: jest.fn(),
+ activeOrder: null,
+ updateActiveOrder: jest.fn(),
+ clearActiveOrder: jest.fn(),
+ }),
+}));
+
// Mock usePredictEligibility hook
const mockUsePredictEligibility = jest.fn();
jest.mock('../../hooks/usePredictEligibility', () => ({
diff --git a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx
index 6744585b834..24207592b8d 100644
--- a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx
+++ b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx
@@ -19,6 +19,7 @@ import Button, {
} from '../../../../../component-library/components/Buttons/Button';
import { useStyles } from '../../../../../component-library/hooks';
import { usePredictActionGuard } from '../../hooks/usePredictActionGuard';
+import { usePredictNavigation } from '../../hooks/usePredictNavigation';
import Routes from '../../../../../constants/navigation/Routes';
import {
PredictMarket as PredictMarketType,
@@ -150,6 +151,7 @@ const PredictMarketSingle: React.FC = ({
const outcome = market.outcomes[0];
const navigation =
useNavigation>();
+ const { navigateToBuyPreview } = usePredictNavigation();
const { styles } = useStyles(styleSheet, { isCarousel });
const tw = useTailwind();
@@ -186,15 +188,15 @@ const PredictMarketSingle: React.FC = ({
const handleBuy = (token: PredictOutcomeToken) => {
executeGuardedAction(
() => {
- navigation.navigate(Routes.PREDICT.ROOT, {
- screen: Routes.PREDICT.MODALS.BUY_PREVIEW,
- params: {
+ navigateToBuyPreview(
+ {
market,
outcome,
outcomeToken: token,
entryPoint: resolvedEntryPoint,
},
- });
+ { throughRoot: true },
+ );
},
{
attemptedAction: PredictEventValues.ATTEMPTED_ACTION.PREDICT,
diff --git a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx
index 8d6a6691907..58bd12ed60f 100644
--- a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx
+++ b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx
@@ -28,6 +28,15 @@ jest.mock('@react-navigation/native', () => ({
}),
}));
+jest.mock('../../hooks/usePredictActiveOrder', () => ({
+ usePredictActiveOrder: () => ({
+ initializeActiveOrder: jest.fn(),
+ activeOrder: null,
+ updateActiveOrder: jest.fn(),
+ clearActiveOrder: jest.fn(),
+ }),
+}));
+
jest.mock('../../hooks/usePredictPositions');
jest.mock('../../hooks/usePredictActionGuard');
jest.mock('../../hooks/usePredictClaim');
diff --git a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx
index 90665a0678f..e65848f9e4e 100644
--- a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx
+++ b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx
@@ -13,7 +13,6 @@ import {
} from '../../types/navigation';
import { PredictEventValues } from '../../constants/eventNames';
import { usePredictEntryPoint } from '../../contexts';
-import Routes from '../../../../../constants/navigation/Routes';
import TrendingFeedSessionManager from '../../../Trending/services/TrendingFeedSessionManager';
import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton';
import { PredictActionButtons } from '../PredictActionButtons';
@@ -21,6 +20,7 @@ import { PredictPicksForCard } from '../PredictPicks';
import { usePredictPositions } from '../../hooks/usePredictPositions';
import { usePredictActionGuard } from '../../hooks/usePredictActionGuard';
import { usePredictClaim } from '../../hooks/usePredictClaim';
+import { usePredictNavigation } from '../../hooks/usePredictNavigation';
import { PREDICT_SPORT_CARD_FOOTER_TEST_IDS } from './PredictSportCardFooter.testIds';
interface PredictSportCardFooterProps {
@@ -67,6 +67,7 @@ const PredictSportCardFooter: React.FC = ({
});
const { claim, isClaimPending } = usePredictClaim();
+ const { navigateToBuyPreview } = usePredictNavigation();
const outcome = market.outcomes?.[0];
const isMarketOpen =
@@ -79,27 +80,19 @@ const PredictSportCardFooter: React.FC = ({
() => {
// When accessed from Carousel, we're outside the Predict navigator,
// so we need to navigate through the ROOT first
- if (
+ const throughRoot =
isCarousel ||
- resolvedEntryPoint === PredictEventValues.ENTRY_POINT.CAROUSEL
- ) {
- navigation.navigate(Routes.PREDICT.ROOT, {
- screen: Routes.PREDICT.MODALS.BUY_PREVIEW,
- params: {
- market,
- outcome,
- outcomeToken: token,
- entryPoint: resolvedEntryPoint,
- },
- });
- } else {
- navigation.navigate(Routes.PREDICT.MODALS.BUY_PREVIEW, {
+ resolvedEntryPoint === PredictEventValues.ENTRY_POINT.CAROUSEL;
+
+ navigateToBuyPreview(
+ {
market,
outcome,
outcomeToken: token,
entryPoint: resolvedEntryPoint,
- });
- }
+ },
+ { throughRoot },
+ );
},
{
attemptedAction: PredictEventValues.ATTEMPTED_ACTION.PREDICT,
@@ -110,7 +103,7 @@ const PredictSportCardFooter: React.FC = ({
executeGuardedAction,
isCarousel,
resolvedEntryPoint,
- navigation,
+ navigateToBuyPreview,
market,
outcome,
],
diff --git a/app/components/UI/Predict/constants/transactions.ts b/app/components/UI/Predict/constants/transactions.ts
new file mode 100644
index 00000000000..65f3fa21200
--- /dev/null
+++ b/app/components/UI/Predict/constants/transactions.ts
@@ -0,0 +1,12 @@
+import type { Hex } from '@metamask/utils';
+
+export const PREDICT_BALANCE_PLACEHOLDER_ADDRESS =
+ '0x0000000000000000000000000000000000000001' as Hex;
+
+export const PREDICT_BALANCE_CHAIN_ID = '0x89' as Hex;
+
+export const MINIMUM_BET = 1; // $1 minimum bet
+
+export const PREDICT_BALANCE_TOKEN_KEY = 'predict-balance';
+
+export const PREDICTION_ERROR_TRANSACTION_BATCH_ID = 'NA';
diff --git a/app/components/UI/Predict/controllers/PredictController.test.ts b/app/components/UI/Predict/controllers/PredictController.test.ts
index 16a02ae4b49..07a81ee7c8b 100644
--- a/app/components/UI/Predict/controllers/PredictController.test.ts
+++ b/app/components/UI/Predict/controllers/PredictController.test.ts
@@ -23,6 +23,7 @@ import {
} from '../../../../util/transaction-controller';
import { PolymarketProvider } from '../providers/polymarket/PolymarketProvider';
import {
+ ActiveOrderState,
type OrderPreview,
PredictBalance,
PredictClaimStatus,
@@ -3543,6 +3544,144 @@ describe('PredictController', () => {
});
});
+ describe('activeOrder and selectedPaymentToken management', () => {
+ it('setActiveOrder updates state with provided order', () => {
+ withController(({ controller }) => {
+ const order: PredictControllerState['activeOrder'] = {
+ amount: 50,
+ state: ActiveOrderState.PREVIEW,
+ };
+
+ controller.setActiveOrder(order);
+
+ expect(controller.state.activeOrder).toEqual(order);
+ });
+ });
+
+ it('clearActiveOrder sets activeOrder to null', () => {
+ withController(({ controller }) => {
+ controller.setActiveOrder({
+ amount: 50,
+ state: ActiveOrderState.PREVIEW,
+ });
+
+ controller.clearActiveOrder();
+
+ expect(controller.state.activeOrder).toBeNull();
+ });
+ });
+
+ it('setSelectedPaymentToken updates state with provided token', () => {
+ withController(({ controller }) => {
+ const token: PredictControllerState['selectedPaymentToken'] = {
+ address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174',
+ chainId: '0x89',
+ symbol: 'USDC',
+ };
+
+ controller.setSelectedPaymentToken(token);
+
+ expect(controller.state.selectedPaymentToken).toEqual(token);
+ });
+ });
+
+ it('setSelectedPaymentToken clears token when called with null', () => {
+ withController(({ controller }) => {
+ controller.setSelectedPaymentToken({
+ address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174',
+ chainId: '0x89',
+ symbol: 'USDC',
+ });
+
+ controller.setSelectedPaymentToken(null);
+
+ expect(controller.state.selectedPaymentToken).toBeNull();
+ });
+ });
+ });
+
+ describe('payWithAnyTokenConfirmation', () => {
+ it('uses predict deposit transaction when setup transactions are present', async () => {
+ const setupTransaction = {
+ params: {
+ to: '0x1000000000000000000000000000000000000001' as `0x${string}`,
+ data: '0x095ea7b3000000000000000000000000' as `0x${string}`,
+ },
+ type: TransactionType.contractInteraction,
+ };
+ const depositTransaction = {
+ params: {
+ to: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as `0x${string}`,
+ data: '0xa9059cbb000000000000000000000000' as `0x${string}`,
+ },
+ type: TransactionType.predictDeposit,
+ };
+
+ mockPolymarketProvider.prepareDeposit.mockResolvedValue({
+ transactions: [setupTransaction, depositTransaction],
+ chainId: '0x89',
+ });
+
+ (addTransactionBatch as jest.Mock).mockResolvedValue({
+ batchId: 'tx-pay-with-any-token',
+ });
+
+ await withController(async ({ controller }) => {
+ const result = await controller.payWithAnyTokenConfirmation();
+
+ expect(result).toEqual({
+ success: true,
+ response: {
+ batchId: 'tx-pay-with-any-token',
+ },
+ });
+
+ expect(addTransactionBatch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ from: '0x1234567890123456789012345678901234567890',
+ transactions: expect.arrayContaining([
+ expect.objectContaining({
+ type: 'predictDepositAndOrder',
+ }),
+ ]),
+ }),
+ );
+ });
+ });
+
+ it('processes batch when no predict deposit transaction type is present', async () => {
+ mockPolymarketProvider.prepareDeposit.mockResolvedValue({
+ transactions: [
+ {
+ params: {
+ to: '0x1000000000000000000000000000000000000001' as `0x${string}`,
+ data: '0x095ea7b3000000000000000000000000' as `0x${string}`,
+ },
+ type: TransactionType.contractInteraction,
+ },
+ ],
+ chainId: '0x89',
+ });
+
+ (addTransactionBatch as jest.Mock).mockResolvedValue({
+ batchId: 'batch-no-deposit',
+ });
+
+ await withController(async ({ controller }) => {
+ const result = await controller.payWithAnyTokenConfirmation();
+
+ expect(result).toEqual({
+ success: true,
+ response: {
+ batchId: 'batch-no-deposit',
+ },
+ });
+
+ expect(addTransactionBatch).toHaveBeenCalled();
+ });
+ });
+ });
+
describe('transactionStatusChanged event', () => {
const accountAddress = '0x1234567890123456789012345678901234567890';
diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts
index 80002023eb3..149d674852f 100644
--- a/app/components/UI/Predict/controllers/PredictController.ts
+++ b/app/components/UI/Predict/controllers/PredictController.ts
@@ -61,6 +61,7 @@ import { PolymarketProvider } from '../providers/polymarket/PolymarketProvider';
import { Signer } from '../providers/types';
import {
AccountState,
+ ActiveOrderState,
ClaimParams,
ConnectionStatus,
GameUpdateCallback,
@@ -117,6 +118,7 @@ import {
} from '../../../../util/remoteFeatureFlag';
import { unwrapRemoteFeatureFlag } from '../utils/flags';
import { parse, PredictFeeCollectionSchema } from '../schemas';
+import { PREDICTION_ERROR_TRANSACTION_BATCH_ID } from '../constants/transactions';
/**
* State shape for PredictController
@@ -148,6 +150,20 @@ export type PredictControllerState = {
// TODO: change to be per-account basis
withdrawTransaction: PredictWithdraw | null;
+ activeOrder?: {
+ amount?: number;
+ batchId?: string;
+ isInputFocused?: boolean;
+ state: ActiveOrderState;
+ error?: string;
+ } | null;
+
+ selectedPaymentToken: {
+ address: string;
+ chainId: string;
+ symbol?: string;
+ } | null;
+
// Persisted data
accountMeta: {
[providerId: string]: { [address: string]: PredictAccountMeta };
@@ -166,6 +182,8 @@ export const getDefaultPredictControllerState = (): PredictControllerState => ({
pendingDeposits: {},
pendingClaims: {},
withdrawTransaction: null,
+ activeOrder: null,
+ selectedPaymentToken: null,
accountMeta: {},
});
@@ -227,6 +245,18 @@ const metadata: StateMetadata = {
includeInStateLogs: false,
usedInUi: true,
},
+ activeOrder: {
+ persist: false,
+ includeInDebugSnapshot: false,
+ includeInStateLogs: false,
+ usedInUi: true,
+ },
+ selectedPaymentToken: {
+ persist: false,
+ includeInDebugSnapshot: false,
+ includeInStateLogs: false,
+ usedInUi: true,
+ },
};
/**
@@ -1894,6 +1924,26 @@ export class PredictController extends BaseController<
this.update(updater);
}
+ public setActiveOrder(order: PredictControllerState['activeOrder']): void {
+ this.update((state) => {
+ state.activeOrder = order;
+ });
+ }
+
+ public clearActiveOrder(): void {
+ this.update((state) => {
+ state.activeOrder = null;
+ });
+ }
+
+ public setSelectedPaymentToken(
+ token: PredictControllerState['selectedPaymentToken'],
+ ): void {
+ this.update((state) => {
+ state.selectedPaymentToken = token;
+ });
+ }
+
public async depositWithConfirmation(
_params: PrepareDepositParams = {},
): Promise> {
@@ -2015,6 +2065,153 @@ export class PredictController extends BaseController<
}
}
+ /**
+ * Prepares and submits a deposit transaction batch using the
+ * `predictDepositAndOrder` transaction type. This triggers the new
+ * deposit-and-order confirmation screen instead of the standard deposit screen.
+ *
+ * The flow reuses `provider.prepareDeposit` but overrides the transaction
+ * type so the confirmation routing in `info-root.tsx` renders
+ * `PredictPayWithAnyTokenInfo`.
+ *
+ * TODO: Remove the cast once `predictDepositAndOrder` is added to
+ * `@metamask/transaction-controller`.
+ */
+ public async payWithAnyTokenConfirmation(): Promise<
+ Result<{ batchId: string }>
+ > {
+ const provider = this.provider;
+
+ try {
+ const signer = this.getSigner();
+
+ this.update((state) => {
+ if (state.activeOrder) {
+ delete state.activeOrder.batchId;
+ }
+ });
+
+ const depositPreparation = await provider.prepareDeposit({
+ signer,
+ });
+
+ if (!depositPreparation) {
+ throw new Error('Deposit preparation returned undefined');
+ }
+
+ const { transactions, chainId } = depositPreparation;
+
+ if (!transactions || transactions.length === 0) {
+ throw new Error('No transactions returned from deposit preparation');
+ }
+
+ if (!chainId) {
+ throw new Error('Chain ID not provided by deposit preparation');
+ }
+
+ // TODO: Remove cast once predictDepositAndOrder is in @metamask/transaction-controller
+ const predictDepositAndOrderType =
+ 'predictDepositAndOrder' as unknown as TransactionType;
+
+ // Override transaction types to predictDepositAndOrder so the
+ // confirmation routing renders the deposit-and-order info component.
+ const depositAndOrderTransactions = transactions.map((tx) => ({
+ ...tx,
+ type:
+ tx.type === TransactionType.predictDeposit
+ ? predictDepositAndOrderType
+ : tx.type,
+ }));
+
+ DevLogger.log(
+ 'PredictController: payWithAnyTokenConfirmation transactions',
+ {
+ count: depositAndOrderTransactions.length,
+ transactions: depositAndOrderTransactions.map((tx, index) => ({
+ index,
+ type: tx?.type,
+ to: tx?.params?.to,
+ dataLength: tx?.params?.data?.length ?? 0,
+ })),
+ },
+ );
+
+ validateDepositTransactions(depositAndOrderTransactions, {
+ providerId: POLYMARKET_PROVIDER_ID,
+ });
+
+ const networkClientId = this.messenger.call(
+ 'NetworkController:findNetworkClientIdByChainId',
+ chainId,
+ );
+
+ if (!networkClientId) {
+ throw new Error(`Network client not found for chain ID: ${chainId}`);
+ }
+
+ const batchResult = await addTransactionBatch({
+ from: signer.address as Hex,
+ origin: ORIGIN_METAMASK,
+ networkClientId,
+ disableHook: true,
+ disableSequential: true,
+ skipInitialGasEstimate: true,
+ transactions: depositAndOrderTransactions,
+ });
+
+ if (!batchResult?.batchId) {
+ throw new Error('Failed to get batch ID from transaction submission');
+ }
+
+ const { batchId } = batchResult;
+
+ this.update((state) => {
+ if (state.activeOrder) {
+ state.activeOrder.batchId = batchId;
+ delete state.activeOrder.error;
+ }
+ });
+
+ return {
+ success: true,
+ response: {
+ batchId,
+ },
+ };
+ } catch (error) {
+ const e = ensureError(error);
+ if (e.message.includes('User denied transaction signature')) {
+ this.update((state) => {
+ if (state.activeOrder) {
+ state.activeOrder = null;
+ }
+ });
+ return {
+ success: true,
+ response: { batchId: PREDICTION_ERROR_TRANSACTION_BATCH_ID },
+ };
+ }
+
+ const errorMessage = e.message ?? PREDICT_ERROR_CODES.DEPOSIT_FAILED;
+
+ this.update((state) => {
+ if (state.activeOrder) {
+ state.activeOrder.error = errorMessage;
+ state.activeOrder.batchId = PREDICTION_ERROR_TRANSACTION_BATCH_ID;
+ }
+ });
+
+ Logger.error(
+ e,
+ this.getErrorContext('payWithAnyTokenConfirmation', {
+ providerId: POLYMARKET_PROVIDER_ID,
+ }),
+ );
+
+ throw new Error(errorMessage);
+ }
+ }
+
public clearPendingDeposit(): void {
const selectedAddress = this.getSigner().address;
this.clearPendingDepositForAddress({ address: selectedAddress });
diff --git a/app/components/UI/Predict/hooks/usePredictActiveOrder.test.ts b/app/components/UI/Predict/hooks/usePredictActiveOrder.test.ts
new file mode 100644
index 00000000000..41199bc7d2f
--- /dev/null
+++ b/app/components/UI/Predict/hooks/usePredictActiveOrder.test.ts
@@ -0,0 +1,370 @@
+import { renderHook, act } from '@testing-library/react-native';
+import { useSelector } from 'react-redux';
+import Engine from '../../../../core/Engine';
+import { usePredictActiveOrder } from './usePredictActiveOrder';
+import { ActiveOrderState, Recurrence } from '../types';
+import { PredictTradeStatus } from '../constants/eventNames';
+
+jest.mock('../../../../core/Engine', () => ({
+ context: {
+ PredictController: {
+ setActiveOrder: jest.fn(),
+ clearActiveOrder: jest.fn(),
+ setSelectedPaymentToken: jest.fn(),
+ trackPredictOrderEvent: jest.fn(),
+ },
+ },
+}));
+
+jest.mock('react-redux', () => ({
+ useSelector: jest.fn(),
+}));
+
+jest.mock('../utils/analytics', () => ({
+ parseAnalyticsProperties: jest.fn(() => ({ marketId: 'market-1' })),
+}));
+
+const mockUseSelector = useSelector as jest.MockedFunction;
+
+describe('usePredictActiveOrder', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseSelector.mockReturnValue(undefined);
+ });
+
+ describe('updateActiveOrder', () => {
+ it('sets full order when state property is present', () => {
+ const { result } = renderHook(() => usePredictActiveOrder());
+
+ act(() => {
+ result.current.updateActiveOrder({ state: ActiveOrderState.PREVIEW });
+ });
+
+ expect(
+ Engine.context.PredictController.setActiveOrder,
+ ).toHaveBeenCalledWith({ state: ActiveOrderState.PREVIEW });
+ });
+
+ it('clears activeOrder when called with null', () => {
+ const { result } = renderHook(() => usePredictActiveOrder());
+
+ act(() => {
+ result.current.updateActiveOrder(null);
+ });
+
+ expect(
+ Engine.context.PredictController.clearActiveOrder,
+ ).toHaveBeenCalled();
+ });
+
+ it('calls clearActiveOrder and setSelectedPaymentToken(null) when null', () => {
+ const { result } = renderHook(() => usePredictActiveOrder());
+
+ act(() => {
+ result.current.updateActiveOrder(null);
+ });
+
+ expect(
+ Engine.context.PredictController.clearActiveOrder,
+ ).toHaveBeenCalled();
+ expect(
+ Engine.context.PredictController.setSelectedPaymentToken,
+ ).toHaveBeenCalledWith(null);
+ });
+
+ it('deletes amount property when amount is null in patch', () => {
+ mockUseSelector.mockReturnValue({
+ state: ActiveOrderState.PREVIEW,
+ amount: '100',
+ });
+
+ const { result } = renderHook(() => usePredictActiveOrder());
+
+ act(() => {
+ result.current.updateActiveOrder({ amount: null });
+ });
+
+ const callArg = (
+ Engine.context.PredictController.setActiveOrder as jest.Mock
+ ).mock.calls[0][0];
+ expect(callArg).not.toHaveProperty('amount');
+ });
+
+ it('deletes batchId property when batchId is null in patch', () => {
+ mockUseSelector.mockReturnValue({
+ state: ActiveOrderState.PREVIEW,
+ batchId: 'batch-123',
+ });
+
+ const { result } = renderHook(() => usePredictActiveOrder());
+
+ act(() => {
+ result.current.updateActiveOrder({ batchId: null });
+ });
+
+ const callArg = (
+ Engine.context.PredictController.setActiveOrder as jest.Mock
+ ).mock.calls[0][0];
+ expect(callArg).not.toHaveProperty('batchId');
+ });
+
+ it('deletes isInputFocused when null in patch', () => {
+ mockUseSelector.mockReturnValue({
+ state: ActiveOrderState.PREVIEW,
+ isInputFocused: true,
+ });
+
+ const { result } = renderHook(() => usePredictActiveOrder());
+
+ act(() => {
+ result.current.updateActiveOrder({ isInputFocused: null });
+ });
+
+ const callArg = (
+ Engine.context.PredictController.setActiveOrder as jest.Mock
+ ).mock.calls[0][0];
+ expect(callArg).not.toHaveProperty('isInputFocused');
+ });
+
+ it('deletes state when null in patch', () => {
+ mockUseSelector.mockReturnValue({
+ state: ActiveOrderState.PREVIEW,
+ });
+
+ const { result } = renderHook(() => usePredictActiveOrder());
+
+ act(() => {
+ result.current.updateActiveOrder({ state: null });
+ });
+
+ expect(
+ Engine.context.PredictController.setActiveOrder,
+ ).toHaveBeenCalledWith(null);
+ });
+
+ it('deletes error when null in patch', () => {
+ mockUseSelector.mockReturnValue({
+ state: ActiveOrderState.PREVIEW,
+ error: 'some error',
+ });
+
+ const { result } = renderHook(() => usePredictActiveOrder());
+
+ act(() => {
+ result.current.updateActiveOrder({ error: null });
+ });
+
+ const callArg = (
+ Engine.context.PredictController.setActiveOrder as jest.Mock
+ ).mock.calls[0][0];
+ expect(callArg).not.toHaveProperty('error');
+ });
+
+ it('merges patch with existing activeOrder state', () => {
+ mockUseSelector.mockReturnValue({
+ state: ActiveOrderState.PREVIEW,
+ isInputFocused: true,
+ });
+
+ const { result } = renderHook(() => usePredictActiveOrder());
+
+ act(() => {
+ result.current.updateActiveOrder({
+ state: ActiveOrderState.PLACING_ORDER,
+ });
+ });
+
+ expect(
+ Engine.context.PredictController.setActiveOrder,
+ ).toHaveBeenCalledWith({
+ state: ActiveOrderState.PLACING_ORDER,
+ isInputFocused: true,
+ });
+ });
+
+ it('passes null to setActiveOrder when state is removed from nextOrder', () => {
+ mockUseSelector.mockReturnValue({
+ state: ActiveOrderState.PREVIEW,
+ isInputFocused: true,
+ });
+
+ const { result } = renderHook(() => usePredictActiveOrder());
+
+ act(() => {
+ result.current.updateActiveOrder({ state: null });
+ });
+
+ expect(
+ Engine.context.PredictController.setActiveOrder,
+ ).toHaveBeenCalledWith(null);
+ });
+ });
+
+ describe('initializeActiveOrder', () => {
+ it('sets state to PREVIEW and isInputFocused to true', () => {
+ const { result } = renderHook(() => usePredictActiveOrder());
+
+ act(() => {
+ result.current.initializeActiveOrder({
+ market: {
+ id: 'market-1',
+ providerId: 'provider-1',
+ slug: 'market-slug',
+ title: 'Market Title',
+ description: 'Market Description',
+ image: 'image-url',
+ status: 'open',
+ recurrence: Recurrence.NONE,
+ category: 'trending' as const,
+ tags: [],
+ outcomes: [],
+ liquidity: 1000,
+ volume: 5000,
+ },
+ outcomeToken: { id: 'token-1', title: 'Yes', price: 0.6 },
+ });
+ });
+
+ expect(
+ Engine.context.PredictController.setActiveOrder,
+ ).toHaveBeenCalledWith({
+ state: ActiveOrderState.PREVIEW,
+ isInputFocused: true,
+ });
+ });
+
+ it('calls setSelectedPaymentToken with null', () => {
+ const { result } = renderHook(() => usePredictActiveOrder());
+
+ act(() => {
+ result.current.initializeActiveOrder({
+ market: {
+ id: 'market-1',
+ providerId: 'provider-1',
+ slug: 'market-slug',
+ title: 'Market Title',
+ description: 'Market Description',
+ image: 'image-url',
+ status: 'open',
+ recurrence: Recurrence.NONE,
+ category: 'trending' as const,
+ tags: [],
+ outcomes: [],
+ liquidity: 1000,
+ volume: 5000,
+ },
+ outcomeToken: { id: 'token-1', title: 'Yes', price: 0.6 },
+ });
+ });
+
+ expect(
+ Engine.context.PredictController.setSelectedPaymentToken,
+ ).toHaveBeenCalledWith(null);
+ });
+
+ it('calls trackPredictOrderEvent with INITIATED status', () => {
+ const { result } = renderHook(() => usePredictActiveOrder());
+
+ act(() => {
+ result.current.initializeActiveOrder({
+ market: {
+ id: 'market-1',
+ providerId: 'provider-1',
+ slug: 'market-slug',
+ title: 'Market Title',
+ description: 'Market Description',
+ image: 'image-url',
+ status: 'open',
+ recurrence: Recurrence.NONE,
+ category: 'trending' as const,
+ tags: [],
+ outcomes: [],
+ liquidity: 1000,
+ volume: 5000,
+ },
+ outcomeToken: { id: 'token-1', title: 'Yes', price: 0.6 },
+ });
+ });
+
+ expect(
+ Engine.context.PredictController.trackPredictOrderEvent,
+ ).toHaveBeenCalledWith(
+ expect.objectContaining({ status: PredictTradeStatus.INITIATED }),
+ );
+ });
+
+ it('passes parsed analytics properties from market/outcomeToken/entryPoint', () => {
+ const { parseAnalyticsProperties } = jest.requireMock(
+ '../utils/analytics',
+ ) as { parseAnalyticsProperties: jest.Mock };
+
+ const mockMarket = {
+ id: 'market-1',
+ providerId: 'provider-1',
+ slug: 'market-slug',
+ title: 'Market Title',
+ description: 'Market Description',
+ image: 'image-url',
+ status: 'open' as const,
+ recurrence: Recurrence.NONE,
+ category: 'trending' as const,
+ tags: [],
+ outcomes: [],
+ liquidity: 1000,
+ volume: 5000,
+ };
+ const mockOutcomeToken = { id: 'token-1', title: 'Yes', price: 0.6 };
+ const mockEntryPoint = 'carousel' as const;
+
+ const { result } = renderHook(() => usePredictActiveOrder());
+
+ act(() => {
+ result.current.initializeActiveOrder({
+ market: mockMarket,
+ outcomeToken: mockOutcomeToken,
+ entryPoint: mockEntryPoint,
+ });
+ });
+
+ expect(parseAnalyticsProperties).toHaveBeenCalledWith(
+ mockMarket,
+ mockOutcomeToken,
+ mockEntryPoint,
+ );
+ expect(
+ Engine.context.PredictController.trackPredictOrderEvent,
+ ).toHaveBeenCalledWith({
+ status: PredictTradeStatus.INITIATED,
+ analyticsProperties: { marketId: 'market-1' },
+ });
+ });
+ });
+
+ describe('clearActiveOrder', () => {
+ it('calls PredictController.clearActiveOrder', () => {
+ const { result } = renderHook(() => usePredictActiveOrder());
+
+ act(() => {
+ result.current.clearActiveOrder();
+ });
+
+ expect(
+ Engine.context.PredictController.clearActiveOrder,
+ ).toHaveBeenCalled();
+ });
+ });
+
+ describe('return values', () => {
+ it('returns activeOrder from useSelector', () => {
+ const mockActiveOrder = {
+ state: ActiveOrderState.PREVIEW,
+ isInputFocused: true,
+ };
+ mockUseSelector.mockReturnValue(mockActiveOrder);
+
+ const { result } = renderHook(() => usePredictActiveOrder());
+
+ expect(result.current.activeOrder).toEqual(mockActiveOrder);
+ });
+ });
+});
diff --git a/app/components/UI/Predict/hooks/usePredictActiveOrder.ts b/app/components/UI/Predict/hooks/usePredictActiveOrder.ts
new file mode 100644
index 00000000000..35f6020d759
--- /dev/null
+++ b/app/components/UI/Predict/hooks/usePredictActiveOrder.ts
@@ -0,0 +1,121 @@
+import { useCallback, useRef } from 'react';
+import { useSelector } from 'react-redux';
+import Engine from '../../../../core/Engine';
+import { PredictControllerState } from '../controllers/PredictController';
+import { selectPredictActiveOrder } from '../selectors/predictController';
+import { parseAnalyticsProperties } from '../utils/analytics';
+import { PredictTradeStatus } from '../constants/eventNames';
+import { ActiveOrderState, PredictMarket, PredictOutcomeToken } from '../types';
+import { PredictEntryPoint } from '../types/navigation';
+
+type PredictActiveOrder = PredictControllerState['activeOrder'];
+type PredictActiveOrderValue = NonNullable;
+type PredictActiveOrderPatch =
+ | {
+ [K in keyof PredictActiveOrderValue]?: PredictActiveOrderValue[K] | null;
+ }
+ | null;
+
+export interface InitializeActiveOrderParams {
+ market: PredictMarket;
+ outcomeToken: PredictOutcomeToken;
+ entryPoint?: PredictEntryPoint;
+}
+
+export const usePredictActiveOrder = () => {
+ const { PredictController } = Engine.context;
+
+ const activeOrder = useSelector(selectPredictActiveOrder);
+
+ const activeOrderRef = useRef(activeOrder);
+ activeOrderRef.current = activeOrder;
+
+ const updateActiveOrder = useCallback(
+ (order: PredictActiveOrderPatch) => {
+ if (order === null) {
+ PredictController.clearActiveOrder();
+ PredictController.setSelectedPaymentToken(null);
+ return;
+ }
+
+ const nextOrder: Partial = {
+ ...(activeOrderRef.current ?? {}),
+ };
+
+ if ('amount' in order) {
+ if (order.amount === null) {
+ delete nextOrder.amount;
+ } else {
+ nextOrder.amount = order.amount;
+ }
+ }
+
+ if ('batchId' in order) {
+ if (order.batchId === null) {
+ delete nextOrder.batchId;
+ } else {
+ nextOrder.batchId = order.batchId;
+ }
+ }
+
+ if ('isInputFocused' in order) {
+ if (order.isInputFocused === null) {
+ delete nextOrder.isInputFocused;
+ } else {
+ nextOrder.isInputFocused = order.isInputFocused;
+ }
+ }
+
+ if ('state' in order) {
+ if (order.state === null) {
+ delete nextOrder.state;
+ } else {
+ nextOrder.state = order.state;
+ }
+ }
+
+ if ('error' in order) {
+ if (order.error === null) {
+ delete nextOrder.error;
+ } else {
+ nextOrder.error = order.error;
+ }
+ }
+
+ PredictController.setActiveOrder(
+ nextOrder.state ? (nextOrder as PredictActiveOrderValue) : null,
+ );
+ },
+ [PredictController],
+ );
+
+ const initializeActiveOrder = useCallback(
+ (params: InitializeActiveOrderParams) => {
+ updateActiveOrder({
+ state: ActiveOrderState.PREVIEW,
+ isInputFocused: true,
+ });
+ PredictController.setSelectedPaymentToken(null);
+ PredictController.trackPredictOrderEvent({
+ status: PredictTradeStatus.INITIATED,
+ analyticsProperties: parseAnalyticsProperties(
+ params.market,
+ params.outcomeToken,
+ params.entryPoint,
+ ),
+ });
+ },
+ [updateActiveOrder, PredictController],
+ );
+
+ const clearActiveOrder = useCallback(() => {
+ PredictController.clearActiveOrder();
+ }, [PredictController]);
+
+ return {
+ activeOrder,
+ updateActiveOrder,
+ clearActiveOrder,
+ initializeActiveOrder,
+ };
+};
diff --git a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts
new file mode 100644
index 00000000000..8a1ae0b2edb
--- /dev/null
+++ b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.test.ts
@@ -0,0 +1,179 @@
+import { renderHook } from '@testing-library/react-native';
+import { AssetType } from '../../../Views/confirmations/types/token';
+import { hasTransactionType } from '../../../Views/confirmations/utils/transaction';
+import {
+ PREDICT_BALANCE_CHAIN_ID,
+ PREDICT_BALANCE_PLACEHOLDER_ADDRESS,
+} from '../constants/transactions';
+import { usePredictBalanceTokenFilter } from './usePredictBalanceTokenFilter';
+
+let mockIsPredictBalanceSelected = false;
+let mockPredictBalance = 100;
+let mockTransactionMeta: { type?: string } | null = null;
+
+jest.mock('./usePredictPaymentToken', () => ({
+ usePredictPaymentToken: () => ({
+ isPredictBalanceSelected: mockIsPredictBalanceSelected,
+ }),
+}));
+
+jest.mock('./usePredictBalance', () => ({
+ usePredictBalance: () => ({
+ data: mockPredictBalance,
+ }),
+}));
+
+jest.mock(
+ '../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest',
+ () => ({
+ useTransactionMetadataRequest: () => mockTransactionMeta,
+ }),
+);
+
+jest.mock('../../SimulationDetails/FiatDisplay/useFiatFormatter', () => ({
+ __esModule: true,
+ default: () => (value: { toString: () => string }) =>
+ `$${Number(value.toString()).toFixed(2)}`,
+}));
+
+jest.mock('../../../Views/confirmations/utils/transaction', () => ({
+ hasTransactionType: jest.fn(),
+}));
+
+const mockHasTransactionType = hasTransactionType as jest.MockedFunction<
+ typeof hasTransactionType
+>;
+
+const createMockToken = (overrides?: Partial): AssetType => ({
+ address: '0xtoken1',
+ chainId: '0x1',
+ tokenId: '0xtoken1',
+ name: 'Test Token',
+ symbol: 'TST',
+ balance: '100',
+ balanceInSelectedCurrency: '$100.00',
+ image: '',
+ logo: '',
+ decimals: 18,
+ isETH: false,
+ isNative: false,
+ isSelected: false,
+ ...overrides,
+});
+
+describe('usePredictBalanceTokenFilter', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockIsPredictBalanceSelected = false;
+ mockPredictBalance = 100;
+ mockTransactionMeta = null;
+ mockHasTransactionType.mockReturnValue(false);
+ });
+
+ it('returns original tokens when transaction type does not match and forceEnabled is false', () => {
+ const tokens = [createMockToken()];
+ mockHasTransactionType.mockReturnValue(false);
+
+ const { result } = renderHook(() => usePredictBalanceTokenFilter());
+ const filteredTokens = result.current(tokens);
+
+ expect(filteredTokens).toEqual(tokens);
+ });
+
+ it('prepends Predict balance token when transaction type matches', () => {
+ const tokens = [createMockToken()];
+ mockHasTransactionType.mockReturnValue(true);
+
+ const { result } = renderHook(() => usePredictBalanceTokenFilter());
+ const filteredTokens = result.current(tokens);
+
+ expect(filteredTokens).toHaveLength(2);
+ expect(filteredTokens[0].address).toBe(PREDICT_BALANCE_PLACEHOLDER_ADDRESS);
+ });
+
+ it('prepends Predict balance token when forceEnabled is true', () => {
+ const tokens = [createMockToken()];
+ mockHasTransactionType.mockReturnValue(false);
+
+ const { result } = renderHook(() => usePredictBalanceTokenFilter(true));
+ const filteredTokens = result.current(tokens);
+
+ expect(filteredTokens).toHaveLength(2);
+ expect(filteredTokens[0].address).toBe(PREDICT_BALANCE_PLACEHOLDER_ADDRESS);
+ });
+
+ it('sets Predict balance token as selected when isPredictBalanceSelected is true', () => {
+ mockIsPredictBalanceSelected = true;
+ mockHasTransactionType.mockReturnValue(true);
+ const tokens = [createMockToken()];
+
+ const { result } = renderHook(() => usePredictBalanceTokenFilter());
+ const filteredTokens = result.current(tokens);
+
+ expect(filteredTokens[0].isSelected).toBe(true);
+ });
+
+ it('deselects existing tokens when Predict balance is selected', () => {
+ mockIsPredictBalanceSelected = true;
+ mockHasTransactionType.mockReturnValue(true);
+ const tokens = [createMockToken({ isSelected: true })];
+
+ const { result } = renderHook(() => usePredictBalanceTokenFilter());
+ const filteredTokens = result.current(tokens);
+
+ expect(filteredTokens[1].isSelected).toBe(false);
+ });
+
+ it('preserves existing token isSelected when Predict balance is not selected', () => {
+ mockIsPredictBalanceSelected = false;
+ mockHasTransactionType.mockReturnValue(true);
+ const tokens = [createMockToken({ isSelected: true })];
+
+ const { result } = renderHook(() => usePredictBalanceTokenFilter());
+ const filteredTokens = result.current(tokens);
+
+ expect(filteredTokens[1].isSelected).toBe(true);
+ });
+
+ it('formats Predict balance using fiat formatter', () => {
+ mockPredictBalance = 42.5;
+ mockHasTransactionType.mockReturnValue(true);
+ const tokens = [createMockToken()];
+
+ const { result } = renderHook(() => usePredictBalanceTokenFilter());
+ const filteredTokens = result.current(tokens);
+
+ expect(filteredTokens[0].balanceInSelectedCurrency).toBe('$42.50');
+ });
+
+ it('uses PREDICT_BALANCE_PLACEHOLDER_ADDRESS for synthetic token', () => {
+ mockHasTransactionType.mockReturnValue(true);
+ const tokens = [createMockToken()];
+
+ const { result } = renderHook(() => usePredictBalanceTokenFilter());
+ const filteredTokens = result.current(tokens);
+
+ expect(filteredTokens[0].address).toBe(PREDICT_BALANCE_PLACEHOLDER_ADDRESS);
+ expect(filteredTokens[0].tokenId).toBe(PREDICT_BALANCE_PLACEHOLDER_ADDRESS);
+ });
+
+ it('uses PREDICT_BALANCE_CHAIN_ID for synthetic token', () => {
+ mockHasTransactionType.mockReturnValue(true);
+ const tokens = [createMockToken()];
+
+ const { result } = renderHook(() => usePredictBalanceTokenFilter());
+ const filteredTokens = result.current(tokens);
+
+ expect(filteredTokens[0].chainId).toBe(PREDICT_BALANCE_CHAIN_ID);
+ });
+
+ it('sets symbol to USDC.e on the Predict balance token', () => {
+ mockHasTransactionType.mockReturnValue(true);
+ const tokens = [createMockToken()];
+
+ const { result } = renderHook(() => usePredictBalanceTokenFilter());
+ const filteredTokens = result.current(tokens);
+
+ expect(filteredTokens[0].symbol).toBe('USDC.e');
+ });
+});
diff --git a/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts
new file mode 100644
index 00000000000..7aa3ac7f69a
--- /dev/null
+++ b/app/components/UI/Predict/hooks/usePredictBalanceTokenFilter.ts
@@ -0,0 +1,75 @@
+import { BigNumber } from 'bignumber.js';
+import { useCallback } from 'react';
+import useFiatFormatter from '../../SimulationDetails/FiatDisplay/useFiatFormatter';
+import { useTransactionMetadataRequest } from '../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest';
+import { AssetType } from '../../../Views/confirmations/types/token';
+import { hasTransactionType } from '../../../Views/confirmations/utils/transaction';
+import {
+ PREDICT_BALANCE_CHAIN_ID,
+ PREDICT_BALANCE_PLACEHOLDER_ADDRESS,
+} from '../constants/transactions';
+import { usePredictBalance } from './usePredictBalance';
+import { usePredictPaymentToken } from './usePredictPaymentToken';
+import { TransactionType } from '@metamask/transaction-controller';
+
+//TODO: Remove this once the predictDepositAndOrder type is added to the transaction controller
+const PREDICT_DEPOSIT_AND_ORDER_TYPE = 'predictDepositAndOrder';
+
+export function usePredictBalanceTokenFilter(
+ forceEnabled = false,
+): (tokens: AssetType[]) => AssetType[] {
+ const transactionMeta = useTransactionMetadataRequest();
+ const { isPredictBalanceSelected } = usePredictPaymentToken();
+ const { data: predictBalance = 0 } = usePredictBalance();
+ const formatFiat = useFiatFormatter({ currency: 'usd' });
+
+ return useCallback(
+ (tokens: AssetType[]): AssetType[] => {
+ if (
+ !forceEnabled &&
+ !hasTransactionType(transactionMeta, [
+ // TODO: Remove this once the predictDepositAndOrder type is added to the transaction controller
+ PREDICT_DEPOSIT_AND_ORDER_TYPE as TransactionType,
+ ])
+ ) {
+ return tokens;
+ }
+
+ const balanceStr = String(predictBalance);
+ const balanceFormatted = formatFiat(new BigNumber(balanceStr));
+
+ const predictBalanceToken: AssetType = {
+ address: PREDICT_BALANCE_PLACEHOLDER_ADDRESS,
+ chainId: PREDICT_BALANCE_CHAIN_ID,
+ tokenId: PREDICT_BALANCE_PLACEHOLDER_ADDRESS,
+ name: 'Predict balance',
+ symbol: 'USDC.e',
+ balance: balanceStr,
+ balanceInSelectedCurrency: balanceFormatted,
+ image: '',
+ logo: '',
+ decimals: 6,
+ isETH: false,
+ isNative: false,
+ isSelected: isPredictBalanceSelected,
+ };
+
+ const mappedTokens = tokens.map((token) => ({
+ ...token,
+ isSelected:
+ token.isSelected && isPredictBalanceSelected
+ ? false
+ : token.isSelected,
+ }));
+
+ return [predictBalanceToken, ...mappedTokens];
+ },
+ [
+ forceEnabled,
+ transactionMeta,
+ isPredictBalanceSelected,
+ predictBalance,
+ formatFiat,
+ ],
+ );
+}
diff --git a/app/components/UI/Predict/hooks/usePredictClaim.test.ts b/app/components/UI/Predict/hooks/usePredictClaim.test.ts
index abfec53b19b..6842d3ca917 100644
--- a/app/components/UI/Predict/hooks/usePredictClaim.test.ts
+++ b/app/components/UI/Predict/hooks/usePredictClaim.test.ts
@@ -159,6 +159,7 @@ describe('usePredictClaim', () => {
getBalance: jest.fn(),
previewOrder: jest.fn(),
deposit: jest.fn(),
+ payWithAnyTokenConfirmation: jest.fn(),
prepareWithdraw: jest.fn(),
} as ReturnType);
diff --git a/app/components/UI/Predict/hooks/usePredictDeposit.ts b/app/components/UI/Predict/hooks/usePredictDeposit.ts
index ef355386fb6..bdd972a2d5d 100644
--- a/app/components/UI/Predict/hooks/usePredictDeposit.ts
+++ b/app/components/UI/Predict/hooks/usePredictDeposit.ts
@@ -1,10 +1,7 @@
import { useNavigation } from '@react-navigation/native';
import { useCallback, useContext } from 'react';
import { useSelector } from 'react-redux';
-import { strings } from '../../../../../locales/i18n';
-import { IconName } from '../../../../component-library/components/Icons/Icon';
import { ToastContext } from '../../../../component-library/components/Toast';
-import { ToastVariants } from '../../../../component-library/components/Toast/Toast.types';
import Engine from '../../../../core/Engine';
import Logger from '../../../../util/Logger';
import { useAppThemeFromContext } from '../../../../util/theme';
@@ -12,7 +9,10 @@ import { ConfirmationLoader } from '../../../Views/confirmations/components/conf
import { useConfirmNavigation } from '../../../Views/confirmations/hooks/useConfirmNavigation';
import { PREDICT_CONSTANTS } from '../constants/errors';
import { selectPredictPendingDepositByAddress } from '../selectors/predictController';
-import { ensureError } from '../utils/predictErrorHandler';
+import {
+ createDepositErrorToast,
+ ensureError,
+} from '../utils/predictErrorHandler';
import { usePredictTrading } from './usePredictTrading';
import { getEvmAccountFromSelectedAccountGroup } from '../utils/accounts';
import { selectSelectedAccountGroupId } from '../../../../selectors/multichainAccounts/accountTreeController';
@@ -86,49 +86,16 @@ export const usePredictDeposit = () => {
},
});
navigation.goBack();
- toastRef?.current?.showToast({
- variant: ToastVariants.Icon,
- labelOptions: [
- { label: strings('predict.deposit.error_title'), isBold: true },
- { label: '\n', isBold: false },
- {
- label: strings('predict.deposit.error_description'),
- isBold: false,
- },
- ],
- iconName: IconName.Error,
- iconColor: theme.colors.error.default,
- backgroundColor: theme.colors.accent04.normal,
- hasNoTimeout: false,
- linkButtonOptions: {
- label: strings('predict.deposit.try_again'),
- onPress: () => deposit(params),
- },
- });
+ toastRef?.current?.showToast(
+ createDepositErrorToast(theme, () => deposit(params)),
+ );
});
} catch (err) {
console.error('Failed to proceed with deposit:', err);
navigation.goBack();
- // Re-throw to allow testing of this error path
- toastRef?.current?.showToast({
- variant: ToastVariants.Icon,
- labelOptions: [
- { label: strings('predict.deposit.error_title'), isBold: true },
- { label: '\n', isBold: false },
- {
- label: strings('predict.deposit.error_description'),
- isBold: false,
- },
- ],
- iconName: IconName.Error,
- iconColor: theme.colors.error.default,
- backgroundColor: theme.colors.accent04.normal,
- hasNoTimeout: false,
- linkButtonOptions: {
- label: strings('predict.deposit.try_again'),
- onPress: () => deposit(params),
- },
- });
+ toastRef?.current?.showToast(
+ createDepositErrorToast(theme, () => deposit(params)),
+ );
// Log error with deposit navigation context
Logger.error(ensureError(err), {
@@ -151,8 +118,7 @@ export const usePredictDeposit = () => {
depositWithConfirmation,
navigateToConfirmation,
navigation,
- theme.colors.accent04.normal,
- theme.colors.error.default,
+ theme,
toastRef,
],
);
diff --git a/app/components/UI/Predict/hooks/usePredictNavigation.test.ts b/app/components/UI/Predict/hooks/usePredictNavigation.test.ts
new file mode 100644
index 00000000000..a24742bbffe
--- /dev/null
+++ b/app/components/UI/Predict/hooks/usePredictNavigation.test.ts
@@ -0,0 +1,214 @@
+import { renderHook, act } from '@testing-library/react-native';
+import { StackActions } from '@react-navigation/native';
+import { usePredictNavigation } from './usePredictNavigation';
+import Routes from '../../../../constants/navigation/Routes';
+import { PredictBuyPreviewParams } from '../types/navigation';
+import { PredictMarket, PredictOutcome, PredictOutcomeToken } from '../types';
+
+const mockNavigate = jest.fn();
+const mockDispatch = jest.fn();
+const mockInitializeActiveOrder = jest.fn();
+
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => ({
+ navigate: mockNavigate,
+ dispatch: mockDispatch,
+ }),
+}));
+
+jest.mock('./usePredictActiveOrder', () => ({
+ usePredictActiveOrder: () => ({
+ initializeActiveOrder: mockInitializeActiveOrder,
+ activeOrder: null,
+ updateActiveOrder: jest.fn(),
+ clearActiveOrder: jest.fn(),
+ }),
+}));
+
+const createMockParams = (
+ overrides?: Partial,
+): PredictBuyPreviewParams => ({
+ market: { id: 'market-1' } as PredictMarket,
+ outcome: { id: 'outcome-1' } as PredictOutcome,
+ outcomeToken: { id: 'token-1' } as PredictOutcomeToken,
+ entryPoint: 'predict_feed',
+ ...overrides,
+});
+
+describe('usePredictNavigation', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('navigateToBuyPreview', () => {
+ it('navigates directly to BuyPreview by default', () => {
+ const { result } = renderHook(() => usePredictNavigation());
+ const params = createMockParams();
+
+ act(() => {
+ result.current.navigateToBuyPreview(params);
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(
+ Routes.PREDICT.MODALS.BUY_PREVIEW,
+ params,
+ );
+ });
+
+ it('navigates through ROOT when throughRoot option is true', () => {
+ const { result } = renderHook(() => usePredictNavigation());
+ const params = createMockParams();
+
+ act(() => {
+ result.current.navigateToBuyPreview(params, { throughRoot: true });
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MODALS.BUY_PREVIEW,
+ params,
+ });
+ });
+
+ it('navigates directly when throughRoot option is false', () => {
+ const { result } = renderHook(() => usePredictNavigation());
+ const params = createMockParams();
+
+ act(() => {
+ result.current.navigateToBuyPreview(params, { throughRoot: false });
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(
+ Routes.PREDICT.MODALS.BUY_PREVIEW,
+ params,
+ );
+ });
+
+ it('navigates directly when no options provided', () => {
+ const { result } = renderHook(() => usePredictNavigation());
+ const params = createMockParams();
+
+ act(() => {
+ result.current.navigateToBuyPreview(params);
+ });
+
+ expect(mockNavigate).toHaveBeenCalledTimes(1);
+ expect(mockNavigate).toHaveBeenCalledWith(
+ Routes.PREDICT.MODALS.BUY_PREVIEW,
+ params,
+ );
+ });
+
+ it('passes all params to the navigation call', () => {
+ const { result } = renderHook(() => usePredictNavigation());
+ const params = createMockParams({
+ isConfirmation: true,
+ animationEnabled: false,
+ });
+
+ act(() => {
+ result.current.navigateToBuyPreview(params);
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(
+ Routes.PREDICT.MODALS.BUY_PREVIEW,
+ expect.objectContaining({
+ isConfirmation: true,
+ animationEnabled: false,
+ }),
+ );
+ });
+
+ it('passes all params through ROOT navigation', () => {
+ const { result } = renderHook(() => usePredictNavigation());
+ const params = createMockParams({
+ entryPoint: 'carousel',
+ });
+
+ act(() => {
+ result.current.navigateToBuyPreview(params, { throughRoot: true });
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MODALS.BUY_PREVIEW,
+ params: expect.objectContaining({
+ entryPoint: 'carousel',
+ }),
+ });
+ });
+
+ it('dispatches StackActions.replace when replace option is true', () => {
+ const { result } = renderHook(() => usePredictNavigation());
+ const params = createMockParams({
+ animationEnabled: false,
+ });
+
+ act(() => {
+ result.current.navigateToBuyPreview(params, { replace: true });
+ });
+
+ expect(mockDispatch).toHaveBeenCalledWith(
+ StackActions.replace(Routes.PREDICT.MODALS.BUY_PREVIEW, params),
+ );
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('replace takes precedence over throughRoot', () => {
+ const { result } = renderHook(() => usePredictNavigation());
+ const params = createMockParams();
+
+ act(() => {
+ result.current.navigateToBuyPreview(params, {
+ replace: true,
+ throughRoot: true,
+ });
+ });
+
+ expect(mockDispatch).toHaveBeenCalledWith(
+ StackActions.replace(Routes.PREDICT.MODALS.BUY_PREVIEW, params),
+ );
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('calls initializeActiveOrder on direct navigation', () => {
+ const { result } = renderHook(() => usePredictNavigation());
+ const params = createMockParams();
+
+ act(() => {
+ result.current.navigateToBuyPreview(params);
+ });
+
+ expect(mockInitializeActiveOrder).toHaveBeenCalledWith({
+ market: params.market,
+ outcomeToken: params.outcomeToken,
+ entryPoint: params.entryPoint,
+ });
+ });
+
+ it('calls initializeActiveOrder on throughRoot navigation', () => {
+ const { result } = renderHook(() => usePredictNavigation());
+ const params = createMockParams();
+
+ act(() => {
+ result.current.navigateToBuyPreview(params, { throughRoot: true });
+ });
+
+ expect(mockInitializeActiveOrder).toHaveBeenCalledWith({
+ market: params.market,
+ outcomeToken: params.outcomeToken,
+ entryPoint: params.entryPoint,
+ });
+ });
+
+ it('does not call initializeActiveOrder on replace navigation', () => {
+ const { result } = renderHook(() => usePredictNavigation());
+ const params = createMockParams();
+
+ act(() => {
+ result.current.navigateToBuyPreview(params, { replace: true });
+ });
+
+ expect(mockInitializeActiveOrder).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/app/components/UI/Predict/hooks/usePredictNavigation.ts b/app/components/UI/Predict/hooks/usePredictNavigation.ts
new file mode 100644
index 00000000000..a50695caf12
--- /dev/null
+++ b/app/components/UI/Predict/hooks/usePredictNavigation.ts
@@ -0,0 +1,46 @@
+import { StackActions, useNavigation } from '@react-navigation/native';
+import { useCallback } from 'react';
+import Routes from '../../../../constants/navigation/Routes';
+import { PredictBuyPreviewParams } from '../types/navigation';
+import { usePredictActiveOrder } from './usePredictActiveOrder';
+
+interface NavigateToBuyPreviewOptions {
+ throughRoot?: boolean;
+ replace?: boolean;
+}
+
+export const usePredictNavigation = () => {
+ const navigation = useNavigation();
+ const { initializeActiveOrder } = usePredictActiveOrder();
+
+ const navigateToBuyPreview = useCallback(
+ (
+ params: PredictBuyPreviewParams,
+ options?: NavigateToBuyPreviewOptions,
+ ) => {
+ if (options?.replace) {
+ navigation.dispatch(
+ StackActions.replace(Routes.PREDICT.MODALS.BUY_PREVIEW, params),
+ );
+ } else {
+ initializeActiveOrder({
+ market: params.market,
+ outcomeToken: params.outcomeToken,
+ entryPoint: params.entryPoint,
+ });
+
+ if (options?.throughRoot) {
+ navigation.navigate(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MODALS.BUY_PREVIEW,
+ params,
+ });
+ } else {
+ navigation.navigate(Routes.PREDICT.MODALS.BUY_PREVIEW, params);
+ }
+ }
+ },
+ [navigation, initializeActiveOrder],
+ );
+
+ return { navigateToBuyPreview };
+};
diff --git a/app/components/UI/Predict/hooks/usePredictOrderPreview.test.ts b/app/components/UI/Predict/hooks/usePredictOrderPreview.test.ts
index a8f42d248ba..e89a4ad0b83 100644
--- a/app/components/UI/Predict/hooks/usePredictOrderPreview.test.ts
+++ b/app/components/UI/Predict/hooks/usePredictOrderPreview.test.ts
@@ -92,6 +92,54 @@ describe('usePredictOrderPreview', () => {
expect(result.current.error).toBeNull();
});
+ it('initializes with initialPreview when provided', () => {
+ const { Wrapper } = createWrapper();
+ const { result } = renderHook(
+ () =>
+ usePredictOrderPreview({
+ ...defaultParams,
+ initialPreview: mockPreview,
+ }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.preview).toEqual(mockPreview);
+ expect(result.current.isCalculating).toBe(true);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+
+ it('replaces initialPreview when new preview loads from API', async () => {
+ const { Wrapper } = createWrapper();
+ const updatedPreview: OrderPreview = {
+ ...mockPreview,
+ sharePrice: 0.75,
+ maxAmountSpent: 200,
+ };
+ mockPreviewOrder.mockResolvedValue(updatedPreview);
+
+ const { result } = renderHook(
+ () =>
+ usePredictOrderPreview({
+ ...defaultParams,
+ initialPreview: mockPreview,
+ }),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current.preview).toEqual(mockPreview);
+
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.preview).toEqual(updatedPreview);
+ });
+
+ expect(result.current.isLoading).toBe(false);
+ });
+
it('calculates preview when size is valid', async () => {
const { Wrapper } = createWrapper();
const { result } = renderHook(
@@ -302,6 +350,30 @@ describe('usePredictOrderPreview', () => {
});
describe('error handling', () => {
+ it('does not log an error when only initialPreview is provided', async () => {
+ const { Wrapper } = createWrapper();
+
+ const { result } = renderHook(
+ () =>
+ usePredictOrderPreview({
+ ...defaultParams,
+ initialPreview: mockPreview,
+ }),
+ { wrapper: Wrapper },
+ );
+
+ await act(async () => {
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.preview).toEqual(mockPreview);
+ });
+
+ expect(result.current.error).toBeNull();
+ expect(Logger.error).not.toHaveBeenCalled();
+ });
+
it('handles preview calculation errors with localized message', async () => {
const { Wrapper } = createWrapper();
mockPreviewOrder.mockRejectedValue(new Error('Failed to calculate'));
diff --git a/app/components/UI/Predict/hooks/usePredictOrderPreview.ts b/app/components/UI/Predict/hooks/usePredictOrderPreview.ts
index 04eea60f598..e32b931bf83 100644
--- a/app/components/UI/Predict/hooks/usePredictOrderPreview.ts
+++ b/app/components/UI/Predict/hooks/usePredictOrderPreview.ts
@@ -19,8 +19,12 @@ interface OrderPreviewResult {
* isLoading/isCalculating flags are used by all 3 consumers for skeleton/inline states.
*/
export function usePredictOrderPreview(
- params: PreviewOrderParams & { autoRefreshTimeout?: number },
+ params: PreviewOrderParams & {
+ autoRefreshTimeout?: number;
+ initialPreview?: OrderPreview | null;
+ },
): OrderPreviewResult {
+ // Destructure params for stable dependencies
const {
marketId,
outcomeId,
@@ -64,7 +68,9 @@ export function usePredictOrderPreview(
hasValidSize && autoRefreshTimeout ? autoRefreshTimeout : false,
});
- const preview = hasValidSize ? (query.data ?? null) : null;
+ const preview = hasValidSize
+ ? (query.data ?? params.initialPreview ?? null)
+ : (params.initialPreview ?? null);
const error = query.error
? parseErrorMessage({
error: query.error,
@@ -76,6 +82,7 @@ export function usePredictOrderPreview(
useEffect(() => {
if (!query.error) return;
+
Logger.error(ensureError(query.error), {
tags: {
feature: PREDICT_CONSTANTS.FEATURE_NAME,
diff --git a/app/components/UI/Predict/hooks/usePredictPayWithAnyToken.test.ts b/app/components/UI/Predict/hooks/usePredictPayWithAnyToken.test.ts
new file mode 100644
index 00000000000..8ff7254ab12
--- /dev/null
+++ b/app/components/UI/Predict/hooks/usePredictPayWithAnyToken.test.ts
@@ -0,0 +1,121 @@
+import { act, renderHook } from '@testing-library/react-native';
+import React from 'react';
+import { ToastContext } from '../../../../component-library/components/Toast';
+import { PredictBuyPreviewParams } from '../types/navigation';
+import { usePredictPayWithAnyToken } from './usePredictPayWithAnyToken';
+import { ConfirmationLoader } from '../../../Views/confirmations/components/confirm/confirm-component';
+
+const mockGoBack = jest.fn();
+const mockPayWithAnyTokenConfirmation = jest.fn();
+const mockNavigateToConfirmation = jest.fn();
+const mockShowToast = jest.fn();
+const mockCloseToast = jest.fn();
+
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => ({
+ goBack: mockGoBack,
+ }),
+}));
+
+jest.mock('./usePredictTrading', () => ({
+ usePredictTrading: () => ({
+ payWithAnyTokenConfirmation: mockPayWithAnyTokenConfirmation,
+ }),
+}));
+
+jest.mock('../../../Views/confirmations/hooks/useConfirmNavigation', () => ({
+ useConfirmNavigation: () => ({
+ navigateToConfirmation: mockNavigateToConfirmation,
+ }),
+}));
+
+jest.mock('../../../Views/confirmations/hooks/tokens/useAddToken', () => ({
+ useAddToken: jest.fn(),
+}));
+
+jest.mock('../../../../../locales/i18n', () => ({
+ strings: (key: string) => key,
+}));
+
+jest.mock('../../../../util/theme', () => ({
+ useAppThemeFromContext: () => ({
+ colors: {
+ error: { default: 'red' },
+ accent04: { normal: 'black' },
+ },
+ }),
+}));
+
+const wrapper = ({ children }: { children: React.ReactNode }) =>
+ React.createElement(
+ ToastContext.Provider,
+ {
+ value: {
+ toastRef: {
+ current: {
+ showToast: mockShowToast,
+ closeToast: mockCloseToast,
+ },
+ },
+ },
+ },
+ children,
+ );
+
+describe('usePredictPayWithAnyToken', () => {
+ const market = { id: 'market-1' } as PredictBuyPreviewParams['market'];
+ const outcome = { id: 'outcome-1' } as PredictBuyPreviewParams['outcome'];
+ const outcomeToken = {
+ id: 'token-1',
+ } as PredictBuyPreviewParams['outcomeToken'];
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockPayWithAnyTokenConfirmation.mockResolvedValue({ response: {} });
+ });
+
+ it('triggers payWithAnyTokenConfirmation and navigates to confirmation', async () => {
+ const { result } = renderHook(() => usePredictPayWithAnyToken(), {
+ wrapper,
+ });
+
+ await act(async () => {
+ result.current.triggerPayWithAnyToken({
+ market,
+ outcome,
+ outcomeToken,
+ });
+ });
+
+ expect(mockPayWithAnyTokenConfirmation).toHaveBeenCalledWith();
+ expect(mockNavigateToConfirmation).toHaveBeenCalledWith({
+ loader: ConfirmationLoader.CustomAmount,
+ headerShown: false,
+ });
+ expect(mockGoBack).not.toHaveBeenCalled();
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+
+ it('goes back and shows error toast when payWithAnyTokenConfirmation fails', async () => {
+ mockPayWithAnyTokenConfirmation.mockImplementation(() => {
+ throw new Error('boom');
+ });
+
+ const { result } = renderHook(() => usePredictPayWithAnyToken(), {
+ wrapper,
+ });
+
+ await act(async () => {
+ result.current.triggerPayWithAnyToken({
+ market,
+ outcome,
+ outcomeToken,
+ });
+ });
+
+ expect(mockGoBack).toHaveBeenCalledTimes(1);
+ expect(mockShowToast).toHaveBeenCalledTimes(1);
+ expect(mockNavigateToConfirmation).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/components/UI/Predict/hooks/usePredictPayWithAnyToken.ts b/app/components/UI/Predict/hooks/usePredictPayWithAnyToken.ts
new file mode 100644
index 00000000000..ef371409a9a
--- /dev/null
+++ b/app/components/UI/Predict/hooks/usePredictPayWithAnyToken.ts
@@ -0,0 +1,102 @@
+import { CHAIN_IDS } from '@metamask/transaction-controller';
+import { NavigationProp, useNavigation } from '@react-navigation/native';
+import { useCallback, useContext } from 'react';
+import { ToastContext } from '../../../../component-library/components/Toast';
+import Logger from '../../../../util/Logger';
+import { useAppThemeFromContext } from '../../../../util/theme';
+import { ConfirmationLoader } from '../../../Views/confirmations/components/confirm/confirm-component';
+import { POLYGON_USDCE } from '../../../Views/confirmations/constants/predict';
+import { useAddToken } from '../../../Views/confirmations/hooks/tokens/useAddToken';
+import { useConfirmNavigation } from '../../../Views/confirmations/hooks/useConfirmNavigation';
+import { PREDICT_CONSTANTS } from '../constants/errors';
+import {
+ PredictBuyPreviewParams,
+ PredictNavigationParamList,
+} from '../types/navigation';
+import {
+ createDepositErrorToast,
+ ensureError,
+} from '../utils/predictErrorHandler';
+import { usePredictTrading } from './usePredictTrading';
+import { OrderPreview } from '../types';
+
+export interface PredictPayWithAnyTokenParams {
+ market: PredictBuyPreviewParams['market'];
+ outcome: PredictBuyPreviewParams['outcome'];
+ outcomeToken: PredictBuyPreviewParams['outcomeToken'];
+ preview?: OrderPreview;
+}
+
+interface UsePredictPayWithAnyTokenResult {
+ triggerPayWithAnyToken: (params: PredictPayWithAnyTokenParams) => void;
+}
+
+export function usePredictPayWithAnyToken(): UsePredictPayWithAnyTokenResult {
+ const { navigateToConfirmation } = useConfirmNavigation();
+ const theme = useAppThemeFromContext();
+ const { toastRef } = useContext(ToastContext);
+ const navigation =
+ useNavigation>();
+
+ useAddToken({
+ chainId: CHAIN_IDS.POLYGON,
+ decimals: POLYGON_USDCE.decimals,
+ name: POLYGON_USDCE.name,
+ symbol: POLYGON_USDCE.symbol,
+ tokenAddress: POLYGON_USDCE.address,
+ });
+
+ const { payWithAnyTokenConfirmation } = usePredictTrading();
+
+ const handleDepositError = useCallback(
+ (err: unknown, action: string) => {
+ Logger.error(ensureError(err), {
+ tags: {
+ feature: PREDICT_CONSTANTS.FEATURE_NAME,
+ component: 'usePredictPayWithAnyToken',
+ },
+ context: {
+ name: 'usePredictPayWithAnyToken',
+ data: {
+ method: 'triggerPayWithAnyToken',
+ action,
+ operation: 'financial_operations',
+ },
+ },
+ });
+
+ navigation.goBack();
+ toastRef?.current?.showToast(createDepositErrorToast(theme));
+ },
+ [navigation, theme, toastRef],
+ );
+
+ const triggerPayWithAnyToken = useCallback(
+ //(params: PredictPayWithAnyTokenParams) => {
+ () => {
+ // TODO: Uncomment this when the confirmation screen is ready
+ try {
+ payWithAnyTokenConfirmation();
+ navigateToConfirmation({
+ loader: ConfirmationLoader.CustomAmount,
+ headerShown: false,
+ /* replace: true,
+ routeParams: {
+ market: params.market,
+ outcome: params.outcome,
+ outcomeToken: params.outcomeToken,
+ isConfirmation: true,
+ preview: params.preview,
+ }, */
+ });
+ } catch (err) {
+ handleDepositError(err, 'pay_with_any_token');
+ }
+ },
+ [payWithAnyTokenConfirmation, handleDepositError, navigateToConfirmation],
+ );
+
+ return {
+ triggerPayWithAnyToken,
+ };
+}
diff --git a/app/components/UI/Predict/hooks/usePredictPaymentToken.test.ts b/app/components/UI/Predict/hooks/usePredictPaymentToken.test.ts
new file mode 100644
index 00000000000..5ec5ccd0533
--- /dev/null
+++ b/app/components/UI/Predict/hooks/usePredictPaymentToken.test.ts
@@ -0,0 +1,425 @@
+import { act, renderHook } from '@testing-library/react-native';
+import { useSelector } from 'react-redux';
+import { Hex } from '@metamask/utils';
+import { usePredictPaymentToken } from './usePredictPaymentToken';
+import { PREDICT_BALANCE_PLACEHOLDER_ADDRESS } from '../constants/transactions';
+import Engine from '../../../../core/Engine';
+import type { AssetType } from '../../../Views/confirmations/types/token';
+
+let mockSelectedPaymentToken: {
+ address: string;
+ chainId: string;
+ symbol?: string;
+} | null = null;
+let mockTransactionMeta: { id: string } | null = null;
+let mockPayToken: { address: Hex; chainId: Hex } | null = null;
+const mockSetPayToken = jest.fn();
+
+const createMockAsset = (overrides?: Partial): AssetType => ({
+ address: '0x1234',
+ chainId: '0x1',
+ decimals: 18,
+ image: 'https://example.com/token.png',
+ name: 'Test Token',
+ symbol: 'TEST',
+ balance: '1000',
+ logo: undefined,
+ isETH: false,
+ ...overrides,
+});
+
+jest.mock('react-redux', () => ({
+ useSelector: jest.fn(),
+}));
+
+jest.mock(
+ '../../../Views/confirmations/hooks/pay/useTransactionPayToken',
+ () => ({
+ useTransactionPayToken: () => ({
+ payToken: mockPayToken,
+ setPayToken: mockSetPayToken,
+ }),
+ }),
+);
+
+jest.mock(
+ '../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest',
+ () => ({
+ useTransactionMetadataRequest: () => mockTransactionMeta,
+ }),
+);
+
+jest.mock('../../../../core/Engine', () => ({
+ context: {
+ PredictController: {
+ setSelectedPaymentToken: jest.fn(),
+ },
+ },
+}));
+
+describe('usePredictPaymentToken', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockSelectedPaymentToken = null;
+ mockTransactionMeta = null;
+ mockPayToken = null;
+ jest.mocked(useSelector).mockImplementation(() => mockSelectedPaymentToken);
+ jest
+ .mocked(Engine.context.PredictController.setSelectedPaymentToken)
+ .mockClear();
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('does not call onTokenSelected on initial render', () => {
+ const onTokenSelected = jest.fn();
+
+ renderHook(() => usePredictPaymentToken({ onTokenSelected }));
+
+ expect(onTokenSelected).not.toHaveBeenCalled();
+ });
+
+ it('calls onTokenSelected when token changes from predict balance to token', async () => {
+ const onTokenSelected = jest.fn();
+ const { rerender } = renderHook(
+ ({ onTokenSelected: selectedCallback }) =>
+ usePredictPaymentToken({ onTokenSelected: selectedCallback }),
+ {
+ initialProps: { onTokenSelected },
+ },
+ );
+
+ mockSelectedPaymentToken = {
+ address: '0x1234',
+ chainId: '0x1',
+ };
+
+ await act(async () => {
+ rerender({ onTokenSelected });
+ });
+
+ expect(onTokenSelected).toHaveBeenCalledWith({
+ tokenAddress: '0x1234',
+ tokenKey: '0x1234',
+ });
+ });
+
+ it('calls onTokenSelected with predict-balance key when switching back to predict balance', async () => {
+ mockSelectedPaymentToken = {
+ address: '0x1234',
+ chainId: '0x1',
+ };
+
+ const onTokenSelected = jest.fn();
+ const { rerender } = renderHook(
+ ({ onTokenSelected: selectedCallback }) =>
+ usePredictPaymentToken({ onTokenSelected: selectedCallback }),
+ {
+ initialProps: { onTokenSelected },
+ },
+ );
+
+ mockSelectedPaymentToken = null;
+
+ await act(async () => {
+ rerender({ onTokenSelected });
+ });
+
+ expect(onTokenSelected).toHaveBeenCalledWith({
+ tokenAddress: null,
+ tokenKey: 'predict-balance',
+ });
+ });
+
+ it('does not call onTokenSelected when token selection does not change', async () => {
+ const onTokenSelected = jest.fn();
+ const { rerender } = renderHook(
+ ({ onTokenSelected: selectedCallback }) =>
+ usePredictPaymentToken({ onTokenSelected: selectedCallback }),
+ {
+ initialProps: { onTokenSelected },
+ },
+ );
+
+ await act(async () => {
+ rerender({ onTokenSelected });
+ });
+
+ expect(onTokenSelected).not.toHaveBeenCalled();
+ });
+
+ describe('onPaymentTokenChange', () => {
+ it('returns early when token is null', () => {
+ const { result } = renderHook(() => usePredictPaymentToken());
+
+ act(() => {
+ result.current.onPaymentTokenChange(null);
+ });
+
+ expect(
+ jest.mocked(Engine.context.PredictController.setSelectedPaymentToken),
+ ).not.toHaveBeenCalled();
+ });
+
+ it('calls setSelectedPaymentToken with null when token address is placeholder', () => {
+ const { result } = renderHook(() => usePredictPaymentToken());
+
+ act(() => {
+ result.current.onPaymentTokenChange(
+ createMockAsset({
+ address: PREDICT_BALANCE_PLACEHOLDER_ADDRESS,
+ }),
+ );
+ });
+
+ expect(
+ jest.mocked(Engine.context.PredictController.setSelectedPaymentToken),
+ ).toHaveBeenCalledWith(null);
+ });
+
+ it('calls setSelectedPaymentToken with token data when token is valid', () => {
+ const { result } = renderHook(() => usePredictPaymentToken());
+ const token = createMockAsset({
+ address: '0xabcd',
+ chainId: '0x1',
+ symbol: 'TEST',
+ });
+
+ act(() => {
+ result.current.onPaymentTokenChange(token);
+ });
+
+ expect(
+ jest.mocked(Engine.context.PredictController.setSelectedPaymentToken),
+ ).toHaveBeenCalledWith({
+ address: '0xabcd',
+ chainId: '0x1',
+ symbol: 'TEST',
+ });
+ });
+
+ it('calls setPayToken when transactionMeta.id exists', () => {
+ mockTransactionMeta = { id: 'tx-123' };
+ const { result } = renderHook(() => usePredictPaymentToken());
+ const token = createMockAsset({
+ address: '0xabcd',
+ chainId: '0x1',
+ });
+
+ act(() => {
+ result.current.onPaymentTokenChange(token);
+ });
+
+ expect(mockSetPayToken).toHaveBeenCalledWith({
+ address: '0xabcd' as Hex,
+ chainId: '0x1' as Hex,
+ });
+ });
+
+ it('does not call setPayToken when transactionMeta is null', () => {
+ mockTransactionMeta = null;
+ const { result } = renderHook(() => usePredictPaymentToken());
+ const token = createMockAsset({
+ address: '0xabcd',
+ chainId: '0x1',
+ });
+
+ act(() => {
+ result.current.onPaymentTokenChange(token);
+ });
+
+ expect(mockSetPayToken).not.toHaveBeenCalled();
+ });
+
+ it('does not call setPayToken when transactionMeta.id is missing', () => {
+ mockTransactionMeta = { id: '' };
+ const { result } = renderHook(() => usePredictPaymentToken());
+ const token = createMockAsset({
+ address: '0xabcd',
+ chainId: '0x1',
+ });
+
+ act(() => {
+ result.current.onPaymentTokenChange(token);
+ });
+
+ expect(mockSetPayToken).not.toHaveBeenCalled();
+ });
+
+ it('handles token with missing chainId', () => {
+ const { result } = renderHook(() => usePredictPaymentToken());
+ const token = createMockAsset({
+ address: '0xabcd',
+ chainId: undefined,
+ symbol: undefined,
+ });
+
+ act(() => {
+ result.current.onPaymentTokenChange(token);
+ });
+
+ expect(
+ jest.mocked(Engine.context.PredictController.setSelectedPaymentToken),
+ ).toHaveBeenCalledWith({
+ address: '0xabcd',
+ chainId: '',
+ symbol: undefined,
+ });
+ });
+ });
+
+ describe('resetSelectedPaymentToken', () => {
+ it('calls setSelectedPaymentToken with null', () => {
+ const { result } = renderHook(() => usePredictPaymentToken());
+
+ act(() => {
+ result.current.resetSelectedPaymentToken();
+ });
+
+ expect(
+ jest.mocked(Engine.context.PredictController.setSelectedPaymentToken),
+ ).toHaveBeenCalledWith(null);
+ });
+ });
+
+ describe('useEffect syncing payToken with selectedPaymentToken', () => {
+ it('skips sync when transactionMeta is missing', () => {
+ mockTransactionMeta = null;
+ mockSelectedPaymentToken = {
+ address: '0xabcd',
+ chainId: '0x1',
+ };
+
+ renderHook(() => usePredictPaymentToken());
+
+ expect(mockSetPayToken).not.toHaveBeenCalled();
+ });
+
+ it('skips sync when isPredictBalanceSelected is true', () => {
+ mockTransactionMeta = { id: 'tx-123' };
+ mockSelectedPaymentToken = null;
+
+ renderHook(() => usePredictPaymentToken());
+
+ expect(mockSetPayToken).not.toHaveBeenCalled();
+ });
+
+ it('skips sync when selectedPaymentToken is null', () => {
+ mockTransactionMeta = { id: 'tx-123' };
+ mockSelectedPaymentToken = null;
+
+ renderHook(() => usePredictPaymentToken());
+
+ expect(mockSetPayToken).not.toHaveBeenCalled();
+ });
+
+ it('skips sync when token is already applied', () => {
+ mockTransactionMeta = { id: 'tx-123' };
+ mockSelectedPaymentToken = {
+ address: '0xabcd',
+ chainId: '0x1',
+ };
+ mockPayToken = {
+ address: '0xabcd' as Hex,
+ chainId: '0x1' as Hex,
+ };
+
+ renderHook(() => usePredictPaymentToken());
+
+ expect(mockSetPayToken).not.toHaveBeenCalled();
+ });
+
+ it('skips sync when token is already applied with different case', () => {
+ mockTransactionMeta = { id: 'tx-123' };
+ mockSelectedPaymentToken = {
+ address: '0xABCD',
+ chainId: '0x1',
+ };
+ mockPayToken = {
+ address: '0xabcd' as Hex,
+ chainId: '0x1' as Hex,
+ };
+
+ renderHook(() => usePredictPaymentToken());
+
+ expect(mockSetPayToken).not.toHaveBeenCalled();
+ });
+
+ it('calls setPayToken when token is not yet applied', () => {
+ mockTransactionMeta = { id: 'tx-123' };
+ mockSelectedPaymentToken = {
+ address: '0xabcd',
+ chainId: '0x1',
+ };
+ mockPayToken = null;
+
+ renderHook(() => usePredictPaymentToken());
+
+ expect(mockSetPayToken).toHaveBeenCalledWith({
+ address: '0xabcd' as Hex,
+ chainId: '0x1' as Hex,
+ });
+ });
+
+ it('calls setPayToken when chainId differs', () => {
+ mockTransactionMeta = { id: 'tx-123' };
+ mockSelectedPaymentToken = {
+ address: '0xabcd',
+ chainId: '0x2',
+ };
+ mockPayToken = {
+ address: '0xabcd' as Hex,
+ chainId: '0x1' as Hex,
+ };
+
+ renderHook(() => usePredictPaymentToken());
+
+ expect(mockSetPayToken).toHaveBeenCalledWith({
+ address: '0xabcd' as Hex,
+ chainId: '0x2' as Hex,
+ });
+ });
+ });
+
+ describe('isPredictBalanceSelected', () => {
+ it('returns true when selectedPaymentToken is null', () => {
+ mockSelectedPaymentToken = null;
+ const { result } = renderHook(() => usePredictPaymentToken());
+
+ expect(result.current.isPredictBalanceSelected).toBe(true);
+ });
+
+ it('returns false when selectedPaymentToken is set', () => {
+ mockSelectedPaymentToken = {
+ address: '0xabcd',
+ chainId: '0x1',
+ };
+ const { result } = renderHook(() => usePredictPaymentToken());
+
+ expect(result.current.isPredictBalanceSelected).toBe(false);
+ });
+ });
+
+ describe('selectedPaymentToken', () => {
+ it('returns null when not selected', () => {
+ mockSelectedPaymentToken = null;
+ const { result } = renderHook(() => usePredictPaymentToken());
+
+ expect(result.current.selectedPaymentToken).toBeNull();
+ });
+
+ it('returns token object when selected', () => {
+ const token = {
+ address: '0xabcd',
+ chainId: '0x1',
+ symbol: 'TEST',
+ };
+ mockSelectedPaymentToken = token;
+ const { result } = renderHook(() => usePredictPaymentToken());
+
+ expect(result.current.selectedPaymentToken).toEqual(token);
+ });
+ });
+});
diff --git a/app/components/UI/Predict/hooks/usePredictPaymentToken.ts b/app/components/UI/Predict/hooks/usePredictPaymentToken.ts
new file mode 100644
index 00000000000..8c073eea7bd
--- /dev/null
+++ b/app/components/UI/Predict/hooks/usePredictPaymentToken.ts
@@ -0,0 +1,140 @@
+import { Hex } from '@metamask/utils';
+import { useCallback, useEffect, useRef } from 'react';
+import { useSelector } from 'react-redux';
+import Engine from '../../../../core/Engine';
+import { useTransactionPayToken } from '../../../Views/confirmations/hooks/pay/useTransactionPayToken';
+import { useTransactionMetadataRequest } from '../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest';
+import { AssetType } from '../../../Views/confirmations/types/token';
+import {
+ PREDICT_BALANCE_PLACEHOLDER_ADDRESS,
+ PREDICT_BALANCE_TOKEN_KEY,
+} from '../constants/transactions';
+import { selectPredictSelectedPaymentToken } from '../selectors/predictController';
+
+interface UsePredictPaymentTokenParams {
+ onTokenSelected?: ({
+ tokenAddress,
+ tokenKey,
+ }: {
+ tokenAddress: string | null;
+ tokenKey: string | null;
+ }) => Promise | void;
+}
+
+export interface UsePredictPaymentTokenResult {
+ onPaymentTokenChange: (token: AssetType | null) => void;
+ isPredictBalanceSelected: boolean;
+ selectedPaymentToken: {
+ address: string;
+ chainId: string;
+ symbol?: string;
+ } | null;
+ resetSelectedPaymentToken: () => void;
+}
+
+export function usePredictPaymentToken({
+ onTokenSelected,
+}: UsePredictPaymentTokenParams = {}): UsePredictPaymentTokenResult {
+ const { payToken, setPayToken } = useTransactionPayToken();
+ const transactionMeta = useTransactionMetadataRequest();
+ const selectedPaymentToken = useSelector(selectPredictSelectedPaymentToken);
+ const isPredictBalanceSelected = selectedPaymentToken === null;
+ const hasInitializedSelectionRef = useRef(false);
+ const previousSelectedTokenKeyRef = useRef(null);
+
+ const { PredictController } = Engine.context;
+
+ const onPaymentTokenChange = useCallback(
+ (token: AssetType | null) => {
+ if (!token) {
+ return;
+ }
+
+ if (token.address === PREDICT_BALANCE_PLACEHOLDER_ADDRESS) {
+ PredictController.setSelectedPaymentToken(null);
+ return;
+ }
+
+ PredictController.setSelectedPaymentToken({
+ address: token.address,
+ chainId: token.chainId ?? '',
+ symbol: token.symbol,
+ });
+ if (transactionMeta?.id) {
+ setPayToken({
+ address: token.address as Hex,
+ chainId: (token.chainId ?? '') as Hex,
+ });
+ }
+ },
+ [PredictController, setPayToken, transactionMeta?.id],
+ );
+
+ useEffect(() => {
+ if (!transactionMeta || isPredictBalanceSelected || !selectedPaymentToken) {
+ return;
+ }
+
+ const hasSelectedTokenApplied =
+ payToken?.address?.toLowerCase() ===
+ selectedPaymentToken.address.toLowerCase() &&
+ payToken?.chainId?.toLowerCase() ===
+ selectedPaymentToken.chainId.toLowerCase();
+
+ if (!hasSelectedTokenApplied) {
+ setPayToken({
+ address: selectedPaymentToken.address as Hex,
+ chainId: selectedPaymentToken.chainId as Hex,
+ });
+ }
+ }, [
+ transactionMeta,
+ isPredictBalanceSelected,
+ selectedPaymentToken,
+ payToken?.address,
+ payToken?.chainId,
+ setPayToken,
+ ]);
+
+ useEffect(() => {
+ const selectedTokenAddress = selectedPaymentToken?.address ?? null;
+ const selectedTokenKey = isPredictBalanceSelected
+ ? PREDICT_BALANCE_TOKEN_KEY
+ : selectedTokenAddress;
+
+ if (!hasInitializedSelectionRef.current) {
+ hasInitializedSelectionRef.current = true;
+ previousSelectedTokenKeyRef.current = selectedTokenKey;
+ return;
+ }
+
+ if (previousSelectedTokenKeyRef.current === selectedTokenKey) {
+ return;
+ }
+
+ previousSelectedTokenKeyRef.current = selectedTokenKey;
+ const callbackResult = onTokenSelected?.({
+ tokenAddress: selectedTokenAddress,
+ tokenKey: selectedTokenKey,
+ });
+
+ if (callbackResult) {
+ Promise.resolve(callbackResult).catch(() => undefined);
+ }
+ }, [
+ isPredictBalanceSelected,
+ onTokenSelected,
+ selectedPaymentToken?.address,
+ ]);
+
+ const resetSelectedPaymentToken = useCallback(() => {
+ PredictController.setSelectedPaymentToken(null);
+ }, [PredictController]);
+
+ return {
+ onPaymentTokenChange,
+ isPredictBalanceSelected,
+ selectedPaymentToken,
+ resetSelectedPaymentToken,
+ };
+}
diff --git a/app/components/UI/Predict/hooks/usePredictPlaceOrder.test.ts b/app/components/UI/Predict/hooks/usePredictPlaceOrder.test.ts
index bfb121ea593..fa07e0c32db 100644
--- a/app/components/UI/Predict/hooks/usePredictPlaceOrder.test.ts
+++ b/app/components/UI/Predict/hooks/usePredictPlaceOrder.test.ts
@@ -4,7 +4,10 @@ import { IconName } from '../../../../component-library/components/Icons/Icon';
import { ToastVariants } from '../../../../component-library/components/Toast';
import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger';
import { type OrderPreview, Result, Side } from '../types';
-import { usePredictPlaceOrder } from './usePredictPlaceOrder';
+import {
+ usePredictPlaceOrder,
+ PlaceOrderOutcome,
+} from './usePredictPlaceOrder';
import { usePredictTrading } from './usePredictTrading';
import { usePredictBalance } from './usePredictBalance';
import { usePredictDeposit } from './usePredictDeposit';
@@ -74,6 +77,7 @@ describe('usePredictPlaceOrder', () => {
const mockClaim = jest.fn();
const mockGetBalance = jest.fn();
const mockDeposit = jest.fn();
+ const mockRefetchBalance = jest.fn();
function createMockOrderPreview(
overrides?: Partial,
@@ -119,8 +123,13 @@ describe('usePredictPlaceOrder', () => {
previewOrder: jest.fn(),
prepareWithdraw: jest.fn(),
deposit: jest.fn(),
+ payWithAnyTokenConfirmation: jest.fn(),
});
- mockUsePredictBalance.mockReturnValue({ data: 1000 } as never);
+ mockRefetchBalance.mockResolvedValue({ data: 1000 });
+ mockUsePredictBalance.mockReturnValue({
+ data: 1000,
+ refetch: mockRefetchBalance,
+ } as never);
mockUsePredictDeposit.mockReturnValue({
deposit: mockDeposit,
isDepositPending: false,
@@ -262,6 +271,10 @@ describe('usePredictPlaceOrder', () => {
result.current.placeOrder(mockOrderParams);
});
+ await act(async () => {
+ await Promise.resolve();
+ });
+
expect(result.current.isLoading).toBe(true);
await act(async () => {
@@ -595,6 +608,73 @@ describe('usePredictPlaceOrder', () => {
expect(mockDeposit).not.toHaveBeenCalled();
expect(mockPlaceOrder).toHaveBeenCalledTimes(1);
});
+
+ it('shows toast and returns deposit_in_progress when deposit is already pending', async () => {
+ mockUsePredictBalance.mockReturnValue({
+ data: INSUFFICIENT_BALANCE,
+ } as never);
+ mockUsePredictDeposit.mockReturnValue({
+ deposit: mockDeposit,
+ isDepositPending: true,
+ });
+
+ const { result } = renderHook(() => usePredictPlaceOrder());
+
+ let outcome: PlaceOrderOutcome | undefined;
+ await act(async () => {
+ outcome = await result.current.placeOrder(mockOrderParams);
+ });
+
+ expect(outcome).toEqual({ status: 'deposit_in_progress' });
+ expect(mockDeposit).not.toHaveBeenCalled();
+ expect(mockPlaceOrder).not.toHaveBeenCalled();
+ expect(mockToastRef.current?.showToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ variant: ToastVariants.Icon,
+ iconName: IconName.Loading,
+ hasNoTimeout: false,
+ }),
+ );
+ });
+
+ it('does not set loading state when deposit is already pending', async () => {
+ mockUsePredictBalance.mockReturnValue({
+ data: INSUFFICIENT_BALANCE,
+ } as never);
+ mockUsePredictDeposit.mockReturnValue({
+ deposit: mockDeposit,
+ isDepositPending: true,
+ });
+
+ const { result } = renderHook(() => usePredictPlaceOrder());
+
+ await act(async () => {
+ await result.current.placeOrder(mockOrderParams);
+ });
+
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('uses refreshed balance to avoid unnecessary deposit retries', async () => {
+ mockPlaceOrder.mockResolvedValue(mockSuccessResult);
+ mockRefetchBalance.mockResolvedValueOnce({
+ data: SUFFICIENT_BALANCE,
+ });
+ mockUsePredictBalance.mockReturnValue({
+ data: INSUFFICIENT_BALANCE,
+ refetch: mockRefetchBalance,
+ } as never);
+
+ const { result } = renderHook(() => usePredictPlaceOrder());
+
+ await act(async () => {
+ await result.current.placeOrder(mockOrderParams);
+ });
+
+ expect(mockRefetchBalance).toHaveBeenCalledTimes(1);
+ expect(mockDeposit).not.toHaveBeenCalled();
+ expect(mockPlaceOrder).toHaveBeenCalledTimes(1);
+ });
});
describe('order not filled detection', () => {
diff --git a/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts b/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts
index d3a902e9de9..c2dde6b1c78 100644
--- a/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts
+++ b/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts
@@ -52,6 +52,9 @@ export type PlaceOrderOutcome =
| {
status: 'deposit_required';
}
+ | {
+ status: 'deposit_in_progress';
+ }
| {
status: 'order_not_filled';
}
@@ -77,8 +80,8 @@ export function usePredictPlaceOrder(
const [isOrderNotFilled, setIsOrderNotFilled] = useState(false);
const { toastRef } = useContext(ToastContext);
const queryClient = useQueryClient();
- const { data: balance = 0 } = usePredictBalance();
- const { deposit } = usePredictDeposit();
+ const { data: balance = 0, refetch: refetchBalance } = usePredictBalance();
+ const { deposit, isDepositPending } = usePredictDeposit();
const showCashedOutToast = useCallback(
(amount: string) => {
@@ -156,8 +159,43 @@ export function usePredictPlaceOrder(
const totalAmount = maxAmountSpent + (fees?.totalFee ?? 0);
+ let latestBalance = balance;
+
+ // Refresh balance before deciding whether to trigger a deposit.
+ // This avoids unnecessary extra deposits when the balance just changed.
+ if (side === Side.BUY) {
+ try {
+ const refreshedBalance = await refetchBalance?.();
+ if (typeof refreshedBalance?.data === 'number') {
+ latestBalance = refreshedBalance.data;
+ }
+ } catch {
+ // If balance refresh fails, fallback to cached value.
+ }
+ }
+
// Check if user has sufficient balance for the bet amount
- if (side === Side.BUY && balance < totalAmount) {
+ if (side === Side.BUY && latestBalance < totalAmount) {
+ if (isDepositPending) {
+ toastRef?.current?.showToast({
+ variant: ToastVariants.Icon,
+ iconName: IconName.Loading,
+ labelOptions: [
+ {
+ label: strings('predict.deposit.in_progress'),
+ isBold: true,
+ },
+ { label: '\n', isBold: false },
+ {
+ label: strings('predict.deposit.in_progress_description'),
+ isBold: false,
+ },
+ ],
+ hasNoTimeout: false,
+ });
+ return { status: 'deposit_in_progress' };
+ }
+
await deposit({
amountUsd: totalAmount,
analyticsProperties: {
@@ -171,13 +209,11 @@ export function usePredictPlaceOrder(
try {
setIsLoading(true);
+ setError(undefined);
// Place order using Predict controller
const orderResult = await controllerPlaceOrder(orderParams);
- // Clear any previous error state
- setError(undefined);
-
onComplete?.(orderResult);
setResult(orderResult);
@@ -256,10 +292,13 @@ export function usePredictPlaceOrder(
},
[
balance,
+ refetchBalance,
+ isDepositPending,
deposit,
+ toastRef,
controllerPlaceOrder,
- queryClient,
onComplete,
+ queryClient,
showOrderPlacedToast,
showCashedOutToast,
onError,
diff --git a/app/components/UI/Predict/hooks/usePredictTrading.test.ts b/app/components/UI/Predict/hooks/usePredictTrading.test.ts
index e7b20226276..fc4d3eef623 100644
--- a/app/components/UI/Predict/hooks/usePredictTrading.test.ts
+++ b/app/components/UI/Predict/hooks/usePredictTrading.test.ts
@@ -17,6 +17,7 @@ jest.mock('../../../../core/Engine', () => ({
calculateCashOutAmounts: jest.fn(),
getBalance: jest.fn(),
deposit: jest.fn(),
+ payWithAnyTokenConfirmation: jest.fn(),
},
},
}));
@@ -226,6 +227,42 @@ describe('usePredictTrading', () => {
});
});
+ describe('payWithAnyTokenConfirmation', () => {
+ it('calls PredictController.payWithAnyTokenConfirmation and returns result', async () => {
+ const mockResult = {
+ success: true,
+ response: { batchId: 'batch-123' },
+ };
+
+ (
+ Engine.context.PredictController
+ .payWithAnyTokenConfirmation as jest.Mock
+ ).mockResolvedValue(mockResult);
+
+ const { result } = renderHook(() => usePredictTrading());
+
+ const response = await result.current.payWithAnyTokenConfirmation();
+
+ expect(
+ Engine.context.PredictController.payWithAnyTokenConfirmation,
+ ).toHaveBeenCalled();
+ expect(response).toEqual(mockResult);
+ });
+
+ it('throws error when PredictController.payWithAnyTokenConfirmation fails', async () => {
+ const mockError = new Error('Failed to pay with any token');
+ (
+ Engine.context.PredictController
+ .payWithAnyTokenConfirmation as jest.Mock
+ ).mockRejectedValue(mockError);
+ const { result } = renderHook(() => usePredictTrading());
+
+ await expect(
+ result.current.payWithAnyTokenConfirmation(),
+ ).rejects.toThrow('Failed to pay with any token');
+ });
+ });
+
describe('hook stability', () => {
it('returns stable function references', () => {
const { result, rerender } = renderHook(() => usePredictTrading());
@@ -234,6 +271,8 @@ describe('usePredictTrading', () => {
const initialClaim = result.current.claim;
const initialGetBalance = result.current.getBalance;
const initialPreviewOrder = result.current.previewOrder;
+ const initialPayWithAnyTokenConfirmation =
+ result.current.payWithAnyTokenConfirmation;
rerender({});
@@ -241,6 +280,9 @@ describe('usePredictTrading', () => {
expect(result.current.claim).toBe(initialClaim);
expect(result.current.getBalance).toBe(initialGetBalance);
expect(result.current.previewOrder).toBe(initialPreviewOrder);
+ expect(result.current.payWithAnyTokenConfirmation).toBe(
+ initialPayWithAnyTokenConfirmation,
+ );
});
});
});
diff --git a/app/components/UI/Predict/hooks/usePredictTrading.ts b/app/components/UI/Predict/hooks/usePredictTrading.ts
index ccbd4c9471f..1da6041130f 100644
--- a/app/components/UI/Predict/hooks/usePredictTrading.ts
+++ b/app/components/UI/Predict/hooks/usePredictTrading.ts
@@ -44,6 +44,11 @@ export function usePredictTrading() {
return controller.depositWithConfirmation(params);
}, []);
+ const payWithAnyTokenConfirmation = useCallback(async () => {
+ const controller = Engine.context.PredictController;
+ return controller.payWithAnyTokenConfirmation();
+ }, []);
+
return {
placeOrder,
claim,
@@ -51,5 +56,6 @@ export function usePredictTrading() {
previewOrder,
prepareWithdraw,
deposit,
+ payWithAnyTokenConfirmation,
};
}
diff --git a/app/components/UI/Predict/routes/index.tsx b/app/components/UI/Predict/routes/index.tsx
index b007c8d3110..2d38dda1036 100644
--- a/app/components/UI/Predict/routes/index.tsx
+++ b/app/components/UI/Predict/routes/index.tsx
@@ -1,5 +1,9 @@
-import { createStackNavigator } from '@react-navigation/stack';
+import {
+ createStackNavigator,
+ type StackNavigationOptions,
+} from '@react-navigation/stack';
import React from 'react';
+import { useSelector } from 'react-redux';
import PredictSellPreview from '../views/PredictSellPreview/PredictSellPreview';
import { strings } from '../../../../../locales/i18n';
import Routes from '../../../../constants/navigation/Routes';
@@ -7,12 +11,45 @@ import { Confirm } from '../../../Views/confirmations/components/confirm';
import PredictMarketDetails from '../views/PredictMarketDetails';
import PredictUnavailableModal from '../views/PredictUnavailableModal';
import PredictBuyPreview from '../views/PredictBuyPreview/PredictBuyPreview';
+import PredictPayWithAnyToken from '../views/PredictBuyWithAnyToken';
import PredictActivityDetail from '../components/PredictActivityDetail/PredictActivityDetail';
import { PredictNavigationParamList } from '../types/navigation';
import PredictAddFundsModal from '../views/PredictAddFundsModal/PredictAddFundsModal';
import PredictFeed from '../views/PredictFeed';
import PredictGTMModal from '../components/PredictGTMModal';
import { Dimensions } from 'react-native';
+import { selectPredictWithAnyTokenEnabledFlag } from '../selectors/featureFlags';
+
+interface PredictConfirmationRouteParams {
+ animationEnabled?: boolean;
+}
+
+const getConfirmationTransitionSpec = (
+ disableOpenAnimation: boolean,
+): StackNavigationOptions['transitionSpec'] =>
+ disableOpenAnimation
+ ? {
+ open: { animation: 'timing' as const, config: { duration: 0 } },
+ close: { animation: 'timing' as const, config: { duration: 300 } },
+ }
+ : undefined;
+
+const getPredictConfirmationScreenOptions = ({
+ route,
+}: {
+ route: {
+ params?: PredictConfirmationRouteParams;
+ };
+}): StackNavigationOptions => {
+ const disableOpenAnimation = route.params?.animationEnabled === false;
+
+ return {
+ headerLeft: () => null,
+ headerShown: true,
+ title: '',
+ transitionSpec: getConfirmationTransitionSpec(disableOpenAnimation),
+ };
+};
const Stack = createStackNavigator();
const ModalStack = createStackNavigator();
@@ -70,116 +107,150 @@ const PredictModalStack = () => (
null,
- headerShown: true,
- title: '',
- }}
+ options={getPredictConfirmationScreenOptions}
/>
{
+ const disableOpenAnimation = route.params?.animationEnabled === false;
+
+ return {
+ headerShown: false,
+ transitionSpec: getConfirmationTransitionSpec(disableOpenAnimation),
+ };
}}
/>
);
-const PredictScreenStack = () => (
-
-
+const PredictScreenStack = () => {
+ const payWithAnyTokenEnabled = useSelector(
+ selectPredictWithAnyTokenEnabledFlag,
+ );
- null,
- headerShown: true,
- title: '',
- }}
- />
+ const BuyPreviewComponent = payWithAnyTokenEnabled
+ ? PredictPayWithAnyToken
+ : PredictBuyPreview;
-
+ return (
+
+
- ({
- cardStyle: {
- transform: [
- {
- translateX: current.progress.interpolate({
- inputRange: [0, 1],
- outputRange: [Dimensions.get('window').width, 0],
- }),
- },
- ],
- },
- }),
- }}
- />
+
- ({
- cardStyle: {
- transform: [
- {
- translateX: current.progress.interpolate({
- inputRange: [0, 1],
- outputRange: [Dimensions.get('window').width, 0],
- }),
- },
- ],
- },
- }),
- }}
- />
+ {
+ const disableOpenAnimation = route.params?.animationEnabled === false;
- ({
- cardStyle: {
- transform: [
- {
- translateX: current.progress.interpolate({
- inputRange: [0, 1],
- outputRange: [Dimensions.get('window').width, 0],
- }),
+ return {
+ headerShown: false,
+ transitionSpec: getConfirmationTransitionSpec(disableOpenAnimation),
+ };
+ }}
+ />
+
+ ({
+ cardStyle: {
+ transform: [
+ {
+ translateX: current.progress.interpolate({
+ inputRange: [0, 1],
+ outputRange: [Dimensions.get('window').width, 0],
+ }),
+ },
+ ],
+ },
+ }),
+ }}
+ />
+
+ {
+ const disableOpenAnimation = route.params?.animationEnabled === false;
+
+ return {
+ headerShown: false,
+ transitionSpec: disableOpenAnimation
+ ? {
+ open: { animation: 'timing', config: { duration: 0 } },
+ close: { animation: 'timing', config: { duration: 300 } },
+ }
+ : undefined,
+ // slide from right to left when entering
+ cardStyleInterpolator: ({ current }) => ({
+ cardStyle: {
+ transform: [
+ {
+ translateX: current.progress.interpolate({
+ inputRange: [0, 1],
+ outputRange: [Dimensions.get('window').width, 0],
+ }),
+ },
+ ],
},
- ],
- },
- }),
- }}
- />
-
-);
+ }),
+ };
+ }}
+ />
+
+ ({
+ cardStyle: {
+ transform: [
+ {
+ translateX: current.progress.interpolate({
+ inputRange: [0, 1],
+ outputRange: [Dimensions.get('window').width, 0],
+ }),
+ },
+ ],
+ },
+ }),
+ }}
+ />
+
+ );
+};
export default PredictScreenStack;
export { PredictModalStack };
diff --git a/app/components/UI/Predict/selectors/featureFlags/index.test.ts b/app/components/UI/Predict/selectors/featureFlags/index.test.ts
index 436b0493870..e665e97304d 100644
--- a/app/components/UI/Predict/selectors/featureFlags/index.test.ts
+++ b/app/components/UI/Predict/selectors/featureFlags/index.test.ts
@@ -3,6 +3,7 @@ import {
selectPredictFakOrdersEnabledFlag,
selectPredictFeeCollectionFlag,
selectPredictHotTabFlag,
+ selectPredictWithAnyTokenEnabledFlag,
} from '.';
import mockedEngine from '../../../../../core/__mocks__/MockedEngine';
import {
@@ -924,4 +925,138 @@ describe('Predict Feature Flag Selectors', () => {
expect(result).toBe(false);
});
});
+
+ describe('selectPredictPayWithAnyTokenEnabledFlag', () => {
+ it('returns true when remote flag is enabled and version check passes', () => {
+ mockHasMinimumRequiredVersion.mockReturnValue(true);
+ const state = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ predictWithAnyToken: {
+ enabled: true,
+ minimumVersion: '1.0.0',
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ const result = selectPredictWithAnyTokenEnabledFlag(state);
+
+ expect(result).toBe(true);
+ });
+
+ it('returns false when remote flag is disabled', () => {
+ mockHasMinimumRequiredVersion.mockReturnValue(true);
+ const state = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ predictWithAnyToken: {
+ enabled: false,
+ minimumVersion: '1.0.0',
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ const result = selectPredictWithAnyTokenEnabledFlag(state);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false when app version is below minimum required version', () => {
+ mockHasMinimumRequiredVersion.mockReturnValue(false);
+ const state = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ predictWithAnyToken: {
+ enabled: true,
+ minimumVersion: '99.0.0',
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ const result = selectPredictWithAnyTokenEnabledFlag(state);
+
+ expect(result).toBe(false);
+ });
+
+ it('defaults to false when remote flag is null', () => {
+ const state = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ predictWithAnyToken: null,
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ const result = selectPredictWithAnyTokenEnabledFlag(state);
+
+ expect(result).toBe(false);
+ });
+
+ it('defaults to false when remote feature flags are empty', () => {
+ const result = selectPredictWithAnyTokenEnabledFlag(
+ mockedEmptyFlagsState,
+ );
+
+ expect(result).toBe(false);
+ });
+
+ it('defaults to false when controller is undefined', () => {
+ const state = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: undefined,
+ },
+ },
+ };
+
+ const result = selectPredictWithAnyTokenEnabledFlag(state);
+
+ expect(result).toBe(false);
+ });
+
+ it('defaults to false when remote flag is invalid', () => {
+ const state = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ predictWithAnyToken: {
+ enabled: 'invalid',
+ minimumVersion: 123,
+ },
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ const result = selectPredictWithAnyTokenEnabledFlag(state);
+
+ expect(result).toBe(false);
+ });
+ });
});
diff --git a/app/components/UI/Predict/selectors/featureFlags/index.ts b/app/components/UI/Predict/selectors/featureFlags/index.ts
index c8cfee9e283..9e8231e7ac8 100644
--- a/app/components/UI/Predict/selectors/featureFlags/index.ts
+++ b/app/components/UI/Predict/selectors/featureFlags/index.ts
@@ -148,3 +148,21 @@ export const selectPredictFakOrdersEnabledFlag = createSelector(
),
) ?? false,
);
+
+/**
+ * Selector for Predict Pay With Any Token enablement
+ *
+ * Uses version-gated feature flag `predictPayWithAnyToken` from remote config.
+ * Falls back to `false` if remote flag is unavailable or invalid.
+ *
+ * @returns {boolean} True if Pay With Any Token is enabled and version requirement is met
+ */
+export const selectPredictWithAnyTokenEnabledFlag = createSelector(
+ selectRemoteFeatureFlags,
+ (remoteFeatureFlags) =>
+ validatedVersionGatedFeatureFlag(
+ unwrapRemoteFeatureFlag(
+ remoteFeatureFlags?.predictWithAnyToken,
+ ),
+ ) ?? false,
+);
diff --git a/app/components/UI/Predict/selectors/predictController/index.test.ts b/app/components/UI/Predict/selectors/predictController/index.test.ts
index 461862c8959..2546b6b2b48 100644
--- a/app/components/UI/Predict/selectors/predictController/index.test.ts
+++ b/app/components/UI/Predict/selectors/predictController/index.test.ts
@@ -10,6 +10,7 @@ import {
selectPredictAccountMeta,
selectPredictAccountMetaByAddress,
selectPredictWithdrawTransaction,
+ selectPredictSelectedPaymentToken,
} from './index';
import { PredictPosition, PredictPositionStatus } from '../../types';
@@ -1254,4 +1255,102 @@ describe('Predict Controller Selectors', () => {
expect(result).toEqual({});
});
});
+
+ describe('selectPredictSelectedPaymentToken', () => {
+ it('returns null when selectedPaymentToken is null', () => {
+ const mockState = {
+ engine: {
+ backgroundState: {
+ PredictController: {
+ selectedPaymentToken: null,
+ },
+ },
+ },
+ };
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const result = selectPredictSelectedPaymentToken(mockState as any);
+
+ expect(result).toBeNull();
+ });
+
+ it('returns token object when selectedPaymentToken is set', () => {
+ const selectedPaymentToken = {
+ address: '0x1234567890123456789012345678901234567890',
+ chainId: '0x1',
+ symbol: 'USDC',
+ };
+
+ const mockState = {
+ engine: {
+ backgroundState: {
+ PredictController: {
+ selectedPaymentToken,
+ },
+ },
+ },
+ };
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const result = selectPredictSelectedPaymentToken(mockState as any);
+
+ expect(result).toEqual(selectedPaymentToken);
+ });
+
+ it('returns null when PredictController state is undefined', () => {
+ const mockState = {
+ engine: {
+ backgroundState: {
+ PredictController: undefined,
+ },
+ },
+ };
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const result = selectPredictSelectedPaymentToken(mockState as any);
+
+ expect(result).toBeNull();
+ });
+
+ it('returns null when selectedPaymentToken property is missing', () => {
+ const mockState = {
+ engine: {
+ backgroundState: {
+ PredictController: {},
+ },
+ },
+ };
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const result = selectPredictSelectedPaymentToken(mockState as any);
+
+ expect(result).toBeNull();
+ });
+
+ it('returns token with all properties', () => {
+ const selectedPaymentToken = {
+ address: '0xabcdef1234567890abcdef1234567890abcdef12',
+ chainId: '0x89',
+ symbol: 'DAI',
+ };
+
+ const mockState = {
+ engine: {
+ backgroundState: {
+ PredictController: {
+ selectedPaymentToken,
+ },
+ },
+ },
+ };
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const result = selectPredictSelectedPaymentToken(mockState as any);
+
+ expect(result).toEqual(selectedPaymentToken);
+ expect(result?.address).toBe(selectedPaymentToken.address);
+ expect(result?.chainId).toBe(selectedPaymentToken.chainId);
+ expect(result?.symbol).toBe(selectedPaymentToken.symbol);
+ });
+ });
});
diff --git a/app/components/UI/Predict/selectors/predictController/index.ts b/app/components/UI/Predict/selectors/predictController/index.ts
index 3344edad5fe..fe2c5770272 100644
--- a/app/components/UI/Predict/selectors/predictController/index.ts
+++ b/app/components/UI/Predict/selectors/predictController/index.ts
@@ -20,6 +20,11 @@ const selectPredictWithdrawTransaction = createSelector(
(predictControllerState) => predictControllerState?.withdrawTransaction,
);
+const selectPredictActiveOrder = createSelector(
+ selectPredictControllerState,
+ (predictState) => predictState?.activeOrder ?? null,
+);
+
const selectPredictClaimablePositions = createSelector(
selectPredictControllerState,
(predictControllerState) => predictControllerState?.claimablePositions || {},
@@ -81,6 +86,11 @@ const selectPredictPendingClaimByAddress = ({ address }: { address: string }) =>
(pendingClaims) => pendingClaims[address] || undefined,
);
+const selectPredictSelectedPaymentToken = createSelector(
+ selectPredictControllerState,
+ (predictState) => predictState?.selectedPaymentToken ?? null,
+);
+
const selectPredictAccountMeta = createSelector(
selectPredictControllerState,
(predictControllerState) => predictControllerState?.accountMeta || {},
@@ -103,6 +113,7 @@ export {
selectPredictPendingDeposits,
selectPredictPendingClaims,
selectPredictWithdrawTransaction,
+ selectPredictActiveOrder,
selectPredictClaimablePositions,
selectPredictClaimablePositionsByAddress,
selectPredictWonPositions,
@@ -114,4 +125,5 @@ export {
selectPredictPendingClaimByAddress,
selectPredictAccountMeta,
selectPredictAccountMetaByAddress,
+ selectPredictSelectedPaymentToken,
};
diff --git a/app/components/UI/Predict/types/index.ts b/app/components/UI/Predict/types/index.ts
index 72248004327..cfabfc6b0cb 100644
--- a/app/components/UI/Predict/types/index.ts
+++ b/app/components/UI/Predict/types/index.ts
@@ -9,6 +9,14 @@ export enum Side {
export type PredictOrderType = 'FOK' | 'FAK';
+export enum ActiveOrderState {
+ PREVIEW = 'preview',
+ DEPOSITING = 'depositing',
+ PLACING_ORDER = 'placing_order',
+ REDIRECTING = 'redirecting',
+ PAY_WITH_ANY_TOKEN = 'pay_with_any_token',
+}
+
export enum PredictPriceHistoryInterval {
ONE_HOUR = '1h',
SIX_HOUR = '6h',
@@ -127,21 +135,21 @@ export type PredictSportsLeague = 'nfl' | 'nba';
export type PredictGameStatus = 'scheduled' | 'ongoing' | 'ended';
// Team data
-export interface PredictSportTeam {
+export type PredictSportTeam = {
id: string;
name: string;
logo: string;
abbreviation: string; // e.g., "SEA", "DEN"
color: string; // Team primary color (hex)
alias: string; // Team alias (e.g., "Seahawks")
-}
+};
// Parsed score data
-export interface PredictGameScore {
+export type PredictGameScore = {
away: number;
home: number;
raw: string; // Original "away-home" format (e.g., "21-14")
-}
+};
export type PredictGamePeriod =
| 'NS' // Not Started
@@ -160,7 +168,7 @@ export type PredictGamePeriod =
| (string & {}); // Escape hatch for future sports with different period formats
// Game data attached to market
-export interface PredictMarketGame {
+export type PredictMarketGame = {
id: string;
startTime: string;
endTime?: string; // ISO date when game ended, available for ended games
@@ -172,7 +180,7 @@ export interface PredictMarketGame {
homeTeam: PredictSportTeam;
awayTeam: PredictSportTeam;
turn?: string; // Team abbreviation with possession
-}
+};
// Live update types for WebSocket data
export interface GameUpdate {
diff --git a/app/components/UI/Predict/types/navigation.ts b/app/components/UI/Predict/types/navigation.ts
index c87cc8ca001..01fc08c1b56 100644
--- a/app/components/UI/Predict/types/navigation.ts
+++ b/app/components/UI/Predict/types/navigation.ts
@@ -4,6 +4,7 @@
import { ParamListBase } from '@react-navigation/native';
import {
+ OrderPreview,
PredictActivityItem,
PredictCategory,
PredictMarket,
@@ -55,6 +56,11 @@ export interface PredictBuyPreviewParams {
outcome: PredictOutcome;
outcomeToken: PredictOutcomeToken;
entryPoint?: PredictEntryPoint;
+ batchId?: string;
+ animationEnabled?: boolean;
+ isConfirmation?: boolean;
+ isConfirming?: boolean;
+ preview?: OrderPreview;
}
/** Predict sell preview parameters */
diff --git a/app/components/UI/Predict/utils/analytics.test.ts b/app/components/UI/Predict/utils/analytics.test.ts
new file mode 100644
index 00000000000..2958071d314
--- /dev/null
+++ b/app/components/UI/Predict/utils/analytics.test.ts
@@ -0,0 +1,426 @@
+import { parseAnalyticsProperties } from './analytics';
+import {
+ Recurrence,
+ type PredictMarket,
+ type PredictOutcomeToken,
+} from '../types';
+import type { PredictEntryPoint } from '../types/navigation';
+
+jest.mock('../constants/eventNames', () => ({
+ PredictEventValues: {
+ ENTRY_POINT: {
+ PREDICT_FEED: 'predict_feed',
+ },
+ TRANSACTION_TYPE: {
+ MM_PREDICT_BUY: 'mm_predict_buy',
+ },
+ MARKET_TYPE: {
+ BINARY: 'binary',
+ MULTI_OUTCOME: 'multi-outcome',
+ },
+ },
+}));
+
+describe('parseAnalyticsProperties', () => {
+ const createMockMarket = (
+ overrides?: Partial,
+ ): PredictMarket => ({
+ id: 'market-123',
+ title: 'Test Market',
+ category: 'sports',
+ tags: ['tag1', 'tag2'],
+ liquidity: 1000,
+ volume: 5000,
+ slug: 'test-market',
+ providerId: 'provider-1',
+ description: 'Test market description',
+ image: 'https://example.com/market.png',
+ status: 'open',
+ recurrence: Recurrence.NONE,
+ outcomes: [
+ {
+ id: 'outcome-1',
+ title: 'Yes',
+ image: 'image-url',
+ tokens: [],
+ groupItemTitle: '',
+ providerId: 'provider-1',
+ marketId: 'market-123',
+ description: '',
+ status: 'open',
+ volume: 0,
+ },
+ ],
+ game: {
+ id: 'game-123',
+ startTime: '2024-01-01T00:00:00Z',
+ league: 'nfl',
+ status: 'ongoing',
+ period: '2',
+ elapsed: '10:30',
+ endTime: undefined,
+ score: null,
+ homeTeam: {
+ id: 'team-1',
+ name: 'Home Team',
+ logo: 'https://example.com/home.png',
+ abbreviation: 'HT',
+ color: 'color-default',
+ alias: 'Home',
+ },
+ awayTeam: {
+ id: 'team-2',
+ name: 'Away Team',
+ logo: 'https://example.com/away.png',
+ abbreviation: 'AT',
+ color: 'color-inverse',
+ alias: 'Away',
+ },
+ },
+ ...overrides,
+ });
+
+ const createMockOutcomeToken = (
+ overrides?: Partial,
+ ): PredictOutcomeToken => ({
+ id: 'token-123',
+ title: 'Yes',
+ price: 0.65,
+ ...overrides,
+ });
+
+ describe('with all fields populated', () => {
+ it('returns all properties from market, outcome token, and entry point', () => {
+ const market = createMockMarket();
+ const outcomeToken = createMockOutcomeToken();
+ const entryPoint: PredictEntryPoint = 'predict_market_details';
+
+ const result = parseAnalyticsProperties(market, outcomeToken, entryPoint);
+
+ expect(result).toEqual({
+ marketId: 'market-123',
+ marketTitle: 'Test Market',
+ marketCategory: 'sports',
+ marketTags: ['tag1', 'tag2'],
+ entryPoint: 'predict_market_details',
+ transactionType: 'mm_predict_buy',
+ liquidity: 1000,
+ volume: 5000,
+ sharePrice: 0.65,
+ marketType: 'binary',
+ outcome: 'yes',
+ marketSlug: 'test-market',
+ gameId: 'game-123',
+ gameStartTime: '2024-01-01T00:00:00Z',
+ gameLeague: 'nfl',
+ gameStatus: 'ongoing',
+ gamePeriod: '2',
+ gameClock: '10:30',
+ });
+ });
+ });
+
+ describe('with undefined market', () => {
+ it('returns undefined for market-related properties', () => {
+ const outcomeToken = createMockOutcomeToken();
+ const entryPoint: PredictEntryPoint = 'predict_feed';
+
+ const result = parseAnalyticsProperties(
+ undefined,
+ outcomeToken,
+ entryPoint,
+ );
+
+ expect(result.marketId).toBeUndefined();
+ expect(result.marketTitle).toBeUndefined();
+ expect(result.marketCategory).toBeUndefined();
+ expect(result.marketTags).toBeUndefined();
+ expect(result.liquidity).toBeUndefined();
+ expect(result.volume).toBeUndefined();
+ expect(result.marketSlug).toBeUndefined();
+ expect(result.gameId).toBeUndefined();
+ expect(result.gameStartTime).toBeUndefined();
+ expect(result.gameLeague).toBeUndefined();
+ expect(result.gameStatus).toBeUndefined();
+ expect(result.gamePeriod).toBeUndefined();
+ expect(result.gameClock).toBeUndefined();
+ });
+
+ it('still returns transaction type and outcome token properties', () => {
+ const outcomeToken = createMockOutcomeToken();
+ const entryPoint: PredictEntryPoint = 'predict_feed';
+
+ const result = parseAnalyticsProperties(
+ undefined,
+ outcomeToken,
+ entryPoint,
+ );
+
+ expect(result.transactionType).toBe('mm_predict_buy');
+ expect(result.sharePrice).toBe(0.65);
+ expect(result.outcome).toBe('yes');
+ });
+ });
+
+ describe('with undefined outcome token', () => {
+ it('returns undefined for outcome token properties', () => {
+ const market = createMockMarket();
+ const entryPoint: PredictEntryPoint = 'predict_feed';
+
+ const result = parseAnalyticsProperties(market, undefined, entryPoint);
+
+ expect(result.sharePrice).toBeUndefined();
+ expect(result.outcome).toBeUndefined();
+ });
+
+ it('still returns market properties', () => {
+ const market = createMockMarket();
+ const entryPoint: PredictEntryPoint = 'predict_feed';
+
+ const result = parseAnalyticsProperties(market, undefined, entryPoint);
+
+ expect(result.marketId).toBe('market-123');
+ expect(result.marketTitle).toBe('Test Market');
+ expect(result.marketType).toBe('binary');
+ });
+ });
+
+ describe('with undefined entry point', () => {
+ it('defaults to PREDICT_FEED entry point', () => {
+ const market = createMockMarket();
+ const outcomeToken = createMockOutcomeToken();
+
+ const result = parseAnalyticsProperties(market, outcomeToken, undefined);
+
+ expect(result.entryPoint).toBe('predict_feed');
+ });
+ });
+
+ describe('market type detection', () => {
+ it('returns BINARY type when market has single outcome', () => {
+ const market = createMockMarket({
+ outcomes: [
+ {
+ id: 'outcome-1',
+ title: 'Yes',
+ image: 'image-url',
+ tokens: [],
+ groupItemTitle: '',
+ providerId: 'provider-1',
+ marketId: 'market-123',
+ description: '',
+ status: 'open',
+ volume: 0,
+ },
+ ],
+ });
+ const outcomeToken = createMockOutcomeToken();
+
+ const result = parseAnalyticsProperties(
+ market,
+ outcomeToken,
+ 'predict_feed',
+ );
+
+ expect(result.marketType).toBe('binary');
+ });
+
+ it('returns MULTI_OUTCOME type when market has multiple outcomes', () => {
+ const market = createMockMarket({
+ outcomes: [
+ {
+ id: 'outcome-1',
+ title: 'Yes',
+ image: 'image-url',
+ tokens: [],
+ groupItemTitle: '',
+ providerId: 'provider-1',
+ marketId: 'market-123',
+ description: '',
+ status: 'open',
+ volume: 0,
+ },
+ {
+ id: 'outcome-2',
+ title: 'No',
+ image: 'image-url',
+ tokens: [],
+ groupItemTitle: '',
+ providerId: 'provider-1',
+ marketId: 'market-123',
+ description: '',
+ status: 'open',
+ volume: 0,
+ },
+ {
+ id: 'outcome-3',
+ title: 'Maybe',
+ image: 'image-url',
+ tokens: [],
+ groupItemTitle: '',
+ providerId: 'provider-1',
+ marketId: 'market-123',
+ description: '',
+ status: 'open',
+ volume: 0,
+ },
+ ],
+ });
+ const outcomeToken = createMockOutcomeToken();
+
+ const result = parseAnalyticsProperties(
+ market,
+ outcomeToken,
+ 'predict_feed',
+ );
+
+ expect(result.marketType).toBe('multi-outcome');
+ });
+ });
+
+ describe('outcome title case conversion', () => {
+ it('converts outcome title to lowercase', () => {
+ const market = createMockMarket();
+ const outcomeToken = createMockOutcomeToken({ title: 'YES' });
+
+ const result = parseAnalyticsProperties(
+ market,
+ outcomeToken,
+ 'predict_feed',
+ );
+
+ expect(result.outcome).toBe('yes');
+ });
+
+ it('handles mixed case outcome titles', () => {
+ const market = createMockMarket();
+ const outcomeToken = createMockOutcomeToken({ title: 'MayBe' });
+
+ const result = parseAnalyticsProperties(
+ market,
+ outcomeToken,
+ 'predict_feed',
+ );
+
+ expect(result.outcome).toBe('maybe');
+ });
+ });
+
+ describe('with game fields present', () => {
+ it('includes all game properties when present', () => {
+ const market = createMockMarket({
+ game: {
+ id: 'game-456',
+ startTime: '2024-02-15T18:00:00Z',
+ league: 'nba',
+ status: 'scheduled',
+ period: '1',
+ elapsed: '5:45',
+ endTime: undefined,
+ score: null,
+ homeTeam: {
+ id: 'team-1',
+ name: 'Home Team',
+ logo: 'https://example.com/home.png',
+ abbreviation: 'HT',
+ color: 'color-default',
+ alias: 'Home',
+ },
+ awayTeam: {
+ id: 'team-2',
+ name: 'Away Team',
+ logo: 'https://example.com/away.png',
+ abbreviation: 'AT',
+ color: 'color-inverse',
+ alias: 'Away',
+ },
+ },
+ });
+ const outcomeToken = createMockOutcomeToken();
+
+ const result = parseAnalyticsProperties(
+ market,
+ outcomeToken,
+ 'predict_feed',
+ );
+
+ expect(result.gameId).toBe('game-456');
+ expect(result.gameStartTime).toBe('2024-02-15T18:00:00Z');
+ expect(result.gameLeague).toBe('nba');
+ expect(result.gameStatus).toBe('scheduled');
+ expect(result.gamePeriod).toBe('1');
+ expect(result.gameClock).toBe('5:45');
+ });
+ });
+
+ describe('with undefined game fields', () => {
+ it('returns undefined for game properties when game is undefined', () => {
+ const market = createMockMarket({ game: undefined });
+ const outcomeToken = createMockOutcomeToken();
+
+ const result = parseAnalyticsProperties(
+ market,
+ outcomeToken,
+ 'predict_feed',
+ );
+
+ expect(result.gameId).toBeUndefined();
+ expect(result.gameStartTime).toBeUndefined();
+ expect(result.gameLeague).toBeUndefined();
+ expect(result.gameStatus).toBeUndefined();
+ expect(result.gamePeriod).toBeUndefined();
+ expect(result.gameClock).toBeUndefined();
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles all parameters as undefined', () => {
+ const result = parseAnalyticsProperties(undefined, undefined, undefined);
+
+ expect(result.entryPoint).toBe('predict_feed');
+ expect(result.transactionType).toBe('mm_predict_buy');
+ expect(result.marketId).toBeUndefined();
+ expect(result.sharePrice).toBeUndefined();
+ });
+
+ it('handles empty market tags array', () => {
+ const market = createMockMarket({ tags: [] });
+ const outcomeToken = createMockOutcomeToken();
+
+ const result = parseAnalyticsProperties(
+ market,
+ outcomeToken,
+ 'predict_feed',
+ );
+
+ expect(result.marketTags).toEqual([]);
+ });
+
+ it('handles zero liquidity and volume', () => {
+ const market = createMockMarket({ liquidity: 0, volume: 0 });
+ const outcomeToken = createMockOutcomeToken();
+
+ const result = parseAnalyticsProperties(
+ market,
+ outcomeToken,
+ 'predict_feed',
+ );
+
+ expect(result.liquidity).toBe(0);
+ expect(result.volume).toBe(0);
+ });
+
+ it('handles zero share price', () => {
+ const market = createMockMarket();
+ const outcomeToken = createMockOutcomeToken({ price: 0 });
+
+ const result = parseAnalyticsProperties(
+ market,
+ outcomeToken,
+ 'predict_feed',
+ );
+
+ expect(result.sharePrice).toBe(0);
+ });
+ });
+});
diff --git a/app/components/UI/Predict/utils/analytics.ts b/app/components/UI/Predict/utils/analytics.ts
new file mode 100644
index 00000000000..898e4dc7d78
--- /dev/null
+++ b/app/components/UI/Predict/utils/analytics.ts
@@ -0,0 +1,33 @@
+import { PredictEventValues } from '../constants/eventNames';
+import type { PredictMarket, PredictOutcomeToken } from '../types';
+import type { PredictEntryPoint } from '../types/navigation';
+
+export function parseAnalyticsProperties(
+ market: PredictMarket | undefined,
+ outcomeToken: PredictOutcomeToken | undefined,
+ entryPoint: PredictEntryPoint | undefined,
+) {
+ return {
+ marketId: market?.id,
+ marketTitle: market?.title,
+ marketCategory: market?.category,
+ marketTags: market?.tags,
+ entryPoint: entryPoint || PredictEventValues.ENTRY_POINT.PREDICT_FEED,
+ transactionType: PredictEventValues.TRANSACTION_TYPE.MM_PREDICT_BUY,
+ liquidity: market?.liquidity,
+ volume: market?.volume,
+ sharePrice: outcomeToken?.price,
+ marketType:
+ market?.outcomes?.length === 1
+ ? PredictEventValues.MARKET_TYPE.BINARY
+ : PredictEventValues.MARKET_TYPE.MULTI_OUTCOME,
+ outcome: outcomeToken?.title?.toLowerCase(),
+ marketSlug: market?.slug,
+ gameId: market?.game?.id,
+ gameStartTime: market?.game?.startTime,
+ gameLeague: market?.game?.league,
+ gameStatus: market?.game?.status,
+ gamePeriod: market?.game?.period,
+ gameClock: market?.game?.elapsed,
+ };
+}
diff --git a/app/components/UI/Predict/utils/predictErrorHandler.test.ts b/app/components/UI/Predict/utils/predictErrorHandler.test.ts
new file mode 100644
index 00000000000..530e72ecfd5
--- /dev/null
+++ b/app/components/UI/Predict/utils/predictErrorHandler.test.ts
@@ -0,0 +1,170 @@
+import { IconName } from '../../../../component-library/components/Icons/Icon';
+import { ToastVariants } from '../../../../component-library/components/Toast/Toast.types';
+import { PREDICT_ERROR_CODES } from '../constants/errors';
+import {
+ ensureError,
+ createDepositErrorToast,
+ parseErrorMessage,
+} from './predictErrorHandler';
+
+jest.mock('../../../../../locales/i18n', () => ({
+ strings: (key: string) => key,
+}));
+
+jest.mock('../constants/errors', () => ({
+ ...jest.requireActual('../constants/errors'),
+ getPredictErrorMessages: () => ({
+ PREDICT_NOT_ELIGIBLE: 'You are not eligible',
+ PREDICT_PLACE_ORDER_FAILED: 'Order placement failed',
+ PREDICT_UNKNOWN_ERROR: 'Something went wrong',
+ }),
+}));
+
+const mockTheme = {
+ colors: {
+ error: { default: 'error-color' },
+ accent04: { normal: 'accent-color' },
+ },
+};
+
+describe('predictErrorHandler', () => {
+ describe('ensureError', () => {
+ it('returns the same Error instance when given an Error', () => {
+ const original = new Error('test error');
+
+ const result = ensureError(original);
+
+ expect(result).toBe(original);
+ expect(result.message).toBe('test error');
+ });
+
+ it('wraps a string into an Error', () => {
+ const result = ensureError('string error');
+
+ expect(result).toBeInstanceOf(Error);
+ expect(result.message).toBe('string error');
+ });
+
+ it('wraps a number into an Error', () => {
+ const result = ensureError(42);
+
+ expect(result).toBeInstanceOf(Error);
+ expect(result.message).toBe('42');
+ });
+
+ it('wraps null into an Error', () => {
+ const result = ensureError(null);
+
+ expect(result).toBeInstanceOf(Error);
+ expect(result.message).toBe('null');
+ });
+
+ it('wraps undefined into an Error', () => {
+ const result = ensureError(undefined);
+
+ expect(result).toBeInstanceOf(Error);
+ expect(result.message).toBe('undefined');
+ });
+
+ it('wraps an object into an Error using String coercion', () => {
+ const result = ensureError({ code: 'FAIL' });
+
+ expect(result).toBeInstanceOf(Error);
+ expect(result.message).toBe('[object Object]');
+ });
+ });
+
+ describe('createDepositErrorToast', () => {
+ it('returns base toast config without retry', () => {
+ const toast = createDepositErrorToast(mockTheme);
+
+ expect(toast).toEqual({
+ variant: ToastVariants.Icon,
+ labelOptions: [
+ { label: 'predict.deposit.error_title', isBold: true },
+ { label: '\n', isBold: false },
+ { label: 'predict.deposit.error_description', isBold: false },
+ ],
+ iconName: IconName.Error,
+ iconColor: 'error-color',
+ backgroundColor: 'accent-color',
+ hasNoTimeout: false,
+ });
+ });
+
+ it('includes linkButtonOptions when onRetry is provided', () => {
+ const onRetry = jest.fn();
+
+ const toast = createDepositErrorToast(mockTheme, onRetry);
+
+ expect(toast.linkButtonOptions).toEqual({
+ label: 'predict.deposit.try_again',
+ onPress: onRetry,
+ });
+ });
+
+ it('does not include linkButtonOptions when onRetry is undefined', () => {
+ const toast = createDepositErrorToast(mockTheme, undefined);
+
+ expect(toast).not.toHaveProperty('linkButtonOptions');
+ });
+ });
+
+ describe('parseErrorMessage', () => {
+ it('returns mapped message for a known error code', () => {
+ const result = parseErrorMessage({
+ error: new Error(PREDICT_ERROR_CODES.NOT_ELIGIBLE),
+ });
+
+ expect(result).toBe('You are not eligible');
+ });
+
+ it('returns mapped message for a different known error code', () => {
+ const result = parseErrorMessage({
+ error: new Error(PREDICT_ERROR_CODES.PLACE_ORDER_FAILED),
+ });
+
+ expect(result).toBe('Order placement failed');
+ });
+
+ it('falls back to defaultCode when error message is not in the map', () => {
+ const result = parseErrorMessage({
+ error: new Error('some random error'),
+ defaultCode: PREDICT_ERROR_CODES.UNKNOWN_ERROR,
+ });
+
+ expect(result).toBe('Something went wrong');
+ });
+
+ it('returns raw error message when neither message nor defaultCode match', () => {
+ const result = parseErrorMessage({
+ error: new Error('completely unknown'),
+ });
+
+ expect(result).toBe('completely unknown');
+ });
+
+ it('converts string error to message', () => {
+ const result = parseErrorMessage({
+ error: 'raw string error',
+ });
+
+ expect(result).toBe('raw string error');
+ });
+
+ it('converts non-string non-Error to string', () => {
+ const result = parseErrorMessage({ error: 404 });
+
+ expect(result).toBe('404');
+ });
+
+ it('uses defaultCode when error is not a known code', () => {
+ const result = parseErrorMessage({
+ error: 12345,
+ defaultCode: PREDICT_ERROR_CODES.PLACE_ORDER_FAILED,
+ });
+
+ expect(result).toBe('Order placement failed');
+ });
+ });
+});
diff --git a/app/components/UI/Predict/utils/predictErrorHandler.ts b/app/components/UI/Predict/utils/predictErrorHandler.ts
index 00b0e37aee6..54c491d2151 100644
--- a/app/components/UI/Predict/utils/predictErrorHandler.ts
+++ b/app/components/UI/Predict/utils/predictErrorHandler.ts
@@ -1,3 +1,6 @@
+import { strings } from '../../../../../locales/i18n';
+import { IconName } from '../../../../component-library/components/Icons/Icon';
+import { ToastVariants } from '../../../../component-library/components/Toast/Toast.types';
import { getPredictErrorMessages } from '../constants/errors';
/**
@@ -13,6 +16,35 @@ export function ensureError(error: unknown): Error {
return new Error(String(error));
}
+export function createDepositErrorToast(
+ theme: {
+ colors: { error: { default: string }; accent04: { normal: string } };
+ },
+ onRetry?: () => void,
+) {
+ return {
+ variant: ToastVariants.Icon as const,
+ labelOptions: [
+ { label: strings('predict.deposit.error_title'), isBold: true },
+ { label: '\n', isBold: false },
+ {
+ label: strings('predict.deposit.error_description'),
+ isBold: false,
+ },
+ ],
+ iconName: IconName.Error,
+ iconColor: theme.colors.error.default,
+ backgroundColor: theme.colors.accent04.normal,
+ hasNoTimeout: false,
+ ...(onRetry && {
+ linkButtonOptions: {
+ label: strings('predict.deposit.try_again'),
+ onPress: onRetry,
+ },
+ }),
+ };
+}
+
export function parseErrorMessage({
error,
defaultCode,
diff --git a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx
index 8c3d284b4b6..fc78e1b5e08 100644
--- a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx
+++ b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx
@@ -4,9 +4,7 @@ import {
BoxFlexDirection,
BoxJustifyContent,
ButtonSize as ButtonSizeHero,
- FontWeight,
Icon,
- IconColor,
IconName,
IconSize,
Text,
@@ -46,35 +44,22 @@ import { usePredictPlaceOrder } from '../../hooks/usePredictPlaceOrder';
import { usePredictOrderPreview } from '../../hooks/usePredictOrderPreview';
import { Side } from '../../types';
import { PredictNavigationParamList } from '../../types/navigation';
-import {
- PredictTradeStatus,
- PredictEventValues,
-} from '../../constants/eventNames';
+import { PredictTradeStatus } from '../../constants/eventNames';
+import { parseAnalyticsProperties } from '../../utils/analytics';
import { formatCents, formatPrice } from '../../utils/format';
import PredictAmountDisplay from '../../components/PredictAmountDisplay';
-import { IconName as IconNameLegacy } from '../../../../../component-library/components/Icons/Icon';
-import KeyValueRow from '../../../../../component-library/components-temp/KeyValueRow';
-import { TooltipSizes } from '../../../../../component-library/components-temp/KeyValueRow/KeyValueRow.types';
-import {
- TextVariant as LegacyTextVariant,
- TextColor as LegacyTextColor,
-} from '../../../../../component-library/components/Texts/Text/Text.types';
-import RewardsAnimations, {
- RewardAnimationState,
-} from '../../../Rewards/components/RewardPointsAnimation';
-import AddRewardsAccount from '../../../Rewards/components/AddRewardsAccount/AddRewardsAccount';
import PredictFeeBreakdownSheet from '../../components/PredictFeeBreakdownSheet';
+import PredictFeeSummary from '../PredictBuyWithAnyToken/components/PredictFeeSummary/PredictFeeSummary';
import PredictOrderRetrySheet from '../../components/PredictOrderRetrySheet';
import PredictKeypad, {
PredictKeypadHandles,
} from '../../components/PredictKeypad';
import { SafeAreaView } from 'react-native-safe-area-context';
import { usePredictBalance } from '../../hooks/usePredictBalance';
-import { usePredictDeposit } from '../../hooks/usePredictDeposit';
import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton';
import { strings } from '../../../../../../locales/i18n';
import ButtonHero from '../../../../../component-library/components-temp/Buttons/ButtonHero';
-import { usePredictRewards } from '../../hooks/usePredictRewards';
+
import { TraceName } from '../../../../../util/trace';
import { usePredictMeasurement } from '../../hooks/usePredictMeasurement';
import { PredictBuyPreviewSelectorsIDs } from '../../Predict.testIds';
@@ -93,29 +78,7 @@ const PredictBuyPreview = () => {
const { market, outcome, outcomeToken, entryPoint } = route.params;
const analyticsProperties = useMemo(
- () => ({
- marketId: market?.id,
- marketTitle: market?.title,
- marketCategory: market?.category,
- marketTags: market?.tags,
- entryPoint: entryPoint || PredictEventValues.ENTRY_POINT.PREDICT_FEED,
- transactionType: PredictEventValues.TRANSACTION_TYPE.MM_PREDICT_BUY,
- liquidity: market?.liquidity,
- volume: market?.volume,
- sharePrice: outcomeToken?.price,
- marketType:
- market?.outcomes?.length === 1
- ? PredictEventValues.MARKET_TYPE.BINARY
- : PredictEventValues.MARKET_TYPE.MULTI_OUTCOME,
- outcome: outcomeToken?.title?.toLowerCase(),
- marketSlug: market?.slug,
- gameId: market?.game?.id,
- gameStartTime: market?.game?.startTime,
- gameLeague: market?.game?.league,
- gameStatus: market?.game?.status,
- gamePeriod: market?.game?.period,
- gameClock: market?.game?.elapsed,
- }),
+ () => parseAnalyticsProperties(market, outcomeToken, entryPoint),
[market, outcomeToken, entryPoint],
);
@@ -131,7 +94,6 @@ const PredictBuyPreview = () => {
const { data: balance = 0, isLoading: isBalanceLoading } =
usePredictBalance();
- const { deposit } = usePredictDeposit();
const fakOrdersEnabled = useSelector(selectPredictFakOrdersEnabledFlag);
const [currentValue, setCurrentValue] = useState(0);
@@ -231,21 +193,8 @@ const PredictBuyPreview = () => {
!isBalanceLoading &&
!isRateLimited;
- const {
- enabled: isRewardsEnabled,
- isLoading: isRewardsLoading,
- accountOptedIn: isAccountOptedIntoRewards,
- rewardsAccountScope,
- estimatedPoints: estimatedRewardsPoints,
- hasError: isRewardsError,
- } = usePredictRewards(
- isLoading || previewError ? undefined : (preview?.fees?.totalFee ?? 0),
- );
-
- // Show rewards row if we have a valid amount
- // && either active account address is opted in or not opted in but opt-in is supported
- const shouldShowRewardsRow =
- isRewardsEnabled && currentValue > 0 && isAccountOptedIntoRewards != null;
+ const rewardsFeeAmountUsd =
+ isLoading || previewError ? undefined : (preview?.fees?.totalFee ?? 0);
const title = market.title;
const outcomeGroupTitle = outcome.groupItemTitle
@@ -492,111 +441,22 @@ const PredictBuyPreview = () => {
return null;
}
- const isLoadingRewardsState =
- (isCalculating && isUserInputChange) || isRewardsLoading;
return (
-
+
-
-
-
- {strings('predict.fee_summary.total')}
-
-
-
- {strings('predict.fee_summary.total_incl_fees')}
-
-
-
-
-
- {formatPrice(total, { maximumDecimals: 2 })}
-
-
-
-
- {shouldShowRewardsRow &&
- (isAccountOptedIntoRewards || rewardsAccountScope) && (
-
- {isAccountOptedIntoRewards ? (
-
- ) : rewardsAccountScope ? (
-
- ) : (
- <>>
- )}
-
- ),
- ...(isRewardsError && {
- tooltip: {
- title: strings('predict.fee_summary.points_error'),
- content: strings(
- 'predict.fee_summary.points_error_content',
- ),
- size: TooltipSizes.Sm,
- iconName: IconNameLegacy.Info,
- },
- }),
- }}
- />
- )}
-
{errorMessage && (
{
setCurrentValue={setCurrentValue}
setCurrentValueUSDString={setCurrentValueUSDString}
setIsInputFocused={setIsInputFocused}
- onAddFunds={deposit}
/>
{renderBottomContent()}
{isFeeBreakdownVisible && (
diff --git a/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx
new file mode 100644
index 00000000000..a752c80ee1e
--- /dev/null
+++ b/app/components/UI/Predict/views/PredictBuyWithAnyToken/PredictBuyWithAnyToken.tsx
@@ -0,0 +1,333 @@
+import {
+ Box,
+ BoxAlignItems,
+ BoxFlexDirection,
+ BoxJustifyContent,
+} from '@metamask/design-system-react-native';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import { RouteProp, useRoute } from '@react-navigation/native';
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { ScrollView } from 'react-native';
+import { Edge, SafeAreaView } from 'react-native-safe-area-context';
+import { useSelector } from 'react-redux';
+import { BottomSheetRef } from '../../../../../component-library/components/BottomSheets/BottomSheet';
+import { TraceName } from '../../../../../util/trace';
+import { PredictBuyPreviewSelectorsIDs } from '../../Predict.testIds';
+import PredictBuyActionButton from './components/PredictBuyActionButton';
+import PredictBuyAmountSection from './components/PredictBuyAmountSection';
+import PredictBuyBottomContent from './components/PredictBuyBottomContent';
+import PredictBuyMinimumError from './components/PredictBuyMinimumError';
+import PredictBuyPreviewHeader from './components/PredictBuyPreviewHeader/PredictBuyPreviewHeader';
+import PredictFeeBreakdownSheet from '../../components/PredictFeeBreakdownSheet';
+import PredictFeeSummary from './components/PredictFeeSummary/PredictFeeSummary';
+import PredictKeypad, {
+ PredictKeypadHandles,
+} from '../../components/PredictKeypad';
+import PredictOrderRetrySheet from '../../components/PredictOrderRetrySheet';
+import PredictPayWithAnyTokenInfo from './components/PredictPayWithAnyTokenInfo';
+import { PredictPayWithRow } from './components/PredictPayWithRow';
+import { usePredictBuyAvailableBalance } from './hooks/usePredictBuyAvailableBalance';
+import usePredictBuyBackSwipe from './hooks/usePredictBuyBackSwipe';
+import { usePredictBuyConditions } from './hooks/usePredictBuyConditions';
+import { usePredictBuyInfo } from './hooks/usePredictBuyInfo';
+import { usePredictBuyInputState } from './hooks/usePredictBuyInputState';
+import { usePredictBuyActions } from './hooks/usePredictBuyPreviewActions';
+import { usePredictMeasurement } from '../../hooks/usePredictMeasurement';
+import { usePredictOrderPreview } from '../../hooks/usePredictOrderPreview';
+import { usePredictOrderRetry } from '../../hooks/usePredictOrderRetry';
+import { usePredictPayWithAnyTokenTracking } from './hooks/usePredictPayWithAnyTokenTracking';
+import { usePredictPaymentToken } from '../../hooks/usePredictPaymentToken';
+import { usePredictPlaceOrder } from '../../hooks/usePredictPlaceOrder';
+import { selectPredictFakOrdersEnabledFlag } from '../../selectors/featureFlags';
+import { Side } from '../../types';
+import { PredictNavigationParamList } from '../../types/navigation';
+import { parseAnalyticsProperties } from '../../utils/analytics';
+import { usePredictOrderTracking } from './hooks/usePredictOrderTracking';
+
+const SHOW_TOKEN_SELECTION = false;
+
+const PredictBuyWithAnyToken = () => {
+ const tw = useTailwind();
+ const keypadRef = useRef(null);
+ const feeBreakdownSheetRef = useRef(null);
+ const route =
+ useRoute>();
+
+ const {
+ market,
+ outcome,
+ outcomeToken,
+ entryPoint,
+ isConfirmation,
+ preview: initialPreview,
+ } = route.params;
+
+ const [isFeeBreakdownVisible, setIsFeeBreakdownVisible] = useState(false);
+
+ const analyticsProperties = useMemo(
+ () => parseAnalyticsProperties(market, outcomeToken, entryPoint),
+ [market, outcomeToken, entryPoint],
+ );
+
+ const { availableBalance, isBalanceLoading } =
+ usePredictBuyAvailableBalance();
+
+ const {
+ currentValue,
+ setCurrentValue,
+ currentValueUSDString,
+ setCurrentValueUSDString,
+ isInputFocused,
+ setIsInputFocused,
+ isUserInputChange,
+ setIsUserInputChange,
+ isConfirming,
+ setIsConfirming,
+ } = usePredictBuyInputState();
+
+ const {
+ placeOrder,
+ isLoading: isPlaceOrderLoading,
+ error: placeOrderError,
+ result,
+ isOrderNotFilled,
+ resetOrderNotFilled,
+ } = usePredictPlaceOrder();
+
+ const handleFeesInfoPress = useCallback(() => {
+ setIsFeeBreakdownVisible(true);
+ }, []);
+
+ const handleFeeBreakdownClose = useCallback(() => {
+ setIsFeeBreakdownVisible(false);
+ }, []);
+
+ const fakOrdersEnabled = useSelector(selectPredictFakOrdersEnabledFlag);
+
+ const {
+ preview,
+ error: previewError,
+ isCalculating: isPreviewCalculating,
+ } = usePredictOrderPreview({
+ marketId: market.id,
+ outcomeId: outcome.id,
+ outcomeTokenId: outcomeToken.id,
+ side: Side.BUY,
+ size: currentValue,
+ autoRefreshTimeout: 1000,
+ initialPreview,
+ });
+
+ const {
+ toWin,
+ metamaskFee,
+ providerFee,
+ total,
+ depositFee,
+ rewardsFeeAmount,
+ errorMessage,
+ } = usePredictBuyInfo({
+ currentValue,
+ preview,
+ previewError,
+ isPlaceOrderLoading,
+ placeOrderError,
+ isOrderNotFilled,
+ isConfirming,
+ });
+
+ const {
+ handleBack,
+ handleBackSwipe,
+ handleTokenSelected,
+ handleConfirm,
+ handleDepositFailed,
+ handlePlaceOrderSuccess,
+ handlePlaceOrderError,
+ } = usePredictBuyActions({
+ currentValue,
+ analyticsProperties,
+ preview,
+ placeOrder,
+ depositAmount: total - depositFee,
+ setIsConfirming,
+ });
+
+ usePredictBuyBackSwipe({ onBack: handleBackSwipe });
+
+ usePredictPayWithAnyTokenTracking({
+ onFail: handleDepositFailed,
+ onConfirm: handleConfirm,
+ });
+
+ const {
+ isPlacingOrder,
+ isBelowMinimum,
+ canPlaceBet,
+ isUserChangeTriggeringCalculation,
+ isPayFeesLoading,
+ isBalancePulsing,
+ } = usePredictBuyConditions({
+ currentValue,
+ preview,
+ isPreviewCalculating,
+ isPlaceOrderLoading,
+ isUserInputChange,
+ isConfirming,
+ });
+
+ usePredictPaymentToken({
+ onTokenSelected: handleTokenSelected,
+ });
+
+ useEffect(() => {
+ if (!isPreviewCalculating) {
+ setIsUserInputChange(false);
+ }
+ }, [isPreviewCalculating, setIsUserInputChange]);
+
+ const {
+ retrySheetRef,
+ retrySheetVariant,
+ isRetrying,
+ handleRetryWithBestPrice,
+ } = usePredictOrderRetry({
+ preview,
+ placeOrder,
+ analyticsProperties,
+ isOrderNotFilled,
+ resetOrderNotFilled,
+ });
+
+ // Track screen load performance (balance + initial preview)
+ usePredictMeasurement({
+ traceName: TraceName.PredictBuyPreviewView,
+ conditions: [!isBalanceLoading, availableBalance !== undefined, !!market],
+ debugContext: {
+ marketId: market?.id,
+ hasBalance: availableBalance !== undefined,
+ isBalanceLoading,
+ },
+ });
+
+ usePredictOrderTracking({
+ result,
+ error: placeOrderError,
+ onSuccess: handlePlaceOrderSuccess,
+ onError: handlePlaceOrderError,
+ });
+
+ const edges = useMemo(
+ () => (isConfirmation ? (['top', 'left', 'right'] as Edge[]) : undefined),
+ [isConfirmation],
+ );
+
+ return (
+
+
+
+